diff --git a/Cargo.lock b/Cargo.lock index 336485026..92168c80e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2292,6 +2292,7 @@ dependencies = [ "insta", "jsonschema", "libc", + "lithos-llm", "paste", "serde", "serde_json", @@ -2318,6 +2319,7 @@ dependencies = [ "fabro-config", "fabro-environment", "fabro-types", + "lithos-llm", "openapiv3", "prettyplease", "progenitor", @@ -2463,6 +2465,7 @@ dependencies = [ "insta", "jsonwebtoken", "libc", + "lithos-llm", "miette", "nix 0.30.1", "object_store", @@ -2514,6 +2517,7 @@ dependencies = [ "futures", "httpmock", "libc", + "lithos-llm", "progenitor-client", "rand 0.9.4", "serde", @@ -2692,6 +2696,7 @@ dependencies = [ "fabro-types", "fabro-util", "httpmock", + "lithos-llm", "regex", "serde", "serde_json", @@ -2751,7 +2756,6 @@ version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", - "base64", "bytes", "fabro-auth", "fabro-config", @@ -2765,11 +2769,9 @@ dependencies = [ "futures", "httpmock", "lithos-llm", - "mime_guess", "serde", "serde_json", "strum 0.28.0", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", @@ -3037,6 +3039,7 @@ dependencies = [ "http-body-util", "httpmock", "jsonwebtoken", + "lithos-llm", "mime_guess", "multer", "object_store", @@ -3118,6 +3121,7 @@ dependencies = [ "futures", "hex", "insta", + "lithos-llm", "object_store", "percent-encoding", "serde", @@ -3375,6 +3379,7 @@ dependencies = [ "hex", "httpmock", "jsonschema", + "lithos-llm", "md5", "miette", "mime_guess", @@ -4952,7 +4957,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lithos-llm" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/lithos-llm?rev=38ccb14f08c56382e4af3888704de2f0951c910a#38ccb14f08c56382e4af3888704de2f0951c910a" +source = "git+https://github.com/lithoscomputer/lithos-llm?rev=a1e3fd37b7153870411701327ac117606753fe90#a1e3fd37b7153870411701327ac117606753fe90" dependencies = [ "async-trait", "aws-config", @@ -4964,6 +4969,7 @@ dependencies = [ "crc32fast", "futures-core", "futures-util", + "mime_guess", "reqwest 0.13.4", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 194d53a90..854cef45e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,7 +94,7 @@ insta = "1" fabro-test = { path = "lib/foundation/fabro-test" } # Provider-neutral LLM catalog and client. Pinned to a revision until 0.x is # published to crates.io. -lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", rev = "38ccb14f08c56382e4af3888704de2f0951c910a", default-features = false } +lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", rev = "a1e3fd37b7153870411701327ac117606753fe90", default-features = false } # Deterministic OpenAI twin used by twin-mode E2E tests; the same revision # lithos-llm verifies its codecs against. twin-openai = { git = "https://github.com/lithoscomputer/twins", rev = "ca45f0e50a6716d716aa2f638ca3cf767e88f613" } diff --git a/docs/internal/llm-client-resolution.md b/docs/internal/llm-client-resolution.md index 3187b22de..891528c85 100644 --- a/docs/internal/llm-client-resolution.md +++ b/docs/internal/llm-client-resolution.md @@ -4,12 +4,12 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l ## Core Rules -- `fabro_auth::CredentialSource` is the credential authority. -- Long-lived runtime contexts store `Arc` and `Arc`, not `Client`. +- The lithos `CredentialProvider` trait is the credential authority; Fabro's vault, SQL secret store, and API-key stores implement it directly. +- Long-lived runtime contexts store `Arc` and `Arc`, not `Client`. - Call `fabro_llm::client::Client::from_source(&source, catalog).await?` at the point of use. - Standalone setup and tests that use default settings build a default `Arc` locally, then pass it explicitly. - `GenerateParams::new(model, client)` always receives an explicit `Arc`. -- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve(catalog)` directly and consume both `credentials` and `auth_issues`. +- When a caller needs diagnostics in runtime request-serving paths, read `FabroClient::ready` and `auth_issues` (from `ClientBuilder::build_ready`), or call `lithos_llm::credentials::readiness` directly. - `VaultCredentialSource` is the normal source for vault-backed runtime contexts; `VaultCredentialSource::environment_only()` serves env-only or no-vault contexts. ## Why diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 05668b258..2c6b3f91b 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -32,7 +32,8 @@ use std::sync::Arc; use fabro_agent::{AgentProfile, AgentProfileBuilder, LocalSandbox, Session, SessionOptions}; use fabro_auth::VaultCredentialSource; use fabro_llm::ClientOptions; -use fabro_types::{AgentProfileKind, provider_ids}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::builtin; #[tokio::main] async fn main() -> Result<(), Box> { @@ -48,7 +49,7 @@ async fn main() -> Result<(), Box> { let profile: Arc = Arc::from( AgentProfileBuilder::new( AgentProfileKind::Anthropic, - provider_ids::anthropic(), + builtin::anthropic(), "claude-sonnet-4.5", Arc::clone(&catalog), ) @@ -300,7 +301,7 @@ All fallible `Session` methods return `Result`: | Variant | Description | |---|---| -| `Llm(LlmError)` | An error from the LLM provider (the stored form of a lithos `Error`). | +| `Llm(Box)` | An error from the LLM provider: the lithos `ErrorData`, the stored form of a lithos `Error`. | | `SessionClosed` | `process_input` was called on a closed session. | | `InvalidState(String)` | The session is in an unexpected state. | | `ToolExecution(String)` | A tool execution failed. | @@ -374,7 +375,7 @@ The `fabro_llm::catalog` module reads Fabro policy from the catalog: `enabled_pr ### Client -`fabro_llm::build_client(catalog, source, options)` returns a `FabroClient`: the lithos `Client`, the providers that are ready, the providers whose credentials could not be used, and the providers lithos could not build an adapter for. Credentials are read from the `CredentialSource` on every provider attempt, so a refreshed OAuth token is picked up without rebuilding the client. +`fabro_llm::build_client(catalog, credentials, options)` takes any lithos `CredentialProvider` and returns a `FabroClient`: the lithos `Client`, the providers that are ready, the providers whose credentials could not be used, and the providers lithos could not build an adapter for. Credentials are read from the provider on every attempt, so a refreshed OAuth token is picked up without rebuilding the client. `ClientOptions::standard()` turns on the lithos retry middleware (three attempts with short exponential backoff) and local attachment inlining. Add middleware with `with_middleware`, replace a provider's adapter with `with_adapter`, or set `http` to inject a configured HTTP client. `fabro_llm::build_offline_client(catalog, options)` builds a client whose only providers are custom adapters, which is how `fabro exec --server` routes every call through a Fabro server. @@ -386,7 +387,7 @@ Credential sources live in `fabro-auth`: `VaultCredentialSource` reads a Fabro v ```rust use fabro_llm::Request; -use fabro_types::{Message, Role}; +use lithos_llm::types::{Message, Role}; let request = Request::builder() .model("openai/gpt-5.4") @@ -425,10 +426,10 @@ A turn that ends with `FinishReason::Length` or `FinishReason::Incomplete` is no ### Structured output -`fabro_llm::structured::complete_object` attaches a JSON Schema as the request's response format and parses the reply: +`Client::complete_object` (a lithos method) attaches a JSON Schema as the request's response format and parses the reply into a `StructuredCompletion` with the response and the parsed document: ```rust -use fabro_llm::{Request, structured}; +use fabro_llm::Request; use serde_json::json; let schema = json!({ @@ -444,31 +445,31 @@ let request = Request::builder() .model("claude-sonnet-4.5") .user("Generate a profile for a fictional character") .build()?; -let completion = structured::complete_object(&client, request, "profile", schema).await?; +let completion = client.complete_object(request, "profile", schema).await?; println!("Name: {}", completion.object["name"]); ``` ### Reasoning -`fabro_llm::reasoning::normalize(&response.content)` folds a response's readable reasoning parts into a `fabro_types::ReasoningOutput` with a summary and a trace. Provider replay data such as signatures and encrypted reasoning never appears in it. +`response.reasoning()` (a lithos method) folds a response's readable reasoning parts into a `ReasoningOutput` with a summary and a trace, whichever channel the provider used. Provider replay data such as signatures and encrypted reasoning never appears in it; `ContentPart::is_replay_material()` marks the parts a conversation keeps for the next request instead. ### Middleware -Middleware is the lithos `Middleware` trait: `handle(&self, call: Call, next: Next)` sees the resolved route and request and returns an `Output` that is either a complete response or a stream. `fabro_llm::attachments::InlineLocalAttachments` is Fabro's own middleware; it rewrites local file references in messages into inline media before dispatch. +Middleware is the lithos `Middleware` trait: `handle(&self, call: Call, next: Next)` sees the resolved route and request and returns an `Output` that is either a complete response or a stream. `ClientOptions::standard()` installs lithos's `InlineLocalFiles`, which rewrites local file paths in messages into inline media before dispatch. ### Error handling -Every fallible operation returns `Result`, the lithos error. `error.kind()` is an `ErrorKind` such as `Authentication`, `RateLimit`, `Server`, `ContextLength`, `ContentFilter`, `Timeout`, `StreamDecode`, or `Cancelled`. `error.data()` is the `ErrorData` snapshot Fabro stores in run events; `fabro_llm::LlmError` wraps it. +Every fallible operation returns `Result`, the lithos error. `error.kind()` is an `ErrorKind` such as `Authentication`, `RateLimit`, `Server`, `ContextLength`, `ContentFilter`, `Timeout`, `StreamDecode`, or `Cancelled`. `error.data()` is the `ErrorData` snapshot Fabro stores in run events; it reads like `Error`, prints its message, and implements `std::error::Error`. -The `fabro_llm::ErrorFacts` trait is implemented for `Error`, `ErrorData`, and `LlmError`, and the classification helpers take any of them: +Both `Error` and `ErrorData` answer the policy questions directly; only the loop-detection signature is Fabro's: | Function | Description | |---|---| -| `is_retryable(&error)` | Safe to retry with the same provider, from lithos's retry classification | -| `failover_eligible(&error)` | Safe to try a different provider | -| `is_auth_error(&error)` | The credential was missing or rejected | -| `is_cancelled(&error)` | The caller cancelled the call | -| `failure_signature_hint(&error)` | A stable string for loop and restart detection | +| `error.is_retryable()` | Safe to retry with the same provider, from lithos's retry classification | +| `error.failover_eligible()` | Safe to try a different provider | +| `error.is_auth_error()` | The credential was missing or rejected | +| `error.is_cancelled()` | The caller cancelled the call | +| `fabro_llm::failure_signature_hint(&data)` | A stable string for loop and restart detection | ### Retries @@ -505,7 +506,7 @@ use std::sync::Arc; use fabro_llm::ClientOptions; use fabro_llm::gateway::GatewayAdapter; -use fabro_types::ProviderId; +use lithos_llm::catalog::ProviderId; let adapter = Arc::new(GatewayAdapter::new(Box::new(my_transport))); let built = fabro_llm::build_offline_client( diff --git a/lib/apps/fabro-cli/Cargo.toml b/lib/apps/fabro-cli/Cargo.toml index 2b9c78d48..8b844e79d 100644 --- a/lib/apps/fabro-cli/Cargo.toml +++ b/lib/apps/fabro-cli/Cargo.toml @@ -45,6 +45,7 @@ fabro-telemetry = { path = "../../foundation/fabro-telemetry" } fabro-store = { path = "../../components/fabro-store" } fabro-vault = { path = "../../foundation/fabro-vault" } fabro-types = { path = "../../foundation/fabro-types", features = ["clap"] } +lithos-llm = { workspace = true, features = ["runtime"] } fabro-redact.workspace = true fabro-util = { path = "../../foundation/fabro-util" } fabro-http.workspace = true diff --git a/lib/apps/fabro-cli/src/args.rs b/lib/apps/fabro-cli/src/args.rs index f44d554f5..cc04ef12a 100644 --- a/lib/apps/fabro-cli/src/args.rs +++ b/lib/apps/fabro-cli/src/args.rs @@ -7,10 +7,11 @@ use fabro_agent::cli::AgentArgs; use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer}; use fabro_server::serve::DEFAULT_TCP_PORT; use fabro_static::EnvVars; -use fabro_types::ReasoningEffort; use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_types::settings::run::MergeStrategy; use fabro_util::printer::Printer; +use lithos_llm::catalog::ProviderId; +use lithos_llm::types::ReasoningEffort; pub(crate) const LONG_VERSION: &str = concat!( env!("CARGO_PKG_VERSION"), @@ -836,7 +837,7 @@ pub(crate) struct ProviderLoginArgs { /// LLM provider to authenticate with #[arg(long)] - pub(crate) provider: fabro_types::ProviderId, + pub(crate) provider: ProviderId, /// Read an API key from stdin instead of prompting #[arg(long)] @@ -1728,7 +1729,7 @@ pub(crate) struct InstallGithubArgs { #[derive(Args, Debug, Clone, Default)] pub(crate) struct InstallNonInteractiveArgs { #[arg(long, hide = true)] - pub(crate) llm_provider: Option, + pub(crate) llm_provider: Option, #[arg(long, hide = true)] pub(crate) llm_api_key_stdin: bool, diff --git a/lib/apps/fabro-cli/src/command_context.rs b/lib/apps/fabro-cli/src/command_context.rs index fea71ff73..0d78fa996 100644 --- a/lib/apps/fabro-cli/src/command_context.rs +++ b/lib/apps/fabro-cli/src/command_context.rs @@ -2,8 +2,9 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use anyhow::{Context as _, Result, bail}; -use fabro_auth::{CredentialSource, SqlVaultCredentialSource}; +use fabro_auth::SqlVaultCredentialSource; use fabro_config::{CliLayer, Storage, load_llm_overlay}; +use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; use fabro_types::UserSettings; use fabro_types::settings::RunNamespace; @@ -44,7 +45,7 @@ pub(crate) struct CommandContext { run_settings_key_presence: RunSettingsKeyPresence, server_mode: ServerMode, server: OnceCell>, - llm_source: OnceCell>, + llm_source: OnceCell>, catalog: OnceLock>, } @@ -163,7 +164,7 @@ impl CommandContext { Ok(Arc::clone(client)) } - pub(crate) async fn llm_source(&self) -> Result> { + pub(crate) async fn llm_source(&self) -> Result> { let storage_dir = self.storage_dir.clone(); let source = self @@ -173,9 +174,9 @@ impl CommandContext { let store = SecretStore::open(storage.sqlite_path(), storage.secrets_path()) .await .context("opening the Fabro secret store")?; - let source: Arc = + let source: Arc = Arc::new(SqlVaultCredentialSource::new(Arc::new(store))); - Ok::, anyhow::Error>(source) + Ok::, anyhow::Error>(source) }) .await?; diff --git a/lib/apps/fabro-cli/src/commands/exec.rs b/lib/apps/fabro-cli/src/commands/exec.rs index 1363f3185..5711729cd 100644 --- a/lib/apps/fabro-cli/src/commands/exec.rs +++ b/lib/apps/fabro-cli/src/commands/exec.rs @@ -7,14 +7,14 @@ use fabro_agent::cli::{ OutputFormat, diagnostic_client_options, run_with_args_and_client_and_catalog, run_with_args_and_source_and_catalog, }; +use fabro_llm::ErrorKind; use fabro_llm::gateway::{GatewayAdapter, GatewayError, GatewayTransport}; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{ErrorFacts, ErrorKind, catalog}; use fabro_mcp::config::McpServerSettings; -use fabro_types::ProviderId; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; use fabro_types::settings::run::ResolvedMcpEntry; use fabro_util::exit::{self, ErrorExt, ExitClass}; +use lithos_llm::catalog::ProviderId; use crate::args::ExecArgs; use crate::command_context::CommandContext; @@ -143,8 +143,10 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu .clone() .unwrap_or_else(|| "anthropic".to_string()); let catalog = ctx.catalog()?; - let provider_id = catalog::canonical_provider_id(&catalog, &provider_name) - .unwrap_or_else(|| ProviderId::new(provider_name.as_str())); + let provider_id = catalog.enabled_provider(&provider_name).map_or_else( + || ProviderId::new(provider_name.as_str()), + |provider| provider.id().clone(), + ); let server_client = server_client::connect_server_target(&target).await?; let adapter = Arc::new(GatewayAdapter::new(Box::new( ServerCompletionTransport::new(server_client), diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index f7817bf2f..1c61c032c 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -34,19 +34,19 @@ use fabro_install::{ restore_optional_file, rollback_dev_token_write, seed_environments_in_storage, write_github_app_settings, write_token_settings, }; -use fabro_llm::catalog; use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_server::serve; use fabro_store::ArtifactStore; +use fabro_types::ServerSettings; use fabro_types::settings::server::ServerAuthMethod; use fabro_types::settings::validate_public_url_with_label; -use fabro_types::{ProviderId, ServerSettings, provider_ids}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::version::FABRO_VERSION; use fabro_util::{browser, dev_token, path, session_secret}; use fabro_vault::SecretType as VaultSecretType; use futures::future::BoxFuture; +use lithos_llm::catalog::{ProviderId, builtin}; use rand::Rng; use tokio::net::TcpListener; use tokio::process::Command as TokioCommand; @@ -82,7 +82,8 @@ fn supports_install_api_key(provider: &CatalogProvider) -> bool { } fn install_llm_provider_ids(catalog: &Catalog) -> Vec { - catalog::listed_providers(catalog) + catalog + .listed_providers() .into_iter() .filter(|provider| supports_install_api_key(provider)) .map(|provider| provider.id().clone()) @@ -90,14 +91,16 @@ fn install_llm_provider_ids(catalog: &Catalog) -> Vec { } fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String { - catalog::provider(catalog, provider.as_str()) + catalog + .enabled_provider(provider.as_str()) .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()) + catalog + .enabled_provider(provider.as_str()) .and_then(fabro_auth::expected_secret_name) .unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase())) } @@ -427,14 +430,14 @@ impl InstallInputSource for InteractiveInstallInputSource { if use_device_auth { let credential = authenticate_provider_with_method( - provider_ids::openai(), + builtin::openai(), AuthMethod::CodexDevice(codex_oauth_config()), s, printer, ) .await?; credentials.push(credential); - configured_providers.push(provider_ids::openai()); + configured_providers.push(builtin::openai()); openai_configured = true; } } @@ -2696,7 +2699,7 @@ client_id = "client-id" description: None, }, credential_secret_request(&LoginResult::ApiKey { - provider: fabro_types::provider_ids::anthropic(), + provider: lithos_llm::catalog::builtin::anthropic(), key: "anthropic-key".to_string(), }) .unwrap(), @@ -3502,9 +3505,9 @@ root = "{}" fn install_llm_providers_come_from_catalog_api_key_providers() { let ids = install_llm_provider_ids(&INSTALL_CATALOG); - assert!(ids.contains(&fabro_types::provider_ids::anthropic())); - assert!(ids.contains(&fabro_types::provider_ids::openai())); - assert!(ids.contains(&fabro_types::provider_ids::gemini())); + assert!(ids.contains(&lithos_llm::catalog::builtin::anthropic())); + assert!(ids.contains(&lithos_llm::catalog::builtin::openai())); + assert!(ids.contains(&lithos_llm::catalog::builtin::gemini())); assert!(ids.contains(&ProviderId::new("moonshot"))); assert!(ids.contains(&ProviderId::new("zai"))); assert!(ids.contains(&ProviderId::new("minimax"))); @@ -3529,7 +3532,7 @@ root = "{}" #[test] fn non_interactive_source_rejects_hidden_args_without_switch() { let args = install_args(false, InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), ..InstallNonInteractiveArgs::default() }); let err = NonInteractiveInstallInputSource::new(&args).unwrap_err(); @@ -3542,7 +3545,7 @@ root = "{}" #[test] fn non_interactive_source_rejects_conflicting_api_key_inputs() { let args = install_args(true, InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_stdin: true, llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), @@ -3638,7 +3641,7 @@ root = "{}" fn non_interactive_source_rejects_missing_github_strategy() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_username: Some("brynary".to_string()), ..InstallNonInteractiveArgs::default() @@ -3656,7 +3659,7 @@ root = "{}" fn non_interactive_source_rejects_missing_github_username_for_new_config() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), ..InstallNonInteractiveArgs::default() @@ -3673,7 +3676,7 @@ root = "{}" fn non_interactive_source_allows_keep_existing_settings_without_username() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), keep_existing_settings: true, @@ -3688,7 +3691,7 @@ root = "{}" fn non_interactive_source_rejects_missing_github_owner_for_app() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::App), ..InstallNonInteractiveArgs::default() @@ -3707,7 +3710,7 @@ root = "{}" fn non_interactive_source_rejects_github_owner_for_token() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), github_owner: Some("personal".to_string()), @@ -3727,7 +3730,7 @@ root = "{}" fn non_interactive_source_rejects_github_username_for_app() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::App), github_owner: Some("personal".to_string()), @@ -3747,7 +3750,7 @@ root = "{}" fn non_interactive_source_allows_github_app_setup() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::App), github_owner: Some("personal".to_string()), @@ -3762,7 +3765,7 @@ root = "{}" async fn non_interactive_source_requires_config_choice_when_settings_exist() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(fabro_types::provider_ids::anthropic()), + llm_provider: Some(lithos_llm::catalog::builtin::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), github_username: Some("brynary".to_string()), diff --git a/lib/apps/fabro-cli/src/commands/model.rs b/lib/apps/fabro-cli/src/commands/model.rs index 6a1eae9f5..47385fb86 100644 --- a/lib/apps/fabro-cli/src/commands/model.rs +++ b/lib/apps/fabro-cli/src/commands/model.rs @@ -2,9 +2,10 @@ use anyhow::{Context, Result, bail}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_api::types as api_types; -use fabro_types::{Model, ModelTestMode, ProviderId}; +use fabro_types::{Model, ModelTestMode}; use fabro_util::terminal::Styles; use futures::{StreamExt, stream}; +use lithos_llm::catalog::ProviderId; use serde::Serialize; use crate::args::{ModelListArgs, ModelTestArgs, ModelsCommand}; @@ -513,9 +514,9 @@ impl Default for ModelsCommand { #[cfg(test)] mod tests { - use fabro_types::{ - ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffort, provider_ids, - }; + use fabro_types::{ModelControls, ModelCosts, ModelFeatures, ModelLimits}; + use lithos_llm::catalog::builtin; + use lithos_llm::types::ReasoningEffort; use super::*; @@ -902,7 +903,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("test-model", provider_ids::anthropic())], + "data": [test_model_json("test-model", builtin::anthropic())], "meta": { "has_more": false } }) .to_string(), @@ -916,7 +917,7 @@ mod tests { mock.assert_async().await; assert_eq!(models.len(), 1); assert_eq!(models[0].id.as_str(), "test-model"); - assert_eq!(models[0].provider, provider_ids::anthropic()); + assert_eq!(models[0].provider, builtin::anthropic()); } #[tokio::test] @@ -933,7 +934,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("model-a", provider_ids::anthropic())], + "data": [test_model_json("model-a", builtin::anthropic())], "meta": { "has_more": false } }) .to_string(), @@ -961,12 +962,12 @@ mod tests { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!({ - "data": [test_model_json("claude-sonnet-4-5", provider_ids::anthropic())], - "meta": { "has_more": false } - }) - .to_string(), - ); + serde_json::json!({ + "data": [test_model_json("claude-sonnet-4-5", builtin::anthropic())], + "meta": { "has_more": false } + }) + .to_string(), + ); }) .await; @@ -991,7 +992,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("model-a", provider_ids::anthropic())], + "data": [test_model_json("model-a", builtin::anthropic())], "meta": { "has_more": true } }) .to_string(), @@ -1008,7 +1009,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("model-b", provider_ids::openai())], + "data": [test_model_json("model-b", builtin::openai())], "meta": { "has_more": false } }) .to_string(), diff --git a/lib/apps/fabro-cli/src/commands/provider/login.rs b/lib/apps/fabro-cli/src/commands/provider/login.rs index 13369584f..3cd0e3db2 100644 --- a/lib/apps/fabro-cli/src/commands/provider/login.rs +++ b/lib/apps/fabro-cli/src/commands/provider/login.rs @@ -1,9 +1,9 @@ use anyhow::{Context, Result}; use fabro_api::types; use fabro_auth::{AuthContextRequest, AuthMethod, LoginResult, OPENAI_CODEX_VAULT_SECRET_NAME}; -use fabro_types::ProviderId; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; +use lithos_llm::catalog::ProviderId; use tokio::task::spawn_blocking; use crate::args::ProviderLoginArgs; diff --git a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs index 6aaebce34..5a7080be7 100644 --- a/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs @@ -460,11 +460,13 @@ mod tests { use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_types::run_event::CliEnsureCompletedProps; use fabro_types::{ - MetadataSnapshotFailureKind, MetadataSnapshotPhase, ModelId, ModelRef, ParallelBranchId, - SandboxProviderKind, StageId, TokenCounts, fixtures, provider_ids, + MetadataSnapshotFailureKind, MetadataSnapshotPhase, ModelRef, ParallelBranchId, + SandboxProviderKind, StageId, fixtures, }; use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}; use fabro_workflow::outcome::billed_model_usage_from_llm; + use lithos_llm::catalog::{ModelId, builtin}; + use lithos_llm::types::TokenCounts; use super::*; use crate::commands::run::run_progress::stage_display::ToolCallStatus; @@ -570,7 +572,7 @@ mod tests { fn assistant_event(model: &str, text: &str) -> AgentEvent { AgentEvent::AssistantMessage { text: text.into(), - model: ModelRef::new(provider_ids::openai(), ModelId::new(model)), + model: ModelRef::new(builtin::openai(), ModelId::new(model)), usage: TokenCounts::default(), cost: None, tool_call_count: 0, @@ -589,7 +591,7 @@ mod tests { fn llm_request_started(stage: &str, model: &str) -> Event { agent_event(stage, AgentEvent::LlmRequestStarted { - requested_model: ModelRef::new(provider_ids::anthropic(), ModelId::new(model)), + requested_model: ModelRef::new(builtin::anthropic(), ModelId::new(model)), }) } @@ -605,7 +607,7 @@ mod tests { billing: Some( billed_model_usage_from_llm( &fabro_llm::test_support::test_catalog(), - &ModelRef::new(provider_ids::openai(), ModelId::new("gpt-5.4")), + &ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")), TokenCounts { input: 1200, output: 300, @@ -836,7 +838,7 @@ mod tests { attempt: 1, delay_secs: 0.1, phase: fabro_types::LlmRetryPhase::Consume, - error: fabro_llm::LlmError::from(fabro_llm::Error::new( + error: fabro_llm::ErrorData::from(fabro_llm::Error::new( fabro_llm::ErrorKind::Configuration, "retry", )), @@ -955,7 +957,7 @@ mod tests { attempt: 2, delay_secs: 1.5, phase: fabro_types::LlmRetryPhase::Open, - error: fabro_llm::LlmError::from(fabro_llm::Error::new( + error: fabro_llm::ErrorData::from(fabro_llm::Error::new( fabro_llm::ErrorKind::Configuration, "busy", )), @@ -1319,7 +1321,7 @@ mod tests { attempt: 2, delay_secs: 1.5, phase: fabro_types::LlmRetryPhase::Open, - error: fabro_llm::LlmError::from(fabro_llm::Error::new( + error: fabro_llm::ErrorData::from(fabro_llm::Error::new( fabro_llm::ErrorKind::Configuration, "busy", )), diff --git a/lib/apps/fabro-cli/src/main.rs b/lib/apps/fabro-cli/src/main.rs index 8374eaafb..3ea411a3d 100644 --- a/lib/apps/fabro-cli/src/main.rs +++ b/lib/apps/fabro-cli/src/main.rs @@ -586,7 +586,7 @@ mod tests { ProviderCommand, ProviderNamespace, }; use clap::error::ErrorKind; - use fabro_types::provider_ids; + use lithos_llm::catalog::{ProviderId, builtin}; use temp_env::with_var; use tokio::runtime::Runtime; @@ -658,7 +658,7 @@ destination = "{destination}" Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), }) => { - assert_eq!(args.provider, provider_ids::openai()); + assert_eq!(args.provider, builtin::openai()); } _ => panic!("unexpected command variant"), } @@ -672,7 +672,7 @@ destination = "{destination}" Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), }) => { - assert_eq!(args.provider, provider_ids::anthropic()); + assert_eq!(args.provider, builtin::anthropic()); } _ => panic!("unexpected command variant"), } @@ -693,7 +693,7 @@ destination = "{destination}" Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), }) => { - assert_eq!(args.provider, provider_ids::anthropic()); + assert_eq!(args.provider, builtin::anthropic()); assert!(args.api_key_stdin); } _ => panic!("unexpected command variant"), @@ -1202,7 +1202,7 @@ destination = "{destination}" Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), }) => { - assert_eq!(args.provider, fabro_types::ProviderId::new("bogus")); + assert_eq!(args.provider, ProviderId::new("bogus")); } _ => panic!("expected provider login command"), } diff --git a/lib/apps/fabro-cli/src/shared/provider_auth.rs b/lib/apps/fabro-cli/src/shared/provider_auth.rs index 6de3d0652..0a337fbb2 100644 --- a/lib/apps/fabro-cli/src/shared/provider_auth.rs +++ b/lib/apps/fabro-cli/src/shared/provider_auth.rs @@ -18,12 +18,11 @@ use fabro_auth::{ AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult, codex_oauth_config, strategy_for, }; -use fabro_llm::catalog; use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus}; -use fabro_types::{ProviderId, provider_ids}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; +use lithos_llm::catalog::{ProviderId, builtin}; use tokio::task::spawn_blocking; // --------------------------------------------------------------------------- @@ -59,7 +58,7 @@ fn default_catalog_for_provider_auth() -> Arc { } pub(crate) fn provider_display_name(provider: &ProviderId, catalog: &Catalog) -> String { - catalog::provider(catalog, provider.as_str()).map_or_else( + catalog.enabled_provider(provider.as_str()).map_or_else( || provider.to_string(), |provider| provider.display_name().to_string(), ) @@ -69,7 +68,8 @@ fn api_key_catalog_provider<'a>( provider: &ProviderId, catalog: &'a Catalog, ) -> Result<&'a CatalogProvider> { - let provider = catalog::provider(catalog, provider.as_str()) + let provider = catalog + .enabled_provider(provider.as_str()) .with_context(|| format!("provider '{provider}' is not configured in the model catalog"))?; anyhow::ensure!( fabro_auth::accepts_api_key(provider), @@ -184,7 +184,7 @@ async fn read_and_validate_api_key( } pub(crate) async fn pick_auth_method(provider: &ProviderId) -> Result { - if provider != &provider_ids::openai() { + if provider != &builtin::openai() { return Ok(AuthMethod::ApiKey); } @@ -374,9 +374,9 @@ mod tests { fn builtin_api_key_providers_have_key_urls() { let catalog = fabro_llm::default_catalog(); for provider in [ - provider_ids::anthropic(), - provider_ids::openai(), - provider_ids::gemini(), + builtin::anthropic(), + builtin::openai(), + builtin::gemini(), ProviderId::new("moonshot"), ProviderId::new("zai"), ProviderId::new("minimax"), @@ -408,7 +408,7 @@ mod tests { #[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] async fn validate_api_key_rejects_invalid_key() { let result = validate_api_key( - &provider_ids::anthropic(), + &builtin::anthropic(), "sk-invalid-key-12345", default_catalog_for_provider_auth(), ) diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index 97bf82e42..3efb0947f 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -44,6 +44,7 @@ fabro-proc = { path = "../../foundation/fabro-proc" } fabro-template = { path = "../../foundation/fabro-template" } fabro-tool = { path = "../../components/fabro-tool" } fabro-types = { path = "../../foundation/fabro-types" } +lithos-llm = { workspace = true, features = ["runtime"] } fabro-util = { path = "../../foundation/fabro-util" } fabro-api = { path = "../../foundation/fabro-api" } fabro-client = { path = "../../foundation/fabro-client" } diff --git a/lib/apps/fabro-server/src/demo/mod.rs b/lib/apps/fabro-server/src/demo/mod.rs index f3001e045..da5c0b574 100644 --- a/lib/apps/fabro-server/src/demo/mod.rs +++ b/lib/apps/fabro-server/src/demo/mod.rs @@ -1097,6 +1097,7 @@ mod runs { RunLifecycle, RunLinks, RunOrigin, RunSize, RunTimestamps, StageId, WorkflowRef, WorkflowSettings, }; + use lithos_llm::catalog::{ModelId, ProviderId}; use super::ts; @@ -1115,7 +1116,7 @@ mod runs { .collect() } - fn billing_model(provider: fabro_types::ProviderId, model_id: &str) -> BillingModelRef { + fn billing_model(provider: ProviderId, model_id: &str) -> BillingModelRef { BillingModelRef { provider, model_id: model_id.into(), @@ -1495,8 +1496,8 @@ mod runs { EventBody::AgentMessage(AgentMessageProps { text: "I'll start by loading the environment configurations for both production and staging to compare them.".into(), model: fabro_types::ModelRef::new( - fabro_types::provider_ids::anthropic(), - fabro_types::ModelId::new("claude-opus-4.6"), + lithos_llm::catalog::builtin::anthropic(), + ModelId::new("claude-opus-4.6"), ), billing: BilledTokenCounts::default(), cost_source: None, @@ -1571,8 +1572,8 @@ mod runs { EventBody::AgentMessage(AgentMessageProps { text: "I've detected drift in 3 resources between production and staging:\n\n1. **redis.max_connections** — production has 200, staging has 100\n2. **redis.tls** — enabled in production, disabled in staging\n3. **iam.session_duration** — production uses 3600s, staging uses 1800s".into(), model: fabro_types::ModelRef::new( - fabro_types::provider_ids::anthropic(), - fabro_types::ModelId::new("claude-opus-4.6"), + lithos_llm::catalog::builtin::anthropic(), + ModelId::new("claude-opus-4.6"), ), billing: BilledTokenCounts::default(), cost_source: None, @@ -1595,7 +1596,7 @@ mod runs { name: "Detect Drift".into(), }, model: Some(billing_model( - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), "claude-opus-4-6", )), billing: BilledTokenCounts { @@ -1617,7 +1618,7 @@ mod runs { name: "Propose Changes".into(), }, model: Some(billing_model( - fabro_types::provider_ids::gemini(), + lithos_llm::catalog::builtin::gemini(), "gemini-3.1-pro-preview", )), billing: BilledTokenCounts { @@ -1639,7 +1640,7 @@ mod runs { name: "Review Changes".into(), }, model: Some(billing_model( - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), "gpt-5.3-codex", )), billing: BilledTokenCounts { @@ -1661,7 +1662,7 @@ mod runs { name: "Apply Changes".into(), }, model: Some(billing_model( - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), "claude-opus-4-6", )), billing: BilledTokenCounts { @@ -1700,7 +1701,7 @@ mod runs { total_usd_micros: Some(1_350_000), }, model: billing_model( - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), "claude-opus-4-6", ), stages: 2, @@ -1716,7 +1717,7 @@ mod runs { total_usd_micros: Some(720_000), }, model: billing_model( - fabro_types::provider_ids::gemini(), + lithos_llm::catalog::builtin::gemini(), "gemini-3.1-pro-preview", ), stages: 1, @@ -1731,7 +1732,7 @@ mod runs { total_tokens: 11760, total_usd_micros: Some(190_000), }, - model: billing_model(fabro_types::provider_ids::openai(), "gpt-5.3-codex"), + model: billing_model(lithos_llm::catalog::builtin::openai(), "gpt-5.3-codex"), stages: 1, }, ], @@ -2074,8 +2075,9 @@ mod workflows { mod billing { use fabro_api::types::*; + use lithos_llm::catalog::ProviderId; - fn billing_model(provider: fabro_types::ProviderId, model_id: &str) -> BillingModelRef { + fn billing_model(provider: ProviderId, model_id: &str) -> BillingModelRef { BillingModelRef { provider, model_id: model_id.into(), @@ -2108,7 +2110,7 @@ mod billing { total_usd_micros: Some(12_150_000), }, model: billing_model( - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), "claude-opus-4-6", ), stages: 18, @@ -2124,7 +2126,7 @@ mod billing { total_usd_micros: Some(6_480_000), }, model: billing_model( - fabro_types::provider_ids::gemini(), + lithos_llm::catalog::builtin::gemini(), "gemini-3.1-pro-preview", ), stages: 9, @@ -2139,7 +2141,7 @@ mod billing { total_tokens: 105_840, total_usd_micros: Some(1_710_000), }, - model: billing_model(fabro_types::provider_ids::openai(), "gpt-5.3-codex"), + model: billing_model(lithos_llm::catalog::builtin::openai(), "gpt-5.3-codex"), stages: 9, }, ], diff --git a/lib/apps/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs index c5d325175..56e93e899 100644 --- a/lib/apps/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -4,15 +4,13 @@ use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use fabro_auth::auth_issue_message; use fabro_http::Response; -use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::Client; +use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::probe::{self, ModelTestStatus}; -use fabro_llm::{Client, catalog}; use fabro_redact::redact_string; use fabro_sandbox::{DockerSandboxProvider, daytona}; use fabro_static::EnvVars; -use fabro_types::ProviderId; use fabro_types::settings::ServerAuthMethod; use fabro_types::settings::server::GithubIntegrationStrategy; use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus}; @@ -20,6 +18,7 @@ use fabro_util::dev_token::validate_dev_token_format; use fabro_util::session_secret; use fabro_util::version::FABRO_VERSION; use futures_util::future::join_all; +use lithos_llm::catalog::ProviderId; use serde::Serialize; use tokio::time::error::Elapsed; use tokio::time::timeout; @@ -219,7 +218,7 @@ pub(crate) async fn test_llm_providers(state: &AppState) -> anyhow::Result, ) -> ProviderProbeResult { if let Some(message) = auth_issue { - // `auth_issue_message` already embeds the provider's display name, so the + // The credential error already names the provider, so the // diagnostics detail uses the message as-is rather than re-prefixing. return provider_probe_error(provider, None, message.clone(), Some(message)); } @@ -250,7 +249,10 @@ async fn probe_single_provider( return provider_probe_error(provider, None, message, None); } - let Some(model) = catalog::probe_model(catalog, provider.as_str()) else { + let Some(model) = catalog + .enabled_provider(provider.as_str()) + .and_then(CatalogProvider::probe_offering) + else { return provider_probe_error( provider, None, diff --git a/lib/apps/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs index 63cc592b1..a0f5b38d4 100644 --- a/lib/apps/fabro-server/src/install.rs +++ b/lib/apps/fabro-server/src/install.rs @@ -24,19 +24,19 @@ use fabro_install::{ write_github_app_settings, write_object_store_settings, write_sandbox_settings, write_token_settings, }; -use fabro_llm::catalog as llm_catalog; use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus}; use fabro_sandbox::daytona; use fabro_static::EnvVars; use fabro_store::ArtifactStore; +use fabro_types::ServerSettings; use fabro_types::settings::run::EnvironmentProvider; use fabro_types::settings::server::ObjectStoreSettings; use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label}; -use fabro_types::{ProviderId, ServerSettings}; use fabro_util::version::FABRO_VERSION; use fabro_util::{Home, session_secret}; use fabro_vault::SecretType as VaultSecretType; +use lithos_llm::catalog::ProviderId; use object_store::aws::resolve_bucket_region; use object_store::path::Path as ObjectStorePath; use object_store::{ClientOptions, RetryConfig}; @@ -846,7 +846,8 @@ async fn put_install_llm( } fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogProvider, String> { - let catalog_provider = llm_catalog::provider(&INSTALL_CATALOG, provider.as_str()) + let catalog_provider = INSTALL_CATALOG + .enabled_provider(provider.as_str()) .ok_or_else(|| format!("provider '{provider}' is not configured in the model catalog"))?; if fabro_auth::accepts_api_key(catalog_provider) { Ok(catalog_provider) @@ -2567,7 +2568,7 @@ mod tests { #[test] fn install_provider_base_url_falls_back_to_catalog_base_url() { let state = InstallAppState::for_test("expected"); - let provider = install_catalog_provider(&fabro_types::provider_ids::openai()).unwrap(); + let provider = install_catalog_provider(&lithos_llm::catalog::builtin::openai()).unwrap(); assert_eq!( provider_base_url_override(&state, provider), @@ -2578,10 +2579,10 @@ mod tests { #[test] fn install_provider_base_url_prefers_state_override() { let state = InstallAppState::for_test("expected").with_provider_base_url( - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), "https://proxy.example.com/v1", ); - let provider = install_catalog_provider(&fabro_types::provider_ids::openai()).unwrap(); + let provider = install_catalog_provider(&lithos_llm::catalog::builtin::openai()).unwrap(); assert_eq!( provider_base_url_override(&state, provider), diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs index cfcbe14d5..0d076f9d6 100644 --- a/lib/apps/fabro-server/src/run_compiler.rs +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -37,8 +37,8 @@ use fabro_llm::lithos_catalog::Catalog; use fabro_types::settings::interp::{InterpString, ResolveError}; use fabro_types::settings::run::{McpServerSettings, RunGoal}; use fabro_types::{ - AutomationRef, GitContext, ManifestPath, ProviderId, RunId, RunProvenance, RunTarget, - WorkflowSettings, WorkflowVersionId, + AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, RunTarget, WorkflowSettings, + WorkflowVersionId, }; use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError}; use fabro_workflow::Error as WorkflowError; @@ -47,6 +47,7 @@ use fabro_workflow::operations::{ CreateRunPersistenceMetadata, MaterializedRun, WorkflowInput, }; use fabro_workflow::workflow_bundle::{BundledWorkflow, WorkflowBundle}; +use lithos_llm::catalog::ProviderId; use tokio::task; /// One project settings source in the acquired source's path namespace. @@ -765,7 +766,8 @@ mod tests { } fn test_provider_ids() -> Vec { - fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog()) + fabro_llm::test_support::test_catalog() + .enabled_provider_ids() .into_iter() .collect() } diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 6b94bfc89..37ad41bf9 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -6,7 +6,6 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use fabro_api::types; -use fabro_auth::auth_issue_message; use fabro_config::parse::SettingsSource; use fabro_config::{ CliLayer, CliOutputLayer, EnvironmentLayer, MergeMap, RunLayer, SettingsLayer, @@ -15,9 +14,9 @@ use fabro_config::{ use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; +use fabro_llm::FabroClient; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::probe::{self, ModelTestStatus}; -use fabro_llm::{FabroClient, catalog}; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::from_environment::{ daytona_config_from_environment, docker_config_from_environment, @@ -31,8 +30,7 @@ use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{EnvironmentProvider, McpServerSettings, RunGoal, RunNamespace}; use fabro_types::{ - ManifestPath, ProviderId, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings, - WorkflowSettings, + ManifestPath, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings, WorkflowSettings, }; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; @@ -45,6 +43,7 @@ use fabro_workflow::pipeline::Validated; use fabro_workflow::run_materialization::materialize_run_with_ready_providers; use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle}; use futures_util::stream::{self, StreamExt}; +use lithos_llm::catalog::ProviderId; use tokio::process::Command; use tokio::time; @@ -1112,7 +1111,7 @@ async fn run_llm_check( status: CheckStatus::Warning, summary: model_id.clone(), details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], - remediation: Some(auth_issue_message(&provider_id, issue)), + remediation: Some(issue.to_string()), })); } else if let Some(issue) = registration_issues .iter() @@ -1210,8 +1209,10 @@ async fn run_llm_check( } fn canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId { - catalog::canonical_provider_id(catalog, provider_name) - .unwrap_or_else(|| ProviderId::new(provider_name)) + catalog.enabled_provider(provider_name).map_or_else( + || ProviderId::new(provider_name), + |provider| provider.id().clone(), + ) } async fn run_github_token_check( @@ -1666,8 +1667,8 @@ fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport { #[cfg(test)] mod tests { - use fabro_types::ProviderId; use fabro_workflow::run_materialization::materialize_run; + use lithos_llm::catalog::ProviderId; use super::*; @@ -2016,7 +2017,7 @@ enabled = {clone_enabled} prepared.settings.clone(), validated.graph(), test_catalog().as_ref(), - &[fabro_types::provider_ids::anthropic()], + &[lithos_llm::catalog::builtin::anthropic()], ) .unwrap() .run; diff --git a/lib/apps/fabro-server/src/run_title_generation.rs b/lib/apps/fabro-server/src/run_title_generation.rs index 070f18d09..dde818ea1 100644 --- a/lib/apps/fabro-server/src/run_title_generation.rs +++ b/lib/apps/fabro-server/src/run_title_generation.rs @@ -2,10 +2,11 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use fabro_llm::{Client, Request, structured}; +use fabro_llm::{Client, Request}; use fabro_template::{TemplateContext, TemplateError}; -use fabro_types::{Graph, MAX_RUN_TITLE_CHARS, ProviderId, RunId}; +use fabro_types::{Graph, MAX_RUN_TITLE_CHARS, RunId}; use fabro_util::error; +use lithos_llm::catalog::ProviderId; use serde::Serialize; use toml::Value as TomlValue; @@ -56,13 +57,10 @@ pub(crate) async fn generate_title_or_current(input: GenerateTitleInput<'_>) -> } }; - let completion = match structured::complete_object( - &input.client, - request, - "run_title", - title_response_schema(), - ) - .await + let completion = match input + .client + .complete_object(request, "run_title", title_response_schema()) + .await { Ok(completion) => completion, Err(err) => { @@ -198,7 +196,8 @@ mod tests { use fabro_llm::adapter::{ProviderAdapter, ResolvedCall}; use fabro_llm::lithos_catalog::AdapterId; use fabro_llm::{Error as LlmError, Response, ResponseStream}; - use fabro_types::{RunId, provider_ids}; + use fabro_types::RunId; + use lithos_llm::catalog::builtin; use toml::Value as TomlValue; use super::*; @@ -361,7 +360,7 @@ mod tests { let title = generate_title_or_current(GenerateTitleInput { client, model_id: "gpt-5.4".to_string(), - provider_id: provider_ids::openai(), + provider_id: builtin::openai(), prompt: TitlePromptInput { run_id: &run_id, current_title: "Current", diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 70e22b006..3de5fdb4d 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -48,7 +48,7 @@ pub use fabro_api::types::{ SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts, TimelineEntryResponse, UpdateVariableRequest, VariableListResponse, VncPreviewResponse, WriteBlobResponse, }; -use fabro_auth::{CredentialSource, SqlVaultCredentialSource, auth_issue_message}; +use fabro_auth::SqlVaultCredentialSource; use fabro_automation::{self, AutomationStore}; use fabro_config::daemon::ServerDaemon; use fabro_config::{LlmLayer, RunLayer, Storage, WorkflowSettingsBuilder}; @@ -57,8 +57,9 @@ use fabro_environment::EnvironmentStore; use fabro_interview::{ Answer, AnswerSubmission, ControlInterviewer, Interviewer, Question, WorkerControlEnvelope, }; +use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{ClientOptions, FabroClient, catalog}; +use fabro_llm::{ClientOptions, FabroClient}; use fabro_mcp_store::McpServerStore; use fabro_redact::redact_jsonl_line; use fabro_sandbox::daytona::{self, DaytonaSandbox}; @@ -92,9 +93,8 @@ use fabro_types::settings::server::{ use fabro_types::{ AgentBackend, AskFabro, AskFabroUnavailableReason, BilledTokenCounts, BlobHash, EventBody, InterviewQuestionRecord, ModelRef, ModelTestMode, PairId, PairMessageId, PairTarget, - PendingReason, Principal, ProviderId, PullRequestLink, QuestionType, RunControlAction, - RunEvent, RunId, RunRunnableSource, RunStatusKind, SandboxProviderKind, ServerSettings, - SessionCapability, + PendingReason, Principal, PullRequestLink, QuestionType, RunControlAction, RunEvent, RunId, + RunRunnableSource, RunStatusKind, SandboxProviderKind, ServerSettings, SessionCapability, }; use fabro_util::error::{ SharedError, collect_causes, render_compact_with_causes, render_with_causes, @@ -115,6 +115,7 @@ use fabro_workflow::run_lookup::{ use fabro_workflow::run_status::{FailureReason, RunStatus, SuccessReason}; use fabro_workflow::{Error as WorkflowError, operations, pull_request}; use futures_util::future::join_all; +use lithos_llm::catalog::ProviderId; use sha2::{Digest, Sha256}; use tempfile::NamedTempFile; use tokio::fs; @@ -1126,7 +1127,7 @@ pub struct AppState { parent_link_lock: AsyncMutex<()>, pub(super) server_secrets: ServerSecrets, - pub(crate) llm_source: Arc, + pub(crate) llm_source: Arc, manifest_run_defaults: RwLock>, manifest_run_settings: RwLock>, pub(crate) server_settings: RwLock>, @@ -1401,7 +1402,7 @@ impl AppState { pub(crate) async fn configured_llm_provider_ids(&self) -> Vec { let catalog = self.catalog(); - self.llm_source.configured_providers(catalog.as_ref()).await + fabro_llm::configured_providers(catalog.as_ref(), self.llm_source.as_ref()).await } /// Resolve the LLM client once and derive the ready provider IDs from it, @@ -1445,8 +1446,8 @@ impl AppState { let default_model = if provider_ids.is_empty() { None } else { - let ready = provider_ids.iter().cloned().collect::>(); - catalog::default_for_ready(&self.catalog(), &ready) + self.catalog() + .default_offering_for(&provider_ids) .map(|entry| entry.model.id().to_string()) }; AskFabroReadiness { default_model } @@ -1679,7 +1680,7 @@ impl AppState { /// Builds the server's LLM client: retries and attachment inlining on, the /// server's HTTP client for provider requests when one is configured. async fn resolve_llm_client_from_source( - source: Arc, + source: Arc, catalog: Arc, http_client: Option, ) -> anyhow::Result { @@ -2455,7 +2456,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result = Arc::new(SqlVaultCredentialSource::vault_only( + let llm_source: Arc = Arc::new(SqlVaultCredentialSource::vault_only( Arc::clone(&secret_store), )); let (global_event_tx, _) = broadcast::channel(4096); diff --git a/lib/apps/fabro-server/src/server/handler/completions.rs b/lib/apps/fabro-server/src/server/handler/completions.rs index b043e54fc..eb701d215 100644 --- a/lib/apps/fabro-server/src/server/handler/completions.rs +++ b/lib/apps/fabro-server/src/server/handler/completions.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{ModelSelectionError, Request, selection, structured}; -use fabro_types::{Message, Role}; +use fabro_llm::{ModelSelectionError, Request, selection}; +use lithos_llm::types::{Message, Role}; use super::super::{ ApiError, AppState, CreateCompletionRequest, IntoResponse, Json, ProviderId, RequiredUser, @@ -138,7 +138,10 @@ async fn create_completion( } if let Some(schema) = req.schema { - return match structured::complete_object(&client, request, "output_schema", schema).await { + return match client + .complete_object(request, "output_schema", schema) + .await + { Ok(completion) => { let mut body = match serde_json::to_value(&completion.response) { Ok(body) => body, diff --git a/lib/apps/fabro-server/src/server/handler/models.rs b/lib/apps/fabro-server/src/server/handler/models.rs index cb47cea3f..02c5f92b8 100644 --- a/lib/apps/fabro-server/src/server/handler/models.rs +++ b/lib/apps/fabro-server/src/server/handler/models.rs @@ -4,15 +4,14 @@ use std::time::Duration; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus}; -use fabro_llm::{ModelSelectionError, api, catalog, selection}; +use fabro_llm::{ModelSelectionError, api, selection}; use fabro_redact::redact_string; -use fabro_types::ReasoningEffort; +use lithos_llm::types::ReasoningEffort; use super::super::{ ApiError, AppState, FromStr, IntoResponse, Json, MAX_PAGE_OFFSET, ModelTestMode, Path, ProviderCredentialTestRequest, ProviderCredentialTestResponse, ProviderId, ProviderList, Query, - RequiredUser, Response, Router, State, StatusCode, auth_issue_message, default_page_limit, - error, get, post, + RequiredUser, Response, Router, State, StatusCode, default_page_limit, error, get, post, }; use crate::diagnostics; @@ -60,8 +59,10 @@ async fn list_models( let catalog = state.catalog(); // An unknown provider filter matches nothing rather than erroring. let provider_id = params.provider.as_deref().map(|selector| { - catalog::canonical_provider_id(&catalog, selector) - .unwrap_or_else(|| ProviderId::new(selector)) + catalog.enabled_provider(selector).map_or_else( + || ProviderId::new(selector), + |provider| provider.id().clone(), + ) }); let query = params.query.as_ref().map(|value| value.to_lowercase()); @@ -251,7 +252,7 @@ async fn test_model( .iter() .find(|(provider, _)| provider == &provider_id) { - return ApiError::bad_request(auth_issue_message(&provider_id, issue)).into_response(); + return ApiError::bad_request(issue.to_string()).into_response(); } if !llm_result.has_provider(&provider_id) { return Json(serde_json::json!({ diff --git a/lib/apps/fabro-server/src/server/handler/pair.rs b/lib/apps/fabro-server/src/server/handler/pair.rs index 6047eadef..9d0d473e5 100644 --- a/lib/apps/fabro-server/src/server/handler/pair.rs +++ b/lib/apps/fabro-server/src/server/handler/pair.rs @@ -848,10 +848,11 @@ mod tests { use chrono::{TimeZone, Utc}; use fabro_types::run_event::AgentMessageProps; use fabro_types::{ - BilledTokenCounts, EventEnvelope, Graph, ModelId, ModelRef, PairMessageId, ProviderId, - RunEvent, StageId, WorkflowSettings, fixtures, test_support, + BilledTokenCounts, EventEnvelope, Graph, ModelRef, PairMessageId, RunEvent, StageId, + WorkflowSettings, fixtures, test_support, }; use fabro_workflow::event as workflow_event; + use lithos_llm::catalog::{ModelId, ProviderId}; use tower::ServiceExt; use super::*; diff --git a/lib/apps/fabro-server/src/server/handler/pull_requests.rs b/lib/apps/fabro-server/src/server/handler/pull_requests.rs index 46b088d76..50b473c6b 100644 --- a/lib/apps/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/apps/fabro-server/src/server/handler/pull_requests.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use std::time::Duration; use axum::http::{HeaderValue, header}; -use fabro_llm::catalog; use super::super::{ ApiError, AppState, CloseRunPullRequestResponse, CreateRunPullRequestRequest, IntoResponse, @@ -344,12 +343,8 @@ async fn create_run_pull_request( model } else { let catalog = state.catalog(); - let configured = state - .ready_llm_provider_ids() - .await - .into_iter() - .collect::>(); - match catalog::default_for_ready(&catalog, &configured) { + let configured = state.ready_llm_provider_ids().await; + match catalog.default_offering_for(&configured) { Some(entry) => entry.model.id().to_string(), None => { return ApiError::bad_request("no LLM model is available for PR generation") diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 4b4f28612..2c2171ae2 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -21,7 +21,7 @@ use fabro_api::types::{ use fabro_config::{CliLayer, RunLayer, Storage, project}; use fabro_environment::{DEFAULT_ENVIRONMENT_ID, EnvironmentId}; use fabro_interview::AnswerSubmission; -use fabro_llm::{Client as LlmClient, catalog}; +use fabro_llm::Client as LlmClient; use fabro_manifest::RunOverrideInput; use fabro_static::EnvVars; use fabro_store::{ @@ -40,6 +40,7 @@ use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_ use fabro_workflow::run_status::RunStatus; use fabro_workflow::workflow_bundle::WorkflowBundle; use fabro_workflow::{Error as WorkflowError, operations}; +use lithos_llm::catalog::ProviderId; use strum::VariantArray as _; use tokio::fs; use tracing::info; @@ -953,11 +954,7 @@ async fn finalize_created_run( let workflow = run_title_generation::workflow_summary(&run_spec.graph); let run_inputs = run_spec.settings.run.inputs.clone(); let title_catalog = state.catalog(); - let ready = ready_provider_ids - .iter() - .cloned() - .collect::>(); - if let Some(title_model) = catalog::small_default_for_ready(&title_catalog, &ready) { + if let Some(title_model) = title_catalog.small_default_for(&ready_provider_ids) { spawn_generated_title_task(GeneratedTitleTask { state: Arc::clone(&state), run_id: created.run_id, @@ -1413,7 +1410,7 @@ struct GeneratedTitleTask { run_inputs: std::collections::HashMap, client: LlmClient, model_id: String, - provider_id: fabro_types::ProviderId, + provider_id: ProviderId, } fn spawn_generated_title_task(task: GeneratedTitleTask) { diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 35203846b..5e6b7e676 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -36,11 +36,12 @@ use fabro_types::run_event::{ }; use fabro_types::settings::ModelRef as SettingsModelRef; use fabro_types::{ - AgentProfileKind, EventBody, EventEnvelope, ProviderId, RunEvent, RunId, SessionDetail, - SessionId, ToolDefinition, TurnId, + AgentProfileKind, EventBody, EventEnvelope, RunEvent, RunId, SessionDetail, SessionId, TurnId, }; use fabro_workflow::handler::llm::api::register_named_fabro_run_tools; use fabro_workflow::services::FabroRunToolServices; +use lithos_llm::catalog::ProviderId; +use lithos_llm::types::ToolDefinition; use serde_json::Value; use tokio::sync::broadcast::error::RecvError; use tokio::sync::mpsc; @@ -829,7 +830,7 @@ fn canonical_session_model( ) -> Result<(ProviderId, String), ApiError> { let explicit_provider = explicit_provider .map(|provider| { - catalog::canonical_provider_id(catalog, provider.as_str()).ok_or_else(|| { + enabled_provider_id(catalog, provider.as_str()).ok_or_else(|| { session_selection_error(&ModelSelectionError::UnknownProvider { provider: provider.to_string(), }) @@ -849,7 +850,10 @@ fn canonical_session_model( // An aggregator's wire id (`openai/gpt-5.6-sol` on OpenRouter) is matched // whole on a pinned provider before its prefix is read as a provider. if let Some(explicit) = explicit_provider.as_ref().filter(|p| eligible.contains(*p)) { - if let Some(entry) = catalog::model_on_provider(catalog, explicit.as_str(), requested) { + if let Some(entry) = catalog + .enabled_provider(explicit.as_str()) + .and_then(|provider| provider.offering(requested)) + { return Ok((explicit.clone(), entry.model.id().to_string())); } } @@ -859,7 +863,7 @@ fn canonical_session_model( .qualify(catalog); let (qualified_provider, selector) = match model_ref { SettingsModelRef::Qualified { provider, selector } => { - let provider = catalog::canonical_provider_id(catalog, &provider).ok_or_else(|| { + let provider = enabled_provider_id(catalog, &provider).ok_or_else(|| { session_selection_error(&ModelSelectionError::UnknownProvider { provider }) })?; // When the prefixed provider is not ready, the whole string may @@ -880,8 +884,8 @@ fn canonical_session_model( (Some(provider), selector) } SettingsModelRef::Bare(selector) => { - if explicit_provider.is_none() && catalog::is_provider_selector(catalog, &selector) { - let detail = if catalog::is_model_selector(catalog, &selector) { + if explicit_provider.is_none() && catalog.enabled_provider(&selector).is_some() { + let detail = if catalog.is_model_selector(&selector) { format!( "Session model reference '{selector}' is ambiguous between a provider and \ a model selector; supply `provider` or use `provider:model`." @@ -908,17 +912,25 @@ fn api_model_on_eligible( api_model: &str, eligible: &std::collections::HashSet, ) -> Option<(ProviderId, String)> { - catalog::enabled_providers(catalog) + catalog + .enabled_providers() .into_iter() .filter(|provider| eligible.contains(provider.id())) .find_map(|provider| { - catalog::provider_models(provider) - .into_iter() + provider + .offerings() .find(|model| model.model.api_model() == api_model) .map(|model| (provider.id().clone(), model.model.id().to_string())) }) } +/// The catalog id of an enabled provider named by id or alias. +fn enabled_provider_id(catalog: &Catalog, selector: &str) -> Option { + catalog + .enabled_provider(selector) + .map(|provider| provider.id().clone()) +} + fn session_selection_error(error: &ModelSelectionError) -> ApiError { ApiError::bad_request(error.to_string()) } @@ -1503,7 +1515,8 @@ mod tests { use fabro_agent::config::ToolAccess; use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource}; - use fabro_types::{ToolCall, ToolDefinition, test_support}; + use fabro_types::test_support; + use lithos_llm::types::{ToolCall, ToolDefinition}; use super::*; @@ -1563,7 +1576,7 @@ enabled = true #[test] fn canonical_session_model_uses_readiness_priority_and_explicit_pins() { let catalog = portable_session_catalog(); - let openai = fabro_types::provider_ids::openai(); + let openai = lithos_llm::catalog::builtin::openai(); let openrouter = ProviderId::new("openrouter"); assert_eq!( @@ -1610,7 +1623,7 @@ enabled = true #[test] fn canonical_session_model_preserves_unknown_passthrough_on_selected_provider() { let catalog = portable_session_catalog(); - let openai = fabro_types::provider_ids::openai(); + let openai = lithos_llm::catalog::builtin::openai(); let openrouter = ProviderId::new("openrouter"); let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]); @@ -1630,7 +1643,7 @@ enabled = true #[test] fn canonical_session_model_passes_through_colon_bearing_model_ids() { let catalog = portable_session_catalog(); - let openai = fabro_types::provider_ids::openai(); + let openai = lithos_llm::catalog::builtin::openai(); let openrouter = ProviderId::new("openrouter"); let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]); @@ -1655,7 +1668,7 @@ enabled = true let catalog = portable_session_catalog(); let error = canonical_session_model( &catalog, - &std::collections::HashSet::from([fabro_types::provider_ids::openai()]), + &std::collections::HashSet::from([lithos_llm::catalog::builtin::openai()]), Some("gpt-56-sol"), Some(&ProviderId::new("openrouter")), ) @@ -1667,7 +1680,7 @@ enabled = true #[test] fn canonical_session_model_normalizes_legacy_builtin_selector_before_qualification() { let catalog = portable_session_catalog(); - let openai = fabro_types::provider_ids::openai(); + let openai = lithos_llm::catalog::builtin::openai(); let openrouter = ProviderId::new("openrouter"); let both = std::collections::HashSet::from([openai.clone(), openrouter.clone()]); @@ -1705,7 +1718,7 @@ enabled = true assert_eq!( canonical_session_model( &catalog, - &fabro_llm::catalog::enabled_provider_ids(&catalog), + &catalog.enabled_provider_ids().into_iter().collect(), Some("openrouter:gpt-56-sol"), None, ) @@ -1719,9 +1732,9 @@ enabled = true let catalog = portable_session_catalog(); let error = canonical_session_model( &catalog, - &fabro_llm::catalog::enabled_provider_ids(&catalog), + &catalog.enabled_provider_ids().into_iter().collect(), Some("openrouter:gpt-56-sol"), - Some(&fabro_types::provider_ids::openai()), + Some(&lithos_llm::catalog::builtin::openai()), ) .unwrap_err(); diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 68b2503a7..d51d98414 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -25,17 +25,21 @@ use fabro_types::settings::ServerAuthMethod; use fabro_types::settings::run::{ApprovalMode, EnvironmentProvider}; use fabro_types::{ AgentBackend, AttrValue, AuthMethod, BlobHash, CommandTermination, FailureCategory, - FailureDetail, GitRunTarget, Graph, InterviewQuestionRecord, ModelId, ModelRef, Node, Outcome, - ParallelBranchId, QuestionType, ReasoningEffort, RunId, RunSpec, RunTarget, - SandboxProviderKind, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory, - StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, - StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind, - TokenCounts, WorkflowSettings, fixtures, test_support, + FailureDetail, GitRunTarget, Graph, InterviewQuestionRecord, ModelRef, Node, Outcome, + ParallelBranchId, QuestionType, RunId, RunSpec, RunTarget, SandboxProviderKind, + StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, + StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning, + StageModelUsage, StageTiming, SuccessReason, SystemActorKind, WorkflowSettings, fixtures, + test_support, }; use fabro_util::check_report::CheckStatus; use fabro_workflow::records::CheckpointExt; use httpmock::Method::{GET, POST}; use httpmock::MockServer; +use lithos_llm::catalog::ModelId; +use lithos_llm::types::{ + ReasoningEffort, ReasoningOutput, Request as LlmRequest, Speed, TokenCounts, +}; use serde_json::json; use tokio::sync::Notify; use tokio_stream::StreamExt as _; @@ -1787,7 +1791,7 @@ async fn resolve_llm_client_reads_openai_token_from_vault() { let llm_result = state.resolve_llm_client().await.unwrap(); assert_eq!(llm_result.provider_ids(), vec![ - fabro_types::provider_ids::openai() + lithos_llm::catalog::builtin::openai() ]); assert!(llm_result.auth_issues.is_empty()); } @@ -1813,22 +1817,18 @@ async fn resolve_llm_client_ignores_env_lookup_provider_tokens() { struct FailingCredentialSource; #[async_trait::async_trait] -impl CredentialSource for FailingCredentialSource { +impl CredentialProvider for FailingCredentialSource { async fn credentials( &self, provider: &fabro_llm::lithos_catalog::CatalogProvider, - ) -> Result { - Err(fabro_auth::ResolveError::NotConfigured( - provider.id().clone(), - )) + ) -> Result { + Err(fabro_llm::credentials::CredentialError::NotConfigured { + provider: provider.id().clone(), + }) } - async fn configured_providers( - &self, - catalog: &fabro_llm::lithos_catalog::Catalog, - ) -> Vec { - let _ = catalog; - Vec::new() + async fn is_configured(&self, _provider: &fabro_llm::lithos_catalog::CatalogProvider) -> bool { + false } } @@ -1864,14 +1864,9 @@ async fn llm_source_configured_providers_reads_openai_token_from_vault() { .await .unwrap(); - let catalog = state.catalog(); - assert_eq!( - state - .llm_source - .configured_providers(catalog.as_ref()) - .await, - vec![fabro_types::provider_ids::openai()] - ); + assert_eq!(state.configured_llm_provider_ids().await, vec![ + lithos_llm::catalog::builtin::openai() + ]); } #[tokio::test] @@ -1912,7 +1907,7 @@ async fn resolve_llm_client_uses_vault_key_without_env_lookup_openai_settings() let response = llm_result .client .complete( - fabro_types::Request::builder() + LlmRequest::builder() .model("openai/gpt-5.4") .user("Hello") .build() @@ -6130,7 +6125,7 @@ fn context_window_event( event: fabro_agent::AgentEvent::AssistantMessage { text: "assistant response".to_string(), model: ModelRef::new( - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), ModelId::new("gpt-5.4"), ), usage: TokenCounts::default(), @@ -7210,7 +7205,10 @@ fn test_billed_usage( output_tokens: u64, ) -> fabro_types::BilledModelUsage { let mut usage = fabro_types::BilledModelUsage::new( - ModelRef::new(fabro_types::provider_ids::openai(), ModelId::new(model_id)), + ModelRef::new( + lithos_llm::catalog::builtin::openai(), + ModelId::new(model_id), + ), TokenCounts { input: input_tokens, output: output_tokens, @@ -9331,16 +9329,17 @@ async fn list_providers_marks_configured_per_provider_and_omits_secrets() { // `model_count` and `default_model` must reflect the catalog truth for // 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"), - ) - .len(); + let anthropic_provider = catalog + .enabled_provider("anthropic") + .expect("anthropic should be listed"); + let expected_model_count = anthropic_provider.offerings().len(); assert_eq!( anthropic["model_count"].as_u64(), Some(expected_model_count as u64), "anthropic model_count should match the catalog" ); - let expected_default = fabro_llm::catalog::default_model(&catalog, "anthropic") + let expected_default = anthropic_provider + .default_offering() .expect("anthropic should have a catalog default model"); assert_eq!( anthropic["default_model"].as_str(), @@ -11396,12 +11395,13 @@ async fn pull_request_creation_returns_the_active_durable_request() { .await .into_iter() .collect::>(); - let expected_default_model = - fabro_llm::catalog::default_for_ready(&state.catalog(), &configured_provider_ids) - .expect("a ready provider should have a default model") - .model - .id() - .to_string(); + let expected_default_model = state + .catalog() + .default_offering_for(&configured_provider_ids) + .expect("a ready provider should have a default model") + .model + .id() + .to_string(); let request_body = json!({ "force": false, "model": null @@ -16775,7 +16775,7 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { agg.total_runs = 1; agg.by_model.insert( ModelRef::new( - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), ModelId::new("claude-opus-4-6"), ), ModelBillingTotals { @@ -16793,7 +16793,7 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { ); agg.by_model.insert( ModelRef::new( - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), ModelId::new("claude-opus-4-6"), ) .with_speed(Some(Speed::Fast)), @@ -16853,7 +16853,10 @@ async fn get_aggregate_billing_saturates_total_cost_across_models() { .expect("aggregate billing lock"); for (model_id, total_usd_micros) in [("maximum", i64::MAX), ("one", 1)] { agg.by_model.insert( - ModelRef::new(fabro_types::provider_ids::openai(), ModelId::new(model_id)), + ModelRef::new( + lithos_llm::catalog::builtin::openai(), + ModelId::new(model_id), + ), ModelBillingTotals { stages: 1, billing: BilledTokenCounts { @@ -16898,7 +16901,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { by_model: vec![ fabro_workflow::ProjectionBillingByModel { model: ModelRef::new( - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), ModelId::new("gpt-5.4"), ), stages: 1, @@ -16914,7 +16917,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { }, fabro_workflow::ProjectionBillingByModel { model: ModelRef::new( - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), ModelId::new("gpt-5.4"), ) .with_speed(Some(Speed::Fast)), @@ -16940,21 +16943,25 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { assert_eq!(accumulator.total_timing.wall_time_ms, 2000); assert_eq!(accumulator.by_model.len(), 2); assert_eq!( - accumulator.by_model - [&ModelRef::new(fabro_types::provider_ids::openai(), ModelId::new("gpt-5.4"))] + accumulator.by_model[&ModelRef::new( + lithos_llm::catalog::builtin::openai(), + ModelId::new("gpt-5.4") + )] .stages, 1 ); assert_eq!( - accumulator.by_model - [&ModelRef::new(fabro_types::provider_ids::openai(), ModelId::new("gpt-5.4"))] + accumulator.by_model[&ModelRef::new( + lithos_llm::catalog::builtin::openai(), + ModelId::new("gpt-5.4") + )] .billing .input_tokens, 100 ); assert_eq!( accumulator.by_model[&ModelRef::new( - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), ModelId::new("gpt-5.4") ) .with_speed(Some(Speed::Fast))] @@ -16963,7 +16970,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { ); assert_eq!( accumulator.by_model[&ModelRef::new( - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), ModelId::new("gpt-5.4") ) .with_speed(Some(Speed::Fast))] @@ -18307,14 +18314,14 @@ async fn attach_stream_replays_agent_message_reasoning() { event: fabro_agent::AgentEvent::AssistantMessage { text: String::new(), model: ModelRef::new( - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), ModelId::new("gpt-5.4"), ), usage: TokenCounts::default(), cost: None, tool_call_count: 1, context_window: None, - reasoning: Some(fabro_types::ReasoningOutput::new( + reasoning: Some(ReasoningOutput::new( "inspect the sink first", "read events.rs, then attach", )), diff --git a/lib/apps/fabro-server/src/test_support.rs b/lib/apps/fabro-server/src/test_support.rs index 3b78dc2ef..72339339c 100644 --- a/lib/apps/fabro-server/src/test_support.rs +++ b/lib/apps/fabro-server/src/test_support.rs @@ -18,16 +18,16 @@ use fabro_config::user::default_storage_dir; use fabro_config::{LlmLayer, RunLayer, ServerSettingsBuilder, Storage, envfile}; use fabro_db::DbPool; use fabro_interview::Interviewer; -use fabro_llm::catalog; use fabro_llm::lithos_catalog::Catalog; use fabro_sandbox::SandboxProviderRegistry; use fabro_static::EnvVars; use fabro_store::{ArtifactStore, Database, test_support as store_test_support}; use fabro_types::settings::ServerAuthMethod; use fabro_types::settings::run::EnvironmentProvider; -use fabro_types::{AuthMethod, IdpIdentity, ProviderId, ServerSettings}; +use fabro_types::{AuthMethod, IdpIdentity, ServerSettings}; use fabro_vault::{SecretType, Vault}; use fabro_workflow::handler::HandlerRegistry; +use lithos_llm::catalog::ProviderId; use object_store::memory::InMemory as MemoryObjectStore; use tokio::runtime::Builder as TokioRuntimeBuilder; use tokio_util::sync::CancellationToken; @@ -67,7 +67,7 @@ pub(crate) fn test_run_materialization_provider_ids( let assume_ready = process_env_var(FABRO_TEST_ASSUME_LLM_READY) .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "no")); if assume_ready { - catalog::enabled_provider_ids(catalog).into_iter().collect() + catalog.enabled_provider_ids().into_iter().collect() } else { ready_provider_ids.to_vec() } diff --git a/lib/apps/fabro-server/tests/it/api/install.rs b/lib/apps/fabro-server/tests/it/api/install.rs index 75fa128be..9efb4a9d0 100644 --- a/lib/apps/fabro-server/tests/it/api/install.rs +++ b/lib/apps/fabro-server/tests/it/api/install.rs @@ -17,11 +17,11 @@ use fabro_server::install::{ InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router, }; use fabro_server::test_support::test_environment_from_storage_dir; -use fabro_types::ProviderId; use fabro_util::Home; use fabro_vault::Vault; use httpmock::Method::GET; use httpmock::MockServer; +use lithos_llm::catalog::ProviderId; use tokio::time::sleep; use tower::ServiceExt; use tracing::field::{Field, Visit}; @@ -1416,7 +1416,7 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() { let app = build_install_router( InstallAppState::for_test("test-install-token") .with_provider_base_url( - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), format!("{}/v1", llm_mock.url("")), ) .with_github_api_base_url(github_mock.url("")), diff --git a/lib/apps/fabro-server/tests/it/api/runs.rs b/lib/apps/fabro-server/tests/it/api/runs.rs index c3167a7a7..05f887387 100644 --- a/lib/apps/fabro-server/tests/it/api/runs.rs +++ b/lib/apps/fabro-server/tests/it/api/runs.rs @@ -1,5 +1,6 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; +use fabro_llm::lithos_catalog::CatalogProvider; use fabro_types::settings::run::EnvironmentProvider; use tower::ServiceExt; @@ -161,7 +162,9 @@ _version = 1 "sandbox_not_ready" ); let catalog = fabro_llm::test_support::test_catalog(); - let default_openai_model = fabro_llm::catalog::default_model(&catalog, "openai") + let default_openai_model = catalog + .enabled_provider("openai") + .and_then(CatalogProvider::default_offering) .expect("the built-in OpenAI provider should have a default model"); assert_eq!( created["ask_fabro"]["default_model"].as_str(), diff --git a/lib/apps/fabro-server/tests/it/scenario/run_completion.rs b/lib/apps/fabro-server/tests/it/scenario/run_completion.rs index 3f2073886..6ceccb1ea 100644 --- a/lib/apps/fabro-server/tests/it/scenario/run_completion.rs +++ b/lib/apps/fabro-server/tests/it/scenario/run_completion.rs @@ -38,7 +38,7 @@ fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String) )); let source_api_key = api_key.clone(); let env_api_key = api_key.clone(); - let llm_source: Arc = + let llm_source: Arc = test_support::env_credential_source(move |name| match name { "OPENAI_API_KEY" => Some(source_api_key.clone()), _ => None, @@ -57,7 +57,7 @@ fn test_app_with_openai_agent_backend(openai_base_url: String, api_key: String) Some(Box::new( fabro_workflow::handler::llm::AgentApiBackend::new_with_catalog( OPENAI_AGENT_MODEL.to_string(), - fabro_types::provider_ids::openai(), + lithos_llm::catalog::builtin::openai(), fabro_workflow::model_fallback::ModelFallbackPolicy::default(), Arc::clone(&llm_source), Arc::clone(&steering_hub), diff --git a/lib/components/fabro-agent/Cargo.toml b/lib/components/fabro-agent/Cargo.toml index 6ecc8ca81..a61a700d5 100644 --- a/lib/components/fabro-agent/Cargo.toml +++ b/lib/components/fabro-agent/Cargo.toml @@ -27,6 +27,7 @@ anyhow.workspace = true fabro-auth = { path = "../../foundation/fabro-auth" } fabro-config = { path = "../../foundation/fabro-config", features = ["clap"] } fabro-types = { path = "../../foundation/fabro-types", features = ["clap"] } +lithos-llm = { workspace = true, features = ["runtime"] } fabro-llm = { path = "../fabro-llm" } fabro-mcp = { path = "../fabro-mcp" } fabro-sandbox = { path = "../fabro-sandbox" } diff --git a/lib/components/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs index a8021f886..faf560e9c 100644 --- a/lib/components/fabro-agent/src/agent_profile.rs +++ b/lib/components/fabro-agent/src/agent_profile.rs @@ -1,8 +1,10 @@ use std::sync::Arc; -use fabro_llm::catalog::{self, ModelEntry}; -use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{AgentProfileKind, ProviderId, ToolDefinition}; +use fabro_llm::catalog; +use fabro_llm::lithos_catalog::{Catalog, Offering}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::ProviderId; +use lithos_llm::types::ToolDefinition; use crate::profiles::EnvContext; use crate::sandbox::Sandbox; @@ -44,9 +46,10 @@ pub trait AgentProfile: Send + Sync { } /// The catalog row for this profile's route, when the catalog knows it. - fn catalog_model(&self) -> Option> { - let catalog = self.catalog()?; - catalog::model_on_provider(catalog, self.provider_id().as_str(), self.model()) + fn catalog_model(&self) -> Option> { + self.catalog()? + .enabled_provider(self.provider_id().as_str())? + .offering(self.model()) } fn context_window_size(&self) -> usize { @@ -65,7 +68,7 @@ pub trait AgentProfile: Send + Sync { fn reasons_by_default(&self) -> bool { self.catalog_model() - .is_some_and(|entry| entry.reasons_by_default()) + .is_some_and(|entry| catalog::reasons_by_default(&entry)) } fn register_subagent_tools( @@ -90,7 +93,8 @@ pub trait AgentProfile: Send + Sync { #[cfg(test)] mod tests { - use fabro_types::{AgentProfileKind, provider_ids}; + use fabro_types::AgentProfileKind; + use lithos_llm::catalog::builtin; use super::*; use crate::test_support::{MockSandbox, TestProfile}; @@ -99,7 +103,7 @@ mod tests { fn profile_provider_and_model() { let profile = TestProfile::new(); assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic); - assert_eq!(profile.provider_id(), provider_ids::anthropic()); + assert_eq!(profile.provider_id(), builtin::anthropic()); assert_eq!(profile.model(), "mock-model"); } diff --git a/lib/components/fabro-agent/src/apply_patch.rs b/lib/components/fabro-agent/src/apply_patch.rs index b237e4a06..cac09a884 100644 --- a/lib/components/fabro-agent/src/apply_patch.rs +++ b/lib/components/fabro-agent/src/apply_patch.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use std::sync::Arc; -use fabro_types::ToolDefinition; +use lithos_llm::types::ToolDefinition; use crate::sandbox::Sandbox; use crate::tool_registry::{RegisteredTool, ToolSource}; @@ -502,7 +502,8 @@ pub fn make_apply_patch_tool() -> RegisteredTool { mod tests { use std::collections::HashMap; - use fabro_types::{ContentPart, ToolCall, tool_result_to_json}; + use fabro_types::tool_result_to_json; + use lithos_llm::types::{ContentPart, ToolCall}; use tokio::fs; use tokio_util::sync::CancellationToken; diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index 84f257831..2d9699090 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -9,17 +9,19 @@ use std::sync::{Arc, Mutex}; use anyhow::Context as _; use clap::{Args, Parser}; -use fabro_auth::{CredentialSource, SqlVaultCredentialSource}; +use fabro_auth::SqlVaultCredentialSource; use fabro_config::Storage; use fabro_config::user::default_storage_dir; -use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::credentials::CredentialProvider; +use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::middleware::{Call, Middleware, Next, Output}; use fabro_llm::{Client, ClientOptions, Error as LlmError, catalog}; use fabro_mcp::config::McpServerSettings; use fabro_static::EnvVars; -use fabro_types::{AgentProfileKind, ModelHandle, ModelId, ProviderId}; +use fabro_types::AgentProfileKind; use fabro_util::terminal::Styles; use fabro_vault::SecretStore; +use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId}; use tokio::io::{AsyncWriteExt, stdout}; use tokio::signal; @@ -192,14 +194,18 @@ fn summarizer_model_id( catalog: &Catalog, selected_model: &str, ) -> ModelHandle { - let model = - catalog::small_default_for_ready(catalog, &std::iter::once(provider_id.clone()).collect()) - .filter(|entry| entry.provider.id() == provider_id) - .or_else(|| catalog::default_model(catalog, provider_id.as_str())) - .map_or_else( - || selected_model.to_string(), - |entry| entry.model.id().to_string(), - ); + let model = catalog + .small_default_for([provider_id]) + .filter(|entry| entry.provider.id() == provider_id) + .or_else(|| { + catalog + .enabled_provider(provider_id.as_str())? + .default_offering() + }) + .map_or_else( + || selected_model.to_string(), + |entry| entry.model.id().to_string(), + ); ModelHandle::new(provider_id.clone(), ModelId::new(model)) } @@ -226,12 +232,12 @@ fn resolve_provider_id( ) -> ProviderId { if args.provider.is_some() { let requested = parse_provider(args); - return catalog::canonical_provider_id(catalog, requested.as_str()).unwrap_or(requested); + return canonical_provider_id(catalog, &requested); } if let Some(model_id) = args.model.as_deref() { // A bare model selector picks the highest-priority eligible provider // offering it, matching how the client resolves the request. - let matches = catalog::models_matching(catalog, model_id); + let matches = catalog.offerings_matching(model_id); if let Some(entry) = matches .iter() .find(|entry| eligible_providers.contains(entry.provider.id())) @@ -241,10 +247,18 @@ fn resolve_provider_id( } } let requested = parse_provider(args); - catalog::canonical_provider_id(catalog, requested.as_str()).unwrap_or(requested) + canonical_provider_id(catalog, &requested) } -async fn standalone_llm_source() -> anyhow::Result> { +/// The catalog id for `requested`, resolving aliases; the request itself when +/// the catalog does not know it, so the error names what the caller typed. +fn canonical_provider_id(catalog: &Catalog, requested: &ProviderId) -> ProviderId { + catalog + .enabled_provider(requested.as_str()) + .map_or_else(|| requested.clone(), |provider| provider.id().clone()) +} + +async fn standalone_llm_source() -> anyhow::Result> { let storage = Storage::new(default_storage_dir()); let store = SecretStore::open(storage.sqlite_path(), storage.secrets_path()) .await @@ -455,7 +469,7 @@ pub async fn run_with_args( )] pub async fn run_with_args_and_source_and_catalog( args: AgentArgs, - llm_source: Arc, + llm_source: Arc, mcp_servers: Vec, catalog: Arc, ) -> anyhow::Result<()> { @@ -527,7 +541,9 @@ async fn run_with_args_and_client_and_catalog_styled( let model = if let Some(model) = args.model.clone() { model } else { - catalog::default_model(&catalog, provider_id.as_str()) + catalog + .enabled_provider(provider_id.as_str()) + .and_then(CatalogProvider::default_offering) .map(|entry| entry.model.id().to_string()) .ok_or_else(|| { anyhow::anyhow!( @@ -807,7 +823,7 @@ mod tests { use fabro_llm::test_support::{ client_with_adapters, test_catalog as fabro_test_catalog, test_catalog_with_overlay, }; - use fabro_types::provider_ids; + use lithos_llm::catalog::builtin; use serde_json::json; use super::*; @@ -942,6 +958,10 @@ mod tests { assert!(approval_fn("shell", &json!({})).is_ok()); } + fn enabled_ids(catalog: &Catalog) -> std::collections::HashSet { + catalog.enabled_provider_ids().into_iter().collect() + } + fn test_catalog() -> Arc { Arc::new(fabro_test_catalog()) } @@ -1008,7 +1028,7 @@ profile = "openai" #[test] fn ensure_provider_registered_reports_missing_credentials() { let client = client_with_adapters(Vec::new(), ClientOptions::default()); - let error = ensure_provider_registered(&client, &provider_ids::anthropic()).unwrap_err(); + let error = ensure_provider_registered(&client, &builtin::anthropic()).unwrap_err(); assert_eq!( error.to_string(), "LLM credentials not configured for provider 'anthropic'" @@ -1034,7 +1054,7 @@ profile = "openai" let args = args_with(None, Some("acme-aws-claude")); assert_eq!( - resolve_provider_id(&catalog, &args, &catalog::enabled_provider_ids(&catalog)), + resolve_provider_id(&catalog, &args, &enabled_ids(&catalog)), ProviderId::new("acme-aws") ); } @@ -1045,7 +1065,7 @@ profile = "openai" let args = args_with(Some("br"), None); assert_eq!( - resolve_provider_id(&catalog, &args, &catalog::enabled_provider_ids(&catalog)), + resolve_provider_id(&catalog, &args, &enabled_ids(&catalog)), ProviderId::new("acme-aws") ); } @@ -1079,9 +1099,9 @@ profile = "openai" #[test] fn summarizer_model_id_prefers_the_provider_small_default() { let catalog = test_catalog(); - let model_id = summarizer_model_id(&provider_ids::openai(), &catalog, "gpt-5.4"); + let model_id = summarizer_model_id(&builtin::openai(), &catalog, "gpt-5.4"); - assert_eq!(model_id.provider(), &provider_ids::openai()); + assert_eq!(model_id.provider(), &builtin::openai()); assert_eq!(model_id.model().as_str(), "gpt-5.4-mini"); } @@ -1091,7 +1111,7 @@ profile = "openai" fn build_profile_can_register_subagent_tools() { let mut profile = AgentProfileBuilder::new( AgentProfileKind::Anthropic, - provider_ids::anthropic(), + builtin::anthropic(), "model", test_catalog(), ) diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index 168c908fe..7112e4566 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -365,10 +365,11 @@ mod tests { use std::sync::Arc; use std::time::SystemTime; - use fabro_llm::catalog::model_on_provider; - use fabro_llm::lithos_catalog::Catalog; + use fabro_llm::catalog; + use fabro_llm::lithos_catalog::{Catalog, Offering}; use fabro_llm::test_support::test_catalog; - use fabro_types::{TokenCounts, ToolCall, tool_result_from_json}; + use fabro_types::tool_result_from_json; + use lithos_llm::types::{TokenCounts, ToolCall}; use super::*; use crate::event::Emitter; @@ -381,6 +382,14 @@ mod tests { test_catalog() } + fn model_on_provider<'a>( + catalog: &'a Catalog, + provider: &str, + id: &str, + ) -> Option> { + catalog.enabled_provider(provider)?.offering(id) + } + fn builtin_summary_max_tokens(catalog: &Catalog, provider: &str, id: &str) -> u32 { let entry = model_on_provider(catalog, provider, id) .unwrap_or_else(|| panic!("{provider}/{id} missing from the catalog")); @@ -388,7 +397,7 @@ mod tests { .model .limits() .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)); - summary_max_tokens(entry.reasons_by_default(), max_output) + summary_max_tokens(catalog::reasons_by_default(&entry), max_output) } #[test] diff --git a/lib/components/fabro-agent/src/config.rs b/lib/components/fabro-agent/src/config.rs index b23c9b89c..f79362691 100644 --- a/lib/components/fabro-agent/src/config.rs +++ b/lib/components/fabro-agent/src/config.rs @@ -5,7 +5,8 @@ use std::time::Duration; use fabro_llm::RetryPolicy; use fabro_llm::client::default_retry_policy; use fabro_mcp::config::McpServerSettings; -use fabro_types::{AgentProfileKind, PermissionLevel, ReasoningEffort, Speed}; +use fabro_types::{AgentProfileKind, PermissionLevel}; +use lithos_llm::types::{ReasoningEffort, Speed}; /// Callback invoked before each tool execution. Return `Ok(())` to allow, /// `Err(message)` to deny with the given message. diff --git a/lib/components/fabro-agent/src/context_window.rs b/lib/components/fabro-agent/src/context_window.rs index 1d7964ee5..9900c3416 100644 --- a/lib/components/fabro-agent/src/context_window.rs +++ b/lib/components/fabro-agent/src/context_window.rs @@ -4,10 +4,10 @@ use chrono::Utc; use fabro_llm::Request; use fabro_llm::estimate::{self, EstimateWarning, TokenEstimate}; use fabro_types::{ - Role, StageContextWindowBreakdownItem, StageContextWindowCategory, - StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, - StageContextWindowWarning, TokenCounts, text_of, + StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, + StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning, text_of, }; +use lithos_llm::types::{Role, TokenCounts}; use crate::memory::MemoryDocument; use crate::native_tool::ToolVocabulary; @@ -372,7 +372,7 @@ fn usage_percent(tokens: u64, denominator: u64) -> f64 { #[cfg(test)] mod tests { - use fabro_types::{Message as LlmMessage, ToolChoice, ToolDefinition}; + use lithos_llm::types::{Message as LlmMessage, ToolChoice, ToolDefinition}; use super::*; use crate::tool_registry::ToolDefinitionWithSource; diff --git a/lib/components/fabro-agent/src/error.rs b/lib/components/fabro-agent/src/error.rs index b3fb48592..fc25ca015 100644 --- a/lib/components/fabro-agent/src/error.rs +++ b/lib/components/fabro-agent/src/error.rs @@ -1,4 +1,4 @@ -use fabro_llm::LlmError; +use fabro_llm::ErrorData; /// Why a session was interrupted. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -21,7 +21,7 @@ impl std::fmt::Display for InterruptReason { #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum CompactionError { #[error("summary request failed: {0}")] - Llm(#[source] LlmError), + Llm(#[source] Box), #[error( "generated summary was empty after trimming; refused to replace \ @@ -34,9 +34,10 @@ pub enum CompactionError { #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum Error { /// A provider call failed. Carries lithos's stored error projection so - /// the failure stays cloneable and serializable. + /// the failure stays cloneable and serializable. Boxed because the + /// projection is large and every other variant is small. #[error("LLM error: {0}")] - Llm(#[from] LlmError), + Llm(Box), #[error("Context compaction failed: {0}")] Compaction(#[from] CompactionError), @@ -54,15 +55,27 @@ pub enum Error { Interrupted(InterruptReason), } +impl From for Error { + fn from(error: ErrorData) -> Self { + Self::Llm(Box::new(error)) + } +} + impl From for Error { fn from(error: fabro_llm::Error) -> Self { - Self::Llm(LlmError::from(error)) + Self::from(ErrorData::from(error)) + } +} + +impl From for CompactionError { + fn from(error: ErrorData) -> Self { + Self::Llm(Box::new(error)) } } impl From for CompactionError { fn from(error: fabro_llm::Error) -> Self { - Self::Llm(LlmError::from(error)) + Self::from(ErrorData::from(error)) } } @@ -72,14 +85,14 @@ pub type Result = std::result::Result; mod tests { use std::time::Duration; - use fabro_llm::{ErrorFacts, ErrorKind, RetryClassification}; - use fabro_types::provider_ids; + use fabro_llm::{ErrorKind, RetryClassification}; use fabro_util::error; + use lithos_llm::catalog::builtin; use super::*; - fn network_error(message: &str) -> LlmError { - LlmError::from( + fn network_error(message: &str) -> ErrorData { + ErrorData::from( fabro_llm::Error::new(ErrorKind::Network, message) .with_retry(RetryClassification::Safe), ) @@ -95,7 +108,7 @@ mod tests { #[test] fn compaction_error_preserves_llm_source_chain() { - let err = Error::Compaction(CompactionError::Llm(network_error("connection refused"))); + let err = Error::Compaction(CompactionError::from(network_error("connection refused"))); let chain = error::collect_chain(&err); @@ -157,7 +170,7 @@ mod tests { #[test] fn serde_roundtrip_llm_network() { - let err = Error::Llm(network_error("connection refused")); + let err = Error::from(network_error("connection refused")); let json = serde_json::to_string(&err).unwrap(); let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); @@ -165,9 +178,9 @@ mod tests { #[test] fn serde_roundtrip_llm_provider() { - let err = Error::Llm(LlmError::from( + let err = Error::from(ErrorData::from( fabro_llm::Error::new(ErrorKind::RateLimit, "too fast") - .with_provider(provider_ids::openai()) + .with_provider(builtin::openai()) .with_status(429) .with_retry(RetryClassification::after(Duration::from_secs(2))), )); @@ -229,7 +242,7 @@ mod tests { #[test] fn clone_all_variants() { let errors: Vec = vec![ - Error::Llm(network_error("refused")), + Error::from(network_error("refused")), Error::Compaction(CompactionError::EmptySummary { summarized_turn_count: 3, }), @@ -247,7 +260,7 @@ mod tests { #[test] fn serde_tag_format_llm() { - let err = Error::Llm(network_error("refused")); + let err = Error::from(network_error("refused")); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["type"], "llm"); diff --git a/lib/components/fabro-agent/src/file_tracker.rs b/lib/components/fabro-agent/src/file_tracker.rs index f9982a513..4b3a49559 100644 --- a/lib/components/fabro-agent/src/file_tracker.rs +++ b/lib/components/fabro-agent/src/file_tracker.rs @@ -1,7 +1,8 @@ use std::collections::BTreeMap; use std::fmt::Write; -use fabro_types::{ToolCall, ToolResult, tool_call_arguments, tool_result_to_json}; +use fabro_types::{tool_call_arguments, tool_result_to_json}; +use lithos_llm::types::{ToolCall, ToolResult}; use crate::native_tool::NativeTool; use crate::tool_permissions::canonical_tool_name; diff --git a/lib/components/fabro-agent/src/history.rs b/lib/components/fabro-agent/src/history.rs index c898d0dce..21d5ec040 100644 --- a/lib/components/fabro-agent/src/history.rs +++ b/lib/components/fabro-agent/src/history.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; -use fabro_llm::reasoning; -use fabro_types::{Message as LlmMessage, SessionMessage, TokenCounts}; +use fabro_types::SessionMessage; +use lithos_llm::types::{Message as LlmMessage, TokenCounts}; use crate::types::Message; @@ -87,7 +87,7 @@ impl History { fn strip_opaque_provider_items(&mut self) { for turn in &mut self.turns { if let Message::Assistant { provider_parts, .. } = turn { - provider_parts.retain(|p| !reasoning::is_opaque_openai(p)); + provider_parts.retain(|p| !p.is_opaque_openai()); } } } @@ -164,10 +164,9 @@ fn add_tool_result_call_ids<'a>(turns: &'a [Message], call_ids: &mut HashSet<&'a mod tests { use std::time::SystemTime; - use fabro_llm::reasoning::OPENAI_REASONING_KIND; - use fabro_types::{ - ContentPart, ReasoningContent, Role, TokenCounts, ToolCall, text_of, tool_result_from_json, - }; + use fabro_llm::types::OPENAI_REASONING_KIND; + use fabro_types::{text_of, tool_result_from_json}; + use lithos_llm::types::{ContentPart, ReasoningContent, Role, TokenCounts, ToolCall}; use super::*; diff --git a/lib/components/fabro-agent/src/loop_detection.rs b/lib/components/fabro-agent/src/loop_detection.rs index 0b313e731..b2282df36 100644 --- a/lib/components/fabro-agent/src/loop_detection.rs +++ b/lib/components/fabro-agent/src/loop_detection.rs @@ -99,7 +99,7 @@ fn is_repeating_pattern(signatures: &[u64], pattern_len: usize) -> bool { mod tests { use std::time::SystemTime; - use fabro_types::{TokenCounts, ToolCall}; + use lithos_llm::types::{TokenCounts, ToolCall}; use super::*; diff --git a/lib/components/fabro-agent/src/mcp_integration.rs b/lib/components/fabro-agent/src/mcp_integration.rs index afd62ec2b..b4c247dc9 100644 --- a/lib/components/fabro-agent/src/mcp_integration.rs +++ b/lib/components/fabro-agent/src/mcp_integration.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use fabro_mcp::connection_manager::{McpConnectionManager, call_result_to_string}; -use fabro_types::ToolDefinition; +use lithos_llm::types::ToolDefinition; use crate::tool_registry::{RegisteredTool, ToolSource}; diff --git a/lib/components/fabro-agent/src/profiles/anthropic.rs b/lib/components/fabro-agent/src/profiles/anthropic.rs index dfcd73340..67bf1dfc5 100644 --- a/lib/components/fabro-agent/src/profiles/anthropic.rs +++ b/lib/components/fabro-agent/src/profiles/anthropic.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{AgentProfileKind, ProviderId, provider_ids}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::{ProviderId, builtin}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -48,7 +49,7 @@ impl AnthropicProfile { Self { base: BaseProfile { profile_kind: AgentProfileKind::Anthropic, - provider_id: provider_ids::anthropic(), + provider_id: builtin::anthropic(), model: model.into(), catalog: None, registry, @@ -116,7 +117,7 @@ mod tests { fn anthropic_profile_identity() { let profile = AnthropicProfile::new("claude-sonnet-4-20250514"); assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic); - assert_eq!(profile.provider_id(), provider_ids::anthropic()); + assert_eq!(profile.provider_id(), builtin::anthropic()); assert_eq!(profile.model(), "claude-sonnet-4-20250514"); } diff --git a/lib/components/fabro-agent/src/profiles/claude5.rs b/lib/components/fabro-agent/src/profiles/claude5.rs index 66ad7a841..c48f0e752 100644 --- a/lib/components/fabro-agent/src/profiles/claude5.rs +++ b/lib/components/fabro-agent/src/profiles/claude5.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{AgentProfileKind, ProviderId, provider_ids}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::{ProviderId, builtin}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -65,7 +66,7 @@ impl Claude5Profile { Self { base: BaseProfile { profile_kind: AgentProfileKind::Claude5, - provider_id: provider_ids::anthropic(), + provider_id: builtin::anthropic(), model: model.into(), catalog: None, registry, @@ -166,7 +167,7 @@ mod tests { fn profile_identity() { let profile = Claude5Profile::new("claude-fable-5"); assert_eq!(profile.profile_kind(), AgentProfileKind::Claude5); - assert_eq!(profile.provider_id(), provider_ids::anthropic()); + assert_eq!(profile.provider_id(), builtin::anthropic()); assert_eq!(profile.model(), "claude-fable-5"); } diff --git a/lib/components/fabro-agent/src/profiles/claude5_tools.rs b/lib/components/fabro-agent/src/profiles/claude5_tools.rs index 069dad511..339d4c13d 100644 --- a/lib/components/fabro-agent/src/profiles/claude5_tools.rs +++ b/lib/components/fabro-agent/src/profiles/claude5_tools.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use std::time::Duration; -use fabro_types::{ToolDefinition, ToolDefinitionKind}; use fabro_util::error as util_error; +use lithos_llm::types::{ToolDefinition, ToolDefinitionKind}; use serde_json::Value; use tokio::time; diff --git a/lib/components/fabro-agent/src/profiles/gemini.rs b/lib/components/fabro-agent/src/profiles/gemini.rs index d4e812b78..03755ffdc 100644 --- a/lib/components/fabro-agent/src/profiles/gemini.rs +++ b/lib/components/fabro-agent/src/profiles/gemini.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{AgentProfileKind, ProviderId, provider_ids}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::{ProviderId, builtin}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -42,7 +43,7 @@ impl GeminiProfile { Self { base: BaseProfile { profile_kind: AgentProfileKind::Gemini, - provider_id: provider_ids::gemini(), + provider_id: builtin::gemini(), model: model.into(), catalog: None, registry, @@ -108,7 +109,7 @@ mod tests { fn gemini_profile_identity() { let profile = GeminiProfile::new("gemini-2.0-flash"); assert_eq!(profile.profile_kind(), AgentProfileKind::Gemini); - assert_eq!(profile.provider_id(), provider_ids::gemini()); + assert_eq!(profile.provider_id(), builtin::gemini()); assert_eq!(profile.model(), "gemini-2.0-flash"); } diff --git a/lib/components/fabro-agent/src/profiles/gpt56.rs b/lib/components/fabro-agent/src/profiles/gpt56.rs index 2bb2dd1c4..a7844b2bc 100644 --- a/lib/components/fabro-agent/src/profiles/gpt56.rs +++ b/lib/components/fabro-agent/src/profiles/gpt56.rs @@ -17,7 +17,9 @@ use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{AgentProfileKind, ProviderId, ToolDefinition, provider_ids}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::{ProviderId, builtin}; +use lithos_llm::types::ToolDefinition; use serde_json::Value; use super::EnvContext; @@ -69,7 +71,7 @@ impl Gpt56Profile { Self { base: BaseProfile { profile_kind: AgentProfileKind::Gpt56, - provider_id: provider_ids::openai(), + provider_id: builtin::openai(), model: model.into(), catalog: None, registry, @@ -252,7 +254,7 @@ enabled = true fn gpt56_profile_identity() { let profile = Gpt56Profile::new("gpt-5.6-sol"); assert_eq!(profile.profile_kind(), AgentProfileKind::Gpt56); - assert_eq!(profile.provider_id(), provider_ids::openai()); + assert_eq!(profile.provider_id(), builtin::openai()); assert_eq!(profile.model(), "gpt-5.6-sol"); } @@ -327,8 +329,7 @@ enabled = true /// it points 5.6 at a tool it was never given. #[test] fn shell_description_names_the_editor_actually_registered() { - let direct = - Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog()); + let direct = Gpt56Profile::new("gpt-5.6-sol").with_route(builtin::openai(), test_catalog()); let shell = direct.tool_registry().get("shell_command").unwrap(); assert!(shell.definition.description.contains("`apply_patch`")); assert!(!shell.definition.description.contains("`edit_file`")); @@ -349,8 +350,7 @@ enabled = true assert!(!rendered.contains("apply_patch")); assert!(!rendered.contains("*** Begin Patch")); - let direct = - Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog()); + let direct = Gpt56Profile::new("gpt-5.6-sol").with_route(builtin::openai(), test_catalog()); let rendered = prompt(&direct); assert!(rendered.contains("Use `apply_patch` for local file edits")); assert!(rendered.contains("*** Begin Patch")); @@ -427,8 +427,7 @@ enabled = true #[test] fn provider_prompt_uses_catalog_display_name() { - let direct = - Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog()); + let direct = Gpt56Profile::new("gpt-5.6-sol").with_route(builtin::openai(), test_catalog()); assert!(prompt(&direct).contains("powered by OpenAI")); let gateway = Gpt56Profile::new("gpt-5.6-sol") @@ -465,7 +464,7 @@ enabled = true #[test] fn catalog_reports_the_5_6_context_window() { let profile = - Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog()); + Gpt56Profile::new("gpt-5.6-sol").with_route(builtin::openai(), test_catalog()); assert_eq!(profile.context_window_size(), 1_050_000); } } diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index ee2dc4d87..a076e0cda 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{AgentProfileKind, ProviderId}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::ProviderId; use super::EnvContext; use crate::agent_profile::AgentProfile; diff --git a/lib/components/fabro-agent/src/profiles/kimi_tools.rs b/lib/components/fabro-agent/src/profiles/kimi_tools.rs index 885943ef9..3bbb858ef 100644 --- a/lib/components/fabro-agent/src/profiles/kimi_tools.rs +++ b/lib/components/fabro-agent/src/profiles/kimi_tools.rs @@ -22,7 +22,7 @@ use std::fmt::Write as _; use std::str::FromStr; use std::sync::Arc; -use fabro_types::ToolDefinition; +use lithos_llm::types::ToolDefinition; use serde_json::Value; use strum::EnumString; diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index 27905fa86..23f241b43 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -2,9 +2,10 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::ProviderId; #[cfg(test)] -use fabro_types::provider_ids; -use fabro_types::{AgentProfileKind, ProviderId}; +use lithos_llm::catalog::builtin; pub mod anthropic; pub mod claude5; @@ -220,7 +221,7 @@ macro_rules! impl_base_profile_accessors { self.base.profile_kind } - fn provider_id(&self) -> ::fabro_types::ProviderId { + fn provider_id(&self) -> ::lithos_llm::catalog::ProviderId { self.base.provider_id.clone() } @@ -456,7 +457,7 @@ pub fn build_env_context_block_with(env: &dyn Sandbox, ctx: &EnvContext) -> Stri #[cfg(test)] mod tests { use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay}; - use fabro_types::ToolDefinition; + use lithos_llm::types::ToolDefinition; use tokio_util::sync::CancellationToken; use super::*; @@ -701,31 +702,23 @@ mod tests { let catalog = Arc::new(test_catalog()); let env = MockSandbox::linux(); let cases = [ - ( - AgentProfileKind::OpenAi, - provider_ids::openai(), - "gpt-5.4-mini", - ), + (AgentProfileKind::OpenAi, builtin::openai(), "gpt-5.4-mini"), ( AgentProfileKind::Anthropic, - provider_ids::anthropic(), + builtin::anthropic(), "claude-haiku-4-5", ), ( AgentProfileKind::Gemini, - provider_ids::gemini(), + builtin::gemini(), "gemini-3-flash-preview", ), ( AgentProfileKind::Claude5, - provider_ids::anthropic(), + builtin::anthropic(), "claude-sonnet-5", ), - ( - AgentProfileKind::Gpt56, - provider_ids::openai(), - "gpt-5.6-sol", - ), + (AgentProfileKind::Gpt56, builtin::openai(), "gpt-5.6-sol"), ]; for (profile_kind, provider_id, model) in cases { @@ -782,7 +775,7 @@ mod tests { ) { let builder = AgentProfileBuilder::new( profile_kind, - provider_ids::anthropic(), + builtin::anthropic(), model, Arc::new(test_catalog()), ); diff --git a/lib/components/fabro-agent/src/profiles/openai.rs b/lib/components/fabro-agent/src/profiles/openai.rs index 29018cfb5..faeebde59 100644 --- a/lib/components/fabro-agent/src/profiles/openai.rs +++ b/lib/components/fabro-agent/src/profiles/openai.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{AgentProfileKind, ProviderId, provider_ids}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::{ProviderId, builtin}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -43,7 +44,7 @@ impl OpenAiProfile { Self { base: BaseProfile { profile_kind: AgentProfileKind::OpenAi, - provider_id: provider_ids::openai(), + provider_id: builtin::openai(), model: model.into(), catalog: None, registry, @@ -117,7 +118,7 @@ mod tests { fn openai_profile_identity() { let profile = OpenAiProfile::new("o3-mini"); assert_eq!(profile.profile_kind(), AgentProfileKind::OpenAi); - assert_eq!(profile.provider_id(), provider_ids::openai()); + assert_eq!(profile.provider_id(), builtin::openai()); assert_eq!(profile.model(), "o3-mini"); } diff --git a/lib/components/fabro-agent/src/question_tools.rs b/lib/components/fabro-agent/src/question_tools.rs index 4a7e0d8f8..a97bee11b 100644 --- a/lib/components/fabro-agent/src/question_tools.rs +++ b/lib/components/fabro-agent/src/question_tools.rs @@ -6,7 +6,8 @@ use std::ops::RangeInclusive; use std::sync::Arc; use async_trait::async_trait; -use fabro_types::{AgentProfileKind, InterviewOption, QuestionType, ToolDefinition}; +use fabro_types::{AgentProfileKind, InterviewOption, QuestionType}; +use lithos_llm::types::ToolDefinition; use serde::Deserialize; use serde_json::json; use tokio_util::sync::CancellationToken; diff --git a/lib/components/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs index cc562da08..671091982 100644 --- a/lib/components/fabro-agent/src/session.rs +++ b/lib/components/fabro-agent/src/session.rs @@ -4,20 +4,24 @@ use std::time::{Duration, Instant, SystemTime}; use fabro_llm::types::ContentBlockKind; use fabro_llm::{ - CallContext, Client, FinishReason, LlmError, Request, Response, RetryClassification, - RetryListener, RetryStage, StreamEvent, reasoning, + CallContext, Client, ErrorData, FinishReason, Request, Response, RetryClassification, + RetryListener, RetryStage, StreamEvent, }; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_mcp::connection_manager::McpConnectionManager; use fabro_mcp::http_transport; use fabro_types::{ - AgentProfileKind, AgentToolSummary, LlmOutputKind, LlmRetryPhase, Message as LlmMessage, - ModelId, ModelRef, PermissionLevel, Principal, ReasoningEffort, Role, SessionMessage, - SessionRecord, Speed, StageContextWindowProjection, SteeringMessage, TokenCounts, ToolCall, - ToolChoice, UsdMicros, billing, + AgentProfileKind, AgentToolSummary, LlmOutputKind, LlmRetryPhase, ModelRef, PermissionLevel, + Principal, SessionMessage, SessionRecord, StageContextWindowProjection, SteeringMessage, + UsdMicros, billing, }; use fabro_util::shell; use futures::StreamExt; +use lithos_llm::catalog::{ModelId, ProviderId}; +use lithos_llm::types::{ + ContentPart, Message as LlmMessage, ReasoningEffort, Role, Speed, TokenCounts, ToolCall, + ToolChoice, +}; use tokio::sync::{Notify, broadcast}; use tokio::time; use tokio_util::sync::CancellationToken; @@ -110,9 +114,9 @@ fn first_output_kind(event: &StreamEvent) -> Option { StreamEvent::TextDelta { .. } => Some(LlmOutputKind::Text), StreamEvent::ToolCallDelta { .. } => Some(LlmOutputKind::ToolCall), StreamEvent::ContentBlockEnd { part, .. } => match part { - fabro_types::ContentPart::Text { .. } => Some(LlmOutputKind::Text), - fabro_types::ContentPart::Reasoning(_) => Some(LlmOutputKind::Reasoning), - fabro_types::ContentPart::ToolCall(_) => Some(LlmOutputKind::ToolCall), + ContentPart::Text { .. } => Some(LlmOutputKind::Text), + ContentPart::Reasoning(_) => Some(LlmOutputKind::Reasoning), + ContentPart::ToolCall(_) => Some(LlmOutputKind::ToolCall), _ => None, }, _ => None, @@ -565,7 +569,7 @@ impl Session { } #[must_use] - pub fn provider_id(&self) -> fabro_types::ProviderId { + pub fn provider_id(&self) -> ProviderId { self.provider_profile.provider_id() } @@ -1138,14 +1142,14 @@ impl Session { } fn emit_llm_error(&mut self, err: fabro_llm::Error) -> Error { - let err = LlmError::from(err); + let err = ErrorData::from(err); self.event_emitter.emit(self.id.clone(), AgentEvent::Error { - error: Error::Llm(err.clone()), + error: Error::from(err.clone()), }); if err.is_auth_error() { self.transition(SessionState::Closed); } - Error::Llm(err) + Error::from(err) } #[must_use] @@ -1585,11 +1589,11 @@ impl Session { let text = response.text(); let tool_calls: Vec = response.tool_calls().cloned().collect(); // Normalize before the response's content moves into history. - let reasoning = reasoning::normalize(&response.content); + let reasoning = response.reasoning(); let provider_parts: Vec<_> = response .content .iter() - .filter(|part| reasoning::is_provider_part(part)) + .filter(|part| part.is_replay_material()) .cloned() .collect(); let usage = response.usage; @@ -1814,7 +1818,7 @@ impl Session { model: requested_model.model_id.to_string(), attempt: usize::try_from(replay_attempt).unwrap_or(usize::MAX), delay_secs: delay.as_secs_f64(), - error: LlmError::from(&error), + error: ErrorData::from(&error), phase: LlmRetryPhase::Consume, }); @@ -2243,15 +2247,15 @@ mod tests { use anyhow::Context as _; use fabro_llm::adapter::{ProviderAdapter, ResolvedCall}; use fabro_llm::lithos_catalog::AdapterId; - use fabro_llm::reasoning::OPENAI_COMPAT_REASONING_DETAILS_KIND; use fabro_llm::test_support::response_to_stream; - use fabro_llm::types::{ContentBlockId, ContentBlockKind, ToolCallKind}; - use fabro_llm::{ErrorFacts, ErrorKind, ResponseStream, RetryPolicy}; - use fabro_types::{ - ContentPart, Cost, CostSource, ReasoningOutput, StageContextWindowCountMethod, - ToolDefinition, provider_ids, text_of, tool_result_to_json, + use fabro_llm::types::{ + ContentBlockId, ContentBlockKind, OPENAI_COMPAT_REASONING_DETAILS_KIND, ToolCallKind, }; + use fabro_llm::{ErrorKind, ResponseStream, RetryPolicy}; + use fabro_types::{StageContextWindowCountMethod, text_of, tool_result_to_json}; use futures::stream; + use lithos_llm::catalog::builtin; + use lithos_llm::types::{ContentPart, Cost, CostSource, ReasoningOutput, ToolDefinition}; use tokio::time::{sleep, timeout}; use super::*; @@ -2385,7 +2389,7 @@ mod tests { impl ScriptedError { fn build(&self) -> fabro_llm::Error { fabro_llm::Error::new(self.kind.clone(), self.message.clone()) - .with_provider(provider_ids::anthropic()) + .with_provider(builtin::anthropic()) .with_retry(self.retry) } } @@ -5438,7 +5442,7 @@ mod tests { #[tokio::test] async fn compaction_includes_structured_prompt_and_file_tracking() { - use fabro_types::ToolDefinition; + use lithos_llm::types::ToolDefinition; use crate::tool_registry::{RegisteredTool, ToolSource}; diff --git a/lib/components/fabro-agent/src/skills.rs b/lib/components/fabro-agent/src/skills.rs index 98e7ef296..d4c485471 100644 --- a/lib/components/fabro-agent/src/skills.rs +++ b/lib/components/fabro-agent/src/skills.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use fabro_types::ToolDefinition; +use lithos_llm::types::ToolDefinition; use tokio_util::sync::CancellationToken; use crate::error::{Error, InterruptReason}; diff --git a/lib/components/fabro-agent/src/subagent.rs b/lib/components/fabro-agent/src/subagent.rs index 8aa6de9b6..5d15b2170 100644 --- a/lib/components/fabro-agent/src/subagent.rs +++ b/lib/components/fabro-agent/src/subagent.rs @@ -3,9 +3,10 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; -use fabro_types::{INITIAL_SUBAGENT_GENERATION, ToolDefinition}; +use fabro_types::INITIAL_SUBAGENT_GENERATION; use fabro_util::error as util_error; use futures::future; +use lithos_llm::types::ToolDefinition; use tokio::sync::{broadcast, mpsc, oneshot, watch}; use tokio::task::{AbortHandle, JoinHandle}; use tokio::time::{Instant, timeout_at}; @@ -1344,7 +1345,8 @@ pub fn make_close_agent_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { #[cfg(test)] mod tests { use fabro_llm::adapter::ProviderAdapter; - use fabro_types::{Role, text_of}; + use fabro_types::text_of; + use lithos_llm::types::Role; use tokio::task::yield_now; use tokio::time; diff --git a/lib/components/fabro-agent/src/task_reminder.rs b/lib/components/fabro-agent/src/task_reminder.rs index 51d8145c4..0e956d3f9 100644 --- a/lib/components/fabro-agent/src/task_reminder.rs +++ b/lib/components/fabro-agent/src/task_reminder.rs @@ -74,7 +74,7 @@ fn is_task_reminder(content: &str) -> bool { mod tests { use std::time::SystemTime; - use fabro_types::{TokenCounts, ToolCall}; + use lithos_llm::types::{TokenCounts, ToolCall}; use super::*; fn assistant(tool_name: Option<&str>) -> Message { diff --git a/lib/components/fabro-agent/src/test_support.rs b/lib/components/fabro-agent/src/test_support.rs index bb3820aab..6076dbd27 100644 --- a/lib/components/fabro-agent/src/test_support.rs +++ b/lib/components/fabro-agent/src/test_support.rs @@ -10,9 +10,9 @@ use fabro_llm::{ Client, ClientOptions, Error as LlmError, FinishReason, Request, Response, ResponseStream, }; pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox}; -use fabro_types::{ - AgentProfileKind, ContentPart, ModelId, ProviderId, TokenCounts, ToolCall, provider_ids, -}; +use fabro_types::AgentProfileKind; +use lithos_llm::catalog::{ModelId, ProviderId, builtin}; +use lithos_llm::types::{ContentPart, TokenCounts, ToolCall}; use crate::agent_profile::AgentProfile; use crate::config::SessionOptions; @@ -24,7 +24,7 @@ use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::{RegisteredTool, ToolRegistry, ToolSource}; /// The provider every test profile routes to. -pub const TEST_PROVIDER: &str = provider_ids::ANTHROPIC; +pub const TEST_PROVIDER: &str = builtin::ids::ANTHROPIC; /// The model every test profile requests. It is not in the catalog, so the /// provider's passthrough route serves it. pub const TEST_MODEL: &str = "mock-model"; @@ -65,7 +65,7 @@ impl AgentProfile for TestProfile { } fn provider_id(&self) -> ProviderId { - provider_ids::anthropic() + builtin::anthropic() } fn model(&self) -> &'static str { @@ -271,7 +271,7 @@ pub async fn make_session_with_tools_and_config( } pub fn make_echo_tool() -> RegisteredTool { - use fabro_types::ToolDefinition; + use lithos_llm::types::ToolDefinition; RegisteredTool { definition: ToolDefinition::function( "echo", @@ -292,7 +292,7 @@ pub fn make_echo_tool() -> RegisteredTool { } pub fn make_error_tool() -> RegisteredTool { - use fabro_types::ToolDefinition; + use lithos_llm::types::ToolDefinition; RegisteredTool { definition: ToolDefinition::function( "fail_tool", diff --git a/lib/components/fabro-agent/src/todo_tools.rs b/lib/components/fabro-agent/src/todo_tools.rs index f30d94aa8..29e114a91 100644 --- a/lib/components/fabro-agent/src/todo_tools.rs +++ b/lib/components/fabro-agent/src/todo_tools.rs @@ -12,7 +12,8 @@ use std::fmt::Write; use std::str::FromStr; use std::sync::Arc; -use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps, ToolDefinition}; +use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps}; +use lithos_llm::types::ToolDefinition; use serde_json::Value; use strum::{EnumString, IntoStaticStr}; diff --git a/lib/components/fabro-agent/src/tool_execution.rs b/lib/components/fabro-agent/src/tool_execution.rs index b444bde38..75d36bb0a 100644 --- a/lib/components/fabro-agent/src/tool_execution.rs +++ b/lib/components/fabro-agent/src/tool_execution.rs @@ -1,8 +1,9 @@ use std::borrow::Cow; use std::sync::Arc; -use fabro_types::{ToolCall, ToolInput, ToolResult, tool_call_arguments, tool_result_from_json}; +use fabro_types::{tool_call_arguments, tool_result_from_json}; use futures::future; +use lithos_llm::types::{ContentPart, ToolCall, ToolDefinitionKind, ToolInput, ToolResult}; use tokio_util::sync::CancellationToken; use tracing::debug; @@ -507,7 +508,7 @@ fn retain_tool_result( previous_stats: Option, ) -> RetainedToolResult { let output_stats = match result.content.as_mut_slice() { - [fabro_types::ContentPart::Text { text: output }] => { + [ContentPart::Text { text: output }] => { let previously_omitted = previous_stats.map_or(0, |stats| stats.omitted_bytes); let previewed = preview_tool_output(output, MAX_RETAINED_TOOL_OUTPUT_BYTES, previously_omitted); @@ -565,9 +566,7 @@ async fn execute_one_tool( _ => tool_call_arguments(tc), }; if matches!(tc.input, ToolInput::Function(_)) { - if let fabro_types::ToolDefinitionKind::Function { input_schema } = - &tool.definition.kind - { + if let ToolDefinitionKind::Function { input_schema } = &tool.definition.kind { if let Err(validation_error) = validate_tool_args(input_schema, &arguments) { return ExecutedToolResult { result: error_result(&tc.id, validation_error), @@ -622,9 +621,11 @@ fn truncate_tool_result( config: &SessionOptions, ) -> ToolResult { let content = match result.content.as_slice() { - [fabro_types::ContentPart::Text { text }] => vec![fabro_types::ContentPart::Text { - text: truncate_tool_output(text, tool_name, config), - }], + [ContentPart::Text { text }] => { + vec![ContentPart::Text { + text: truncate_tool_output(text, tool_name, config), + }] + } other => other.to_vec(), }; @@ -672,7 +673,8 @@ mod tests { use async_trait::async_trait; use fabro_types::run_event::{AgentToolCompletedProps, MAX_RUN_EVENT_BODY_BYTES}; - use fabro_types::{AgentProfileKind, ToolCall, ToolDefinition, tool_result_to_json}; + use fabro_types::{AgentProfileKind, tool_result_to_json}; + use lithos_llm::types::{ToolCall, ToolDefinition}; use tokio::sync::broadcast; use super::*; diff --git a/lib/components/fabro-agent/src/tool_registry.rs b/lib/components/fabro-agent/src/tool_registry.rs index c8b02e267..b963cfbcb 100644 --- a/lib/components/fabro-agent/src/tool_registry.rs +++ b/lib/components/fabro-agent/src/tool_registry.rs @@ -3,7 +3,8 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use fabro_types::{AgentToolCategory, AgentToolSource, AgentToolSummary, ToolDefinition}; +use fabro_types::{AgentToolCategory, AgentToolSource, AgentToolSummary}; +use lithos_llm::types::{ToolDefinition, ToolDefinitionKind}; use tokio_util::sync::CancellationToken; use crate::config::{ToolAccessPolicy, ToolExposureMode}; @@ -83,14 +84,14 @@ pub trait ToolDefinitionExt { impl ToolDefinitionExt for ToolDefinition { fn parameters(&self) -> &serde_json::Value { match &self.kind { - fabro_types::ToolDefinitionKind::Function { input_schema } => input_schema, + ToolDefinitionKind::Function { input_schema } => input_schema, _ => panic!("custom tool '{}' has no parameter schema", self.name), } } fn custom_format(&self) -> Option<&serde_json::Value> { match &self.kind { - fabro_types::ToolDefinitionKind::Custom { format } => Some(format), + ToolDefinitionKind::Custom { format } => Some(format), _ => None, } } diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index 7e284954a..b7c288407 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use fabro_llm::{Client, Request}; #[cfg(test)] use fabro_static::EnvVars; -use fabro_types::{ModelHandle, ToolDefinition}; use futures::{StreamExt, stream}; +use lithos_llm::catalog::ModelHandle; +use lithos_llm::types::ToolDefinition; use tokio::task; use crate::config::NativeToolOptions; @@ -732,7 +733,8 @@ mod tests { use std::collections::HashMap; use fabro_llm::adapter::ProviderAdapter; - use fabro_types::{CommandTermination, ModelId, provider_ids}; + use fabro_types::CommandTermination; + use lithos_llm::catalog::{ModelId, builtin}; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; @@ -1978,7 +1980,7 @@ mod tests { let client = make_client(provider).await; let summarizer = WebFetchSummarizer { client, - model_id: ModelHandle::new(provider_ids::anthropic(), ModelId::new("mock-model")), + model_id: ModelHandle::new(builtin::anthropic(), ModelId::new("mock-model")), }; let tool = make_web_fetch_tool(Some(summarizer)); @@ -2075,7 +2077,7 @@ mod tests { let summarizer = WebFetchSummarizer { client, - model_id: ModelHandle::new(provider_ids::anthropic(), ModelId::new("target-model")), + model_id: ModelHandle::new(builtin::anthropic(), ModelId::new("target-model")), }; let tool = make_web_fetch_tool(Some(summarizer)); diff --git a/lib/components/fabro-agent/src/types.rs b/lib/components/fabro-agent/src/types.rs index cf1627e03..e10174589 100644 --- a/lib/components/fabro-agent/src/types.rs +++ b/lib/components/fabro-agent/src/types.rs @@ -1,11 +1,14 @@ use std::time::SystemTime; use chrono::{DateTime, Utc}; -use fabro_llm::LlmError; +use fabro_llm::ErrorData; use fabro_types::{ - CommandTermination, ContentPart, Cost, ExecOutputTail, LlmOutputKind, LlmRetryPhase, - Message as LlmMessage, ModelRef, ReasoningOutput, Role, SessionMessage, Speed, - StageContextWindowProjection, TokenCounts, ToolCall, ToolResult, + CommandTermination, ExecOutputTail, LlmOutputKind, LlmRetryPhase, ModelRef, SessionMessage, + StageContextWindowProjection, +}; +use lithos_llm::types::{ + ContentPart, Cost, Message as LlmMessage, ReasoningOutput, Role, Speed, TokenCounts, ToolCall, + ToolResult, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -401,7 +404,7 @@ pub enum AgentEvent { model: String, attempt: usize, delay_secs: f64, - error: LlmError, + error: ErrorData, phase: LlmRetryPhase, }, SubAgentSpawned { @@ -816,13 +819,14 @@ pub struct SessionEvent { #[cfg(test)] mod tests { - use fabro_llm::{ErrorFacts, ErrorKind, RetryClassification}; - use fabro_types::{CostSource, ModelId, ProviderId, provider_ids}; + use fabro_llm::{ErrorKind, RetryClassification}; + use lithos_llm::catalog::{ModelId, ProviderId, builtin}; + use lithos_llm::types::CostSource; use super::*; - fn network_error(message: &str) -> LlmError { - LlmError::from( + fn network_error(message: &str) -> ErrorData { + ErrorData::from( fabro_llm::Error::new(ErrorKind::Network, message) .with_retry(RetryClassification::Safe), ) @@ -1102,7 +1106,7 @@ mod tests { }; let event = AgentEvent::AssistantMessage { text: "Hello".into(), - model: ModelRef::new(provider_ids::openai(), ModelId::new("test-model")), + model: ModelRef::new(builtin::openai(), ModelId::new("test-model")), usage, cost: Some(Cost { usd_micros: 125_000, @@ -1152,7 +1156,7 @@ mod tests { #[test] fn error_event_serde_roundtrip_with_agent_error() { let event = AgentEvent::Error { - error: Error::Llm(network_error("refused")), + error: Error::from(network_error("refused")), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); @@ -1172,7 +1176,7 @@ mod tests { attempt: 1, delay_secs: 2.0, phase: LlmRetryPhase::Open, - error: LlmError::from( + error: ErrorData::from( fabro_llm::Error::new(ErrorKind::RateLimit, "too fast") .with_provider(ProviderId::new("openai")) .with_status(429) diff --git a/lib/components/fabro-agent/src/web_search.rs b/lib/components/fabro-agent/src/web_search.rs index 6c9fdf151..47b74f90f 100644 --- a/lib/components/fabro-agent/src/web_search.rs +++ b/lib/components/fabro-agent/src/web_search.rs @@ -7,7 +7,7 @@ use std::fmt::Write; use std::sync::OnceLock; use std::time::Duration; -use fabro_types::ToolDefinition; +use lithos_llm::types::ToolDefinition; use crate::config::ToolSecrets; use crate::tool_registry::{RegisteredTool, ToolSource}; diff --git a/lib/components/fabro-agent/tests/it/guardrails.rs b/lib/components/fabro-agent/tests/it/guardrails.rs index e84599131..1b43401ee 100644 --- a/lib/components/fabro-agent/tests/it/guardrails.rs +++ b/lib/components/fabro-agent/tests/it/guardrails.rs @@ -7,9 +7,9 @@ use fabro_llm::test_support::test_catalog; #[test] fn profile_context_window_matches_catalog_for_default_models() { let catalog = Arc::new(test_catalog()); - for provider in catalog::listed_providers(&catalog) { + for provider in catalog.listed_providers() { let provider_id = provider.id().clone(); - let Some(default) = catalog::default_model(&catalog, provider_id.as_str()) else { + let Some(default) = provider.default_offering() else { // Deployment-defined providers (LiteLLM, Modal, Ollama) carry no // built-in default model. continue; @@ -21,7 +21,7 @@ fn profile_context_window_matches_catalog_for_default_models() { ); let profile: Box = AgentProfileBuilder::new( - default.agent_profile(), + catalog::offering_agent_profile(&default), provider_id.clone(), model.as_str(), Arc::clone(&catalog), diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index f85b0b5f2..b542f537d 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -18,7 +18,8 @@ use fabro_llm::lithos_catalog::Catalog; use fabro_llm::test_support::client_from_env; use fabro_llm::{Client, ClientOptions, catalog}; use fabro_test::{EnvVars, TwinScenario, TwinScenarios, TwinToolCall, twin_openai}; -use fabro_types::{ModelHandle, ModelId, ProviderId, provider_ids}; +use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId, builtin}; +use lithos_llm::types::ReasoningEffort; type Provider = ProviderId; @@ -30,11 +31,11 @@ struct OpenAiTwinOptions { fn summarizer_model_id(provider: &Provider) -> ModelHandle { let (provider, model) = match provider.as_str() { - provider_ids::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => { - (provider_ids::openai(), "gpt-5.4-mini") + builtin::ids::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => { + (builtin::openai(), "gpt-5.4-mini") } - provider_ids::GEMINI => (provider_ids::gemini(), "gemini-3-flash-preview"), - provider_ids::ANTHROPIC => (provider_ids::anthropic(), "claude-haiku-4.5"), + builtin::ids::GEMINI => (builtin::gemini(), "gemini-3-flash-preview"), + builtin::ids::ANTHROPIC => (builtin::anthropic(), "claude-haiku-4.5"), other => panic!("unexpected provider {other}"), }; ModelHandle::new(provider, ModelId::new(model)) @@ -142,7 +143,7 @@ fn twin_catalog(base_url: &str, overlay: &str) -> Catalog { } async fn make_client(provider: &Provider, twin: Option<&OpenAiTwinOptions>) -> Client { - if provider == &provider_ids::openai() && fabro_test::TestMode::from_env().is_twin() { + if provider == &builtin::openai() && fabro_test::TestMode::from_env().is_twin() { return make_twin_client(twin.expect("openai twin config should be provided")).await; } @@ -261,7 +262,7 @@ macro_rules! openai_twin_provider_test { .await; } let mut session = make_session( - provider_ids::openai(), + builtin::openai(), "gpt-5.4-mini", tmp.path(), ToolSecrets::default(), @@ -278,14 +279,14 @@ macro_rules! provider_tests { ($scenario:ident) => { provider_test!( $scenario, - provider_ids::anthropic(), + builtin::anthropic(), "claude-haiku-4.5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( $scenario, - provider_ids::gemini(), + builtin::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -406,21 +407,21 @@ provider_tests!(subagent_spawn); provider_test!( web_fetch, - provider_ids::anthropic(), + builtin::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( web_fetch, - provider_ids::openai(), + builtin::openai(), "gpt-5.4-mini", openai, keys = ["OPENAI_API_KEY"] ); provider_test!( web_fetch, - provider_ids::gemini(), + builtin::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -457,19 +458,19 @@ provider_test!( ); web_search_provider_test!( - provider_ids::anthropic(), + builtin::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY", "BRAVE_SEARCH_API_KEY"] ); web_search_provider_test!( - provider_ids::openai(), + builtin::openai(), "gpt-5.4-mini", openai, keys = ["OPENAI_API_KEY", "BRAVE_SEARCH_API_KEY"] ); web_search_provider_test!( - provider_ids::gemini(), + builtin::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY", "BRAVE_SEARCH_API_KEY"] @@ -518,14 +519,14 @@ macro_rules! non_openai_provider_tests { ($scenario:ident) => { provider_test!( $scenario, - provider_ids::anthropic(), + builtin::anthropic(), "claude-haiku-4.5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( $scenario, - provider_ids::gemini(), + builtin::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -785,7 +786,7 @@ macro_rules! reasoning_effort_tests { async fn $test_name() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let config = SessionOptions { - reasoning_effort: Some(fabro_types::ReasoningEffort::Low), + reasoning_effort: Some(ReasoningEffort::Low), ..SessionOptions::default() }; let mut session = @@ -800,7 +801,7 @@ macro_rules! reasoning_effort_tests { } reasoning_effort_tests!( - provider_ids::anthropic(), + builtin::anthropic(), "claude-haiku-4.5", anthropic_reasoning_effort, keys = ["ANTHROPIC_API_KEY"] @@ -808,7 +809,7 @@ reasoning_effort_tests!( // gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI // test. reasoning_effort_tests!( - provider_ids::gemini(), + builtin::gemini(), "gemini-3-flash-preview", gemini_reasoning_effort, keys = ["GEMINI_API_KEY"] @@ -878,19 +879,19 @@ macro_rules! loop_detection_tests { } loop_detection_tests!( - provider_ids::anthropic(), + builtin::anthropic(), "claude-haiku-4-5", anthropic_loop_detection, keys = ["ANTHROPIC_API_KEY"] ); loop_detection_tests!( - provider_ids::openai(), + builtin::openai(), "gpt-5.4-mini", openai_loop_detection, keys = ["OPENAI_API_KEY"] ); loop_detection_tests!( - provider_ids::gemini(), + builtin::gemini(), "gemini-3-flash-preview", gemini_loop_detection, keys = ["GEMINI_API_KEY"] diff --git a/lib/components/fabro-hooks/Cargo.toml b/lib/components/fabro-hooks/Cargo.toml index fe9659c76..183c5a970 100644 --- a/lib/components/fabro-hooks/Cargo.toml +++ b/lib/components/fabro-hooks/Cargo.toml @@ -18,6 +18,7 @@ fabro-auth = { path = "../../foundation/fabro-auth" } fabro-llm = { path = "../fabro-llm" } fabro-redact.workspace = true fabro-types = { path = "../../foundation/fabro-types" } +lithos-llm = { workspace = true, features = ["runtime"] } fabro-util = { path = "../../foundation/fabro-util" } fabro-http.workspace = true serde.workspace = true diff --git a/lib/components/fabro-hooks/src/bridge.rs b/lib/components/fabro-hooks/src/bridge.rs index 6f49a6a00..b064e6171 100644 --- a/lib/components/fabro-hooks/src/bridge.rs +++ b/lib/components/fabro-hooks/src/bridge.rs @@ -78,6 +78,7 @@ mod tests { use std::path::PathBuf; use std::sync::Mutex; + use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; use fabro_types::fixtures; @@ -100,7 +101,7 @@ mod tests { context: &HookContext, _sandbox: Arc, execution_context: &HookExecutionContext, - _llm_source: Arc, + _llm_source: Arc, _catalog: Arc, ) -> HookResult { self.captured_contexts.lock().unwrap().push(context.clone()); diff --git a/lib/components/fabro-hooks/src/executor.rs b/lib/components/fabro-hooks/src/executor.rs index 3d208ed7e..443573866 100644 --- a/lib/components/fabro-hooks/src/executor.rs +++ b/lib/components/fabro-hooks/src/executor.rs @@ -6,12 +6,13 @@ use std::time::Instant; use async_trait::async_trait; use fabro_agent::Sandbox; use fabro_agent::tool_registry::ToolContext; -use fabro_auth::CredentialSource; +use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{Client, ClientOptions, Request, structured}; +use fabro_llm::{Client, ClientOptions, Request}; use fabro_redact::redacted_url_for_log; use fabro_types::settings::{InterpString, ResolveCtx, ResolveError}; -use fabro_types::{Message, Role, ToolCall, tool_call_arguments, tool_result_from_json}; +use fabro_types::{tool_call_arguments, tool_result_from_json}; +use lithos_llm::types::{ContentPart, Message, Role, ToolCall}; use tokio::process::Command as TokioCommand; use tokio::time::timeout as tokio_timeout; use tokio_util::sync::CancellationToken; @@ -48,7 +49,7 @@ pub trait HookExecutor: Send + Sync { context: &HookContext, sandbox: Arc, execution_context: &HookExecutionContext, - llm_source: Arc, + llm_source: Arc, catalog: Arc, ) -> HookResult; } @@ -281,7 +282,7 @@ impl HookExecutorImpl { prompt: &InterpString, model: Option<&InterpString>, context: &HookContext, - llm_source: Arc, + llm_source: Arc, catalog: Arc, ) -> HookDecision { let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model) { @@ -320,12 +321,8 @@ impl HookExecutorImpl { } }; - match structured::complete_object( - &client, - request, - "hook_response", - HOOK_RESPONSE_SCHEMA.clone(), - ) + match client + .complete_object(request, "hook_response", HOOK_RESPONSE_SCHEMA.clone()) .await { Ok(completion) => { @@ -361,7 +358,7 @@ impl HookExecutorImpl { max_tool_rounds: Option, context: &HookContext, sandbox: Arc, - llm_source: Arc, + llm_source: Arc, catalog: Arc, ) -> HookDecision { let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model) { @@ -461,7 +458,7 @@ impl HookExecutorImpl { true, ), }; - results.push(fabro_types::ContentPart::ToolResult(result)); + results.push(ContentPart::ToolResult(result)); } messages.push(Message::new(Role::Tool, results)); } @@ -476,7 +473,7 @@ impl HookExecutorImpl { /// serve, with standard retries. async fn build_client( catalog: Arc, - llm_source: Arc, + llm_source: Arc, ) -> Result { fabro_llm::build_client( Catalog::clone(&catalog), @@ -662,7 +659,7 @@ impl HookExecutor for HookExecutorImpl { context: &HookContext, sandbox: Arc, execution_context: &HookExecutionContext, - llm_source: Arc, + llm_source: Arc, catalog: Arc, ) -> HookResult { use std::sync::OnceLock; @@ -761,7 +758,8 @@ impl HookExecutor for HookExecutorImpl { #[cfg(test)] mod tests { - use fabro_auth::{CredentialSource, test_support}; + use fabro_auth::test_support; + use fabro_llm::credentials::CredentialProvider; use fabro_types::fixtures; use fabro_types::settings::ResolveErrorKind; @@ -779,7 +777,7 @@ mod tests { )) } - fn test_llm_source() -> Arc { + fn test_llm_source() -> Arc { test_support::vault_only_credential_source() } diff --git a/lib/components/fabro-hooks/src/runner.rs b/lib/components/fabro-hooks/src/runner.rs index 489edd0dd..7a9f8fd57 100644 --- a/lib/components/fabro-hooks/src/runner.rs +++ b/lib/components/fabro-hooks/src/runner.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_agent::Sandbox; -use fabro_auth::CredentialSource; #[cfg(test)] use fabro_auth::test_support; +use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; use crate::config::{HookDefinition, HookSettings}; @@ -16,7 +16,7 @@ use crate::types::{HookContext, HookDecision, HookExecutionContext}; pub struct HookRunner { config: HookSettings, executor: Arc, - llm_source: Arc, + llm_source: Arc, catalog: Arc, /// Pre-compiled regexes keyed by matcher pattern string. compiled_matchers: HashMap, @@ -26,7 +26,7 @@ impl HookRunner { #[must_use] pub fn new( config: HookSettings, - llm_source: Arc, + llm_source: Arc, catalog: Arc, ) -> Self { let compiled_matchers = Self::compile_matchers(&config); @@ -256,7 +256,7 @@ mod tests { _context: &HookContext, _sandbox: Arc, _execution_context: &HookExecutionContext, - _llm_source: Arc, + _llm_source: Arc, _catalog: Arc, ) -> HookResult { HookResult { @@ -277,7 +277,7 @@ mod tests { HookContext::new(event, fixtures::RUN_1, "test-wf".into()) } - fn test_llm_source() -> Arc { + fn test_llm_source() -> Arc { test_support::vault_only_credential_source() } diff --git a/lib/components/fabro-hooks/tests/host_command_hooks.rs b/lib/components/fabro-hooks/tests/host_command_hooks.rs index 74cacd33c..8cac52b80 100644 --- a/lib/components/fabro-hooks/tests/host_command_hooks.rs +++ b/lib/components/fabro-hooks/tests/host_command_hooks.rs @@ -2,16 +2,17 @@ use std::path::Path; use std::sync::Arc; use fabro_agent::{LocalSandbox, Sandbox}; -use fabro_auth::{CredentialSource, test_support}; +use fabro_auth::test_support; use fabro_hooks::{ HookContext, HookDecision, HookDefinition, HookEvent, HookExecutionContext, HookRunner, HookSettings, InterpString, }; +use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; use fabro_types::RunId; use tokio::fs; -fn test_llm_source() -> Arc { +fn test_llm_source() -> Arc { test_support::vault_only_credential_source() } diff --git a/lib/components/fabro-llm/Cargo.toml b/lib/components/fabro-llm/Cargo.toml index 546fd1f1e..cccc5fee2 100644 --- a/lib/components/fabro-llm/Cargo.toml +++ b/lib/components/fabro-llm/Cargo.toml @@ -19,7 +19,6 @@ workspace = true [dependencies] anyhow.workspace = true async-trait.workspace = true -base64.workspace = true bytes.workspace = true fabro-auth = { path = "../../foundation/fabro-auth" } fabro-config = { path = "../../foundation/fabro-config" } @@ -28,8 +27,7 @@ fabro-redact.workspace = true fabro-static.workspace = true fabro-types = { path = "../../foundation/fabro-types" } futures.workspace = true -lithos-llm = { workspace = true, features = ["builtin-catalog", "openai", "anthropic", "gemini", "openai-compatible", "bedrock", "bedrock-aws"] } -mime_guess = "2" +lithos-llm = { workspace = true, features = ["builtin-catalog", "openai", "anthropic", "gemini", "openai-compatible", "bedrock", "bedrock-aws", "local-files"] } serde.workspace = true serde_json.workspace = true strum.workspace = true @@ -45,5 +43,4 @@ fabro-llm = { path = ".", features = ["test-support"] } fabro-macros = { path = "../../foundation/fabro-macros" } fabro-test = { workspace = true } httpmock = "0.8" -tempfile = "3" tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/components/fabro-llm/src/api.rs b/lib/components/fabro-llm/src/api.rs index e08f6ec7b..d432951e6 100644 --- a/lib/components/fabro-llm/src/api.rs +++ b/lib/components/fabro-llm/src/api.rs @@ -5,35 +5,34 @@ use std::collections::HashSet; -use fabro_types::{ - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId, - ReasoningEffort, -}; -use lithos_llm::catalog::{Catalog, CatalogProvider}; - -use crate::catalog::{self, ModelEntry}; +use fabro_types::{Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider}; +use lithos_llm::catalog::{Catalog, CatalogProvider, Offering, ProviderId}; +use lithos_llm::types::ReasoningEffort; const USD_MICROS_PER_USD: f64 = 1_000_000.0; /// Every enabled model on every listed provider, provider priority order. #[must_use] pub fn models(catalog: &Catalog, configured: &HashSet) -> Vec { - catalog::models(catalog) - .iter() - .map(|entry| model_view(entry, configured.contains(entry.provider.id()))) + catalog + .listed_providers() + .into_iter() + .flat_map(CatalogProvider::offerings) + .map(|offering| model_view(&offering, configured.contains(offering.provider.id()))) .collect() } /// Every listed provider, priority order. #[must_use] pub fn providers(catalog: &Catalog, configured: &HashSet) -> Vec { - catalog::listed_providers(catalog) - .iter() + catalog + .listed_providers() + .into_iter() .map(|provider| provider_view(provider, configured.contains(provider.id()))) .collect() } -fn model_view(entry: &ModelEntry<'_>, configured: bool) -> Model { +fn model_view(entry: &Offering<'_>, configured: bool) -> Model { let model = entry.model; let capabilities = model.capabilities(); let pricing = model.pricing(); @@ -95,7 +94,7 @@ fn provider_view(provider: &CatalogProvider, configured: bool) -> Provider { 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), + model_count: u32::try_from(provider.offerings().len()).unwrap_or(u32::MAX), default_model: provider.default_model().map(str::to_string), configured, expected_secret_name: fabro_auth::expected_secret_name(provider), @@ -116,7 +115,7 @@ fn saturating_i64(value: u64) -> i64 { #[cfg(test)] mod tests { - use fabro_types::provider_ids; + use lithos_llm::catalog::builtin; use super::*; use crate::test_support::test_catalog; @@ -124,17 +123,17 @@ mod tests { #[test] fn models_are_stamped_with_configured_providers() { let catalog = test_catalog(); - let configured = HashSet::from([provider_ids::openai()]); + let configured = HashSet::from([builtin::openai()]); let models = models(&catalog, &configured); let openai = models .iter() - .find(|model| model.provider == provider_ids::openai()) + .find(|model| model.provider == builtin::openai()) .expect("openai models listed"); assert!(openai.configured); assert!(openai.limits.context_window > 0); let anthropic = models .iter() - .find(|model| model.provider == provider_ids::anthropic()) + .find(|model| model.provider == builtin::anthropic()) .expect("anthropic models listed"); assert!(!anthropic.configured); assert!(models.iter().any(|model| model.default)); @@ -144,12 +143,12 @@ mod tests { fn providers_skip_stand_ins_and_disabled_entries() { let catalog = test_catalog(); let providers = providers(&catalog, &HashSet::new()); - assert!(providers.iter().any(|p| p.id == provider_ids::openai())); + assert!(providers.iter().any(|p| p.id == builtin::openai())); assert!(providers.iter().all(|p| p.id.as_str() != "openai-codex")); assert!(providers.iter().all(|p| p.id.as_str() != "ollama")); let openai = providers .iter() - .find(|p| p.id == provider_ids::openai()) + .find(|p| p.id == builtin::openai()) .unwrap(); assert_eq!( openai.expected_secret_name.as_deref(), diff --git a/lib/components/fabro-llm/src/attachments.rs b/lib/components/fabro-llm/src/attachments.rs deleted file mode 100644 index 37c9c8432..000000000 --- a/lib/components/fabro-llm/src/attachments.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Inlines local file attachments before a request reaches a codec. -//! -//! lithos accepts media as a URL or as base64. Fabro lets a caller point an -//! image, document, or audio part at a local path; this middleware reads the -//! file and rewrites the part to inline base64 with an inferred media type. -//! A part whose file cannot be read is dropped, so the model sees the rest of -//! the message rather than a request that fails outright. - -use std::sync::Arc; - -use async_trait::async_trait; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use fabro_static::EnvVars; -use lithos_llm::middleware::{Call, Middleware, Next, Output}; -use lithos_llm::types::{ - AudioContent, ContentPart, DocumentContent, Error, ImageContent, MediaSource, Message, Request, - ToolResult, -}; -use tokio::fs; - -/// Resolves an environment variable name to its value. -type EnvLookup = Arc Option + Send + Sync>; - -/// Middleware that inlines local-path media parts. -#[derive(Clone, Default)] -pub struct InlineLocalAttachments { - env_lookup: Option, -} - -impl std::fmt::Debug for InlineLocalAttachments { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("InlineLocalAttachments") - .finish_non_exhaustive() - } -} - -impl InlineLocalAttachments { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Resolves `~/` against this lookup instead of the process environment. - #[must_use] - pub fn with_env_lookup(env_lookup: EnvLookup) -> Self { - Self { - env_lookup: Some(env_lookup), - } - } - - #[expect( - clippy::disallowed_methods, - reason = "Attachment path expansion supports the conventional HOME env var." - )] - fn home(&self) -> Option { - match &self.env_lookup { - Some(lookup) => lookup(EnvVars::HOME), - None => std::env::var(EnvVars::HOME).ok(), - } - } - - fn expand(&self, path: &str) -> String { - path.strip_prefix("~/").map_or_else( - || path.to_string(), - |rest| format!("{}/{rest}", self.home().unwrap_or_else(|| "/".to_string())), - ) - } - - async fn load(&self, path: &str) -> Option { - let expanded = self.expand(path); - match fs::read(&expanded).await { - Ok(bytes) => Some(MediaSource::base64( - BASE64_STANDARD.encode(bytes), - media_type_for_path(&expanded), - )), - Err(err) => { - tracing::warn!(path = %expanded, error = %err, "dropping unreadable attachment"); - None - } - } - } - - async fn inline_part(&self, part: ContentPart) -> Option { - match part { - ContentPart::Image(ImageContent { source, detail }) if is_local_file(&source) => { - let source = self.load(url_of(&source)).await?; - Some(ContentPart::Image(ImageContent { source, detail })) - } - ContentPart::Document(DocumentContent { source, name }) if is_local_file(&source) => { - let source = self.load(url_of(&source)).await?; - Some(ContentPart::Document(DocumentContent { source, name })) - } - ContentPart::Audio(AudioContent { source }) if is_local_file(&source) => { - let source = self.load(url_of(&source)).await?; - Some(ContentPart::Audio(AudioContent { source })) - } - ContentPart::ToolResult(result) if result.content.iter().any(part_is_local_file) => { - let mut content = Vec::with_capacity(result.content.len()); - for part in result.content { - if let Some(part) = Box::pin(self.inline_part(part)).await { - content.push(part); - } - } - Some(ContentPart::ToolResult(ToolResult { content, ..result })) - } - other => Some(other), - } - } - - async fn inline_request(&self, request: Request) -> Request { - let mut messages = Vec::with_capacity(request.messages().len()); - for message in request.messages() { - let mut content = Vec::with_capacity(message.content().len()); - for part in message.content() { - if let Some(part) = self.inline_part(part.clone()).await { - content.push(part); - } - } - let mut rebuilt = Message::new(message.role(), content); - if let Some(name) = message.name() { - rebuilt = rebuilt.with_name(name); - } - if let Some(id) = message.tool_call_id() { - rebuilt = rebuilt.with_tool_call_id(id); - } - messages.push(rebuilt); - } - replace_messages(&request, messages).unwrap_or(request) - } -} - -/// Rebuilds `request` with `messages` in place of its own. -/// -/// The request builder appends messages and has no way to clear them, so the -/// swap goes through the request's serde form. -fn replace_messages(request: &Request, messages: Vec) -> Option { - let mut value = serde_json::to_value(request).ok()?; - value["messages"] = serde_json::to_value(messages).ok()?; - serde_json::from_value(value).ok() -} - -fn part_is_local_file(part: &ContentPart) -> bool { - match part { - ContentPart::Image(ImageContent { source, .. }) - | ContentPart::Document(DocumentContent { source, .. }) - | ContentPart::Audio(AudioContent { source }) => is_local_file(source), - _ => false, - } -} - -fn url_of(source: &MediaSource) -> &str { - match source { - MediaSource::Url { url, .. } => url, - _ => "", - } -} - -fn is_local_file(source: &MediaSource) -> bool { - matches!( - source, - MediaSource::Url { url, .. } - if url.starts_with('/') || url.starts_with("./") || url.starts_with("~/") - ) -} - -fn needs_inlining(request: &Request) -> bool { - request.messages().iter().any(|message| { - message.content().iter().any(|part| match part { - ContentPart::ToolResult(result) => result.content.iter().any(part_is_local_file), - part => part_is_local_file(part), - }) - }) -} - -/// Media type for a local path, from its extension. -#[must_use] -pub fn media_type_for_path(path: &str) -> String { - mime_guess::from_path(path) - .first_raw() - .unwrap_or("application/octet-stream") - .to_string() -} - -#[async_trait] -impl Middleware for InlineLocalAttachments { - async fn handle(&self, call: Call, next: Next) -> Result { - if !needs_inlining(call.request()) { - return next.run(call).await; - } - let inlined = self.inline_request(call.request().clone()).await; - let call = call.map_request(|_| Ok(inlined))?; - next.run(call).await - } -} - -#[cfg(test)] -mod tests { - use lithos_llm::types::Role; - - use super::*; - - fn request_with(part: ContentPart) -> Request { - Request::builder() - .model("openai/gpt-5.4") - .message(Message::new(Role::User, [ - ContentPart::Text { - text: "look".to_string(), - }, - part, - ])) - .build() - .unwrap() - } - - #[tokio::test] - async fn inlines_local_images_and_drops_missing_files() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("pixel.png"); - fs::write(&path, b"\x89PNG").await.unwrap(); - let middleware = InlineLocalAttachments::new(); - - let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( - path.to_string_lossy().to_string(), - )))); - let inlined = middleware.inline_request(request).await; - match &inlined.messages()[0].content()[1] { - ContentPart::Image(image) => { - assert_eq!(image.source.media_type(), Some("image/png")); - assert_eq!( - image.source.base64_data(), - Some(BASE64_STANDARD.encode(b"\x89PNG").as_str()) - ); - } - other => panic!("expected inlined image, got {other:?}"), - } - - let missing = request_with(ContentPart::Document(DocumentContent::new( - MediaSource::url("/definitely/missing.pdf"), - ))); - let inlined = middleware.inline_request(missing).await; - assert_eq!(inlined.messages()[0].content().len(), 1); - } - - #[test] - fn remote_urls_and_inline_data_pass_through() { - let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( - "https://example.com/a.png", - )))); - assert!(!needs_inlining(&request)); - let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::base64( - "AAAA", - "image/png", - )))); - assert!(!needs_inlining(&request)); - let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( - "~/shot.png", - )))); - assert!(needs_inlining(&request)); - } - - #[test] - fn media_types_follow_extensions() { - assert_eq!(media_type_for_path("a.jpg"), "image/jpeg"); - assert_eq!(media_type_for_path("a.pdf"), "application/pdf"); - assert_eq!(media_type_for_path("a.bin"), "application/octet-stream"); - } -} diff --git a/lib/components/fabro-llm/src/catalog.rs b/lib/components/fabro-llm/src/catalog.rs index a12b27c8a..5314ba346 100644 --- a/lib/components/fabro-llm/src/catalog.rs +++ b/lib/components/fabro-llm/src/catalog.rs @@ -1,18 +1,18 @@ -//! Catalog construction and the queries Fabro's dispatch boundaries share. +//! Catalog construction and the agent-profile reading that is Fabro's own. //! //! 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}; +//! overlay. Which providers are on, which model a selector names, and which +//! model to pick for a job are lithos questions, answered by +//! [`Catalog`] and [`CatalogProvider`] (`enabled_providers`, +//! `offerings_matching`, `default_offering_for`, and the rest). What stays +//! here is the coding harness a model expects, read from the shared +//! `metadata.agent` namespace that Pebble reads too. use fabro_config::LlmLayer; use fabro_static::EnvVars; -use fabro_types::{AgentProfileKind, Cost, ModelId, ModelRef, ProviderId, TokenCounts}; +use fabro_types::AgentProfileKind; +pub use lithos_llm::catalog::Offering; use lithos_llm::catalog::{Catalog, CatalogError, CatalogModel, CatalogProvider, Metadata}; -use lithos_llm::resolver::ResolvedRoute; use serde::Deserialize; /// The metadata namespace agent harnesses read. @@ -53,40 +53,6 @@ pub fn default_catalog() -> Catalog { 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)] @@ -104,6 +70,35 @@ fn agent_metadata(metadata: &Metadata) -> AgentMetadata { .unwrap_or_default() } +/// Whether requests to `offering` 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(offering: &Offering<'_>) -> bool { + agent_metadata(offering.model.metadata()) + .reasoning_by_default + .or(agent_metadata(offering.provider.metadata()).reasoning_by_default) + .unwrap_or_else(|| { + offering.model.capabilities().reasoning().is_supported() + && offering.model.protocol_options().reasoning_effort_levels + }) +} + +/// The agent harness `offering` runs under: the model's own answer, then +/// the provider's, then the profile implied by the provider's adapter. +#[must_use] +pub fn offering_agent_profile(offering: &Offering<'_>) -> AgentProfileKind { + model_agent_profile(offering.provider, offering.model) +} + +fn model_agent_profile(provider: &CatalogProvider, model: &CatalogModel) -> AgentProfileKind { + agent_metadata(model.metadata()) + .profile + .unwrap_or_else(|| provider_agent_profile(provider)) +} + /// 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. @@ -117,260 +112,22 @@ fn provider_agent_profile(provider: &CatalogProvider) -> AgentProfileKind { }) } -/// Estimates the catalog cost of `usage` on `model`, when the catalog prices -/// that route. Passthrough models and unknown providers have no price. -#[must_use] -pub fn estimate_cost(catalog: &Catalog, model: &ModelRef, usage: TokenCounts) -> Option { - let entry = model_on_provider(catalog, model.provider.as_str(), model.model_id.as_str())?; - ResolvedRoute::try_new(entry.provider.clone(), entry.model.clone()) - .ok()? - .estimate_cost(usage, model.speed) -} - -/// Enabled providers, highest priority first, ties broken by id. -#[must_use] -pub fn enabled_providers(catalog: &Catalog) -> Vec<&CatalogProvider> { - let mut providers: Vec<_> = catalog - .providers() - .filter(|provider| provider.is_enabled()) - .collect(); - providers.sort_by(|left, right| { - right - .priority() - .cmp(&left.priority()) - .then_with(|| left.id().cmp(right.id())) - }); - providers -} - -/// 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<&CatalogProvider> { - enabled_providers(catalog) - .into_iter() - .filter(|provider| provider.stands_in_for().is_none()) - .collect() -} - -/// The ids of every enabled provider. -#[must_use] -pub fn enabled_provider_ids(catalog: &Catalog) -> HashSet { - enabled_providers(catalog) - .into_iter() - .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<&'a CatalogProvider> { - catalog - .provider(selector) - .ok() - .filter(|provider| provider.is_enabled()) -} - -/// Canonicalizes a provider id or alias to its catalog id, when enabled. -#[must_use] -pub fn canonical_provider_id(catalog: &Catalog, selector: &str) -> Option { - provider(catalog, selector).map(|provider| provider.id().clone()) -} - -/// The models of a provider, in catalog order. -#[must_use] -pub fn provider_models(provider: &CatalogProvider) -> Vec> { - provider - .models() - .map(|model| ModelEntry { provider, model }) - .collect() -} - -/// Every model across listed providers, provider priority order. -#[must_use] -pub fn models(catalog: &Catalog) -> Vec> { - listed_providers(catalog) - .into_iter() - .flat_map(provider_models) - .collect() -} - -/// Finds a model on an enabled provider by id, alias, or wire id. -#[must_use] -pub fn model_on_provider<'a>( - catalog: &'a Catalog, - provider_selector: &str, - model_selector: &str, -) -> Option> { - let 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 = provider.model(model_selector).or_else(|| { - provider - .models() - .find(|model| model.api_model() == model_selector) - })?; - Some(ModelEntry { provider, model }) -} - -/// Models matching `selector` by id or alias, ordered like lithos selection: -/// exact ids before aliases, then provider priority. -#[must_use] -pub fn models_matching<'a>(catalog: &'a Catalog, selector: &str) -> Vec> { - let mut matches: Vec<_> = enabled_providers(catalog) - .into_iter() - .flat_map(provider_models) - .filter(|entry| { - entry.model.id().as_str() == selector - || entry.model.aliases().iter().any(|alias| alias == selector) - }) - .collect(); - matches.sort_by_key(|entry| entry.model.id().as_str() != selector); - matches -} - -/// 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() -} - -/// Whether `selector` names an enabled provider. -#[must_use] -pub fn is_provider_selector(catalog: &Catalog, selector: &str) -> bool { - provider(catalog, selector).is_some() -} - -/// The default model of an enabled provider. -#[must_use] -pub fn default_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { - let provider = provider(catalog, provider_selector)?; - let default = provider.default_model()?; - model_on_provider(catalog, provider.id().as_str(), default) -} - -/// The model Fabro probes a provider with: the `probe` model, else the -/// provider default. -#[must_use] -pub fn probe_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { - let provider = provider(catalog, provider_selector)?; - provider_models(provider) - .into_iter() - .find(|entry| entry.model.is_probe()) - .or_else(|| default_model(catalog, provider_selector)) -} - -/// The default model across `ready` providers: the highest-priority ready -/// provider's default. Falls back to any enabled provider's default when no -/// provider is ready, so callers always have a model to name. -#[must_use] -pub fn default_for_ready<'a>( - catalog: &'a Catalog, - ready: &HashSet, -) -> Option> { - let providers = enabled_providers(catalog); - providers - .iter() - .filter(|provider| ready.contains(provider.id())) - .chain(providers.iter()) - .find_map(|provider| default_model(catalog, provider.id().as_str())) -} - -/// The small utility model across `ready` providers: the first -/// `small_default` model in provider priority order, else the ready default. -#[must_use] -pub fn small_default_for_ready<'a>( - catalog: &'a Catalog, - ready: &HashSet, -) -> Option> { - 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)) -} - -/// Canonicalizes a model selector to a catalog model id, preferring -/// `provider`'s offering. Unknown selectors pass through verbatim so -/// passthrough models keep their names. -#[must_use] -pub fn canonical_model_id(catalog: &Catalog, provider: &ProviderId, selector: &str) -> String { - model_on_provider(catalog, provider.as_str(), selector) - .map(|entry| entry.model.id().to_string()) - .or_else(|| { - models_matching(catalog, selector) - .first() - .map(|entry| entry.model.id().to_string()) - }) - .unwrap_or_else(|| selector.to_string()) -} - -/// The agent profile for a route. Unknown (passthrough) models take the -/// provider default. +/// The agent profile for a route on an enabled provider. Unknown +/// (passthrough) models take the provider default; a disabled or unknown +/// provider has none. #[must_use] pub fn agent_profile( catalog: &Catalog, provider_selector: &str, model_selector: Option<&str>, ) -> Option { - let provider = provider(catalog, provider_selector)?; - let model = model_selector.and_then(|selector| provider.model(selector)); - Some(match model { - Some(model) => ModelEntry { provider, model }.agent_profile(), - None => provider_agent_profile(provider), - }) -} - -/// The `target` provider's model closest to `reference` in capability and -/// input price, for provider-level fallbacks. -#[must_use] -pub fn closest_model<'a>( - catalog: &'a Catalog, - target: &str, - reference: &CatalogModel, -) -> Option> { - let target = provider(catalog, target)?; - let reference_caps = reference.capabilities(); - let reference_price = reference - .pricing() - .and_then(|pricing| pricing.input_usd_micros_per_million) - .unwrap_or(0); - provider_models(target) - .into_iter() - .filter(|entry| { - let caps = entry.model.capabilities(); - caps.tools().is_supported() == reference_caps.tools().is_supported() - && caps.images().is_supported() == reference_caps.images().is_supported() - && caps.reasoning().is_supported() == reference_caps.reasoning().is_supported() - }) - .min_by_key(|entry| { - let price = entry - .model - .pricing() - .and_then(|pricing| pricing.input_usd_micros_per_million) - .unwrap_or(0); - price.abs_diff(reference_price) - }) -} - -/// Model ids grouped by provider, for diagnostics and documentation. -#[must_use] -pub fn model_ids_by_provider(catalog: &Catalog) -> BTreeMap> { - listed_providers(catalog) - .into_iter() - .map(|provider| { - ( - provider.id().clone(), - provider_models(provider) - .into_iter() - .map(|entry| entry.model.id().clone()) - .collect(), - ) - }) - .collect() + let provider = catalog.enabled_provider(provider_selector)?; + Some( + match model_selector.and_then(|selector| provider.model(selector)) { + Some(model) => model_agent_profile(provider, model), + None => provider_agent_profile(provider), + }, + ) } #[cfg(test)] @@ -378,27 +135,6 @@ mod tests { use super::*; use crate::test_support::test_catalog; - #[test] - fn builtins_ship_fabro_defaults() { - let catalog = test_catalog(); - let ids: Vec<_> = enabled_providers(&catalog) - .iter() - .map(|provider| provider.id().to_string()) - .collect(); - assert_eq!(ids[0], "anthropic"); - assert!(ids.contains(&"openai".to_string())); - assert!( - !ids.contains(&"bedrock".to_string()), - "bedrock ships disabled" - ); - assert!( - !listed_providers(&catalog) - .iter() - .any(|provider| provider.id().as_str() == "openai-codex"), - "stand-in providers are not listed" - ); - } - #[test] fn operator_overlay_applies_last() { let overlay = LlmLayer( @@ -412,7 +148,7 @@ enabled = false .unwrap(), ); let catalog = build_catalog(&overlay, &|_| None).unwrap(); - assert!(provider(&catalog, "openai").is_none()); + assert!(catalog.enabled_provider("openai").is_none()); assert_eq!( catalog.provider("openai").unwrap().priority(), 500, @@ -433,61 +169,8 @@ enabled = false } #[test] - fn probe_and_small_default_follow_the_catalog() { + fn agent_profiles_follow_the_model_then_the_provider() { let catalog = test_catalog(); - assert_eq!( - probe_model(&catalog, "openai").unwrap().model.id().as_str(), - "gpt-5.4-mini" - ); - assert_eq!( - probe_model(&catalog, "anthropic") - .unwrap() - .model - .id() - .as_str(), - "claude-haiku-4.5" - ); - let ready = HashSet::from([ProviderId::new("openai")]); - assert_eq!( - small_default_for_ready(&catalog, &ready) - .unwrap() - .model - .id() - .as_str(), - "gpt-5.4-mini" - ); - assert_eq!( - default_for_ready(&catalog, &ready) - .unwrap() - .model - .id() - .as_str(), - "gpt-5.6-sol" - ); - assert_eq!( - default_for_ready(&catalog, &HashSet::new()) - .unwrap() - .provider - .id() - .as_str(), - "anthropic" - ); - } - - #[test] - fn selectors_resolve_aliases_and_canonical_ids() { - let catalog = test_catalog(); - assert!(is_model_selector(&catalog, "sonnet")); - assert!(is_model_selector(&catalog, "gpt-5.4-mini")); - assert!(!is_model_selector(&catalog, "nope")); - assert_eq!( - canonical_model_id(&catalog, &ProviderId::new("openai"), "codex"), - "gpt-5.4" - ); - assert_eq!( - canonical_model_id(&catalog, &ProviderId::new("openai"), "unknown-model"), - "unknown-model" - ); assert_eq!( agent_profile(&catalog, "openai", Some("gpt-5.6-sol")), Some(AgentProfileKind::Gpt56) @@ -523,12 +206,15 @@ enabled = false #[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(); + let moonshot = catalog.enabled_provider("moonshot").unwrap(); + let kimi = moonshot.offering("kimi-k2.5").unwrap(); + assert!(reasons_by_default(&kimi), "the catalog row says so"); + let anthropic = catalog.enabled_provider("anthropic").unwrap(); + let sonnet = anthropic.offering("claude-sonnet-4.5").unwrap(); assert!( - !sonnet.reasons_by_default(), + !reasons_by_default(&sonnet), "a thinking-budget model reasons only when asked" ); + assert_eq!(offering_agent_profile(&kimi), AgentProfileKind::Kimi); } } diff --git a/lib/components/fabro-llm/src/client.rs b/lib/components/fabro-llm/src/client.rs index 412456ffb..64df12e17 100644 --- a/lib/components/fabro-llm/src/client.rs +++ b/lib/components/fabro-llm/src/client.rs @@ -3,18 +3,14 @@ use std::sync::Arc; use std::time::Duration; -use fabro_auth::{CredentialSource, ResolveError, lithos_credentials}; -use fabro_types::ProviderId; use lithos_llm::adapter::ProviderAdapter; -use lithos_llm::catalog::Catalog; +use lithos_llm::catalog::{Catalog, ProviderId}; use lithos_llm::client::{Client, ClientBuildError, ClientBuilder, ProviderBuildIssue}; +use lithos_llm::credentials::{CredentialError, CredentialProvider}; use lithos_llm::middleware::{ - Call, Middleware, Observer, RetryMiddleware, RetryPolicy, RetryStage, + Call, InlineLocalFiles, Middleware, Observer, RetryMiddleware, RetryPolicy, RetryStage, }; -use lithos_llm::types::Error; - -use crate::attachments::InlineLocalAttachments; -use crate::error::LlmError; +use lithos_llm::types::{Error, ErrorData}; /// The application name lithos reports to providers that ask, such as the /// `originator` header on the OpenAI Codex deployment. @@ -36,7 +32,7 @@ pub fn default_retry_policy() -> RetryPolicy { #[derive(Clone, Debug)] pub struct RetryNotice { /// The failure that ended the attempt. - pub error: LlmError, + pub error: ErrorData, /// The attempt that failed, counted from 1. pub attempt: u32, /// How long the middleware waits before the next attempt. @@ -78,7 +74,7 @@ impl Observer for RetryNotifier { ) { if let Some(listener) = call.context().extensions().get::() { listener.notify(RetryNotice { - error: LlmError::from(error), + error: ErrorData::from(error), attempt, delay, stage, @@ -150,7 +146,7 @@ impl ClientOptions { builder = builder.middleware(retry_middleware(policy)); } if self.inline_attachments { - builder = builder.middleware(InlineLocalAttachments::new()); + builder = builder.middleware(InlineLocalFiles::new()); } for middleware in self.middleware { builder = builder.middleware_arc(middleware); @@ -167,8 +163,9 @@ pub struct FabroClient { pub client: Client, /// Enabled providers with working credentials, in catalog order. pub ready: Vec, - /// Enabled providers whose credential material could not be used. - pub auth_issues: Vec<(ProviderId, ResolveError)>, + /// Enabled providers whose credential material could not be used. The + /// error's `Display` is the operator-facing line. + pub auth_issues: Vec<(ProviderId, CredentialError)>, /// Ready providers lithos could not build an adapter for. pub build_issues: Vec, } @@ -193,34 +190,42 @@ pub enum LlmSetupError { Build(#[from] ClientBuildError), } -/// Builds a client whose ready providers are those the credential source can -/// serve. Credentials are re-read from `source` on every provider attempt. +/// Builds a client whose ready providers are those `credentials` can serve. +/// Credentials are re-read on every provider attempt, so a refreshed OAuth +/// token is picked up by the next retry. pub async fn build_client( catalog: Catalog, - source: Arc, + credentials: Arc, options: ClientOptions, ) -> Result { - let resolved = source.resolve_all(&catalog).await; - let mut ready = resolved.ready; - for provider in options.adapter_providers() { - if !ready.contains(provider) { - ready.push(provider.clone()); - } - } let builder = Client::builder() .catalog(catalog) .application(APPLICATION_NAME) - .credentials_arc(lithos_credentials(source)) - .enabled_providers(ready.iter().cloned()); - let build = options.apply(builder).build()?; + .credentials_arc(credentials); + let build = options.apply(builder).build_ready().await?; Ok(FabroClient { - client: build.client, - ready, - auth_issues: resolved.auth_issues, + client: build.client, + ready: build.ready, + auth_issues: build.credential_issues, build_issues: build.issues, }) } +/// The enabled providers `credentials` holds material for, in catalog order, +/// without refreshing anything. Cheap enough for listings. +pub async fn configured_providers( + catalog: &Catalog, + credentials: &dyn CredentialProvider, +) -> Vec { + let mut configured = Vec::new(); + for provider in catalog.providers().filter(|provider| provider.is_enabled()) { + if credentials.is_configured(provider).await { + configured.push(provider.id().clone()); + } + } + configured +} + /// Builds a client that needs no credentials: every available provider is /// served by a custom adapter from `options.adapters`, such as the /// `fabro exec` gateway or a test double. diff --git a/lib/components/fabro-llm/src/error.rs b/lib/components/fabro-llm/src/error.rs index 05eec0e1c..3b0104da6 100644 --- a/lib/components/fabro-llm/src/error.rs +++ b/lib/components/fabro-llm/src/error.rs @@ -1,270 +1,41 @@ -//! Classification of lithos errors for Fabro's retry, failover, and failure -//! signature policies, plus the stored form of a failure. +//! The one failure-classification rule that is Fabro's own. //! -//! lithos's live [`Error`] carries a source chain and is therefore neither -//! `Clone` nor serializable. Fabro records failures in events and agent -//! errors, so it works with [`LlmError`], a thin wrapper over lithos's own -//! [`ErrorData`] projection. Every policy here reads through [`ErrorFacts`] -//! and so applies to both forms. +//! Retry, auth, cancellation, and failover questions are answered by the +//! lithos `Error` and `ErrorData` themselves. What stays here is the loop and +//! restart detector's signature format, which names Fabro's own categories. -use std::fmt; -use std::time::Duration; - -use fabro_types::ProviderId; -use lithos_llm::types::{Error, ErrorData, ErrorKind, RetryClassification}; -use serde::{Deserialize, Serialize}; - -/// The facts Fabro's policies read from an LLM failure. -pub trait ErrorFacts { - fn kind(&self) -> ErrorKind; - fn message(&self) -> &str; - fn provider(&self) -> Option<&ProviderId>; - fn provider_code(&self) -> Option<&str>; - fn status(&self) -> Option; - fn retry_classification(&self) -> RetryClassification; - - /// The delay the classification advises, when repeating is safe after - /// a wait. - fn retry_after(&self) -> Option { - self.retry_classification().delay() - } -} - -impl ErrorFacts for Error { - fn kind(&self) -> ErrorKind { - Self::kind(self) - } - - fn message(&self) -> &str { - Self::message(self) - } - - fn provider(&self) -> Option<&ProviderId> { - Self::provider(self) - } - - fn provider_code(&self) -> Option<&str> { - Self::provider_code(self) - } - - fn status(&self) -> Option { - Self::status(self) - } - - fn retry_classification(&self) -> RetryClassification { - Self::retry_classification(self) - } -} - -impl ErrorFacts for ErrorData { - fn kind(&self) -> ErrorKind { - self.kind.clone() - } - - fn message(&self) -> &str { - &self.message - } - - fn provider(&self) -> Option<&ProviderId> { - self.provider.as_ref() - } - - fn provider_code(&self) -> Option<&str> { - self.provider_code.as_deref() - } - - fn status(&self) -> Option { - self.status - } - - fn retry_classification(&self) -> RetryClassification { - self.retry - } -} - -/// A cloneable, serializable LLM failure. -/// -/// This is lithos's [`ErrorData`] projection with Fabro's policy helpers -/// attached. It is what agent errors, run events, and API responses carry; -/// the live [`Error`] converts into it at the boundary where a failure stops -/// being handled and starts being recorded. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct LlmError(Box); - -impl LlmError { - /// A failure Fabro itself raises, never retried. - #[must_use] - pub fn new(kind: ErrorKind, message: impl Into) -> Self { - Self::from(Error::new(kind, message)) - } - - #[must_use] - pub fn data(&self) -> &ErrorData { - &self.0 - } - - #[must_use] - pub fn into_data(self) -> ErrorData { - *self.0 - } - - /// The immediate source of the failure, rendered as text. - #[must_use] - pub fn source_message(&self) -> Option<&str> { - self.0.source_message.as_deref() - } - - /// The provider's advised wait, whatever the error kind. - #[must_use] - pub fn provider_retry_after(&self) -> Option { - self.0 - .provider_retry_after_millis - .map(Duration::from_millis) - } - - #[must_use] - pub fn is_retryable(&self) -> bool { - is_retryable(self) - } - - #[must_use] - pub fn is_auth_error(&self) -> bool { - is_auth_error(self) - } - - #[must_use] - pub fn is_cancelled(&self) -> bool { - is_cancelled(self) - } - - #[must_use] - pub fn failover_eligible(&self) -> bool { - failover_eligible(self) - } - - #[must_use] - pub fn failure_signature_hint(&self) -> String { - failure_signature_hint(self) - } -} - -impl ErrorFacts for LlmError { - fn kind(&self) -> ErrorKind { - self.0.kind.clone() - } - - fn message(&self) -> &str { - &self.0.message - } - - fn provider(&self) -> Option<&ProviderId> { - self.0.provider.as_ref() - } - - fn provider_code(&self) -> Option<&str> { - self.0.provider_code.as_deref() - } - - fn status(&self) -> Option { - self.0.status - } - - fn retry_classification(&self) -> RetryClassification { - self.0.retry - } -} - -impl fmt::Display for LlmError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0.message) - } -} - -impl std::error::Error for LlmError {} - -impl From for LlmError { - fn from(error: Error) -> Self { - Self(Box::new(error.data())) - } -} - -impl From<&Error> for LlmError { - fn from(error: &Error) -> Self { - Self(Box::new(error.data())) - } -} - -impl From for LlmError { - fn from(data: ErrorData) -> Self { - Self(Box::new(data)) - } -} - -/// Whether repeating the same call on the same provider may succeed. -#[must_use] -pub fn is_retryable(error: &E) -> bool { - !matches!(error.retry_classification(), RetryClassification::Never) -} - -/// Whether the failure came from a credential problem. -#[must_use] -pub fn is_auth_error(error: &E) -> bool { - matches!( - error.kind(), - ErrorKind::Authentication | ErrorKind::AccessDenied - ) -} - -/// Whether the call was cancelled by Fabro rather than failed by the provider. -#[must_use] -pub fn is_cancelled(error: &E) -> bool { - error.kind() == ErrorKind::Cancelled -} - -/// Whether another provider is worth trying. -/// -/// Everything retryable qualifies, plus failures that are local to this -/// provider: credentials, access policy, model inventory, quota, and a -/// provider that ran out of time. A different provider has its own. -#[must_use] -pub fn failover_eligible(error: &E) -> bool { - if is_retryable(error) { - return true; - } - matches!( - error.kind(), - ErrorKind::Authentication - | ErrorKind::AccessDenied - | ErrorKind::NotFound - | ErrorKind::QuotaExceeded - | ErrorKind::RateLimit - | ErrorKind::Server - | ErrorKind::Network - | ErrorKind::Timeout - | ErrorKind::StreamDecode - ) || (error.kind() == ErrorKind::ContentFilter && error.provider_code() == Some("refusal")) -} +use lithos_llm::catalog::ProviderId; +use lithos_llm::types::{ErrorData, ErrorKind}; /// A stable `category|provider|detail` string for loop and restart detection. +/// +/// The category is `api_canceled` for a cancelled call, `api_transient` for a +/// failure the provider may be asked to repeat, and `api_deterministic` for +/// everything else; the detail is the error kind's stored spelling. #[must_use] -pub fn failure_signature_hint(error: &E) -> String { +pub fn failure_signature_hint(error: &ErrorData) -> String { let provider = error.provider().map_or("unknown", ProviderId::as_str); - let category = match error.kind() { - ErrorKind::Cancelled => "api_canceled", - _ if is_retryable(error) => "api_transient", - _ => "api_deterministic", + let category = if error.is_cancelled() { + "api_canceled" + } else if error.is_retryable() { + "api_transient" + } else { + "api_deterministic" }; - let detail = error.kind().as_str().to_string(); - format!("{category}|{provider}|{detail}") + let kind: ErrorKind = error.kind(); + format!("{category}|{provider}|{}", kind.as_str()) } #[cfg(test)] mod tests { + use lithos_llm::types::{Error, RetryClassification}; + use super::*; - fn error(kind: ErrorKind) -> Error { - Error::new(kind, "boom").with_provider(ProviderId::new("openai")) + fn error(kind: ErrorKind) -> ErrorData { + Error::new(kind, "boom") + .with_provider(ProviderId::new("openai")) + .data() } #[test] @@ -275,7 +46,10 @@ mod tests { ); assert_eq!( failure_signature_hint( - &error(ErrorKind::RateLimit).with_retry(RetryClassification::Safe) + &Error::new(ErrorKind::RateLimit, "boom") + .with_provider(ProviderId::new("openai")) + .with_retry(RetryClassification::Safe) + .data() ), "api_transient|openai|rate_limit" ); @@ -284,42 +58,4 @@ mod tests { "api_canceled|openai|cancelled" ); } - - #[test] - fn failover_covers_provider_local_failures() { - assert!(failover_eligible(&error(ErrorKind::Authentication))); - assert!(failover_eligible(&error(ErrorKind::QuotaExceeded))); - assert!(!failover_eligible(&error(ErrorKind::InvalidRequest))); - assert!(!failover_eligible(&error(ErrorKind::ContextLength))); - assert!(!failover_eligible(&error(ErrorKind::ContentFilter))); - assert!(failover_eligible( - &error(ErrorKind::ContentFilter).with_provider_code("refusal") - )); - } - - #[test] - fn stored_errors_keep_the_facts_and_round_trip() { - let live = error(ErrorKind::RateLimit) - .with_status(429) - .with_provider_code("slow") - .with_retry(RetryClassification::after(Duration::from_secs(2))) - .with_source(std::io::Error::other("socket closed")); - let stored = LlmError::from(&live); - assert_eq!(stored.kind(), ErrorKind::RateLimit); - assert_eq!(stored.status(), Some(429)); - assert_eq!(stored.provider_code(), Some("slow")); - assert_eq!(stored.retry_after(), Some(Duration::from_secs(2))); - assert_eq!(stored.source_message(), Some("socket closed")); - assert_eq!(stored.to_string(), "boom"); - assert!(stored.is_retryable()); - assert_eq!( - stored.failure_signature_hint(), - failure_signature_hint(&live) - ); - - let json = serde_json::to_value(&stored).unwrap(); - assert_eq!(json["kind"], "rate_limit"); - let decoded: LlmError = serde_json::from_value(json).unwrap(); - assert_eq!(decoded, stored); - } } diff --git a/lib/components/fabro-llm/src/lib.rs b/lib/components/fabro-llm/src/lib.rs index 31cd9d74b..27199a2ed 100644 --- a/lib/components/fabro-llm/src/lib.rs +++ b/lib/components/fabro-llm/src/lib.rs @@ -4,42 +4,36 @@ //! the client. This crate adds what is specific to Fabro: //! //! - building the catalog from the lithos built-ins and the operator `[llm]` -//! overlay, and the catalog queries Fabro's dispatch boundaries share -//! ([`catalog`]); +//! overlay, and reading the agent harness a model expects ([`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; -//! - constructing a client from a Fabro credential source ([`client`]); -//! - inlining local file attachments ([`attachments`]); -//! - normalizing readable reasoning into [`fabro_types::ReasoningOutput`] -//! ([`reasoning`]); -//! - one-shot structured output ([`structured`]); +//! - constructing a client from a Fabro credential store ([`client`]); //! - model and provider probes ([`probe`]), and the API views of the catalog //! ([`api`]); //! - the `fabro exec` gateway adapter that speaks to a Fabro server //! ([`gateway`]); -//! - error classification for retries, failover, and failure signatures -//! ([`error`]). +//! - the failure signature loop detection reads ([`error`]). +//! +//! Local-file inlining, structured output, readable-reasoning normalization, +//! and the retry, auth, and failover predicates are lithos-llm's own. pub mod api; -pub mod attachments; pub mod catalog; pub mod client; pub mod error; pub mod gateway; pub mod probe; -pub mod reasoning; pub mod selection; -pub mod structured; #[cfg(any(test, feature = "test-support"))] pub mod test_support; pub use catalog::{build_catalog, default_catalog}; pub use client::{ ClientOptions, FabroClient, LlmSetupError, RetryListener, RetryNotice, build_client, - build_offline_client, + build_offline_client, configured_providers, }; -pub use error::{ErrorFacts, LlmError}; +pub use error::failure_signature_hint; pub use lithos_llm::client::{Client, ClientBuild}; pub use lithos_llm::middleware::{CallContext, CancellationToken, RetryPolicy, RetryStage}; pub use lithos_llm::resolver::ModelSelectionError as RouteSelectionError; diff --git a/lib/components/fabro-llm/src/probe.rs b/lib/components/fabro-llm/src/probe.rs index 1e2cbcb25..9c44c5395 100644 --- a/lib/components/fabro-llm/src/probe.rs +++ b/lib/components/fabro-llm/src/probe.rs @@ -4,12 +4,12 @@ use std::sync::Arc; use std::time::Duration; use fabro_auth::ApiKeyCredentialSource; -use fabro_types::{ModelTestMode, ProviderId, ReasoningEffort}; -use lithos_llm::catalog::Catalog; +use fabro_types::ModelTestMode; +use lithos_llm::catalog::{Catalog, ProviderId}; use lithos_llm::client::{Client, ProbeOptions, ProbeOutcome}; +use lithos_llm::types::ReasoningEffort; use strum::IntoStaticStr; -use crate::catalog; use crate::client::{ClientOptions, LlmSetupError, build_client}; #[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] @@ -104,13 +104,15 @@ pub async fn probe_provider_with_api_key( api_key: String, timeout: Duration, ) -> Result { - let catalog_provider = catalog::provider(&catalog, provider.as_str()) + let catalog_provider = catalog + .enabled_provider(provider.as_str()) .ok_or_else(|| ApiKeyProbeError::UnknownProvider(provider.to_string()))?; 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()) + let model = catalog_provider + .probe_offering() .ok_or_else(|| ApiKeyProbeError::NoProbeModel(provider_id.clone()))?; let selector = format!("{provider_id}/{}", model.model.id()); let source = Arc::new(ApiKeyCredentialSource::new(provider_id.clone(), api_key)); @@ -120,10 +122,7 @@ pub async fn probe_provider_with_api_key( .iter() .find(|(candidate, _)| candidate == &provider_id) { - return Ok(ModelTestOutcome::error(fabro_auth::auth_issue_message( - &provider_id, - issue, - ))); + return Ok(ModelTestOutcome::error(issue.to_string())); } Ok(run_basic_probe(&built.client, &selector, timeout).await) } diff --git a/lib/components/fabro-llm/src/reasoning.rs b/lib/components/fabro-llm/src/reasoning.rs deleted file mode 100644 index ca34594d2..000000000 --- a/lib/components/fabro-llm/src/reasoning.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Normalization of provider reasoning material into [`ReasoningOutput`]. -//! -//! Every provider that returns readable reasoning does it differently, and -//! several return more than one channel at once. This module reduces a final -//! response's content parts to the two normalized fields without reaching -//! into opaque material (signatures, item ids, encrypted payloads) and -//! without failing a completion it cannot classify. -//! -//! Parsing is deliberately tolerant: provider payloads are read as -//! `serde_json::Value` with optional lookups, so unknown detail variants, -//! missing members, extra members, and unexpected member types are ignored -//! rather than surfaced as errors. - -use fabro_types::{ContentPart, ReasoningOutput}; - -/// OpenAI Responses reasoning items, as lithos stores them. -pub const OPENAI_REASONING_KIND: &str = "openai.reasoning"; -/// OpenAI Responses message items, as lithos stores them. -pub const OPENAI_MESSAGE_KIND: &str = "openai.message"; -/// OpenAI-compatible `reasoning_details` arrays, as lithos stores them. -pub const OPENAI_COMPAT_REASONING_DETAILS_KIND: &str = "openai_compatible.reasoning_details"; - -/// Separator between distinct complete reasoning blocks. -const BLOCK_SEPARATOR: &str = "\n\n"; - -#[derive(Default)] -struct Blocks<'a> { - explicit_summary: Vec<&'a str>, - explicit_trace: Vec<&'a str>, - fallback_trace: Vec<&'a str>, -} - -impl Blocks<'_> { - fn into_output(self) -> Option { - let summary = join_blocks(&self.explicit_summary); - let trace = join_blocks(&self.explicit_trace) - .or_else(|| join_blocks(&self.fallback_trace)) - .filter(|trace| summary.as_ref() != Some(trace)); - - match (summary, trace) { - (Some(summary), Some(trace)) => Some(ReasoningOutput::new(summary, trace)), - (Some(summary), None) => Some(ReasoningOutput::from_summary(summary)), - (None, Some(trace)) => Some(ReasoningOutput::from_trace(trace)), - (None, None) => None, - } - } -} - -fn join_blocks(blocks: &[&str]) -> Option { - (!blocks.is_empty()).then(|| blocks.join(BLOCK_SEPARATOR)) -} - -fn push_block<'a>(blocks: &mut Vec<&'a str>, block: &'a str) { - if !block.trim().is_empty() { - blocks.push(block); - } -} - -fn readable_member<'a>(entry: &'a serde_json::Value, member: &str) -> Option<&'a str> { - entry.get(member).and_then(serde_json::Value::as_str) -} - -fn collect_openai_reasoning_item<'a>(item: &'a serde_json::Value, blocks: &mut Blocks<'a>) { - if let Some(entries) = item.get("summary").and_then(serde_json::Value::as_array) { - for entry in entries { - if let Some(text) = entry.as_str() { - push_block(&mut blocks.explicit_summary, text); - } else if let Some(text) = entry.get("text").and_then(serde_json::Value::as_str) { - push_block(&mut blocks.explicit_summary, text); - } - } - } - if let Some(entries) = item.get("content").and_then(serde_json::Value::as_array) { - for entry in entries { - let Some(text) = entry.get("text").and_then(serde_json::Value::as_str) else { - continue; - }; - let entry_type = entry - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - if entry_type == "reasoning_text" { - push_block(&mut blocks.explicit_trace, text); - } - } - } -} - -fn collect_reasoning_details<'a>(details: &'a serde_json::Value, blocks: &mut Blocks<'a>) { - let Some(entries) = details.as_array() else { - return; - }; - for entry in entries { - let detail_type = entry - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - match detail_type { - "reasoning.text" => { - if let Some(text) = readable_member(entry, "text") { - push_block(&mut blocks.explicit_trace, text); - } - } - "reasoning.summary" => { - if let Some(text) = readable_member(entry, "summary") { - push_block(&mut blocks.explicit_summary, text); - } - } - _ => {} - } - } -} - -/// Normalizes the content parts of a final response into readable reasoning. -/// -/// Returns `None` when the response carries no readable reasoning. -#[must_use] -pub fn normalize(content: &[ContentPart]) -> Option { - let mut blocks = Blocks::default(); - for part in content { - match part { - ContentPart::Reasoning(reasoning) if !reasoning.redacted => { - push_block(&mut blocks.fallback_trace, &reasoning.text); - } - ContentPart::Opaque { kind, data } if kind == OPENAI_REASONING_KIND => { - collect_openai_reasoning_item(data, &mut blocks); - } - ContentPart::Opaque { kind, data } if kind == OPENAI_COMPAT_REASONING_DETAILS_KIND => { - collect_reasoning_details(data, &mut blocks); - } - _ => {} - } - } - blocks.into_output() -} - -/// Whether a part is provider-native replay material Fabro keeps in history -/// but never renders. -#[must_use] -pub fn is_provider_part(part: &ContentPart) -> bool { - matches!(part, ContentPart::Reasoning(_) | ContentPart::Opaque { .. }) -} - -/// Whether a part is an OpenAI Responses item tied to one specific API -/// response. Such items become invalid once compaction replaces their -/// surrounding context. -#[must_use] -pub fn is_opaque_openai(part: &ContentPart) -> bool { - matches!( - part, - ContentPart::Opaque { kind, .. } - if kind == OPENAI_REASONING_KIND || kind == OPENAI_MESSAGE_KIND - ) -} - -#[cfg(test)] -mod tests { - use fabro_types::ReasoningContent; - use serde_json::json; - - use super::*; - - fn thinking(text: &str) -> ContentPart { - ContentPart::Reasoning(ReasoningContent { - text: text.to_string(), - signature: None, - signature_origin: None, - redacted: false, - }) - } - - fn openai_reasoning(item: serde_json::Value) -> ContentPart { - ContentPart::opaque(OPENAI_REASONING_KIND, item) - } - - fn reasoning_details(details: serde_json::Value) -> ContentPart { - ContentPart::opaque(OPENAI_COMPAT_REASONING_DETAILS_KIND, details) - } - - #[test] - fn non_redacted_thinking_becomes_a_trace() { - let output = normalize(&[thinking("weighing the options")]).unwrap(); - assert!(output.summary().is_none()); - assert_eq!(output.trace(), Some("weighing the options")); - } - - #[test] - fn redacted_thinking_yields_no_readable_reasoning() { - let redacted = ContentPart::Reasoning(ReasoningContent { - text: "AAAAopaque".to_string(), - signature: Some("sig".to_string()), - signature_origin: Some("anthropic".to_string()), - redacted: true, - }); - assert!(normalize(&[redacted]).is_none()); - } - - #[test] - fn responses_item_with_summary_and_reasoning_text_produces_both_fields() { - let output = normalize(&[openai_reasoning(json!({ - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "gAAAAA", - "summary": [{"type": "summary_text", "text": "inspect first"}], - "content": [{"type": "reasoning_text", "text": "step one"}], - }))]) - .unwrap(); - assert_eq!(output.summary(), Some("inspect first")); - assert_eq!(output.trace(), Some("step one")); - } - - #[test] - fn responses_blocks_join_in_provider_order() { - let output = normalize(&[openai_reasoning(json!({ - "summary": [ - {"type": "summary_text", "text": "first"}, - {"type": "summary_text", "text": "second"}, - ], - }))]) - .unwrap(); - assert_eq!(output.summary(), Some("first\n\nsecond")); - } - - #[test] - fn structured_details_produce_summary_and_trace() { - let output = normalize(&[reasoning_details(json!([ - {"type": "reasoning.summary", "summary": "checked the parser"}, - {"type": "reasoning.text", "text": "read convert.rs", "signature": "sig"}, - {"type": "reasoning.encrypted", "data": "gAAAAAsecret"}, - ]))]) - .unwrap(); - assert_eq!(output.summary(), Some("checked the parser")); - assert_eq!(output.trace(), Some("read convert.rs")); - } - - #[test] - fn malformed_details_are_ignored_without_failing() { - assert!(normalize(&[reasoning_details(json!("not-an-array"))]).is_none()); - assert!( - normalize(&[reasoning_details(json!([ - 42, - {"type": "reasoning.summary", "summary": 7}, - {"no_type": true}, - ]))]) - .is_none() - ); - } - - #[test] - fn structured_details_suppress_a_duplicate_flattened_value() { - let output = normalize(&[ - reasoning_details(json!([ - {"type": "reasoning.summary", "summary": "checked the parser"}, - ])), - thinking("checked the parser"), - ]) - .unwrap(); - assert_eq!(output.summary(), Some("checked the parser")); - assert!(output.trace().is_none()); - } - - #[test] - fn structured_trace_takes_precedence_over_flattened_trace() { - let output = normalize(&[ - reasoning_details(json!([{"type": "reasoning.text", "text": "verbatim"}])), - thinking("flattened"), - ]) - .unwrap(); - assert_eq!(output.trace(), Some("verbatim")); - } - - #[test] - fn whitespace_only_fragments_do_not_create_reasoning() { - assert!(normalize(&[thinking(" \n ")]).is_none()); - let output = normalize(&[thinking(" indented thought\n")]).unwrap(); - assert_eq!(output.trace(), Some(" indented thought\n")); - } - - #[test] - fn opaque_openai_items_are_recognized() { - assert!(is_opaque_openai(&openai_reasoning(json!({})))); - assert!(is_opaque_openai(&ContentPart::opaque( - OPENAI_MESSAGE_KIND, - json!({}) - ))); - assert!(!is_opaque_openai(&thinking("x"))); - assert!(is_provider_part(&thinking("x"))); - assert!(!is_provider_part(&ContentPart::Text { - text: "x".to_string(), - })); - } -} diff --git a/lib/components/fabro-llm/src/selection.rs b/lib/components/fabro-llm/src/selection.rs index 80c6d6fbd..4372afddd 100644 --- a/lib/components/fabro-llm/src/selection.rs +++ b/lib/components/fabro-llm/src/selection.rs @@ -19,12 +19,9 @@ use std::collections::HashSet; use std::fmt; -use fabro_types::{ModelId, ProviderId}; -use lithos_llm::catalog::Catalog; +use lithos_llm::catalog::{Catalog, ModelId, Offering, ProviderId}; use thiserror::Error; -use crate::catalog::{self, ModelEntry}; - /// A provider/model pair one of the selection functions chose. /// /// `model` is the canonical catalog id when the selector matched an offering, @@ -94,11 +91,12 @@ pub fn require_provider( catalog: &Catalog, selector: &str, ) -> Result { - catalog::canonical_provider_id(catalog, selector).ok_or_else(|| { - ModelSelectionError::UnknownProvider { + catalog + .enabled_provider(selector) + .map(|provider| provider.id().clone()) + .ok_or_else(|| ModelSelectionError::UnknownProvider { provider: selector.to_string(), - } - }) + }) } /// Canonicalizes a provider and requires it to be in the eligible set. @@ -120,14 +118,15 @@ pub fn resolve_on_provider<'a>( catalog: &'a Catalog, provider: &ProviderId, selector: &str, -) -> Result, ModelSelectionError> { +) -> Result, ModelSelectionError> { let provider = require_provider(catalog, provider.as_str())?; - catalog::model_on_provider(catalog, provider.as_str(), selector).ok_or( - ModelSelectionError::UnknownSelectorOnProvider { + catalog + .enabled_provider(provider.as_str()) + .and_then(|provider| provider.offering(selector)) + .ok_or(ModelSelectionError::UnknownSelectorOnProvider { selector: selector.to_string(), provider, - }, - ) + }) } /// Selects a catalog model for `selector`, requiring a real offering. @@ -140,7 +139,7 @@ pub fn select<'a>( selector: &str, explicit_provider: Option<&ProviderId>, eligible: &HashSet, -) -> Result, ModelSelectionError> { +) -> Result, ModelSelectionError> { if let Some(explicit) = explicit_provider { let provider = ready_provider(catalog, explicit, eligible)?; return resolve_on_provider(catalog, &provider, selector); @@ -149,12 +148,12 @@ pub fn select<'a>( // it at request time. A slash whose prefix is not a provider (an // aggregator's `vendor/model` api id) falls through to plain matching. if let Some((prefix, rest)) = selector.split_once('/') { - if let Some(provider) = catalog::canonical_provider_id(catalog, prefix) { - let provider = ready_provider(catalog, &provider, eligible)?; + if let Some(provider) = catalog.enabled_provider(prefix) { + let provider = ready_provider(catalog, provider.id(), eligible)?; return resolve_on_provider(catalog, &provider, rest); } } - let matches = catalog::models_matching(catalog, selector); + let matches = catalog.offerings_matching(selector); if matches.is_empty() { return Err(ModelSelectionError::UnknownSelector { selector: selector.to_string(), @@ -178,19 +177,21 @@ pub fn select<'a>( pub fn select_default<'a>( catalog: &'a Catalog, eligible: &HashSet, -) -> Result, ModelSelectionError> { +) -> Result, ModelSelectionError> { let eligible = canonical_eligible(catalog, eligible); - let providers_with_defaults: Vec<_> = catalog::enabled_providers(catalog) + let providers_with_defaults: Vec<_> = catalog + .enabled_providers() .into_iter() .filter_map(|provider| { - catalog::default_model(catalog, provider.id().as_str()) - .map(|model| (provider.id().clone(), model)) + provider + .default_offering() + .map(|offering| (provider.id().clone(), offering)) }) .collect(); providers_with_defaults .iter() .find(|(provider, _)| eligible.contains(provider)) - .map(|(_, model)| model.clone()) + .map(|(_, offering)| *offering) .ok_or_else(|| ModelSelectionError::NoDefaultModel { providers: providers_with_defaults .into_iter() @@ -258,7 +259,7 @@ pub fn resolve_selection_with_catalog_fallback( catalog, selector, explicit_provider, - &catalog::enabled_provider_ids(catalog), + &catalog.enabled_provider_ids().into_iter().collect(), ), result => result, } @@ -267,13 +268,14 @@ pub fn resolve_selection_with_catalog_fallback( fn canonical_eligible(catalog: &Catalog, eligible: &HashSet) -> HashSet { eligible .iter() - .filter_map(|id| catalog::canonical_provider_id(catalog, id.as_str())) + .filter_map(|id| catalog.enabled_provider(id.as_str())) + .map(|provider| provider.id().clone()) .collect() } #[cfg(test)] mod tests { - use fabro_types::provider_ids; + use lithos_llm::catalog::builtin; use super::*; use crate::test_support::{test_catalog, test_catalog_with_overlay}; @@ -288,7 +290,7 @@ mod tests { let selected = resolve_selection(&catalog, Some("sonnet"), None, &eligible(&["anthropic"])).unwrap(); assert_eq!(selected, SelectedModel { - provider: provider_ids::anthropic(), + provider: builtin::anthropic(), model: "claude-sonnet-5".to_string(), }); } @@ -303,7 +305,7 @@ mod tests { &eligible(&["openai", "anthropic"]), ) .unwrap(); - assert_eq!(selected.provider, provider_ids::anthropic()); + assert_eq!(selected.provider, builtin::anthropic()); assert_eq!(selected.model, "totally-new-model"); } @@ -318,7 +320,7 @@ mod tests { ) .unwrap(); assert_eq!(selected, SelectedModel { - provider: provider_ids::openai(), + provider: builtin::openai(), model: "gpt-5.6-sol".to_string(), }); @@ -330,7 +332,7 @@ mod tests { ) .unwrap(); assert_eq!(unknown, SelectedModel { - provider: provider_ids::openai(), + provider: builtin::openai(), model: "brand-new-model".to_string(), }); @@ -343,7 +345,7 @@ mod tests { assert_eq!( unavailable, Err(ModelSelectionError::ProviderUnavailable { - provider: provider_ids::openai(), + provider: builtin::openai(), }) ); } @@ -370,13 +372,13 @@ mod tests { let error = resolve_selection( &catalog, Some("gpt-5.4"), - Some(&provider_ids::openai()), + Some(&builtin::openai()), &eligible(&["anthropic"]), ) .unwrap_err(); assert!(matches!( error, - ModelSelectionError::ProviderUnavailable { provider } if provider == provider_ids::openai() + ModelSelectionError::ProviderUnavailable { provider } if provider == builtin::openai() )); } @@ -386,11 +388,11 @@ mod tests { let selected = resolve_selection_with_catalog_fallback( &catalog, Some("gpt-5.4"), - Some(&provider_ids::openai()), + Some(&builtin::openai()), &eligible(&["anthropic"]), ) .unwrap(); - assert_eq!(selected.provider, provider_ids::openai()); + assert_eq!(selected.provider, builtin::openai()); let error = resolve_selection_with_catalog_fallback( &catalog, None, diff --git a/lib/components/fabro-llm/src/structured.rs b/lib/components/fabro-llm/src/structured.rs deleted file mode 100644 index 3bc5b730e..000000000 --- a/lib/components/fabro-llm/src/structured.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! One-shot structured output. - -use lithos_llm::client::Client; -use lithos_llm::middleware::CallContext; -use lithos_llm::types::{Error, ErrorKind, Request, Response, ResponseFormat}; - -/// A completion whose text parsed as the requested JSON object. -#[derive(Debug, Clone)] -pub struct StructuredCompletion { - pub response: Response, - pub object: serde_json::Value, -} - -/// Completes `request` under a JSON schema and parses the reply. -/// -/// The schema is attached as the request's response format, so providers -/// with native structured output enforce it. The reply text must still parse -/// as JSON; a reply that does not is a `ResponseDecode` error. -pub async fn complete_object( - client: &Client, - request: Request, - schema_name: &str, - schema: serde_json::Value, -) -> Result { - complete_object_with_context(client, request, schema_name, schema, CallContext::new()).await -} - -pub async fn complete_object_with_context( - client: &Client, - request: Request, - schema_name: &str, - schema: serde_json::Value, - context: CallContext, -) -> Result { - let request = request - .into_builder() - .response_format(ResponseFormat::JsonSchema { - name: schema_name.to_string(), - schema, - }) - .build() - .map_err(|source| { - Error::new( - ErrorKind::InvalidRequest, - "structured output request is invalid", - ) - .with_source(source) - })?; - let response = client.complete_with_context(request, context).await?; - let object = parse_object(&response)?; - Ok(StructuredCompletion { response, object }) -} - -/// Parses a response's JSON output: a `Json` part when the provider returned -/// one, else the concatenated text. -pub fn parse_object(response: &Response) -> Result { - if let Some(value) = response.content.iter().find_map(|part| match part { - fabro_types::ContentPart::Json { value } => Some(value.clone()), - _ => None, - }) { - return Ok(value); - } - let text = response.text(); - serde_json::from_str(text.trim()).map_err(|source| { - Error::new( - ErrorKind::ResponseDecode, - format!("the model did not return a JSON object: {source}"), - ) - .with_provider(response.model.provider().clone()) - .with_source(source) - }) -} - -#[cfg(test)] -mod tests { - use fabro_types::{ContentPart, ModelId, ProviderId}; - use serde_json::json; - - use super::*; - - fn response(parts: Vec) -> Response { - Response::new(ProviderId::new("openai"), ModelId::new("gpt-5.4"), parts) - } - - #[test] - fn parses_text_or_json_parts() { - let text = response(vec![ContentPart::Text { - text: " {\"title\": \"x\"} ".to_string(), - }]); - assert_eq!(parse_object(&text).unwrap(), json!({"title": "x"})); - let json = response(vec![ContentPart::Json { - value: json!({"a": 1}), - }]); - assert_eq!(parse_object(&json).unwrap(), json!({"a": 1})); - let prose = response(vec![ContentPart::Text { - text: "sorry".to_string(), - }]); - assert_eq!( - parse_object(&prose).unwrap_err().kind(), - ErrorKind::ResponseDecode - ); - } -} diff --git a/lib/components/fabro-llm/src/test_support.rs b/lib/components/fabro-llm/src/test_support.rs index ac008e96f..61acf8c9b 100644 --- a/lib/components/fabro-llm/src/test_support.rs +++ b/lib/components/fabro-llm/src/test_support.rs @@ -7,15 +7,14 @@ use std::time::Duration; use async_trait::async_trait; use fabro_auth::test_support::env_credential_source; use fabro_config::LlmLayer; -use fabro_types::{ContentPart, ModelId, ProviderId, TokenCounts}; use futures::stream; use lithos_llm::adapter::{ProviderAdapter, ResolvedCall}; -use lithos_llm::catalog::{AdapterId, Catalog}; +use lithos_llm::catalog::{AdapterId, Catalog, ModelId, ProviderId}; use lithos_llm::client::Client; use lithos_llm::middleware::RetryPolicy; use lithos_llm::types::{ - ContentBlockId, ContentBlockKind, Error, FinishReason, Response, ResponseStream, StreamEvent, - ToolCallKind, + ContentBlockId, ContentBlockKind, ContentPart, Error, FinishReason, Response, ResponseStream, + StreamEvent, TokenCounts, ToolCallKind, ToolInput, }; use crate::client::{ClientOptions, build_client, build_offline_client}; @@ -94,7 +93,7 @@ pub fn response_to_stream(response: Response) -> ResponseStream { id: call.id.clone(), name: Some(call.name.clone()), kind: match call.input { - fabro_types::ToolInput::Custom(_) => ToolCallKind::Custom, + ToolInput::Custom(_) => ToolCallKind::Custom, _ => ToolCallKind::Function, }, }, diff --git a/lib/components/fabro-store/Cargo.toml b/lib/components/fabro-store/Cargo.toml index 56d8e3acd..b97c58202 100644 --- a/lib/components/fabro-store/Cargo.toml +++ b/lib/components/fabro-store/Cargo.toml @@ -17,6 +17,7 @@ test-support = ["dep:fabro-db"] [dependencies] fabro-db = { path = "../../foundation/fabro-db", optional = true } fabro-types = { path = "../../foundation/fabro-types" } +lithos-llm = { workspace = true, features = ["runtime"] } fabro-util = { path = "../../foundation/fabro-util" } hex.workspace = true slatedb.workspace = true diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index b2df4d2af..4f2c62395 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -1759,15 +1759,16 @@ mod tests { AgentBackend, AgentControlState, AttrValue, AutomationRef, BilledModelUsage, BilledTokenCounts, BlobHash, BlockedReason, Checkpoint, CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph, - McpServerStatus, ModelId, Node, Outcome, ParallelBranchId, PendingReason, PermissionLevel, - ProviderId, PullRequestCreationStatus, PullRequestLink, QuestionType, ReasoningEffort, - RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, - RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory, - StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, - StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageState, - StageTiming, SubAgentStatus, SuccessReason, WorkflowSettings, first_event_seq, fixtures, - test_support, + McpServerStatus, Node, Outcome, ParallelBranchId, PendingReason, PermissionLevel, + PullRequestCreationStatus, PullRequestLink, QuestionType, RunApprovalState, + RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, + StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, + StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning, + StageHandler, StageModelUsage, StageOutcome, StageState, StageTiming, SubAgentStatus, + SuccessReason, WorkflowSettings, first_event_seq, fixtures, test_support, }; + use lithos_llm::catalog::{ModelId, ProviderId}; + use lithos_llm::types::{ReasoningEffort, Speed}; use serde_json::json; use super::{RunProjection, RunProjectionReducer, build_summary}; @@ -1782,9 +1783,8 @@ mod tests { AgentLlmFirstOutputProps, AgentLlmRetryProps, AgentLlmStartedProps, AgentToolCompletedProps, AgentToolStartedProps, }; - use fabro_types::{ - LlmOutputKind, LlmRetryPhase, ModelRef, Speed, StageOutcome, StageProjection, - }; + use fabro_types::{LlmOutputKind, LlmRetryPhase, ModelRef, StageOutcome, StageProjection}; + use lithos_llm::types::Speed; use super::*; @@ -7565,9 +7565,8 @@ mod tests { use fabro_types::run_event::{ AgentErrorProps, AgentLlmFirstOutputProps, AgentLlmRetryProps, AgentLlmStartedProps, }; - use fabro_types::{ - LlmOutputKind, LlmRetryPhase, ModelRef, Speed, StageInferenceProjection, - }; + use fabro_types::{LlmOutputKind, LlmRetryPhase, ModelRef, StageInferenceProjection}; + use lithos_llm::types::Speed; use super::*; diff --git a/lib/components/fabro-validate/src/rules/model_support.rs b/lib/components/fabro-validate/src/rules/model_support.rs index 21735ef5a..50c4e20d6 100644 --- a/lib/components/fabro-validate/src/rules/model_support.rs +++ b/lib/components/fabro-validate/src/rules/model_support.rs @@ -1,4 +1,3 @@ -use fabro_llm::catalog; use fabro_llm::lithos_catalog::Catalog; use crate::{Diagnostic, Severity}; @@ -10,7 +9,7 @@ pub(super) fn check_model_known( context: &str, node_id: Option, ) -> Option { - if catalog::is_model_selector(catalog, model) { + if catalog.is_model_selector(model) { return None; } Some(Diagnostic { @@ -34,10 +33,11 @@ pub(super) fn check_provider_known( context: &str, node_id: Option, ) -> Option { - if catalog::is_provider_selector(catalog, provider) { + if catalog.enabled_provider(provider).is_some() { return None; } - let valid: Vec = catalog::listed_providers(catalog) + let valid: Vec = catalog + .listed_providers() .iter() .map(|provider| provider.id().to_string()) .collect(); diff --git a/lib/components/fabro-workflow/Cargo.toml b/lib/components/fabro-workflow/Cargo.toml index 388078b5b..7d31fa61b 100644 --- a/lib/components/fabro-workflow/Cargo.toml +++ b/lib/components/fabro-workflow/Cargo.toml @@ -43,6 +43,7 @@ fabro-core = { path = "../../foundation/fabro-core" } fabro-store = { path = "../fabro-store" } fabro-static.workspace = true fabro-types = { path = "../../foundation/fabro-types" } +lithos-llm = { workspace = true, features = ["runtime"] } fabro-http.workspace = true thiserror.workspace = true strum.workspace = true diff --git a/lib/components/fabro-workflow/src/billing_rollup.rs b/lib/components/fabro-workflow/src/billing_rollup.rs index 9414a96a3..5dc6ca164 100644 --- a/lib/components/fabro-workflow/src/billing_rollup.rs +++ b/lib/components/fabro-workflow/src/billing_rollup.rs @@ -6,9 +6,10 @@ pub use fabro_types::billing_rollup::{ #[cfg(test)] mod tests { use fabro_types::{ - AttrValue, BilledTokenCounts, Graph, ModelId, ModelRef, Node, RunProjection, RunSpec, - StageCompletion, StageOutcome, first_event_seq, provider_ids, test_support, + AttrValue, BilledTokenCounts, Graph, ModelRef, Node, RunProjection, RunSpec, + StageCompletion, StageOutcome, first_event_seq, test_support, }; + use lithos_llm::catalog::{ModelId, builtin}; use super::billing_rollup_from_projection; use crate::test_support::test_usage; @@ -134,7 +135,7 @@ mod tests { #[test] fn rollup_keeps_in_flight_stage_usage_unpriced() { let mut projection = test_projection(); - let model = ModelRef::new(provider_ids::openai(), ModelId::new("gpt-5.4")); + let model = ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")); let stage = projection.stage_entry("agent", 1, first_event_seq(1)); stage.started_at = Some(chrono::Utc::now()); stage.usage = BilledTokenCounts { diff --git a/lib/components/fabro-workflow/src/error.rs b/lib/components/fabro-workflow/src/error.rs index d0b7eae10..319987070 100644 --- a/lib/components/fabro-workflow/src/error.rs +++ b/lib/components/fabro-workflow/src/error.rs @@ -2,7 +2,7 @@ use std::fmt; use std::sync::{Arc, LazyLock}; use fabro_graphviz::Error as GraphvizError; -use fabro_llm::{ErrorFacts, ErrorKind, LlmError, ModelSelectionError}; +use fabro_llm::{ErrorData, ErrorKind, ModelSelectionError, failure_signature_hint}; use fabro_template::TemplateError; pub use fabro_types::failure_signature::FailureSignature; pub use fabro_types::outcome::FailureCategory; @@ -18,7 +18,7 @@ use crate::outcome::{FailureDetail, Outcome, StageOutcome}; /// Classify an LLM error into a `FailureCategory` based on its structure. #[must_use] -pub fn classify_sdk_error(err: &E) -> FailureCategory { +pub fn classify_sdk_error(err: &ErrorData) -> FailureCategory { match err.kind() { ErrorKind::RateLimit | ErrorKind::Server @@ -309,7 +309,7 @@ pub enum Error { }, #[error("LLM error: {0}")] - Llm(LlmError), + Llm(Box), #[error("Checkpoint error: {0}")] Checkpoint(String), @@ -583,7 +583,7 @@ impl Error { #[must_use] pub fn failure_signature_hint(&self) -> Option { match self { - Self::Llm(sdk_err) => Some(FailureSignature(sdk_err.failure_signature_hint())), + Self::Llm(sdk_err) => Some(FailureSignature(failure_signature_hint(sdk_err))), _ => None, } } @@ -684,15 +684,15 @@ impl From for Error { } } -impl From for Error { - fn from(err: LlmError) -> Self { - Self::Llm(err) +impl From for Error { + fn from(err: ErrorData) -> Self { + Self::Llm(Box::new(err)) } } impl From for Error { fn from(err: fabro_llm::Error) -> Self { - Self::Llm(LlmError::from(err)) + Self::from(ErrorData::from(err)) } } @@ -749,17 +749,18 @@ mod tests { use super::*; /// A stored LLM error of `kind` from the `openai` provider. - fn sdk_error(kind: ErrorKind, message: &str) -> LlmError { - LlmError::from( - fabro_llm::Error::new(kind, message).with_provider(fabro_types::provider_ids::openai()), + fn sdk_error(kind: ErrorKind, message: &str) -> ErrorData { + ErrorData::from( + fabro_llm::Error::new(kind, message) + .with_provider(lithos_llm::catalog::builtin::openai()), ) } /// A transient failure the provider may be asked to repeat. - fn transient_error(kind: ErrorKind, message: &str) -> LlmError { - LlmError::from( + fn transient_error(kind: ErrorKind, message: &str) -> ErrorData { + ErrorData::from( fabro_llm::Error::new(kind, message) - .with_provider(fabro_types::provider_ids::openai()) + .with_provider(lithos_llm::catalog::builtin::openai()) .with_retry(RetryClassification::Safe), ) } @@ -1157,16 +1158,16 @@ mod tests { #[test] fn llm_error_display() { let sdk_err = transient_error(ErrorKind::Network, "connection refused"); - let err = Error::Llm(sdk_err); + let err = Error::from(sdk_err); assert_eq!(err.to_string(), "LLM error: connection refused"); } #[test] fn llm_error_retryable_delegates_to_sdk() { - let retryable = Error::Llm(transient_error(ErrorKind::Network, "timeout")); + let retryable = Error::from(transient_error(ErrorKind::Network, "timeout")); assert!(retryable.is_retryable()); - let non_retryable = Error::Llm(sdk_error(ErrorKind::Configuration, "bad config")); + let non_retryable = Error::from(sdk_error(ErrorKind::Configuration, "bad config")); assert!(!non_retryable.is_retryable()); } @@ -1221,31 +1222,31 @@ mod tests { #[test] fn failure_class_llm_rate_limit() { - let err = Error::Llm(transient_error(ErrorKind::RateLimit, "too fast")); + let err = Error::from(transient_error(ErrorKind::RateLimit, "too fast")); assert_eq!(err.failure_category(), FailureCategory::TransientInfra); } #[test] fn failure_class_llm_context_length() { - let err = Error::Llm(sdk_error(ErrorKind::ContextLength, "too long")); + let err = Error::from(sdk_error(ErrorKind::ContextLength, "too long")); assert_eq!(err.failure_category(), FailureCategory::BudgetExhausted); } #[test] fn failure_class_llm_auth() { - let err = Error::Llm(sdk_error(ErrorKind::Authentication, "bad key")); + let err = Error::from(sdk_error(ErrorKind::Authentication, "bad key")); assert_eq!(err.failure_category(), FailureCategory::Deterministic); } #[test] fn failure_class_llm_abort() { - let err = Error::Llm(sdk_error(ErrorKind::Cancelled, "user cancelled")); + let err = Error::from(sdk_error(ErrorKind::Cancelled, "user cancelled")); assert_eq!(err.failure_category(), FailureCategory::Canceled); } #[test] fn failure_class_llm_timeout() { - let err = Error::Llm(transient_error(ErrorKind::Timeout, "timed out")); + let err = Error::from(transient_error(ErrorKind::Timeout, "timed out")); assert_eq!(err.failure_category(), FailureCategory::TransientInfra); } @@ -1941,7 +1942,7 @@ mod tests { #[test] fn failure_signature_hint_llm_returns_some() { - let err = Error::Llm(sdk_error(ErrorKind::Authentication, "bad key")); + let err = Error::from(sdk_error(ErrorKind::Authentication, "bad key")); assert_eq!( err.failure_signature_hint(), Some(FailureSignature( @@ -1966,7 +1967,7 @@ mod tests { #[test] fn to_fail_outcome_llm_has_class_and_signature() { - let err = Error::Llm(sdk_error(ErrorKind::Authentication, "bad key")); + let err = Error::from(sdk_error(ErrorKind::Authentication, "bad key")); let outcome = err.to_fail_outcome(); assert_eq!(outcome.status, crate::outcome::StageOutcome::Failed { retry_requested: false, @@ -1993,7 +1994,7 @@ mod tests { #[test] fn to_fail_outcome_includes_error_message_as_reason() { - let err = Error::Llm(transient_error(ErrorKind::Network, "connection refused")); + let err = Error::from(transient_error(ErrorKind::Network, "connection refused")); let outcome = err.to_fail_outcome(); assert!( outcome @@ -2005,7 +2006,7 @@ mod tests { #[test] fn to_fail_outcome_no_context_updates() { - let err = Error::Llm(transient_error(ErrorKind::Network, "refused")); + let err = Error::from(transient_error(ErrorKind::Network, "refused")); let outcome = err.to_fail_outcome(); assert!(outcome.context_updates.is_empty()); } @@ -2057,7 +2058,7 @@ mod tests { Error::engine("engine err"), Error::publish("publish err"), Error::handler("handler err"), - Error::Llm(transient_error(ErrorKind::Network, "refused")), + Error::from(transient_error(ErrorKind::Network, "refused")), Error::Checkpoint("cp err".into()), Error::Stylesheet("style err".into()), Error::Io("io err".into()), @@ -2165,7 +2166,7 @@ mod tests { // 1. Create SdkError → Error let sdk_err = transient_error(ErrorKind::RateLimit, "too fast"); - let arc_err = Error::Llm(sdk_err); + let arc_err = Error::from(sdk_err); assert_eq!(arc_err.failure_category(), FailureCategory::TransientInfra); // 2. Error → Outcome @@ -2236,7 +2237,7 @@ mod tests { fn e2e_serde_stability_agent_error() { use fabro_agent::Error as AgentError; - let err = AgentError::Llm(transient_error(ErrorKind::RateLimit, "too fast")); + let err = AgentError::from(transient_error(ErrorKind::RateLimit, "too fast")); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["type"], "llm"); diff --git a/lib/components/fabro-workflow/src/event/convert.rs b/lib/components/fabro-workflow/src/event/convert.rs index da235b8ac..bd1fe1d34 100644 --- a/lib/components/fabro-workflow/src/event/convert.rs +++ b/lib/components/fabro-workflow/src/event/convert.rs @@ -1461,16 +1461,17 @@ mod tests { use std::collections::BTreeMap; use ::fabro_types::{ - AutomationRef, EventBody, FailureReason, ModelId, ModelRef, ParallelBranchId, Principal, - ProviderId, RunNoticeCode, RunNoticeLevel, RunProvenance, StageId, SystemActorKind, - TokenCounts as LlmTokenCounts, fixtures, provider_ids, run_event as fabro_types, - test_support, + AutomationRef, EventBody, FailureReason, ModelRef, ParallelBranchId, Principal, + RunNoticeCode, RunNoticeLevel, RunProvenance, StageId, SystemActorKind, fixtures, + run_event as fabro_types, test_support, }; use chrono::Utc; use fabro_agent::{ AgentEvent, McpToolSummary, MemoryFileSummary, SandboxEvent, SkillActivationSource, SkillSummary, }; + use lithos_llm::catalog::{ModelId, ProviderId, builtin}; + use lithos_llm::types::{Cost, CostSource, ReasoningOutput, TokenCounts as LlmTokenCounts}; use super::*; use crate::error::Error; @@ -2522,10 +2523,7 @@ mod tests { visit: 1, event: AgentEvent::AssistantMessage { text: "ok".to_string(), - model: ModelRef::new( - provider_ids::anthropic(), - ModelId::new("claude-sonnet"), - ), + model: ModelRef::new(builtin::anthropic(), ModelId::new("claude-sonnet")), usage: LlmTokenCounts::default(), cost: None, tool_call_count: 0, @@ -2596,10 +2594,10 @@ mod tests { output: 34, ..LlmTokenCounts::default() }, - cost: Some(::fabro_types::Cost { + cost: Some(Cost { usd_micros: 125_000, - source: ::fabro_types::CostSource::Provider, + source: CostSource::Provider, }), tool_call_count: 0, context_window: None, @@ -2614,10 +2612,7 @@ mod tests { panic!("expected agent message body"); }; assert_eq!(message.billing.total_usd_micros, Some(125_000)); - assert_eq!( - message.cost_source, - Some(::fabro_types::CostSource::Provider) - ); + assert_eq!(message.cost_source, Some(CostSource::Provider)); } #[test] @@ -2644,7 +2639,7 @@ mod tests { visit: 1, event: AgentEvent::AssistantMessage { text: "ok".to_string(), - model: ModelRef::new(provider_ids::openai(), ModelId::new("gpt-5.4")), + model: ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")), usage: LlmTokenCounts::default(), cost: None, tool_call_count: 0, @@ -2674,12 +2669,12 @@ mod tests { visit: 1, event: AgentEvent::AssistantMessage { text: String::new(), - model: ModelRef::new(provider_ids::openai(), ModelId::new("gpt-5.4")), + model: ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")), usage: LlmTokenCounts::default(), cost: None, tool_call_count: 1, context_window: None, - reasoning: Some(::fabro_types::ReasoningOutput::new( + reasoning: Some(ReasoningOutput::new( "inspect the conversion first", "read convert.rs, then the sink", )), diff --git a/lib/components/fabro-workflow/src/event/events.rs b/lib/components/fabro-workflow/src/event/events.rs index f8adcb0ca..65bc43770 100644 --- a/lib/components/fabro-workflow/src/event/events.rs +++ b/lib/components/fabro-workflow/src/event/events.rs @@ -4,12 +4,13 @@ use ::fabro_types::{ AutomationRef, BilledTokenCounts, BlobHash, BlockedReason, CommandTermination, DiffSummary, FailureReason, ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget, ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, Principal, - PullRequestCreationId, PullRequestLink, ReasoningEffort, ReviewTarget, RunFailure, RunId, - RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, - RunTarget, RunTiming, SandboxProviderKind, Speed, StageId, StageOutcome, StageTiming, - SuccessReason, WorkflowVersionId, run_event as fabro_types, + PullRequestCreationId, PullRequestLink, ReviewTarget, RunFailure, RunId, RunNoticeLevel, + RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, RunTarget, + RunTiming, SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason, + WorkflowVersionId, run_event as fabro_types, }; use fabro_agent::{AgentEvent, SandboxEvent}; +use lithos_llm::types::{ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use crate::error::{Error, run_failure_from_error}; diff --git a/lib/components/fabro-workflow/src/event/redaction.rs b/lib/components/fabro-workflow/src/event/redaction.rs index 9cfe29ee7..510a9659f 100644 --- a/lib/components/fabro-workflow/src/event/redaction.rs +++ b/lib/components/fabro-workflow/src/event/redaction.rs @@ -30,11 +30,10 @@ pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result AgentA )) } fabro_agent::Error::Llm(err) if allow_failover && err.failover_eligible() => { - AgentApiErrorDisposition::FailoverEligible(err) + AgentApiErrorDisposition::FailoverEligible(*err) } fabro_agent::Error::Llm(err) => AgentApiErrorDisposition::Terminal(Error::Llm(err)), other @ (fabro_agent::Error::SessionClosed @@ -638,7 +640,7 @@ pub struct AgentApiBackend { mcp_servers: Vec, tool_secrets: ToolSecrets, run_model_controls: RunModelControls, - source: Arc, + source: Arc, steering_hub: Arc, catalog: Arc, fabro_run_tools: Option, @@ -755,7 +757,7 @@ impl LiveAgentInvocation { error: fabro_agent::Error, allow_failover: bool, emitter: &Arc, - ) -> Result { + ) -> Result { let disposition = classify_agent_error(error, allow_failover); self.abort_and_discard(emitter).await; match disposition { @@ -784,7 +786,7 @@ impl AgentApiBackend { model: String, provider_id: impl Into, fallbacks: ModelFallbackPolicy, - source: Arc, + source: Arc, steering_hub: Arc, ) -> Self { let catalog = Arc::new(fabro_llm::default_catalog()); @@ -803,7 +805,7 @@ impl AgentApiBackend { model: String, provider_id: ProviderId, fallbacks: ModelFallbackPolicy, - source: Arc, + source: Arc, steering_hub: Arc, catalog: Arc, ) -> Self { @@ -888,19 +890,17 @@ impl AgentApiBackend { let Some(requested_effort) = requested.reasoning_effort else { return FallbackControls::Usable(requested); }; - let Some(offering) = catalog::model_on_provider( - &self.catalog, - target.provider.as_str(), - target.model.as_str(), - ) else { + let Some(offering) = self + .catalog + .enabled_provider(target.provider.as_str()) + .and_then(|provider| provider.offering(target.model.as_str())) + else { // A catalog-unknown passthrough target has no advertised controls. // Preserve the request and let the provider validate it. return FallbackControls::Usable(requested); }; let capabilities = offering.model.capabilities(); - let effective_effort = controls::closest_supported_effort(requested_effort, |effort| { - capabilities.reasoning_effort(effort).is_supported() - }); + let effective_effort = capabilities.closest_supported_effort(requested_effort); match effective_effort { Some(effort) => FallbackControls::Usable(EffectiveRequestControls { reasoning_effort: Some(effort), @@ -925,7 +925,7 @@ impl AgentApiBackend { provider: &ProviderId, requested_controls: EffectiveRequestControls, ) -> (FallbackPlan, Vec) { - let primary_model = catalog::canonical_model_id(&self.catalog, provider, model); + let primary_model = canonical_model_id(&self.catalog, provider, model); let original = LlmRoute { target: FallbackTarget::new(provider, &primary_model), controls: requested_controls, @@ -1051,7 +1051,7 @@ impl AgentApiBackend { controls: EffectiveRequestControls, node: &Node, sandbox: &Arc, - source: Arc, + source: Arc, catalog: Arc, tool_env: Option<&Arc>, tool_hooks: Option>, @@ -1197,7 +1197,7 @@ impl AgentApiBackend { async fn failover_agent_session( &self, fallback_plan: &mut FallbackPlan, - initial_error: LlmError, + initial_error: ErrorData, request: &CodergenRunRequest<'_>, input: &str, stage_scope: &StageScope, @@ -1205,7 +1205,7 @@ impl AgentApiBackend { live: &mut LiveAgentInvocation, ) -> Result<(), Error> { let emitter = request.emitter; - let mut last_error = Error::Llm(initial_error); + let mut last_error = Error::from(initial_error); while fallback_plan.advance() { Self::emit_failover( @@ -1269,7 +1269,7 @@ impl AgentApiBackend { begin_session_lifecycle(&live.session, emitter, None); if let Err(error) = live.session.initialize().await { let allow_failover = fallback_plan.has_next(); - last_error = Error::Llm( + last_error = Error::from( live.discard_for_error(error, allow_failover, emitter) .await?, ); @@ -1302,7 +1302,7 @@ impl AgentApiBackend { } Err(error) => { let allow_failover = fallback_plan.has_next(); - last_error = Error::Llm( + last_error = Error::from( live.discard_for_error(error, allow_failover, emitter) .await?, ); @@ -1371,13 +1371,11 @@ impl AgentApiBackend { fn route_max_tokens(&self, node: &Node, route: &LlmRoute) -> Option { node_max_output_tokens(node).or_else(|| { - catalog::model_on_provider( - &self.catalog, - route.target.provider.as_str(), - route.target.model.as_str(), - ) - .and_then(|entry| entry.model.limits()) - .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)) + self.catalog + .enabled_provider(route.target.provider.as_str()) + .and_then(|provider| provider.offering(route.target.model.as_str())) + .and_then(|entry| entry.model.limits()) + .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)) }) } @@ -1433,7 +1431,7 @@ impl AgentApiBackend { .with_speed(route.controls.speed), }); } - Err(error) if failover_eligible(&error) && plan.has_next() => { + Err(error) if error.failover_eligible() && plan.has_next() => { let error_message = error.to_string(); plan.advance(); Self::emit_failover(node, emitter, stage_scope, plan, &error_message); @@ -1444,7 +1442,7 @@ impl AgentApiBackend { request.response_format().cloned(), )?; } - Err(error) => return Err(Error::Llm(LlmError::from(error))), + Err(error) => return Err(Error::from(error)), } } } @@ -1453,7 +1451,7 @@ impl AgentApiBackend { /// Build the LLM client a stage session dispatches through. async fn build_llm_client( catalog: &Arc, - source: Arc, + source: Arc, ) -> Result { fabro_llm::build_client(Catalog::clone(catalog), source, ClientOptions::standard()) .await @@ -1904,14 +1902,16 @@ mod tests { use fabro_llm::{ErrorKind, ResponseStream, RetryClassification}; use fabro_tool::FabroToolBackend; use fabro_types::{ - ContentPart, EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, + EventEnvelope, FailureReason, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunPairStatusResponse, RunProjection, RunStatus, RunTimestamps, SuccessReason, WorkflowRef, - provider_ids, test_support, + test_support, }; use fabro_vault::{SecretType, Vault}; use futures::stream; use httpmock::Method::POST; use httpmock::MockServer; + use lithos_llm::catalog::builtin; + use lithos_llm::types::ContentPart; use tokio::sync::RwLock as AsyncRwLock; use tokio_util::sync::CancellationToken; @@ -1937,7 +1937,7 @@ mod tests { } fn provider_id(&self) -> ProviderId { - provider_ids::openai() + builtin::openai() } fn model(&self) -> &str { @@ -2255,20 +2255,20 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = fn agent_backend_stores_config() { let backend = AgentApiBackend::new( "claude-opus-4-6".to_string(), - provider_ids::openai(), + builtin::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), ); assert_eq!(backend.model, "claude-opus-4-6"); - assert_eq!(backend.provider_id, provider_ids::openai()); + assert_eq!(backend.provider_id, builtin::openai()); } #[test] fn agent_backend_initializes_empty_sessions() { let backend = AgentApiBackend::new( "claude-opus-4-6".to_string(), - provider_ids::anthropic(), + builtin::anthropic(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -2957,7 +2957,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = fn build_profile_can_register_subagent_tools() { let mut profile = AgentProfileBuilder::new( AgentProfileKind::Anthropic, - provider_ids::anthropic(), + builtin::anthropic(), "claude-opus-4-6", Arc::new(test_catalog()), ) @@ -3135,7 +3135,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = .resolve_provider_context("gpt-5.4", Some("openai")) .unwrap(); - assert_eq!(provider.provider_id, provider_ids::openai()); + assert_eq!(provider.provider_id, builtin::openai()); } #[test] @@ -3180,7 +3180,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = fn api_backend_selects_claude5_profile_for_sonnet5() { let backend = AgentApiBackend::new_with_catalog( "claude-sonnet-5".to_string(), - provider_ids::anthropic(), + builtin::anthropic(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3191,7 +3191,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = .resolve_provider_context("claude-sonnet-5", None) .unwrap(); - assert_eq!(provider.provider_id, provider_ids::anthropic()); + assert_eq!(provider.provider_id, builtin::anthropic()); assert_eq!(provider.profile_kind, AgentProfileKind::Claude5); } @@ -3219,7 +3219,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = fn run_model_controls_apply_when_node_omits_controls() { let backend = AgentApiBackend::new( "gpt-5.4".to_string(), - provider_ids::openai(), + builtin::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3240,7 +3240,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = fn node_controls_override_run_model_controls() { let backend = AgentApiBackend::new( "gpt-5.4".to_string(), - provider_ids::openai(), + builtin::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3269,7 +3269,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = fn omitted_reasoning_effort_stays_unset() { let backend = AgentApiBackend::new( "gpt-5.4".to_string(), - provider_ids::openai(), + builtin::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3337,7 +3337,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = ])); let backend = AgentApiBackend::new_with_catalog( "claude-fable-5".to_string(), - provider_ids::anthropic(), + builtin::anthropic(), policy, auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3345,7 +3345,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = ); let (mut plan, notices) = backend.fallback_plan( "claude-fable-5", - &provider_ids::anthropic(), + &builtin::anthropic(), EffectiveRequestControls::default(), ); @@ -3380,7 +3380,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = .unwrap(); let backend = AgentApiBackend::new( "claude-opus-4-6".to_string(), - provider_ids::anthropic(), + builtin::anthropic(), ModelFallbackPolicy::default(), Arc::new(VaultCredentialSource::with_env_lookup( Arc::new(AsyncRwLock::new(vault)), @@ -3395,7 +3395,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = assert_eq!( client.available_providers().iter().collect::>(), - vec![&provider_ids::anthropic()] + vec![&builtin::anthropic()] ); } @@ -3408,7 +3408,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = )])); let backend = AgentApiBackend::new( "claude-fable-5".to_string(), - provider_ids::anthropic(), + builtin::anthropic(), fallback_policy, auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3445,7 +3445,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = .unwrap(); let (mut fallback_plan, notices) = backend.fallback_plan( "claude-fable-5", - &provider_ids::anthropic(), + &builtin::anthropic(), EffectiveRequestControls::default(), ); assert!(notices.is_empty()); @@ -3463,7 +3463,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = .unwrap(); assert_eq!(completion.response.text(), "fallback ok"); - assert_eq!(completion.model.provider, provider_ids::openai()); + assert_eq!(completion.model.provider, builtin::openai()); assert_eq!(completion.model.model_id.as_str(), "gpt-5.5"); let failover = emitted_failover .lock() @@ -3912,7 +3912,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = async fn api_backend_shutdown_closes_cached_sessions_once() { let backend = AgentApiBackend::new( "gpt-5.4".to_string(), - provider_ids::openai(), + builtin::openai(), ModelFallbackPolicy::default(), auth_test_support::vault_only_credential_source(), SteeringHub::for_tests(), @@ -3945,7 +3945,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = ); let (fallback_plan, notices) = backend.fallback_plan( "gpt-5.4", - &provider_ids::openai(), + &builtin::openai(), EffectiveRequestControls::default(), ); assert!(notices.is_empty()); @@ -4034,18 +4034,18 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = // --- Bridge guard tests --- - fn failover_eligible_llm_error() -> LlmError { - LlmError::from( + fn failover_eligible_llm_error() -> ErrorData { + ErrorData::from( fabro_llm::Error::new(ErrorKind::Network, "boom") - .with_provider(provider_ids::openai()) + .with_provider(builtin::openai()) .with_retry(RetryClassification::Safe), ) } - fn non_failover_llm_error() -> LlmError { - LlmError::from( + fn non_failover_llm_error() -> ErrorData { + ErrorData::from( fabro_llm::Error::new(ErrorKind::InvalidRequest, "bad key") - .with_provider(provider_ids::openai()) + .with_provider(builtin::openai()) .with_status(401), ) } @@ -4055,7 +4055,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = ErrorKind::ContentFilter, "claude-fable-5 refused the request", ) - .with_provider(provider_ids::anthropic()) + .with_provider(builtin::anthropic()) .with_provider_code("refusal") .with_raw_data(serde_json::json!({ "stop_reason": "refusal", @@ -4272,7 +4272,7 @@ profile = "anthropic" #[test] fn classify_failover_eligible_llm_returns_failover_when_allowed() { - let err = fabro_agent::Error::Llm(failover_eligible_llm_error()); + let err = fabro_agent::Error::from(failover_eligible_llm_error()); assert!(matches!( classify_agent_error(err, true), AgentApiErrorDisposition::FailoverEligible(_) @@ -4281,7 +4281,7 @@ profile = "anthropic" #[test] fn classify_failover_eligible_llm_returns_terminal_when_not_allowed() { - let err = fabro_agent::Error::Llm(failover_eligible_llm_error()); + let err = fabro_agent::Error::from(failover_eligible_llm_error()); match classify_agent_error(err, false) { AgentApiErrorDisposition::Terminal(Error::Llm(_)) => {} _ => panic!("expected Terminal(Error::Llm) when failover disallowed"), @@ -4290,7 +4290,7 @@ profile = "anthropic" #[test] fn classify_non_failover_eligible_llm_is_terminal_llm() { - let err = fabro_agent::Error::Llm(non_failover_llm_error()); + let err = fabro_agent::Error::from(non_failover_llm_error()); match classify_agent_error(err, true) { AgentApiErrorDisposition::Terminal(Error::Llm(_)) => {} _ => panic!("expected Terminal(Error::Llm) for non-failover-eligible LLM error"), @@ -4299,7 +4299,7 @@ profile = "anthropic" #[test] fn classify_refusal_llm_returns_failover_when_allowed() { - let err = fabro_agent::Error::Llm(LlmError::from(refusal_llm_error())); + let err = fabro_agent::Error::from(refusal_llm_error()); assert!(matches!( classify_agent_error(err, true), AgentApiErrorDisposition::FailoverEligible(_) @@ -4308,7 +4308,7 @@ profile = "anthropic" #[test] fn classify_refusal_llm_returns_terminal_when_not_allowed() { - let err = fabro_agent::Error::Llm(LlmError::from(refusal_llm_error())); + let err = fabro_agent::Error::from(refusal_llm_error()); match classify_agent_error(err, false) { AgentApiErrorDisposition::Terminal(Error::Llm(llm_err)) => { assert!(llm_err.to_string().contains("claude-fable-5 refused")); diff --git a/lib/components/fabro-workflow/src/handler/llm/preamble.rs b/lib/components/fabro-workflow/src/handler/llm/preamble.rs index 5f812e533..e69e5771d 100644 --- a/lib/components/fabro-workflow/src/handler/llm/preamble.rs +++ b/lib/components/fabro-workflow/src/handler/llm/preamble.rs @@ -589,7 +589,9 @@ fn build_summary_preamble( #[cfg(test)] mod tests { use fabro_graphviz::graph::AttrValue; - use fabro_types::{ModelId, ModelRef, TokenCounts, provider_ids}; + use fabro_types::ModelRef; + use lithos_llm::catalog::{ModelId, builtin}; + use lithos_llm::types::TokenCounts; use super::*; use crate::outcome::{BilledModelUsage, billed_model_usage_from_llm}; @@ -597,7 +599,7 @@ mod tests { fn stage_usage(model: &str, input: u64, output: u64) -> BilledModelUsage { billed_model_usage_from_llm( &fabro_llm::test_support::test_catalog(), - &ModelRef::new(provider_ids::anthropic(), ModelId::new(model)), + &ModelRef::new(builtin::anthropic(), ModelId::new(model)), TokenCounts { input, output, diff --git a/lib/components/fabro-workflow/src/handler/llm/router.rs b/lib/components/fabro-workflow/src/handler/llm/router.rs index 3e0580016..ceb84b979 100644 --- a/lib/components/fabro-workflow/src/handler/llm/router.rs +++ b/lib/components/fabro-workflow/src/handler/llm/router.rs @@ -81,7 +81,7 @@ mod tests { use async_trait::async_trait; use fabro_agent::{LocalSandbox, Sandbox}; use fabro_graphviz::graph::{AttrValue, Node}; - use fabro_types::{ReasoningEffort, Speed}; + use lithos_llm::types::{ReasoningEffort, Speed}; use tokio_util::sync::CancellationToken; use super::*; diff --git a/lib/components/fabro-workflow/src/handler/llm/routing.rs b/lib/components/fabro-workflow/src/handler/llm/routing.rs index c3db8ae5d..59d26bcc2 100644 --- a/lib/components/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/components/fabro-workflow/src/handler/llm/routing.rs @@ -1,7 +1,8 @@ use fabro_graphviz::graph::{self, Node}; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::{ModelSelectionError, catalog, selection}; -use fabro_types::{AgentBackend, AgentProfileKind, ProviderId}; +use fabro_types::{AgentBackend, AgentProfileKind}; +use lithos_llm::catalog::ProviderId; use crate::error::Error; @@ -51,10 +52,17 @@ pub(crate) fn resolve_provider_context( provider_attr: Option<&str>, ) -> Result { let provider_id = if let Some(provider) = provider_attr { - catalog::canonical_provider_id(catalog, provider).ok_or_else(|| { - Error::Precondition(format!("Provider \"{provider}\" is not configured")) - })? - } else if catalog::model_on_provider(catalog, default_provider_id.as_str(), model).is_some() { + catalog + .enabled_provider(provider) + .map(|found| found.id().clone()) + .ok_or_else(|| { + Error::Precondition(format!("Provider \"{provider}\" is not configured")) + })? + } else if catalog + .enabled_provider(default_provider_id.as_str()) + .and_then(|provider| provider.offering(model)) + .is_some() + { // The run's selected provider is a pin whenever it offers the model. default_provider_id.clone() } else { @@ -62,7 +70,7 @@ pub(crate) fn resolve_provider_context( catalog, model, None, - &catalog::enabled_provider_ids(catalog), + &catalog.enabled_provider_ids().into_iter().collect(), ) { Ok(entry) => entry.provider.id().clone(), Err(ModelSelectionError::UnknownSelector { .. }) => default_provider_id.clone(), @@ -70,8 +78,10 @@ pub(crate) fn resolve_provider_context( } }; - let provider_id = - catalog::canonical_provider_id(catalog, provider_id.as_str()).ok_or_else(|| { + let provider_id = catalog + .enabled_provider(provider_id.as_str()) + .map(|provider| provider.id().clone()) + .ok_or_else(|| { Error::Precondition(format!("Provider \"{provider_id}\" is not configured")) })?; let profile_kind = catalog::agent_profile(catalog, provider_id.as_str(), Some(model)) diff --git a/lib/components/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs index 5f0778fca..1137c1738 100644 --- a/lib/components/fabro-workflow/src/handler/prompt.rs +++ b/lib/components/fabro-workflow/src/handler/prompt.rs @@ -221,7 +221,9 @@ mod tests { use fabro_graphviz::graph::AttrValue; use fabro_store::{Database, RunDatabase, StageId}; - use fabro_types::{ReasoningEffort, Speed, fixtures, test_support}; + use fabro_types::{fixtures, test_support}; + use lithos_llm::catalog::ProviderId; + use lithos_llm::types::{ReasoningEffort, Speed}; use object_store::memory::InMemory; use tempfile::TempDir; @@ -722,7 +724,7 @@ mod tests { ))) .with_catalog_context( Arc::clone(&catalog), - fabro_types::ProviderId::new("acme"), + ProviderId::new("acme"), "acme-claude".to_string(), ); @@ -796,7 +798,7 @@ mod tests { ))) .with_catalog_context( Arc::clone(&catalog), - fabro_types::ProviderId::new("acme"), + ProviderId::new("acme"), "acme-claude".to_string(), ); diff --git a/lib/components/fabro-workflow/src/lifecycle/git.rs b/lib/components/fabro-workflow/src/lifecycle/git.rs index 509f1a397..6c05fb46f 100644 --- a/lib/components/fabro-workflow/src/lifecycle/git.rs +++ b/lib/components/fabro-workflow/src/lifecycle/git.rs @@ -1306,7 +1306,7 @@ mod tests { None, finalize_locations, tokio_util::sync::CancellationToken::new(), - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), "claude-sonnet-4-6".to_string(), auth_test_support::vault_only_credential_source(), Arc::new(fabro_llm::test_support::test_catalog()), diff --git a/lib/components/fabro-workflow/src/model_fallback.rs b/lib/components/fabro-workflow/src/model_fallback.rs index 4a9d3c73f..3334a2650 100644 --- a/lib/components/fabro-workflow/src/model_fallback.rs +++ b/lib/components/fabro-workflow/src/model_fallback.rs @@ -1,10 +1,11 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use fabro_llm::catalog::ModelEntry; -use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{FallbackTarget, ModelSelectionError, catalog, selection}; +use fabro_llm::lithos_catalog::{Catalog, Offering}; +use fabro_llm::{FallbackTarget, ModelSelectionError, selection}; use fabro_types::settings::{ModelRef, ResolvedModelRef}; -use fabro_types::{ProviderId, ReasoningEffort, RunNoticeCode, RunNoticeLevel}; +use fabro_types::{RunNoticeCode, RunNoticeLevel}; +use lithos_llm::catalog::ProviderId; +use lithos_llm::types::ReasoningEffort; use crate::Error; @@ -31,7 +32,7 @@ impl ModelFallbackPolicy { provider: &ProviderId, model: &str, ) -> Option<&'a [FallbackTarget]> { - self.chain_for_canonical(&catalog::canonical_model_id(catalog, provider, model)) + self.chain_for_canonical(&canonical_model_id(catalog, provider, model)) } /// Look up a chain by an already-canonicalized requested model ID. @@ -224,8 +225,9 @@ pub fn resolve_model_fallbacks( } let primary = FallbackTarget::new(&selected.provider, &requested_model); - let primary_model = - catalog::model_on_provider(catalog, selected.provider.as_str(), &requested_model); + let primary_model = catalog + .enabled_provider(selected.provider.as_str()) + .and_then(|provider| provider.offering(&requested_model)); let mut targets = Vec::new(); for model_ref in references { @@ -295,11 +297,23 @@ enum FallbackCandidate { Skipped(ModelFallbackNotice), } +/// The catalog id for `selector` on `provider`, else anywhere; the selector +/// itself for a passthrough model the catalog does not know. +pub(crate) fn canonical_model_id( + catalog: &Catalog, + provider: &ProviderId, + selector: &str, +) -> String { + catalog + .canonical_model_id(Some(provider), selector) + .map_or_else(|| selector.to_string(), ToString::to_string) +} + fn resolve_fallback_candidate( catalog: &Catalog, requested_model: &str, primary: &FallbackTarget, - primary_model: Option<&ModelEntry<'_>>, + primary_model: Option<&Offering<'_>>, eligible: &HashSet, model_ref: &ModelRef, ) -> Result { @@ -326,7 +340,10 @@ fn resolve_fallback_candidate( }, )); }; - match catalog::closest_model(catalog, provider.as_str(), primary_model.model) { + match catalog + .enabled_provider(provider.as_str()) + .and_then(|target| target.closest_offering(primary_model.model)) + { Some(entry) => { FallbackCandidate::Target(FallbackTarget::new(provider, entry.model.id())) } @@ -392,7 +409,7 @@ mod tests { use fabro_llm::FallbackTarget; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::test_support::test_catalog_with_overlay; - use fabro_types::ProviderId; + use lithos_llm::catalog::ProviderId; use super::{ModelFallbackNotice, resolve_model_fallbacks}; diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index c0cbe93bf..3847ba7f0 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -16,10 +16,11 @@ use fabro_llm::lithos_catalog::Catalog; use fabro_store::{BlobStore, Database}; use fabro_template::TemplateContext; use fabro_types::{ - AutomationRef, BlobHash, ForkSourceRef, GitContext, ManifestPath, ProviderId, RunId, - RunProvenance, RunTarget, WorkflowSettings, WorkflowVersionId, + AutomationRef, BlobHash, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, + RunTarget, WorkflowSettings, WorkflowVersionId, }; use fabro_util::json::normalize_json_value; +use lithos_llm::catalog::ProviderId; use tokio::task::spawn_blocking; use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; @@ -688,9 +689,10 @@ mod tests { use fabro_store::Database; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunMode; - use fabro_types::{EventBody, WorkflowSettings, fixtures, provider_ids, test_support}; + use fabro_types::{EventBody, WorkflowSettings, fixtures, test_support}; use fabro_util::error::collect_chain; use fabro_validate::Severity; + use lithos_llm::catalog::builtin; use object_store::local::LocalFileSystem; use object_store::memory::InMemory; @@ -753,7 +755,8 @@ mod tests { } fn test_provider_ids() -> Vec { - fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog()) + fabro_llm::test_support::test_catalog() + .enabled_provider_ids() .into_iter() .collect() } @@ -2027,19 +2030,19 @@ mod tests { }"#; let catalog = portable_model_catalog(); let cases = [ - (vec![provider_ids::openai()], None, provider_ids::openai()), + (vec![builtin::openai()], None, builtin::openai()), ( vec![ProviderId::new("openrouter")], None, ProviderId::new("openrouter"), ), ( - vec![provider_ids::openai(), ProviderId::new("openrouter")], + vec![builtin::openai(), ProviderId::new("openrouter")], None, - provider_ids::openai(), + builtin::openai(), ), ( - vec![provider_ids::openai(), ProviderId::new("openrouter")], + vec![builtin::openai(), ProviderId::new("openrouter")], Some("openrouter"), ProviderId::new("openrouter"), ), diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index f5a5c8ad0..ceb18a061 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -4,8 +4,9 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use fabro_auth::{CredentialSource, VaultCredentialSource}; +use fabro_auth::VaultCredentialSource; use fabro_interview::{AutoApproveInterviewer, Interviewer}; +use fabro_llm::credentials::readiness; use fabro_llm::lithos_catalog::Catalog; use fabro_mcp::config::McpServerSettings; use fabro_sandbox::daytona::DaytonaConfig; @@ -23,11 +24,12 @@ use fabro_types::settings::run::{ RunPrepareSettings as ResolvedRunPrepareSettings, }; use fabro_types::{ - ManifestPath, ProviderId, RunId, RunRunnableSource, RunSpec, RunTarget, SandboxProviderKind, + ManifestPath, RunId, RunRunnableSource, RunSpec, RunTarget, SandboxProviderKind, TargetValidationError, }; use fabro_util::error::collect_chain; use fabro_vault::Vault; +use lithos_llm::catalog::ProviderId; use tokio::runtime::Handle; use tokio::sync::RwLock as AsyncRwLock; use tokio::{fs, time}; @@ -746,11 +748,8 @@ async fn configured_providers_for_start( vault: &Arc>, catalog: Arc, ) -> Vec { - let source: Arc = Arc::new(VaultCredentialSource::with_env_lookup( - Arc::clone(vault), - process_env_var, - )); - source.resolve_all(catalog.as_ref()).await.ready + let source = VaultCredentialSource::with_env_lookup(Arc::clone(vault), process_env_var); + readiness(catalog.enabled_providers(), &source).await.ready } fn git_checkpoint_options_from_start( @@ -1312,9 +1311,10 @@ mod tests { }; use fabro_types::{ BilledModelUsage, GitContext, ManifestPath, RunTarget, StageTiming, WorkflowSettings, - fixtures, provider_ids, test_support, + fixtures, test_support, }; use fabro_vault::SecretType; + use lithos_llm::catalog::builtin; use object_store::memory::InMemory; use super::*; @@ -1437,7 +1437,8 @@ mod tests { } fn test_provider_ids() -> Vec { - fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog()) + fabro_llm::test_support::test_catalog() + .enabled_provider_ids() .into_iter() .collect() } @@ -1476,7 +1477,7 @@ mod tests { error, Error::ModelSelection(fabro_llm::ModelSelectionError::ProviderUnavailable { provider - }) if provider == provider_ids::openai() + }) if provider == builtin::openai() )); } diff --git a/lib/components/fabro-workflow/src/operations/validate.rs b/lib/components/fabro-workflow/src/operations/validate.rs index 3a8db0c5b..7f99eb887 100644 --- a/lib/components/fabro-workflow/src/operations/validate.rs +++ b/lib/components/fabro-workflow/src/operations/validate.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use std::sync::Arc; use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{ProviderId, WorkflowSettings}; +use fabro_types::WorkflowSettings; +use lithos_llm::catalog::ProviderId; use super::create::{configured_default_provider, preprocess_and_validate, template_context}; use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; diff --git a/lib/components/fabro-workflow/src/outcome.rs b/lib/components/fabro-workflow/src/outcome.rs index e41717ca5..d456a149c 100644 --- a/lib/components/fabro-workflow/src/outcome.rs +++ b/lib/components/fabro-workflow/src/outcome.rs @@ -1,10 +1,10 @@ pub use fabro_core::outcome::{ FailureCategory, FailureDetail, OutcomeMeta, StageOutcome, StageState, }; -use fabro_llm::catalog; use fabro_llm::lithos_catalog::Catalog; pub use fabro_types::BilledModelUsage; -use fabro_types::{BilledTokenCounts, ModelRef, TokenCounts}; +use fabro_types::{BilledTokenCounts, ModelRef}; +use lithos_llm::types::TokenCounts; use crate::error::{Error, FailureSignature, classify_failure_reason}; @@ -19,13 +19,13 @@ pub fn billed_model_usage_from_llm( model: &ModelRef, usage: TokenCounts, ) -> Result { - if catalog::provider(catalog, model.provider.as_str()).is_none() { + if catalog.enabled_provider(model.provider.as_str()).is_none() { return Err(Error::Precondition(format!( "Provider \"{}\" is not configured", model.provider ))); } - let cost = catalog::estimate_cost(catalog, model, usage); + let cost = catalog.estimate_cost(&model.handle(), usage, model.speed); Ok(BilledModelUsage::new(model.clone(), usage, cost)) } @@ -126,7 +126,9 @@ pub fn format_cost(cost: f64) -> String { mod tests { use fabro_llm::lithos_catalog::Catalog; use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay}; - use fabro_types::{ModelId, ModelRef, ProviderId, Speed, TokenCounts, UsdMicros, provider_ids}; + use fabro_types::{ModelRef, UsdMicros}; + use lithos_llm::catalog::{ModelId, ProviderId, builtin}; + use lithos_llm::types::{Speed, TokenCounts}; use super::{OutcomeExt, billed_model_usage_from_llm}; @@ -150,7 +152,7 @@ mod tests { }; let billed = billed_model_usage_from_llm( &catalog(), - &model_ref(provider_ids::openai(), "gpt-5.4", None), + &model_ref(builtin::openai(), "gpt-5.4", None), usage, ) .unwrap(); @@ -170,7 +172,7 @@ mod tests { }; let billed = billed_model_usage_from_llm( &catalog(), - &model_ref(provider_ids::openai(), "gpt-5.4", None), + &model_ref(builtin::openai(), "gpt-5.4", None), usage, ) .unwrap() @@ -200,11 +202,7 @@ mod tests { }; let billed = billed_model_usage_from_llm( &catalog(), - &model_ref( - provider_ids::anthropic(), - "claude-opus-5", - Some(Speed::Fast), - ), + &model_ref(builtin::anthropic(), "claude-opus-5", Some(Speed::Fast)), usage, ) .unwrap(); @@ -254,7 +252,7 @@ pricing = { input_usd_micros_per_million = 1000000, output_usd_micros_per_millio fn passthrough_model_on_known_provider_has_no_cost() { let billed = billed_model_usage_from_llm( &catalog(), - &model_ref(provider_ids::openai(), "brand-new-model", None), + &model_ref(builtin::openai(), "brand-new-model", None), TokenCounts { input: 10, output: 5, diff --git a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs index ec254b3d2..426cf15a4 100644 --- a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs @@ -265,7 +265,7 @@ async fn execute_test_run_with_options( }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_types::provider_ids::anthropic(), + provider_id: lithos_llm::catalog::builtin::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -325,7 +325,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_types::provider_ids::anthropic(), + provider_id: lithos_llm::catalog::builtin::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -466,7 +466,7 @@ async fn resumed_in_flight_node_starts_a_new_stage_execution() { }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_types::provider_ids::anthropic(), + provider_id: lithos_llm::catalog::builtin::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -580,7 +580,7 @@ async fn run_with_lifecycle( }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_types::provider_ids::anthropic(), + provider_id: lithos_llm::catalog::builtin::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), diff --git a/lib/components/fabro-workflow/src/pipeline/finalize.rs b/lib/components/fabro-workflow/src/pipeline/finalize.rs index bc1ec1860..9d40f7520 100644 --- a/lib/components/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/components/fabro-workflow/src/pipeline/finalize.rs @@ -996,7 +996,7 @@ mod tests { None, locations, tokio_util::sync::CancellationToken::new(), - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), "claude-sonnet-4-6".to_string(), auth_test_support::vault_only_credential_source(), Arc::new(fabro_llm::test_support::test_catalog()), @@ -1029,7 +1029,7 @@ mod tests { None, locations, tokio_util::sync::CancellationToken::new(), - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), "claude-sonnet-4-6".to_string(), auth_test_support::vault_only_credential_source(), Arc::new(fabro_llm::test_support::test_catalog()), diff --git a/lib/components/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs index 38fc31e51..79bde9850 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -4,10 +4,11 @@ use std::sync::Arc; use std::time::Instant; use fabro_agent::{Sandbox, ToolSecrets}; -use fabro_auth::{CredentialSource, ExtraHeadersCredentialSource, VaultCredentialSource}; +use fabro_auth::{ExtraHeadersCredentialSource, VaultCredentialSource}; use fabro_github::token_source::InstallationTokenSource; use fabro_graphviz::graph; use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner}; +use fabro_llm::credentials::{CredentialProvider, readiness}; use fabro_llm::lithos_catalog::Catalog; use fabro_sandbox::{ GitSetupIntent, SandboxEventCallback, SandboxSpec, reconnect_for_run_with_callback, shell_quote, @@ -210,7 +211,7 @@ async fn build_registry( tool_env_provider: Arc, github_token_refresh_managed: bool, graph: &graph::Graph, - llm_source: Arc, + llm_source: Arc, catalog: Arc, tool_secrets: ToolSecrets, fabro_run_tools: Option, @@ -276,11 +277,17 @@ async fn build_registry( return Ok((build_llm_registry(), false)); } - let result = llm_source.resolve_all(catalog.as_ref()).await; + let result = readiness(catalog.enabled_providers(), llm_source.as_ref()).await; if result.ready.is_empty() { if graph_needs_llm { - let detail = - (!result.auth_issues.is_empty()).then(|| result.issue_messages().join("; ")); + let detail = (!result.issues.is_empty()).then(|| { + result + .issues + .iter() + .map(|(_, issue)| issue.to_string()) + .collect::>() + .join("; ") + }); let prefix = detail.map_or_else( || "No LLM providers configured".to_string(), |detail| format!("No usable LLM providers configured: {detail}"), @@ -314,7 +321,7 @@ const SESSION_ID_HEADER: &str = "x-session-id"; fn build_llm_source( vault: Arc>, run_id: fabro_types::RunId, -) -> Arc { +) -> Arc { Arc::new(ExtraHeadersCredentialSource::new( Arc::new(VaultCredentialSource::new(vault)), HashMap::from([(SESSION_ID_HEADER.to_string(), run_id.to_string())]), @@ -894,7 +901,7 @@ mod tests { sandbox: SandboxSpec::Local { working_directory }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_types::provider_ids::anthropic(), + provider_id: lithos_llm::catalog::builtin::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -1084,17 +1091,16 @@ mod tests { assert_eq!(initialized.model, "test-model"); assert_eq!( initialized.engine.run.provider_id, - fabro_types::provider_ids::anthropic() + lithos_llm::catalog::builtin::anthropic() ); assert!( - initialized - .engine - .run - .llm_source - .resolve_all(&initialized.engine.run.catalog) - .await - .ready - .is_empty() + readiness( + initialized.engine.run.catalog.enabled_providers(), + initialized.engine.run.llm_source.as_ref(), + ) + .await + .ready + .is_empty() ); } @@ -1211,7 +1217,7 @@ mod tests { let (_registry, effective_dry_run) = build_registry( &LlmSpec { model: "claude-opus-4-6".to_string(), - provider_id: fabro_types::provider_ids::anthropic(), + provider_id: lithos_llm::catalog::builtin::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -1244,7 +1250,7 @@ mod tests { let source = build_llm_source(vault, run_id); let catalog = test_catalog(); - let resolved = source.resolve_all(catalog.as_ref()).await; + let resolved = readiness(catalog.enabled_providers(), source.as_ref()).await; assert!(!resolved.ready.is_empty()); for provider in &resolved.ready { @@ -1338,7 +1344,7 @@ mod tests { }, llm: LlmSpec { model: "fake-acp".to_string(), - provider_id: fabro_types::provider_ids::openai(), + provider_id: lithos_llm::catalog::builtin::openai(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -1441,7 +1447,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_types::provider_ids::anthropic(), + provider_id: lithos_llm::catalog::builtin::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), @@ -1583,7 +1589,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider_id: fabro_types::provider_ids::anthropic(), + provider_id: lithos_llm::catalog::builtin::anthropic(), fallbacks: ModelFallbackPolicy::default(), mcp_servers: Vec::new(), model_controls: RunModelControls::default(), diff --git a/lib/components/fabro-workflow/src/pipeline/pull_request.rs b/lib/components/fabro-workflow/src/pipeline/pull_request.rs index f6fb74de5..879d91eaa 100644 --- a/lib/components/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/components/fabro-workflow/src/pipeline/pull_request.rs @@ -2,15 +2,17 @@ use std::collections::HashSet; use std::sync::{Arc, LazyLock}; use std::time::Duration; -use fabro_auth::CredentialSource; use fabro_github::{self as github_app, ssh_url_to_https}; use fabro_graphviz::parser; +use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{Client, ClientOptions, Request, selection, structured}; +use fabro_llm::{Client, ClientOptions, Request, selection}; use fabro_store::RunProjection; +use fabro_types::PullRequestLink; use fabro_types::settings::run::MergeStrategy; -use fabro_types::{ProviderId, PullRequestLink, Role}; use fabro_util::text::strip_goal_decoration; +use lithos_llm::catalog::ProviderId; +use lithos_llm::types::{Message, Role}; use tokio::time::sleep; use tracing::{debug, info, warn}; @@ -333,7 +335,7 @@ pub async fn build_pr_content( goal: &str, model: &str, run_store: &RunStoreHandle, - llm_source: Arc, + llm_source: Arc, catalog: Arc, conclusion: Option<&Conclusion>, run_state: Option<&RunProjection>, @@ -405,13 +407,13 @@ async fn build_pr_content_with_client( let request = Request::builder() .model(model) .system(PR_BODY_SYSTEM_PROMPT) - .message(fabro_types::Message::text(Role::User, prompt)) + .message(Message::text(Role::User, prompt)) .build() .map_err(|e| format!("invalid PR content request: {e}"))?; - let completion = - structured::complete_object(&client, request, "pr_content", PR_CONTENT_SCHEMA.clone()) - .await - .map_err(|e| format!("LLM generation failed: {e}"))?; + let completion = client + .complete_object(request, "pr_content", PR_CONTENT_SCHEMA.clone()) + .await + .map_err(|e| format!("LLM generation failed: {e}"))?; let generated: PrContent = serde_json::from_value(completion.object) .map_err(|e| format!("Failed to deserialize PR content: {e}"))?; @@ -462,7 +464,7 @@ pub struct OpenPullRequestRequest<'a> { pub draft: bool, pub auto_merge: Option, pub run_store: &'a RunStoreHandle, - pub llm_source: Arc, + pub llm_source: Arc, pub catalog: Arc, pub conclusion: Option<&'a Conclusion>, pub run_state: Option<&'a RunProjection>, @@ -686,19 +688,21 @@ mod tests { use std::time::Duration; use chrono::Utc; - use fabro_auth::{CredentialSource, VaultCredentialSource}; + use fabro_auth::VaultCredentialSource; use fabro_graphviz::graph::Graph; use fabro_llm::adapter::{ProviderAdapter, ResolvedCall}; + use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::AdapterId; use fabro_llm::{Response, ResponseStream}; use fabro_store::Database; use fabro_types::{ - BilledTokenCounts, ContentPart, RunProjection, RunSpec, SuccessReason, TokenCounts, - WorkflowSettings, first_event_seq, fixtures, test_support, + BilledTokenCounts, RunProjection, RunSpec, SuccessReason, WorkflowSettings, + first_event_seq, fixtures, test_support, }; use fabro_vault::{SecretType, Vault}; use httpmock::Method::{GET, POST}; use httpmock::MockServer; + use lithos_llm::types::{ContentPart, TokenCounts}; use object_store::memory::InMemory; use tokio::sync::RwLock as AsyncRwLock; @@ -801,7 +805,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr let mut options = fabro_llm::ClientOptions::default(); options .adapters - .push((fabro_types::ProviderId::new(provider_name), adapter)); + .push((ProviderId::new(provider_name), adapter)); Arc::new( fabro_llm::build_offline_client(mock_catalog(), options) .expect("mock client should build") @@ -1308,9 +1312,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr None, ) .unwrap(); - let llm_source: Arc = Arc::new(VaultCredentialSource::new(Arc::new( - AsyncRwLock::new(vault), - ))); + let llm_source: Arc = Arc::new(VaultCredentialSource::new( + Arc::new(AsyncRwLock::new(vault)), + )); // Use catalog settings to override base_url instead of env var let catalog = test_catalog_with_provider_base_url("openai", &server.url("/v1")); @@ -1484,7 +1488,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr assert_eq!( truncation_caps( "unknown-model", - &fabro_llm::catalog::enabled_provider_ids(&mock_catalog()), + &mock_catalog().enabled_provider_ids().into_iter().collect(), &mock_catalog(), ), TruncationCaps { @@ -1696,7 +1700,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr branch_mock_id: usize, reconcile_mock_id: usize, github_mock_id: usize, - llm_source: Arc, + llm_source: Arc, catalog: Arc, creds: fabro_github::GitHubCredentials, run_store: RunStoreHandle, @@ -1804,9 +1808,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr None, ) .unwrap(); - let llm_source: Arc = Arc::new(VaultCredentialSource::new(Arc::new( - AsyncRwLock::new(vault), - ))); + let llm_source: Arc = Arc::new(VaultCredentialSource::new( + Arc::new(AsyncRwLock::new(vault)), + )); // Use catalog settings to override base_url instead of env var let catalog = test_catalog_with_provider_base_url("openai", &openai_server.url("/v1")); diff --git a/lib/components/fabro-workflow/src/pipeline/types.rs b/lib/components/fabro-workflow/src/pipeline/types.rs index 669db75e3..d2bba100d 100644 --- a/lib/components/fabro-workflow/src/pipeline/types.rs +++ b/lib/components/fabro-workflow/src/pipeline/types.rs @@ -11,9 +11,10 @@ use fabro_template::TemplateContext; use fabro_types::settings::run::{ PullRequestSettings, ResolvedGithubIntegration, RunModelControls, }; -use fabro_types::{ManifestPath, ProviderId, RunId, RunProjection}; +use fabro_types::{ManifestPath, RunId, RunProjection}; use fabro_validate::{Diagnostic, Severity}; use fabro_vault::Vault; +use lithos_llm::catalog::ProviderId; use tokio::sync::RwLock as AsyncRwLock; use crate::artifact_upload::ArtifactSink; diff --git a/lib/components/fabro-workflow/src/run_materialization.rs b/lib/components/fabro-workflow/src/run_materialization.rs index 714e184e1..35ce47e7d 100644 --- a/lib/components/fabro-workflow/src/run_materialization.rs +++ b/lib/components/fabro-workflow/src/run_materialization.rs @@ -3,9 +3,10 @@ use std::collections::HashSet; use fabro_graphviz::graph::Graph; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::{ModelSelectionError, selection}; +use fabro_types::WorkflowSettings; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; -use fabro_types::{ProviderId, WorkflowSettings}; +use lithos_llm::catalog::ProviderId; use crate::error::Error; diff --git a/lib/components/fabro-workflow/src/services.rs b/lib/components/fabro-workflow/src/services.rs index b1662de01..6054282e4 100644 --- a/lib/components/fabro-workflow/src/services.rs +++ b/lib/components/fabro-workflow/src/services.rs @@ -5,12 +5,13 @@ use std::sync::Arc; use std::time::Duration; use fabro_agent::{Sandbox, ToolEnvProvider}; -use fabro_auth::CredentialSource; use fabro_github::token_source::InstallationTokenSource; use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner}; use fabro_interview::Interviewer; +use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; -use fabro_types::{ManifestPath, ProviderId, RunId}; +use fabro_types::{ManifestPath, RunId}; +use lithos_llm::catalog::ProviderId; use tokio_util::sync::CancellationToken; use crate::event::Emitter; @@ -99,7 +100,7 @@ pub struct RunServices { pub(crate) cancel_token: CancellationToken, pub provider_id: ProviderId, pub model: String, - pub llm_source: Arc, + pub llm_source: Arc, pub catalog: Arc, pub(crate) sandbox_git: Arc, pub(crate) metadata_runtime: Arc, @@ -121,7 +122,7 @@ impl RunServices { cancel_token: CancellationToken, provider_id: ProviderId, model: String, - llm_source: Arc, + llm_source: Arc, catalog: Arc, sandbox_git: Arc, metadata_runtime: Arc, @@ -268,19 +269,22 @@ impl EngineServices { struct StubCredentialSource; #[async_trait::async_trait] - impl CredentialSource for StubCredentialSource { + impl CredentialProvider for StubCredentialSource { async fn credentials( &self, provider: &fabro_llm::lithos_catalog::CatalogProvider, - ) -> Result { - Err(fabro_auth::ResolveError::NotConfigured( - provider.id().clone(), - )) + ) -> Result + { + Err(fabro_llm::credentials::CredentialError::NotConfigured { + provider: provider.id().clone(), + }) } - async fn configured_providers(&self, catalog: &Catalog) -> Vec { - let _ = catalog; - Vec::new() + async fn is_configured( + &self, + _provider: &fabro_llm::lithos_catalog::CatalogProvider, + ) -> bool { + false } } @@ -317,7 +321,7 @@ impl EngineServices { None, locations, CancellationToken::new(), - fabro_types::provider_ids::anthropic(), + lithos_llm::catalog::builtin::anthropic(), "claude-sonnet-4.6".to_string(), Arc::new(StubCredentialSource), Arc::new(fabro_llm::default_catalog()), @@ -383,12 +387,12 @@ mod tests { let services = EngineServices::test_default(); assert!( - services - .run - .llm_source - .configured_providers(&services.run.catalog) - .await - .is_empty() + fabro_llm::configured_providers( + &services.run.catalog, + services.run.llm_source.as_ref() + ) + .await + .is_empty() ); } diff --git a/lib/components/fabro-workflow/src/test_support.rs b/lib/components/fabro-workflow/src/test_support.rs index 9efbeae84..6fb04fd24 100644 --- a/lib/components/fabro-workflow/src/test_support.rs +++ b/lib/components/fabro-workflow/src/test_support.rs @@ -5,16 +5,18 @@ use std::sync::Arc; use std::time::Duration; use fabro_agent::Sandbox; -use fabro_auth::{CredentialSource, test_support as auth_test_support}; +use fabro_auth::test_support as auth_test_support; use fabro_graphviz::graph::Graph as GvGraph; use fabro_interview::AutoApproveInterviewer; -use fabro_llm::catalog; +use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::test_support::test_catalog; use fabro_store::{ArtifactStore, RunProjection, test_support as store_test_support}; +use fabro_types::ModelRef; #[cfg(feature = "test-support")] -use fabro_types::ProviderId; -use fabro_types::{ModelId, ModelRef, provider_ids}; +use lithos_llm::catalog::ProviderId; +use lithos_llm::catalog::{ModelId, builtin}; +use lithos_llm::types::TokenCounts; use object_store::local::LocalFileSystem; use crate::artifact_upload::ArtifactSink; @@ -39,7 +41,7 @@ pub(crate) fn test_configured_provider_ids( assume_ready: bool, ) -> Vec { if assume_ready { - catalog::enabled_provider_ids(catalog).into_iter().collect() + catalog.enabled_provider_ids().into_iter().collect() } else { configured_provider_ids } @@ -89,11 +91,11 @@ pub fn test_usage( output_tokens: u64, ) -> fabro_types::BilledModelUsage { let mut usage = fabro_types::BilledModelUsage::new( - ModelRef::new(provider_ids::openai(), ModelId::new(model_id)), - fabro_types::TokenCounts { + ModelRef::new(builtin::openai(), ModelId::new(model_id)), + TokenCounts { input: input_tokens, output: output_tokens, - ..fabro_types::TokenCounts::default() + ..TokenCounts::default() }, None, ); @@ -145,7 +147,7 @@ struct InitializedOptions { hook_runner: Option>, env: HashMap, checkpoint: Option, - llm_source: Option>, + llm_source: Option>, } struct InitializedState { @@ -269,7 +271,7 @@ async fn initialized( options.hook_runner, locations, run_options.cancel_token.clone(), - provider_ids::anthropic(), + builtin::anthropic(), "claude-sonnet-4-6".to_string(), options .llm_source @@ -481,7 +483,7 @@ pub async fn run_graph_with_state_and_llm_source( sandbox: Arc, graph: &GvGraph, run_options: &RunOptions, - llm_source: Arc, + llm_source: Arc, ) -> Result<(Outcome, RunProjection)> { let initialized = initialized( registry, @@ -576,7 +578,7 @@ impl WorkflowRunner { &self, graph: &GvGraph, run_options: &RunOptions, - llm_source: Arc, + llm_source: Arc, ) -> Result<(Outcome, RunProjection)> { let registry = self .registry diff --git a/lib/components/fabro-workflow/src/transforms/model_resolution.rs b/lib/components/fabro-workflow/src/transforms/model_resolution.rs index 22f1cf33c..450fb65b6 100644 --- a/lib/components/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/components/fabro-workflow/src/transforms/model_resolution.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{catalog, selection}; -use fabro_types::ProviderId; +use fabro_llm::selection; +use lithos_llm::catalog::ProviderId; use super::Transform; use crate::error::Error; @@ -21,7 +21,7 @@ pub struct ModelResolutionTransform { impl ModelResolutionTransform { #[must_use] pub fn new(catalog: Arc) -> Self { - let eligible_providers = catalog::enabled_provider_ids(&catalog); + let eligible_providers = catalog.enabled_provider_ids().into_iter().collect(); Self { catalog, default_provider: None, diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index ee407786e..96c354a00 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -32,9 +32,7 @@ use fabro_interview::{ }; use fabro_llm::lithos_catalog::Catalog; use fabro_store::{ArtifactKey, ArtifactStore}; -use fabro_types::{ - EventBody, ProviderId, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref, -}; +use fabro_types::{EventBody, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::artifact; use fabro_workflow::context::Context; @@ -62,6 +60,7 @@ use fabro_workflow::test_support::{ }; use fabro_workflow::transforms::stylesheet::{apply_stylesheet, parse_stylesheet}; use fabro_workflow::transforms::{StylesheetApplicationTransform, TemplateTransform, Transform}; +use lithos_llm::catalog::ProviderId; use object_store::local::LocalFileSystem; use tokio_util::sync::CancellationToken; use ulid::Ulid; @@ -7395,7 +7394,7 @@ mod real_llm { } fabro_test::require_env("ANTHROPIC_API_KEY")?; - let source: Arc = + let source: Arc = Arc::new(VaultCredentialSource::environment_only()); Some(Arc::new( fabro_llm::build_client( @@ -8081,7 +8080,8 @@ fn openai_responses_payload(text: &str) -> serde_json::Value { #[tokio::test] async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() { use chrono::Utc; - use fabro_auth::{CredentialSource, VaultCredentialSource}; + use fabro_auth::VaultCredentialSource; + use fabro_llm::credentials::CredentialProvider; use fabro_types::Conclusion; use fabro_vault::{SecretType, Vault}; use httpmock::Method::POST; @@ -8138,7 +8138,7 @@ async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() { None, ) .unwrap(); - let llm_source: Arc = Arc::new(VaultCredentialSource::new(Arc::new( + let llm_source: Arc = Arc::new(VaultCredentialSource::new(Arc::new( AsyncRwLock::new(vault), ))); // Use catalog settings to override base_url instead of env var diff --git a/lib/components/fabro-workflow/tests/materialize_run.rs b/lib/components/fabro-workflow/tests/materialize_run.rs index be8a28522..04939cb0a 100644 --- a/lib/components/fabro-workflow/tests/materialize_run.rs +++ b/lib/components/fabro-workflow/tests/materialize_run.rs @@ -1,10 +1,11 @@ use fabro_graphviz::graph::Graph; use fabro_graphviz::parser; use fabro_llm::test_support::test_catalog; +use fabro_types::WorkflowSettings; use fabro_types::settings::InterpString; use fabro_types::settings::run::{PullRequestSettings, RunGoal, RunModelSettings, RunNamespace}; -use fabro_types::{WorkflowSettings, provider_ids}; use fabro_workflow::run_materialization::materialize_run; +use lithos_llm::catalog::builtin; fn graph(source: &str) -> Graph { parser::parse(source).expect("graph should parse") @@ -35,7 +36,7 @@ fn materialize_run_applies_graph_and_catalog_defaults() { }; let materialized = materialize_run(settings, &graph(source), &test_catalog(), &[ - provider_ids::anthropic(), + builtin::anthropic(), ]) .unwrap(); let resolved = &materialized.run; @@ -62,7 +63,7 @@ fn materialize_run_uses_configured_provider_defaults() { WorkflowSettings::default(), &graph(source), &test_catalog(), - &[provider_ids::openai()], + &[builtin::openai()], ) .unwrap(); let resolved = &materialized.run; diff --git a/lib/foundation/fabro-api/Cargo.toml b/lib/foundation/fabro-api/Cargo.toml index 1930ef92f..02f85c3df 100644 --- a/lib/foundation/fabro-api/Cargo.toml +++ b/lib/foundation/fabro-api/Cargo.toml @@ -19,6 +19,7 @@ fabro-automation = { path = "../../components/fabro-automation" } fabro-config = { path = "../fabro-config" } fabro-environment.workspace = true fabro-types = { path = "../fabro-types" } +lithos-llm = { workspace = true, features = ["runtime"] } progenitor-client = "0.13" regress = "0.10" reqwest.workspace = true diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 6f4c965f1..9630aa75b 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -504,19 +504,19 @@ fn main() { "fabro_types::PendingInterviewRecord", &[], ), - ("CompletionUsage", "fabro_types::TokenCounts", &[]), + ("CompletionUsage", "lithos_llm::types::TokenCounts", &[]), ("BilledTokenCounts", "fabro_types::BilledTokenCounts", &[]), ("BillingModelRef", "fabro_types::ModelRef", &[]), - ("BillingSpeed", "fabro_types::Speed", &[]), + ("BillingSpeed", "lithos_llm::types::Speed", &[]), ("ExecOutputTail", "fabro_types::ExecOutputTail", &[]), ("StageTiming", "fabro_types::StageTiming", &[]), ("RunTiming", "fabro_types::RunTiming", &[]), - ("ProviderId", "fabro_types::ProviderId", &[]), - ("ModelHandle", "fabro_types::ModelHandle", &[]), + ("ProviderId", "lithos_llm::catalog::ProviderId", &[]), + ("ModelHandle", "lithos_llm::catalog::ModelHandle", &[]), ("Model", "fabro_types::Model", &[]), ("Provider", "fabro_types::Provider", &[]), ("ModelLimits", "fabro_types::ModelLimits", &[]), - ("ReasoningEffort", "fabro_types::ReasoningEffort", &[]), + ("ReasoningEffort", "lithos_llm::types::ReasoningEffort", &[]), ("ModelFeatures", "fabro_types::ModelFeatures", &[]), ("ModelControls", "fabro_types::ModelControls", &[]), ("ModelCosts", "fabro_types::ModelCosts", &[]), @@ -730,27 +730,31 @@ fn main() { ("SessionRecord", "fabro_types::SessionRecord", &[]), ("SessionSummary", "fabro_types::SessionSummary", &[]), ("SessionDetail", "fabro_types::SessionDetail", &[]), - ("ReasoningOutput", "fabro_types::ReasoningOutput", &[]), - ("CompletionMessage", "fabro_types::Message", &[]), - ("CompletionMessageRole", "fabro_types::Role", &[]), - ("CompletionContentPart", "fabro_types::ContentPart", &[]), + ("ReasoningOutput", "lithos_llm::types::ReasoningOutput", &[]), + ("CompletionMessage", "lithos_llm::types::Message", &[]), + ("CompletionMessageRole", "lithos_llm::types::Role", &[]), + ( + "CompletionContentPart", + "lithos_llm::types::ContentPart", + &[], + ), ( "CompletionToolDefinition", - "fabro_types::ToolDefinition", + "lithos_llm::types::ToolDefinition", &[], ), ( "CompletionToolDefinitionKind", - "fabro_types::ToolDefinitionKind", + "lithos_llm::types::ToolDefinitionKind", &[], ), - ("CompletionToolChoice", "fabro_types::ToolChoice", &[]), + ("CompletionToolChoice", "lithos_llm::types::ToolChoice", &[]), ( "CompletionResponseFormat", - "fabro_types::ResponseFormat", + "lithos_llm::types::ResponseFormat", &[], ), - ("CompletionCost", "fabro_types::Cost", &[]), + ("CompletionCost", "lithos_llm::types::Cost", &[]), ("WorkflowVersion", "fabro_types::WorkflowVersion", &[]), ("RunIntent", "fabro_types::RunIntent", &[]), ("RunIntentArgs", "fabro_types::RunIntentArgs", &[]), @@ -759,7 +763,7 @@ fn main() { ("WorkflowPath", "fabro_types::WorkflowPath", &[]), ("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]), ("BlobHash", "fabro_types::BlobHash", &[]), - ("CostSource", "fabro_types::CostSource", &[]), + ("CostSource", "lithos_llm::types::CostSource", &[]), ]; for (name, path, impls) in replacements { settings.with_replacement(*name, *path, impls.iter().copied()); diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 58a98e6b0..794a934db 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -41,45 +41,47 @@ pub mod types { ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash, - CommandTermination, Conclusion, ContentPart, Cost as CompletionCost, CostSource, - CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, - FailureCategory, FailureDetail, FailureSignature, GitContext, GitRunTarget, - GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, IntegrationConnectionKind, - IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider, - IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind, - McpServerDraft as CreateMcpServerRequest, McpServerProjection, + CommandTermination, Conclusion, CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, + EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, FailureSignature, + GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, + IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, + IntegrationProvider, IntegrationStatus, InterviewOption, InterviewQuestionRecord, + LlmOutputKind, McpServerDraft as CreateMcpServerRequest, McpServerProjection, McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer, - McpTransportView, Message, Model, ModelControls, ModelCosts, ModelFeatures, ModelHandle, - ModelLimits, ModelRef as BillingModelRef, ModelTestMode, PairId, PairMessageId, - PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, PairStatus, - PairTarget, PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId, - ParallelBranchResult, PendingInterviewRecord, PermissionLevel, Principal, Provider, - ProviderId, PullRequest, PullRequestCreation, PullRequestCreationId, - PullRequestCreationStatus, PullRequestDetails, PullRequestDetailsStatus, - PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, - QuestionType, ReasoningEffort, ReasoningOutput, RepositoryRef, - ResponseFormat as CompletionResponseFormat, ReviewTarget, ReviewTargetKind, Role, Run, - RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind, - RunEventDetailResponse, RunFailure, RunIntent, RunIntentArgs, RunPairStatusResponse, - RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure, - RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, - RunSize, RunTarget, SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, - SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind, - SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse, - SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, - SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, - SkillsProjection, Speed as BillingSpeed, StageCompletion, StageContextWindow, + McpTransportView, Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, + ModelRef as BillingModelRef, ModelTestMode, PairId, PairMessageId, PairMessageRecord, + PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget, + PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, + PendingInterviewRecord, PermissionLevel, Principal, Provider, PullRequest, + PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails, + PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink, + PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, ReviewTarget, + ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent, + RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunIntent, RunIntentArgs, + RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox, + RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, + RunServerProvenance, RunSize, RunTarget, SandboxDetails, SandboxInfo, SandboxListMeta, + SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, + SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxService, + SandboxServiceListResponse, SandboxState, SandboxTimestamps, SecretMetadata, SecretType, + ServerSettings, SessionDetail, SessionId, SessionMessage, SessionRecord, SessionStatus, + SessionSummary, SessionTurn, SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindowWarning, StageHandler, StageId, StageInferenceProjection, StageModelUsage, StageOutcome, StageProjection, StageState, StageToolBatchProjection, SubAgentProjection, SubAgentStatus, SystemActorKind, - SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection, + SystemIntegrationStatus, SystemIntegrationsResponse, TodoListProjection, TurnId, + UpdateVariableRequest, UserPrincipal, Variable, VariableListResponse, WorkflowPath, + WorkflowSettings, WorkflowVersion, WorkflowVersionId, + }; + pub use lithos_llm::catalog::{ModelHandle, ProviderId}; + pub use lithos_llm::types::{ + ContentPart, Cost as CompletionCost, CostSource, Message, ReasoningEffort, ReasoningOutput, + ResponseFormat as CompletionResponseFormat, Role, Speed as BillingSpeed, TokenCounts as CompletionUsage, ToolChoice as CompletionToolChoice, ToolDefinition as CompletionToolDefinition, - ToolDefinitionKind as CompletionToolDefinitionKind, TurnId, UpdateVariableRequest, - UserPrincipal, Variable, VariableListResponse, WorkflowPath, WorkflowSettings, - WorkflowVersion, WorkflowVersionId, + ToolDefinitionKind as CompletionToolDefinitionKind, }; pub use crate::generated::types::*; diff --git a/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs b/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs index 1cbf8dc04..4060a6fb7 100644 --- a/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs +++ b/lib/foundation/fabro-api/tests/agent_session_activated_props_round_trip.rs @@ -2,7 +2,8 @@ use std::any::{TypeId, type_name}; use fabro_api::types::AgentSessionActivatedProps as ApiAgentSessionActivatedProps; use fabro_types::run_event::AgentSessionActivatedProps; -use fabro_types::{PermissionLevel, ReasoningEffort, SessionCapability, Speed}; +use fabro_types::{PermissionLevel, SessionCapability}; +use lithos_llm::types::{ReasoningEffort, Speed}; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/completion_message_round_trip.rs b/lib/foundation/fabro-api/tests/completion_message_round_trip.rs index 3b4fc1419..f6b914e36 100644 --- a/lib/foundation/fabro-api/tests/completion_message_round_trip.rs +++ b/lib/foundation/fabro-api/tests/completion_message_round_trip.rs @@ -7,7 +7,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ContentPart as ApiContentPart, Message as ApiMessage, Role as ApiRole}; -use fabro_types::{ContentPart, Message, Role, ToolCall, ToolResult}; +use lithos_llm::types::{ContentPart, Message, Role, ToolCall, ToolResult}; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/completion_usage_round_trip.rs b/lib/foundation/fabro-api/tests/completion_usage_round_trip.rs index 5c38c4e74..03eef28ec 100644 --- a/lib/foundation/fabro-api/tests/completion_usage_round_trip.rs +++ b/lib/foundation/fabro-api/tests/completion_usage_round_trip.rs @@ -1,7 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::CompletionUsage as ApiCompletionUsage; -use fabro_types::TokenCounts; +use lithos_llm::types::TokenCounts; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/cost_source_round_trip.rs b/lib/foundation/fabro-api/tests/cost_source_round_trip.rs index 4b69183d6..484e5847a 100644 --- a/lib/foundation/fabro-api/tests/cost_source_round_trip.rs +++ b/lib/foundation/fabro-api/tests/cost_source_round_trip.rs @@ -1,7 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{CompletionCost as ApiCost, CostSource as ApiCostSource}; -use fabro_types::{Cost, CostSource}; +use lithos_llm::types::{Cost, CostSource}; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/create_completion_request_round_trip.rs b/lib/foundation/fabro-api/tests/create_completion_request_round_trip.rs index 9abcfa7b2..345e6fd55 100644 --- a/lib/foundation/fabro-api/tests/create_completion_request_round_trip.rs +++ b/lib/foundation/fabro-api/tests/create_completion_request_round_trip.rs @@ -1,5 +1,5 @@ use fabro_api::types::CreateCompletionRequest; -use fabro_types::{ReasoningEffort, ResponseFormat, Speed, ToolChoice, ToolDefinitionKind}; +use lithos_llm::types::{ReasoningEffort, ResponseFormat, Speed, ToolChoice, ToolDefinitionKind}; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/model_round_trip.rs b/lib/foundation/fabro-api/tests/model_round_trip.rs index 0a18443f1..092afcf16 100644 --- a/lib/foundation/fabro-api/tests/model_round_trip.rs +++ b/lib/foundation/fabro-api/tests/model_round_trip.rs @@ -1,9 +1,9 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{Model as ApiModel, ModelControls as ApiModelControls}; -use fabro_types::{ - Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffort, provider_ids, -}; +use fabro_types::{Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits}; +use lithos_llm::catalog::builtin; +use lithos_llm::types::ReasoningEffort; #[test] fn model_reuses_canonical_type() { @@ -15,7 +15,7 @@ fn model_reuses_canonical_type() { fn model_json_matches_openapi_shape() { let model = Model { id: "claude-opus-4.7".into(), - provider: provider_ids::anthropic(), + provider: builtin::anthropic(), family: "claude-4".to_string(), display_name: "Claude Opus 4.7".to_string(), limits: ModelLimits { diff --git a/lib/foundation/fabro-api/tests/provider_id_round_trip.rs b/lib/foundation/fabro-api/tests/provider_id_round_trip.rs index 877cc39ef..1ac9faae0 100644 --- a/lib/foundation/fabro-api/tests/provider_id_round_trip.rs +++ b/lib/foundation/fabro-api/tests/provider_id_round_trip.rs @@ -1,7 +1,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ModelHandle as ApiModelHandle, ProviderId as ApiProviderId}; -use fabro_types::{ModelHandle, ModelId, ProviderId, provider_ids}; +use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId, builtin}; use serde_json::json; #[test] @@ -13,7 +13,7 @@ fn provider_id_and_model_handle_reuse_lithos_types() { #[test] fn provider_id_json_is_a_bare_string() { assert_eq!( - serde_json::to_value(provider_ids::anthropic()).unwrap(), + serde_json::to_value(builtin::anthropic()).unwrap(), json!("anthropic") ); assert_eq!( @@ -24,7 +24,7 @@ fn provider_id_json_is_a_bare_string() { #[test] fn model_handle_json_matches_openapi_shape() { - let handle = ModelHandle::new(provider_ids::openai(), ModelId::new("gpt-5.4")); + let handle = ModelHandle::new(builtin::openai(), ModelId::new("gpt-5.4")); let json = serde_json::to_value(&handle).unwrap(); assert_eq!(json, json!({"provider": "openai", "model": "gpt-5.4"})); let round_trip: ApiModelHandle = serde_json::from_value(json).unwrap(); diff --git a/lib/foundation/fabro-api/tests/provider_round_trip.rs b/lib/foundation/fabro-api/tests/provider_round_trip.rs index e15492123..00d3fa3a4 100644 --- a/lib/foundation/fabro-api/tests/provider_round_trip.rs +++ b/lib/foundation/fabro-api/tests/provider_round_trip.rs @@ -1,7 +1,8 @@ use std::any::{TypeId, type_name}; use fabro_api::types::Provider as ApiProvider; -use fabro_types::{Provider, ProviderId, provider_ids}; +use fabro_types::Provider; +use lithos_llm::catalog::{ProviderId, builtin}; #[test] fn provider_reuses_canonical_type() { @@ -11,7 +12,7 @@ fn provider_reuses_canonical_type() { #[test] fn provider_json_matches_openapi_shape() { let provider = Provider { - id: provider_ids::anthropic(), + id: builtin::anthropic(), display_name: "Anthropic".to_string(), adapter: "anthropic".to_string(), base_url: "https://api.anthropic.test".to_string(), diff --git a/lib/foundation/fabro-api/tests/reasoning_output_round_trip.rs b/lib/foundation/fabro-api/tests/reasoning_output_round_trip.rs index 035e66c23..4b8566ce9 100644 --- a/lib/foundation/fabro-api/tests/reasoning_output_round_trip.rs +++ b/lib/foundation/fabro-api/tests/reasoning_output_round_trip.rs @@ -3,7 +3,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ AgentMessageProps as ApiAgentMessageProps, ReasoningOutput as ApiReasoningOutput, }; -use fabro_types::ReasoningOutput; +use lithos_llm::types::ReasoningOutput; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs b/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs index 4dec969dd..c73963932 100644 --- a/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs +++ b/lib/foundation/fabro-api/tests/run_billing_stage_round_trip.rs @@ -1,7 +1,8 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{BillingByModel, BillingModelRef, BillingSpeed, RunBillingStage}; -use fabro_types::{ModelRef, Speed, StageState}; +use fabro_types::{ModelRef, StageState}; +use lithos_llm::types::Speed; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/session_contract_round_trip.rs b/lib/foundation/fabro-api/tests/session_contract_round_trip.rs index 0afb3c683..044e8a0b6 100644 --- a/lib/foundation/fabro-api/tests/session_contract_round_trip.rs +++ b/lib/foundation/fabro-api/tests/session_contract_round_trip.rs @@ -33,7 +33,7 @@ fn session_detail_round_trips_messages_active_turn_and_last_seq() { title: Some("Ask Fabro".to_string()), status: SessionStatus::Running, model: Some("gpt-5.4".to_string()), - provider: Some(fabro_types::provider_ids::openai()), + provider: Some(lithos_llm::catalog::builtin::openai()), active_turn: Some(SessionTurn { id: turn_id, started_at: turn_started_at, diff --git a/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs b/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs index 0b57cf53a..a6b78d6ba 100644 --- a/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs +++ b/lib/foundation/fabro-api/tests/stage_model_usage_round_trip.rs @@ -3,7 +3,8 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ ReasoningEffort as ApiReasoningEffort, StageModelUsage as ApiStageModelUsage, }; -use fabro_types::{ReasoningEffort, Speed, StageModelUsage}; +use fabro_types::StageModelUsage; +use lithos_llm::types::{ReasoningEffort, Speed}; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs index baa71e871..9e3bde8c4 100644 --- a/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs +++ b/lib/foundation/fabro-api/tests/stage_projection_round_trip.rs @@ -25,14 +25,15 @@ use fabro_api::types::{ use fabro_types::{ ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary, - AgentToolsAvailableProps, LlmOutputKind, McpServerProjection, McpServerStatus, ModelId, - ModelRef, ParallelBranchId, ParallelBranchResult, PermissionLevel, ProviderId, - SkillsProjection, Speed, StageContextWindow, StageContextWindowBreakdownItem, - StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, - StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindowWarning, - StageId, StageInferenceProjection, StageProjection, StageToolBatchProjection, - SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, + AgentToolsAvailableProps, LlmOutputKind, McpServerProjection, McpServerStatus, ModelRef, + ParallelBranchId, ParallelBranchResult, PermissionLevel, SkillsProjection, StageContextWindow, + StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, + StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason, + StageContextWindowWarning, StageId, StageInferenceProjection, StageProjection, + StageToolBatchProjection, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, }; +use lithos_llm::catalog::{ModelId, ProviderId}; +use lithos_llm::types::Speed; use serde_json::json; #[test] diff --git a/lib/foundation/fabro-auth/src/api_key_source.rs b/lib/foundation/fabro-auth/src/api_key_source.rs index 17b706498..621b696f6 100644 --- a/lib/foundation/fabro-auth/src/api_key_source.rs +++ b/lib/foundation/fabro-auth/src/api_key_source.rs @@ -9,14 +9,14 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_vault::Vault; -use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; -use lithos_llm::credentials::{ConventionalCredentials, CredentialProvider, Credentials}; +use lithos_llm::catalog::{CatalogProvider, ProviderId}; +use lithos_llm::credentials::{ + ConventionalCredentials, CredentialError, CredentialProvider, Credentials, +}; use tokio::sync::RwLock as AsyncRwLock; -use crate::credential_source::CredentialSource; -use crate::error::ResolveError; use crate::secrets::expected_secret_name; -use crate::vault_source::{auth_scheme_name, interpolated_headers, resolve_error}; +use crate::vault_source::interpolated_headers; pub struct ApiKeyCredentialSource { provider: ProviderId, @@ -61,19 +61,17 @@ pub(crate) async fn credentials_for_api_key( provider: &CatalogProvider, key: String, vault: &Vault, -) -> Result { +) -> Result { let Some(name) = expected_secret_name(provider) else { - return Err(ResolveError::SchemeMismatch { + return Err(CredentialError::SchemeMismatch { provider: provider.id().clone(), - scheme: auth_scheme_name(provider.auth()).to_string(), }); }; let interpolated = interpolated_headers(vault, provider)?; let mut credentials = ConventionalCredentials::new() .with_lookup(move |candidate| (candidate == name).then(|| key.clone())) .credentials(provider) - .await - .map_err(|err| resolve_error(provider, &err))?; + .await?; if let Credentials::Http(http) = &mut credentials { http.extra_headers.extend(interpolated); } @@ -81,21 +79,22 @@ pub(crate) async fn credentials_for_api_key( } #[async_trait] -impl CredentialSource for ApiKeyCredentialSource { - async fn credentials(&self, provider: &CatalogProvider) -> Result { +impl CredentialProvider for ApiKeyCredentialSource { + async fn credentials( + &self, + provider: &CatalogProvider, + ) -> Result { if provider.id() != &self.provider { - return Err(ResolveError::NotConfigured(provider.id().clone())); + return Err(CredentialError::NotConfigured { + provider: provider.id().clone(), + }); } let vault = self.vault.read().await.clone(); credentials_for_api_key(provider, self.key.clone(), &vault).await } - async fn configured_providers(&self, catalog: &Catalog) -> Vec { - catalog - .provider(self.provider.as_str()) - .ok() - .map(|provider| vec![provider.id().clone()]) - .unwrap_or_default() + async fn is_configured(&self, provider: &CatalogProvider) -> bool { + provider.id() == &self.provider } } diff --git a/lib/foundation/fabro-auth/src/credential_source.rs b/lib/foundation/fabro-auth/src/credential_source.rs deleted file mode 100644 index 69b677385..000000000 --- a/lib/foundation/fabro-auth/src/credential_source.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Per-attempt credential lookup for LLM providers. -//! -//! [`CredentialSource`] is Fabro's storage-aware credential seam: the vault, -//! the SQL secret store, and the process environment each implement it. -//! [`lithos_credentials`] adapts a source into the lithos -//! [`CredentialProvider`] the client calls before every provider attempt, so a -//! refreshed OAuth token is picked up by the next retry. - -use std::sync::Arc; - -use async_trait::async_trait; -use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; -use lithos_llm::credentials::{CredentialError, CredentialProvider, Credentials}; - -use crate::{ResolveError, auth_issue_message}; - -/// Which providers a source can serve right now, and why the rest cannot. -#[derive(Debug, Default)] -pub struct ResolvedCredentials { - /// Enabled providers whose credentials resolved. - pub ready: Vec, - /// Enabled providers with credential material that failed to resolve, - /// such as an expired OAuth token that could not be refreshed. Providers - /// with no material at all are not issues; they are simply absent. - pub auth_issues: Vec<(ProviderId, ResolveError)>, -} - -impl ResolvedCredentials { - /// A human-readable line per auth issue. - #[must_use] - pub fn issue_messages(&self) -> Vec { - self.auth_issues - .iter() - .map(|(provider, issue)| auth_issue_message(provider, issue)) - .collect() - } -} - -#[async_trait] -pub trait CredentialSource: Send + Sync { - /// Resolves `provider`'s credentials for one request attempt. - async fn credentials(&self, provider: &CatalogProvider) -> Result; - - /// Providers with credential material present. Does not refresh or - /// validate anything, so it is cheap enough for listings. - async fn configured_providers(&self, catalog: &Catalog) -> Vec; - - /// Resolves every enabled provider once, separating the ready set from - /// the providers that have material but cannot use it. - async fn resolve_all(&self, catalog: &Catalog) -> ResolvedCredentials { - let mut resolved = ResolvedCredentials::default(); - for provider in catalog.providers().filter(|provider| provider.is_enabled()) { - match self.credentials(provider).await { - Ok(_) => resolved.ready.push(provider.id().clone()), - Err(ResolveError::NotConfigured(_)) => {} - Err(err) => resolved.auth_issues.push((provider.id().clone(), err)), - } - } - resolved - } -} - -/// Adapts a [`CredentialSource`] into the lithos credential provider. -#[must_use] -pub fn lithos_credentials(source: Arc) -> Arc { - Arc::new(SourceCredentialProvider { source }) -} - -struct SourceCredentialProvider { - source: Arc, -} - -#[async_trait] -impl CredentialProvider for SourceCredentialProvider { - async fn credentials( - &self, - provider: &CatalogProvider, - ) -> Result { - self.source.credentials(provider).await.map_err(|err| { - tracing::warn!( - provider = %provider.id(), - error = %err, - "LLM credentials could not be resolved for this attempt" - ); - match err { - ResolveError::NotConfigured(provider) => { - CredentialError::NotConfigured { provider } - } - ResolveError::SchemeMismatch { provider, .. } => { - CredentialError::SchemeMismatch { provider } - } - other => CredentialError::NotConfigured { - provider: other.provider().clone(), - }, - } - }) - } -} diff --git a/lib/foundation/fabro-auth/src/error.rs b/lib/foundation/fabro-auth/src/error.rs deleted file mode 100644 index 7986637da..000000000 --- a/lib/foundation/fabro-auth/src/error.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Why a provider's credentials could not be resolved. - -use fabro_types::settings::ResolveError as InterpResolveError; -use fabro_vault::SecretType; -use lithos_llm::catalog::ProviderId; - -#[derive(Debug, thiserror::Error)] -pub enum ResolveError { - #[error("{0} is not configured")] - NotConfigured(ProviderId), - #[error("{provider} header interpolation failed: {source}")] - Interpolation { - provider: ProviderId, - #[source] - source: InterpResolveError, - }, - #[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")] - VaultDecodeFailed { - provider: ProviderId, - name: String, - #[source] - source: serde_json::Error, - }, - #[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")] - VaultSchemaMismatch { - provider: ProviderId, - name: String, - actual: SecretType, - }, - #[error("{provider} requires re-authentication: {source}")] - RefreshFailed { - provider: ProviderId, - #[source] - source: anyhow::Error, - }, - #[error("{0} requires re-authentication: missing refresh token")] - RefreshTokenMissing(ProviderId), - #[error("{provider} resolved a secret its `{scheme}` auth scheme cannot use")] - SchemeMismatch { - provider: ProviderId, - scheme: String, - }, -} - -impl ResolveError { - #[must_use] - pub fn provider(&self) -> &ProviderId { - match self { - Self::NotConfigured(provider) - | Self::RefreshTokenMissing(provider) - | Self::Interpolation { provider, .. } - | Self::VaultDecodeFailed { provider, .. } - | Self::VaultSchemaMismatch { provider, .. } - | Self::RefreshFailed { provider, .. } - | Self::SchemeMismatch { provider, .. } => provider, - } - } -} - -#[must_use] -pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String { - match err { - ResolveError::NotConfigured(_) => format!("{provider} is not configured"), - ResolveError::Interpolation { source, .. } => { - format!("{provider} header interpolation failed: {source}") - } - ResolveError::VaultDecodeFailed { name, source, .. } => { - format!("{provider} vault credential '{name}' is not valid OAuth JSON: {source}") - } - ResolveError::VaultSchemaMismatch { name, actual, .. } => format!( - "{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth" - ), - ResolveError::RefreshFailed { source, .. } => { - format!("{provider} requires re-authentication: {source}") - } - ResolveError::RefreshTokenMissing(_) => { - format!("{provider} requires re-authentication: refresh token missing") - } - ResolveError::SchemeMismatch { scheme, .. } => { - format!("{provider} resolved a secret its `{scheme}` auth scheme cannot use") - } - } -} diff --git a/lib/foundation/fabro-auth/src/extra_headers_source.rs b/lib/foundation/fabro-auth/src/extra_headers_source.rs index 0226e1fee..8b358bdde 100644 --- a/lib/foundation/fabro-auth/src/extra_headers_source.rs +++ b/lib/foundation/fabro-auth/src/extra_headers_source.rs @@ -2,33 +2,35 @@ use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; -use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; -use lithos_llm::credentials::{CredentialHeader, Credentials, SecretValue}; +use lithos_llm::catalog::CatalogProvider; +use lithos_llm::credentials::{ + CredentialError, CredentialHeader, CredentialProvider, Credentials, SecretValue, +}; -use crate::ResolveError; -use crate::credential_source::CredentialSource; - -/// Decorates another [`CredentialSource`] by appending fixed extra headers to -/// every HTTP credential it resolves. +/// Decorates another [`CredentialProvider`] by appending fixed extra headers +/// to every HTTP credential it resolves. /// /// Headers already present on a credential (for example from explicit /// provider configuration) are left untouched. AWS-signed credentials carry /// no header list and pass through unchanged. pub struct ExtraHeadersCredentialSource { - inner: Arc, + inner: Arc, headers: HashMap, } impl ExtraHeadersCredentialSource { #[must_use] - pub fn new(inner: Arc, headers: HashMap) -> Self { + pub fn new(inner: Arc, headers: HashMap) -> Self { Self { inner, headers } } } #[async_trait] -impl CredentialSource for ExtraHeadersCredentialSource { - async fn credentials(&self, provider: &CatalogProvider) -> Result { +impl CredentialProvider for ExtraHeadersCredentialSource { + async fn credentials( + &self, + provider: &CatalogProvider, + ) -> Result { let mut credentials = self.inner.credentials(provider).await?; if let Credentials::Http(http) = &mut credentials { for (name, value) in &self.headers { @@ -48,8 +50,8 @@ impl CredentialSource for ExtraHeadersCredentialSource { Ok(credentials) } - async fn configured_providers(&self, catalog: &Catalog) -> Vec { - self.inner.configured_providers(catalog).await + async fn is_configured(&self, provider: &CatalogProvider) -> bool { + self.inner.is_configured(provider).await } } @@ -61,16 +63,16 @@ mod tests { use crate::test_support::test_catalog; struct StubSource { - configured_providers: Vec, - existing_header: Option<(String, String)>, + configured: bool, + existing_header: Option<(String, String)>, } #[async_trait] - impl CredentialSource for StubSource { + impl CredentialProvider for StubSource { async fn credentials( &self, provider: &CatalogProvider, - ) -> Result { + ) -> Result { if provider.id().as_str() == "bedrock" { return Ok(Credentials::AwsDefaultChain { region: None }); } @@ -86,8 +88,8 @@ mod tests { Ok(credentials) } - async fn configured_providers(&self, _catalog: &Catalog) -> Vec { - self.configured_providers.clone() + async fn is_configured(&self, _provider: &CatalogProvider) -> bool { + self.configured } } @@ -107,8 +109,8 @@ mod tests { let catalog = test_catalog(); let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { - configured_providers: Vec::new(), - existing_header: None, + configured: false, + existing_header: None, }), HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), ); @@ -133,8 +135,8 @@ mod tests { let catalog = test_catalog(); let source = ExtraHeadersCredentialSource::new( Arc::new(StubSource { - configured_providers: vec![ProviderId::new("openai")], - existing_header: Some(("X-Session-Id".to_string(), "configured".to_string())), + configured: true, + existing_header: Some(("X-Session-Id".to_string(), "configured".to_string())), }), HashMap::from([("x-session-id".to_string(), "run-123".to_string())]), ); @@ -143,8 +145,10 @@ mod tests { .await .unwrap(); assert_eq!(header(&credentials, "x-session-id"), Some("configured")); - assert_eq!(source.configured_providers(&catalog).await, vec![ - ProviderId::new("openai") - ]); + assert!( + source + .is_configured(catalog.provider("openai").unwrap()) + .await + ); } } diff --git a/lib/foundation/fabro-auth/src/lib.rs b/lib/foundation/fabro-auth/src/lib.rs index 34cd2590e..878863027 100644 --- a/lib/foundation/fabro-auth/src/lib.rs +++ b/lib/foundation/fabro-auth/src/lib.rs @@ -1,8 +1,16 @@ +//! Fabro's credential stores and login flows over lithos-llm. +//! +//! Every store implements the lithos [`CredentialProvider`] trait, so the +//! client, `credentials::readiness`, and `ClientBuilder::build_ready` read +//! them directly. What is Fabro's own: the vault and the SQL secret store as +//! the place secrets live, the Codex OAuth login and refresh, and the +//! `{{ secrets.NAME }}` interpolation of a provider's `default_headers`. +//! +//! [`CredentialProvider`]: lithos_llm::credentials::CredentialProvider + mod api_key_source; mod context; mod credential; -mod credential_source; -mod error; mod extra_headers_source; mod refresh; mod secrets; @@ -18,8 +26,6 @@ pub mod strategies; pub use api_key_source::ApiKeyCredentialSource; pub use context::{AuthContextRequest, AuthContextResponse}; pub use credential::{OAuthConfig, OAuthCredential, OAuthTokens}; -pub use credential_source::{CredentialSource, ResolvedCredentials, lithos_credentials}; -pub use error::{ResolveError, auth_issue_message}; pub use extra_headers_source::ExtraHeadersCredentialSource; pub use refresh::refresh_oauth_credential; pub use secrets::{accepts_api_key, expected_secret_name, secret_names}; diff --git a/lib/foundation/fabro-auth/src/secrets.rs b/lib/foundation/fabro-auth/src/secrets.rs index 3661f660f..09d0a7d17 100644 --- a/lib/foundation/fabro-auth/src/secrets.rs +++ b/lib/foundation/fabro-auth/src/secrets.rs @@ -6,8 +6,7 @@ //! vault under those same names, so the vault entry an operator creates and //! the environment variable a shell exports are spelled alike. -use fabro_types::provider_ids; -use lithos_llm::catalog::{AuthScheme, CatalogProvider, ProviderId}; +use lithos_llm::catalog::{AuthScheme, CatalogProvider, ProviderId, builtin}; use lithos_llm::credentials::ConventionalCredentials; use crate::OPENAI_CODEX_VAULT_SECRET_NAME; @@ -43,5 +42,5 @@ pub fn accepts_api_key(provider: &CatalogProvider) -> bool { /// The vault entry holding `provider`'s OAuth credential, for the providers /// Fabro can log into with a browser flow. pub(crate) fn oauth_secret_name(provider: &ProviderId) -> Option<&'static str> { - (provider.as_str() == provider_ids::OPENAI_CODEX).then_some(OPENAI_CODEX_VAULT_SECRET_NAME) + (provider.as_str() == builtin::ids::OPENAI_CODEX).then_some(OPENAI_CODEX_VAULT_SECRET_NAME) } diff --git a/lib/foundation/fabro-auth/src/sql_vault_source.rs b/lib/foundation/fabro-auth/src/sql_vault_source.rs index 6f0c0803b..330eede74 100644 --- a/lib/foundation/fabro-auth/src/sql_vault_source.rs +++ b/lib/foundation/fabro-auth/src/sql_vault_source.rs @@ -3,13 +3,13 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_types::SecretType; use fabro_vault::{SecretSnapshot, SecretStore, SecretStoreError, Vault}; -use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; -use lithos_llm::credentials::Credentials; +use lithos_llm::catalog::{CatalogProvider, ProviderId}; +use lithos_llm::credentials::{CredentialError, CredentialProvider, Credentials}; use tokio::sync::RwLock; use tracing::error; -use crate::credential_source::CredentialSource; -use crate::{EnvLookup, ResolveError, VaultCredentialSource}; +use crate::vault_source::unusable; +use crate::{EnvLookup, VaultCredentialSource}; /// Credentials backed by the SQL secret store. /// @@ -90,11 +90,12 @@ impl SqlVaultCredentialSource { Ok(true) } - fn store_error(provider: &ProviderId, err: SecretStoreError) -> ResolveError { - ResolveError::RefreshFailed { - provider: provider.clone(), - source: anyhow::Error::new(err), - } + fn store_error(provider: &ProviderId, err: SecretStoreError) -> CredentialError { + unusable( + provider, + format!("the secret store could not be read: {err}"), + Some(Box::new(err)), + ) } } @@ -106,8 +107,11 @@ impl std::fmt::Debug for SqlVaultCredentialSource { } #[async_trait] -impl CredentialSource for SqlVaultCredentialSource { - async fn credentials(&self, provider: &CatalogProvider) -> Result { +impl CredentialProvider for SqlVaultCredentialSource { + async fn credentials( + &self, + provider: &CatalogProvider, + ) -> Result { for _ in 0..2 { let before = self .store @@ -134,22 +138,23 @@ impl CredentialSource for SqlVaultCredentialSource { return Ok(credentials); } } - Err(ResolveError::RefreshFailed { - provider: provider.id().clone(), - source: anyhow::anyhow!("OAuth credential changed concurrently during refresh"), - }) + Err(unusable( + provider.id(), + "the OAuth credential changed concurrently during refresh", + None, + )) } - async fn configured_providers(&self, catalog: &Catalog) -> Vec { + async fn is_configured(&self, provider: &CatalogProvider) -> bool { let snapshot = match self.store.snapshot().await { Ok(snapshot) => snapshot, Err(err) => { - error!(error = ?err, "Failed to load configured providers from secret store"); - return Vec::new(); + error!(error = ?err, "Failed to read the secret store while checking a provider"); + return false; } }; self.source_for_snapshot(snapshot) - .configured_providers(catalog) + .is_configured(provider) .await } } diff --git a/lib/foundation/fabro-auth/src/strategies/codex_device.rs b/lib/foundation/fabro-auth/src/strategies/codex_device.rs index 47681b2e1..005a1b8a3 100644 --- a/lib/foundation/fabro-auth/src/strategies/codex_device.rs +++ b/lib/foundation/fabro-auth/src/strategies/codex_device.rs @@ -5,7 +5,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::{DateTime, Utc}; use fabro_http::HttpClient; -use fabro_types::provider_ids; +use lithos_llm::catalog::builtin; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::time::sleep; @@ -299,7 +299,7 @@ impl AuthStrategy for CodexDeviceStrategy { .map_err(anyhow::Error::msg)?; Ok(LoginResult::OAuth { - provider: provider_ids::openai(), + provider: builtin::openai(), credential: OAuthCredential { tokens: OAuthTokens { access_token: token_response.access_token, diff --git a/lib/foundation/fabro-auth/src/strategy.rs b/lib/foundation/fabro-auth/src/strategy.rs index 4c91b2486..8db2fbf22 100644 --- a/lib/foundation/fabro-auth/src/strategy.rs +++ b/lib/foundation/fabro-auth/src/strategy.rs @@ -1,6 +1,5 @@ use async_trait::async_trait; -use fabro_types::provider_ids; -use lithos_llm::catalog::{Catalog, ProviderId}; +use lithos_llm::catalog::{Catalog, ProviderId, builtin}; use crate::context::{AuthContextRequest, AuthContextResponse}; use crate::credential::{OAuthConfig, OAuthCredential}; @@ -74,7 +73,7 @@ pub fn strategy_for( // forgets the constraint. assert_eq!( provider_id.as_str(), - provider_ids::OPENAI, + builtin::ids::OPENAI, "CodexDevice auth is only constructed by CLI code for the \ OpenAI provider; all existing call sites enforce this pairing: \ got provider_id={provider_id}" diff --git a/lib/foundation/fabro-auth/src/test_support.rs b/lib/foundation/fabro-auth/src/test_support.rs index 0b12e04d7..4030d87c0 100644 --- a/lib/foundation/fabro-auth/src/test_support.rs +++ b/lib/foundation/fabro-auth/src/test_support.rs @@ -9,9 +9,9 @@ use std::sync::Arc; use fabro_vault::Vault; use lithos_llm::catalog::Catalog; +use lithos_llm::credentials::CredentialProvider; use tokio::sync::RwLock as AsyncRwLock; -use crate::credential_source::CredentialSource; use crate::vault_source::VaultCredentialSource; /// The lithos built-in catalog. @@ -45,7 +45,7 @@ pub fn empty_vault() -> Arc> { /// Tests that inject fake provider keys use this instead of reading the real /// process environment, which would make them order-dependent. #[must_use] -pub fn env_credential_source(env_lookup: F) -> Arc +pub fn env_credential_source(env_lookup: F) -> Arc where F: Fn(&str) -> Option + Send + Sync + 'static, { @@ -57,6 +57,6 @@ where /// A vault-backed source over an empty vault with no process-env fallback. #[must_use] -pub fn vault_only_credential_source() -> Arc { +pub fn vault_only_credential_source() -> Arc { Arc::new(VaultCredentialSource::vault_only(empty_vault())) } diff --git a/lib/foundation/fabro-auth/src/vault_source.rs b/lib/foundation/fabro-auth/src/vault_source.rs index f99654d91..7f211d7d3 100644 --- a/lib/foundation/fabro-auth/src/vault_source.rs +++ b/lib/foundation/fabro-auth/src/vault_source.rs @@ -14,14 +14,14 @@ //! never reaches the wire; //! - OpenAI organization and project headers from the environment. +use std::error::Error as StdError; use std::sync::Arc; use async_trait::async_trait; use fabro_static::EnvVars; -use fabro_types::provider_ids; use fabro_types::settings::{InterpString, ResolveCtx}; use fabro_vault::{SecretType, Vault}; -use lithos_llm::catalog::{AuthScheme, Catalog, CatalogProvider, ProviderId}; +use lithos_llm::catalog::{CatalogProvider, ProviderId, builtin}; use lithos_llm::credentials::{ ConventionalCredentials, CredentialError, CredentialHeader, CredentialProvider, Credentials, HttpAuthentication, HttpCredentials, SecretValue, @@ -30,8 +30,6 @@ use tokio::sync::RwLock as AsyncRwLock; use tokio::task::spawn_blocking; use crate::credential::OAuthCredential; -use crate::credential_source::CredentialSource; -use crate::error::ResolveError; use crate::refresh::refresh_oauth_credential; use crate::secrets::oauth_secret_name; use crate::vault_ext::{VaultLookupError, vault_get_oauth, vault_set_oauth, vault_token_lookup}; @@ -106,7 +104,7 @@ impl VaultCredentialSource { &self, provider: &CatalogProvider, vault: &Vault, - ) -> Result, ResolveError> { + ) -> Result, CredentialError> { let Some(name) = oauth_secret_name(provider.id()) else { return Ok(None); }; @@ -124,13 +122,20 @@ impl VaultCredentialSource { .expect("entry is present"); let credential = if credential.needs_refresh() { if credential.tokens.refresh_token.is_none() { - return Err(ResolveError::RefreshTokenMissing(provider.id().clone())); + return Err(unusable( + provider.id(), + "requires re-authentication: refresh token missing", + None, + )); } let refreshed = refresh_oauth_credential(&credential) .await - .map_err(|source| ResolveError::RefreshFailed { - provider: provider.id().clone(), - source, + .map_err(|source| { + unusable( + provider.id(), + format!("requires re-authentication: {source}"), + Some(source.into()), + ) })?; self.persist_oauth(provider.id(), name, &refreshed).await?; refreshed @@ -145,13 +150,16 @@ impl VaultCredentialSource { provider: &ProviderId, name: &str, refreshed: &OAuthCredential, - ) -> Result<(), ResolveError> { + ) -> Result<(), CredentialError> { let refreshed = refreshed.clone(); let name = name.to_string(); let vault = Arc::clone(&self.vault); - let failed = |source| ResolveError::RefreshFailed { - provider: provider.clone(), - source, + let failed = |source: anyhow::Error| { + unusable( + provider, + format!("the refreshed token could not be stored: {source}"), + Some(source.into()), + ) }; spawn_blocking(move || { let mut vault = vault.blocking_write(); @@ -175,7 +183,7 @@ impl VaultCredentialSource { ) -> Credentials { if let Credentials::Http(http) = &mut credentials { http.extra_headers.extend(interpolated); - if provider.id().as_str() == provider_ids::OPENAI { + if provider.id().as_str() == builtin::ids::OPENAI { for (variable, header) in [ (EnvVars::OPENAI_ORG_ID, OPENAI_ORGANIZATION_HEADER), (EnvVars::OPENAI_PROJECT_ID, OPENAI_PROJECT_HEADER), @@ -204,30 +212,25 @@ impl std::fmt::Debug for VaultCredentialSource { } #[async_trait] -impl CredentialSource for VaultCredentialSource { - async fn credentials(&self, provider: &CatalogProvider) -> Result { +impl CredentialProvider for VaultCredentialSource { + async fn credentials( + &self, + provider: &CatalogProvider, + ) -> Result { let vault = self.snapshot().await; let interpolated = interpolated_headers(&vault, provider)?; if let Some(oauth) = self.oauth_credentials(provider, &vault).await? { return Ok(self.decorate(provider, oauth, interpolated)); } - let credentials = self - .conventional(&vault) - .credentials(provider) - .await - .map_err(|err| resolve_error(provider, &err))?; + let credentials = self.conventional(&vault).credentials(provider).await?; Ok(self.decorate(provider, credentials, interpolated)) } - async fn configured_providers(&self, catalog: &Catalog) -> Vec { + /// Presence without refresh: an OAuth entry under the provider's name, or + /// a conventional secret that resolves. + async fn is_configured(&self, provider: &CatalogProvider) -> bool { let vault = self.snapshot().await; - let mut configured = Vec::new(); - for provider in catalog.providers().filter(|provider| provider.is_enabled()) { - if self.has_credential_material(&vault, provider).await { - configured.push(provider.id().clone()); - } - } - configured + self.has_credential_material(&vault, provider).await } } @@ -254,7 +257,7 @@ fn oauth_bearer(credential: &OAuthCredential) -> Credentials { pub(crate) fn interpolated_headers( vault: &Vault, provider: &CatalogProvider, -) -> Result, ResolveError> { +) -> Result, CredentialError> { let mut ctx = ResolveCtx::new().with_secrets(|secret_name| vault_token_lookup(vault, secret_name)); provider @@ -263,55 +266,42 @@ pub(crate) fn interpolated_headers( .map(|(name, source)| (name, InterpString::parse(source))) .filter(|(_, template)| !template.is_literal()) .map(|(name, template)| { - let value = - template - .resolve_with(&mut ctx) - .map_err(|source| ResolveError::Interpolation { - provider: provider.id().clone(), - source, - })?; + let value = template.resolve_with(&mut ctx).map_err(|source| { + unusable( + provider.id(), + format!("header interpolation failed: {source}"), + Some(Box::new(source)), + ) + })?; Ok(CredentialHeader::new(name.clone(), SecretValue::new(value))) }) .collect() } -pub(crate) fn auth_scheme_name(scheme: &AuthScheme) -> &'static str { - match scheme { - AuthScheme::None => "none", - AuthScheme::Bearer { .. } => "bearer", - AuthScheme::Header { .. } => "header", - AuthScheme::Headers => "headers", - AuthScheme::Aws { .. } => "aws", - AuthScheme::BedrockBearer => "bedrock_bearer", - _ => "unknown", +/// Material is present but cannot be used. `reason` is the operator-facing +/// line and must not carry secret content. +pub(crate) fn unusable( + provider: &ProviderId, + reason: impl Into, + source: Option>, +) -> CredentialError { + CredentialError::Unusable { + provider: provider.clone(), + reason: reason.into(), + source, } } -/// Maps a lithos lookup failure onto Fabro's vocabulary. A missing secret is -/// not an issue to report; the provider is simply not configured. -pub(crate) fn resolve_error(provider: &CatalogProvider, err: &CredentialError) -> ResolveError { - match err { - CredentialError::SchemeMismatch { .. } => ResolveError::SchemeMismatch { - provider: provider.id().clone(), - scheme: auth_scheme_name(provider.auth()).to_string(), - }, - _ => ResolveError::NotConfigured(provider.id().clone()), - } -} - -fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError { - match err { - VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch { - provider: provider.clone(), - name: name.to_string(), - actual, - }, - VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed { - provider: provider.clone(), - name: name.to_string(), - source, - }, - } +fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> CredentialError { + let reason = match &err { + VaultLookupError::SchemaMismatch { actual, .. } => { + format!("vault credential '{name}' has schema {actual:?}, expected Token or Oauth") + } + VaultLookupError::DecodeFailed { .. } => { + format!("vault credential '{name}' is not valid OAuth JSON") + } + }; + unusable(provider, reason, Some(Box::new(err))) } #[cfg(test)] @@ -322,12 +312,13 @@ mod tests { use httpmock::Method::POST; use httpmock::MockServer; use lithos_llm::catalog::Catalog; + use lithos_llm::credentials::readiness; use super::*; + use crate::OPENAI_CODEX_VAULT_SECRET_NAME; use crate::credential::{OAuthConfig, OAuthTokens}; use crate::test_support::{test_catalog, test_catalog_with_overlay}; use crate::vault_ext::vault_set_token; - use crate::{OPENAI_CODEX_VAULT_SECRET_NAME, auth_issue_message}; fn oauth_credential(token_url: String, expires_at: chrono::DateTime) -> OAuthCredential { OAuthCredential { @@ -488,7 +479,10 @@ api_model = "large" .credentials(catalog.provider("gateway").unwrap()) .await .unwrap_err(); - assert!(matches!(err, ResolveError::Interpolation { .. }), "{err}"); + assert!( + matches!(&err, CredentialError::Unusable { reason, .. } if reason.contains("header interpolation failed")), + "{err}" + ); assert!(!err.to_string().contains("gw-key")); } @@ -565,10 +559,8 @@ api_model = "large" .credentials(catalog.provider("anthropic").unwrap()) .await .unwrap_err(); - assert!(matches!( - err, - ResolveError::NotConfigured(provider) if provider.as_str() == "anthropic" - )); + assert!(err.is_not_configured(), "{err}"); + assert_eq!(err.provider().as_str(), "anthropic"); } #[tokio::test] @@ -578,10 +570,13 @@ api_model = "large" let source = source_with(vault, |_| None); let catalog = test_catalog(); let modal = catalog.provider("modal").unwrap(); - assert!(matches!( - source.credentials(modal).await.unwrap_err(), - ResolveError::NotConfigured(_) - )); + assert!( + source + .credentials(modal) + .await + .unwrap_err() + .is_not_configured() + ); let mut vault = empty_vault(); vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap(); @@ -592,6 +587,16 @@ api_model = "large" assert_eq!(header_value(&credentials, "Modal-Secret"), Some("ws-test")); } + async fn configured(source: &VaultCredentialSource, catalog: &Catalog) -> Vec { + let mut ids = Vec::new(); + for provider in catalog.enabled_providers() { + if source.is_configured(provider).await { + ids.push(provider.id().clone()); + } + } + ids + } + #[tokio::test] async fn configured_providers_reads_vault_and_env_without_refreshing() { let mut vault = empty_vault(); @@ -609,7 +614,7 @@ api_model = "large" (name == "ANTHROPIC_API_KEY").then(|| "env".to_string()) }); let catalog = test_catalog(); - let configured = source.configured_providers(&catalog).await; + let configured = configured(&source, &catalog).await; assert!(configured.contains(&ProviderId::new("openai"))); assert!(configured.contains(&ProviderId::new("anthropic"))); assert!(configured.contains(&ProviderId::new("openai-codex"))); @@ -632,13 +637,16 @@ api_model = "large" .unwrap(); vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap(); let source = source_with(vault, |_| None); - let resolved = source.resolve_all(&test_catalog()).await; + let catalog = test_catalog(); + let resolved = readiness(catalog.enabled_providers(), &source).await; assert_eq!(resolved.ready, vec![ProviderId::new("anthropic")]); - assert_eq!(resolved.auth_issues.len(), 1); - assert!(matches!( - &resolved.auth_issues[0].1, - ResolveError::RefreshFailed { provider, .. } if provider.as_str() == "openai-codex" - )); + assert_eq!(resolved.issues.len(), 1); + let (provider, issue) = &resolved.issues[0]; + assert_eq!(provider.as_str(), "openai-codex"); + assert!( + issue.to_string().contains("requires re-authentication"), + "{issue}" + ); } #[tokio::test] @@ -646,10 +654,10 @@ api_model = "large" let catalog = test_catalog(); let vault_only = VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(empty_vault()))); - assert!(vault_only.configured_providers(&catalog).await.is_empty()); - let resolved = vault_only.resolve_all(&catalog).await; + assert!(configured(&vault_only, &catalog).await.is_empty()); + let resolved = readiness(catalog.enabled_providers(), &vault_only).await; assert!(resolved.ready.is_empty()); - assert!(resolved.auth_issues.is_empty()); + assert!(resolved.issues.is_empty()); } #[tokio::test] @@ -722,10 +730,9 @@ api_model = "large" .credentials(catalog.provider("openai-codex").unwrap()) .await .unwrap_err(); - assert!(matches!(err, ResolveError::RefreshTokenMissing(_))); assert_eq!( - auth_issue_message(&ProviderId::new("openai-codex"), &err), - "openai-codex requires re-authentication: refresh token missing" + err.to_string(), + "credentials for provider openai-codex cannot be used: requires re-authentication: refresh token missing" ); } } diff --git a/lib/foundation/fabro-client/Cargo.toml b/lib/foundation/fabro-client/Cargo.toml index c12175924..64ba40768 100644 --- a/lib/foundation/fabro-client/Cargo.toml +++ b/lib/foundation/fabro-client/Cargo.toml @@ -20,6 +20,7 @@ fabro-api = { path = "../fabro-api" } fabro-http.workspace = true fabro-static.workspace = true fabro-types = { path = "../fabro-types" } +lithos-llm = { workspace = true, features = ["runtime"] } fabro-util = { path = "../fabro-util" } fs2.workspace = true futures.workspace = true diff --git a/lib/foundation/fabro-client/src/client.rs b/lib/foundation/fabro-client/src/client.rs index f8536bbcf..4f91a7a38 100644 --- a/lib/foundation/fabro-client/src/client.rs +++ b/lib/foundation/fabro-client/src/client.rs @@ -13,13 +13,15 @@ use fabro_http::multipart::{Form, Part}; use fabro_types::settings::run::MergeStrategy; use fabro_types::{ ArtifactUpload, BlobHash, EventEnvelope, Model, ModelTestMode, PairId, PairMessageRecord, - PairMessageRequest, PairRecord, PairStartRequest, PairTranscriptResponse, ProviderId, - ReasoningEffort, Run, RunEvent, RunEventDetailResponse, RunId, RunPairStatusResponse, - RunProjection, SessionId, SessionRecord, StageId, WorkflowVersion, WorkflowVersionId, + PairMessageRequest, PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunEvent, + RunEventDetailResponse, RunId, RunPairStatusResponse, RunProjection, SessionId, SessionRecord, + StageId, WorkflowVersion, WorkflowVersionId, }; use fabro_util::exit::{ErrorExt, ExitClass}; use futures::future::BoxFuture; use futures::{Stream, StreamExt}; +use lithos_llm::catalog::ProviderId; +use lithos_llm::types::ReasoningEffort; use serde::{Deserialize, Serialize}; use tokio::fs::File; use tokio::sync::Mutex; diff --git a/lib/foundation/fabro-types/src/billing.rs b/lib/foundation/fabro-types/src/billing.rs index 1fe4c77ae..2f483f73f 100644 --- a/lib/foundation/fabro-types/src/billing.rs +++ b/lib/foundation/fabro-types/src/billing.rs @@ -7,7 +7,7 @@ //! grouped under. use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId}; -pub use lithos_llm::types::{Cost, CostSource, Speed, TokenCounts}; +use lithos_llm::types::{Cost, Speed, TokenCounts}; use serde::{Deserialize, Serialize}; const USD_MICROS_PER_USD_F64: f64 = 1_000_000.0; @@ -330,6 +330,7 @@ impl BilledTokenCounts { #[cfg(test)] mod tests { + use lithos_llm::types::CostSource; use serde_json::json; use super::*; diff --git a/lib/foundation/fabro-types/src/catalog_api.rs b/lib/foundation/fabro-types/src/catalog_api.rs index f304dc0e7..35076048c 100644 --- a/lib/foundation/fabro-types/src/catalog_api.rs +++ b/lib/foundation/fabro-types/src/catalog_api.rs @@ -4,10 +4,10 @@ //! lithos catalog plus Fabro policy, stamped per request with whether the //! server holds credentials for each provider. +use lithos_llm::catalog::{ModelId, ProviderId}; +use lithos_llm::types::ReasoningEffort; use serde::{Deserialize, Serialize}; -use crate::{ModelId, ProviderId, ReasoningEffort}; - /// Token limits for a model. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelLimits { diff --git a/lib/foundation/fabro-types/src/controls.rs b/lib/foundation/fabro-types/src/controls.rs deleted file mode 100644 index 0017f6bc7..000000000 --- a/lib/foundation/fabro-types/src/controls.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Helpers over the lithos request-control enums. -//! -//! lithos owns [`ReasoningEffort`] and [`Speed`], their spellings, and their -//! parsing (`ALL`, `as_str`, `Display`, `FromStr`). What stays here is -//! Fabro's own rule for substituting a reasoning level a model lacks. - -pub use lithos_llm::types::{ReasoningEffort, Speed}; - -/// Position of an effort in the least-to-most ordering. -fn effort_rank(effort: ReasoningEffort) -> usize { - ReasoningEffort::ALL - .iter() - .position(|candidate| *candidate == effort) - .unwrap_or(ReasoningEffort::ALL.len()) -} - -/// Selects the supported effort nearest to `requested`. -/// -/// When two supported values are equally distant, the higher effort wins. -/// Returns `None` when nothing is supported. -#[must_use] -pub fn closest_supported_effort( - requested: ReasoningEffort, - supported: impl Fn(ReasoningEffort) -> bool, -) -> Option { - let target = effort_rank(requested); - ReasoningEffort::ALL - .into_iter() - .filter(|effort| supported(*effort)) - .min_by_key(|effort| { - let rank = effort_rank(*effort); - (rank.abs_diff(target), std::cmp::Reverse(rank)) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lithos_spellings_match_serde() { - for effort in ReasoningEffort::ALL { - let json = serde_json::to_string(&effort).unwrap(); - assert_eq!(json, format!("\"{effort}\"")); - assert_eq!(effort.as_str().parse::().unwrap(), effort); - } - for speed in Speed::ALL { - let json = serde_json::to_string(&speed).unwrap(); - assert_eq!(json, format!("\"{speed}\"")); - assert_eq!(speed.as_str().parse::().unwrap(), speed); - } - assert!("standard".parse::().is_err()); - assert!("standard".parse::().is_err()); - } - - #[test] - fn closest_supported_prefers_the_higher_neighbor_on_ties() { - let supported = |effort| matches!(effort, ReasoningEffort::Low | ReasoningEffort::High); - assert_eq!( - closest_supported_effort(ReasoningEffort::Medium, supported), - Some(ReasoningEffort::High) - ); - assert_eq!( - closest_supported_effort(ReasoningEffort::Max, supported), - Some(ReasoningEffort::High) - ); - assert_eq!( - closest_supported_effort(ReasoningEffort::Minimal, supported), - Some(ReasoningEffort::Low) - ); - assert_eq!( - closest_supported_effort(ReasoningEffort::Medium, |_| false), - None - ); - } -} diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index 24a51c0a0..97d16671c 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -11,7 +11,6 @@ pub mod catalog_api; pub mod checkpoint; pub mod command_output; pub mod conclusion; -pub mod controls; pub mod dense; pub mod diff; pub mod event_envelope; @@ -28,9 +27,7 @@ pub mod outcome; pub mod pair; pub mod parallel; pub mod principal; -pub mod provider_ids; pub mod pull_request; -pub mod reasoning; pub mod repository; pub mod run; pub mod run_event; @@ -68,16 +65,13 @@ pub mod workflow_version_id; pub use agent_profile::AgentProfileKind; pub use artifact::ArtifactUpload; pub use auth::{IdpIdentity, IdpIdentityError}; -pub use billing::{ - BilledModelUsage, BilledTokenCounts, Cost, CostSource, ModelRef, Speed, TokenCounts, UsdMicros, -}; +pub use billing::{BilledModelUsage, BilledTokenCounts, ModelRef, UsdMicros}; 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 checkpoint::Checkpoint; pub use command_output::{CommandOutputStream, CommandTermination}; pub use conclusion::{Conclusion, StageSummary}; -pub use controls::ReasoningEffort; pub use dense::{ServerSettings, UserSettings, WorkflowSettings}; pub use diff::{DiffStats, DiffSummary, RunDiff}; pub use event_envelope::EventEnvelope; @@ -93,10 +87,6 @@ pub use input_scalar::{ pub use interview::{ InterviewQuestionRecord, QuestionType, ReviewTarget, ReviewTargetError, ReviewTargetKind, }; -pub use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId}; -pub use lithos_llm::types::{ - FinishReason, Request, RequestBuildError, RequestBuilder, Response, ResponseFormat, StreamEvent, -}; pub use llm_backend::AgentBackend; pub use manifest_path::{ManifestPath, ManifestPathParseError}; pub use mcp_store::{ @@ -125,7 +115,6 @@ pub use pull_request::{ PullRequestDetailsUnavailableReason, PullRequestGithubDetail, PullRequestLink, PullRequestMeta, PullRequestRef, PullRequestResponse, PullRequestTimestamps, PullRequestUser, }; -pub use reasoning::ReasoningOutput; pub use repository::{ GitHubRepositorySlug, GitHubRepositorySlugError, RepositoryProvider, RepositoryRef, is_valid_git_branch_name, is_valid_git_tag_name, normalize_git_commit_sha, @@ -201,10 +190,8 @@ pub use system_integrations::{ pub use timing::{RunTiming, StageTiming}; pub use todo::{TodoListKind, TodoListProjection, TodoPatch, TodoProjection, TodoStatus}; pub use transcript::{ - AudioContent, ContentPart, DocumentContent, ImageContent, MediaSource, Message, MessageId, - MessageKind, MessageSource, PairMessageRef, ReasoningContent, Role, ToolCall, ToolCallKind, - ToolChoice, ToolDefinition, ToolDefinitionKind, ToolInput, ToolResult, TranscriptMessage, - text_of, tool_call_arguments, tool_result_from_json, tool_result_to_json, + MessageId, MessageKind, MessageSource, PairMessageRef, TranscriptMessage, text_of, + tool_call_arguments, tool_result_from_json, tool_result_to_json, }; pub use variable::{ CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name, diff --git a/lib/foundation/fabro-types/src/provider_ids.rs b/lib/foundation/fabro-types/src/provider_ids.rs deleted file mode 100644 index e3ab92142..000000000 --- a/lib/foundation/fabro-types/src/provider_ids.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Well-known provider identifiers. -//! -//! Provider identity is open-ended catalog data, so [`ProviderId`] is a plain -//! string newtype. The first-party providers are named here because code -//! paths such as Codex login and the install flow refer to them directly. - -use lithos_llm::catalog::ProviderId; - -pub const ANTHROPIC: &str = "anthropic"; -pub const OPENAI: &str = "openai"; -/// The ChatGPT-subscription deployment that stands in for [`OPENAI`] when a -/// Codex OAuth credential is present. -pub const OPENAI_CODEX: &str = "openai-codex"; -pub const GEMINI: &str = "gemini"; - -#[must_use] -pub fn anthropic() -> ProviderId { - ProviderId::new(ANTHROPIC) -} - -#[must_use] -pub fn openai() -> ProviderId { - ProviderId::new(OPENAI) -} - -#[must_use] -pub fn openai_codex() -> ProviderId { - ProviderId::new(OPENAI_CODEX) -} - -#[must_use] -pub fn gemini() -> ProviderId { - ProviderId::new(GEMINI) -} diff --git a/lib/foundation/fabro-types/src/reasoning.rs b/lib/foundation/fabro-types/src/reasoning.rs deleted file mode 100644 index 89d521e9f..000000000 --- a/lib/foundation/fabro-types/src/reasoning.rs +++ /dev/null @@ -1,142 +0,0 @@ -use serde::{Deserialize, Serialize, de}; - -/// Readable model reasoning normalized into a provider-neutral shape. -/// -/// Providers expose reasoning through several unrelated channels: OpenAI -/// Responses reasoning items, OpenAI-compatible `reasoning_details`, and -/// flattened `reasoning`/`reasoning_content`/`thinking` strings. This type -/// reduces all of them to the two capabilities consumers actually care -/// about, so the durable event contract does not change shape when a -/// provider dialect does. -/// -/// Both fields may be populated for the same response. An emitted object -/// always carries at least one of them; opaque provider material -/// (signatures, IDs, encrypted or redacted payloads) never appears here. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ReasoningOutput { - /// Model-authored summary of its reasoning, safe to show to users. - #[serde(default, skip_serializing_if = "Option::is_none")] - summary: Option, - /// Verbatim readable reasoning text, when the provider returns it in - /// addition to (or instead of) a summary. - #[serde(default, skip_serializing_if = "Option::is_none")] - trace: Option, -} - -impl ReasoningOutput { - /// Creates reasoning output with both a model-authored summary and a - /// verbatim trace. - #[must_use] - pub fn new(summary: impl Into, trace: impl Into) -> Self { - Self { - summary: Some(summary.into()), - trace: Some(trace.into()), - } - } - - /// Creates reasoning output containing only a model-authored summary. - #[must_use] - pub fn from_summary(summary: impl Into) -> Self { - Self { - summary: Some(summary.into()), - trace: None, - } - } - - /// Creates reasoning output containing only a verbatim trace. - #[must_use] - pub fn from_trace(trace: impl Into) -> Self { - Self { - summary: None, - trace: Some(trace.into()), - } - } - - /// Returns the model-authored summary, when present. - #[must_use] - pub fn summary(&self) -> Option<&str> { - self.summary.as_deref() - } - - /// Returns the verbatim readable reasoning trace, when present. - #[must_use] - pub fn trace(&self) -> Option<&str> { - self.trace.as_deref() - } -} - -impl<'de> Deserialize<'de> for ReasoningOutput { - fn deserialize>(deserializer: D) -> Result { - #[derive(Deserialize)] - struct Fields { - #[serde(default)] - summary: Option, - #[serde(default)] - trace: Option, - } - - let Fields { summary, trace } = Fields::deserialize(deserializer)?; - match (summary, trace) { - (Some(summary), Some(trace)) => Ok(Self::new(summary, trace)), - (Some(summary), None) => Ok(Self::from_summary(summary)), - (None, Some(trace)) => Ok(Self::from_trace(trace)), - (None, None) => Err(de::Error::custom( - "reasoning output requires a summary or trace", - )), - } - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn summary_only_round_trips_without_trace_member() { - let output = ReasoningOutput::from_summary("checked the parser first"); - let v = serde_json::to_value(&output).unwrap(); - assert_eq!(v, json!({"summary": "checked the parser first"})); - assert_eq!( - serde_json::from_value::(v).unwrap(), - output - ); - } - - #[test] - fn trace_only_round_trips_without_summary_member() { - let output = ReasoningOutput::from_trace("step one, step two"); - let v = serde_json::to_value(&output).unwrap(); - assert_eq!(v, json!({"trace": "step one, step two"})); - assert_eq!( - serde_json::from_value::(v).unwrap(), - output - ); - } - - #[test] - fn both_fields_round_trip() { - let output = ReasoningOutput::new("summary", "trace"); - let v = serde_json::to_value(&output).unwrap(); - assert_eq!(v, json!({"summary": "summary", "trace": "trace"})); - assert_eq!( - serde_json::from_value::(v).unwrap(), - output - ); - } - - #[test] - fn empty_object_is_rejected() { - let error = serde_json::from_value::(json!({})).unwrap_err(); - assert!(error.to_string().contains("requires a summary or trace")); - } - - #[test] - fn explicit_nulls_are_rejected() { - let error = - serde_json::from_value::(json!({"summary": null, "trace": null})) - .unwrap_err(); - assert!(error.to_string().contains("requires a summary or trace")); - } -} diff --git a/lib/foundation/fabro-types/src/run_event/agent.rs b/lib/foundation/fabro-types/src/run_event/agent.rs index b06f21e08..b42f1d730 100644 --- a/lib/foundation/fabro-types/src/run_event/agent.rs +++ b/lib/foundation/fabro-types/src/run_event/agent.rs @@ -1,13 +1,15 @@ +use lithos_llm::types::{ + CostSource, ReasoningEffort, ReasoningOutput, Speed, ToolCall, ToolResult, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use strum::{Display, EnumString, IntoStaticStr}; use super::{BilledTokenCounts, ExecOutputTail}; -use crate::transcript::{ToolCall, ToolResult, TranscriptMessage}; +use crate::transcript::TranscriptMessage; use crate::{ - CommandTermination, CostSource, MessageId, ModelRef, PairId, PairMessageId, - PairSystemMessageKind, PermissionLevel, ReasoningEffort, ReasoningOutput, Speed, - StageContextWindowProjection, TurnId, + CommandTermination, MessageId, ModelRef, PairId, PairMessageId, PairSystemMessageKind, + PermissionLevel, StageContextWindowProjection, TurnId, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -523,16 +525,15 @@ pub struct AgentSkillActivatedProps { #[cfg(test)] mod tests { + use lithos_llm::catalog::builtin; + use lithos_llm::types::ContentPart; use serde_json::json; use super::*; - use crate::provider_ids; - use crate::transcript::{ - ContentPart, MessageKind, MessageSource, TranscriptMessage, tool_result_from_json, - }; + use crate::transcript::{MessageKind, MessageSource, TranscriptMessage, tool_result_from_json}; fn sample_model_ref() -> ModelRef { - ModelRef::new(provider_ids::openai(), "gpt-5".into()) + ModelRef::new(builtin::openai(), "gpt-5".into()) } #[test] diff --git a/lib/foundation/fabro-types/src/run_event/misc.rs b/lib/foundation/fabro-types/src/run_event/misc.rs index b9206ac33..bb3116992 100644 --- a/lib/foundation/fabro-types/src/run_event/misc.rs +++ b/lib/foundation/fabro-types/src/run_event/misc.rs @@ -1,9 +1,10 @@ +use lithos_llm::types::ReasoningEffort; use serde::{Deserialize, Serialize}; use super::ExecOutputTail; use crate::{ - CommandTermination, ParallelBranchResult, PullRequestCreationId, PullRequestLink, - ReasoningEffort, ReviewTarget, StageId, StageOutcome, + CommandTermination, ParallelBranchResult, PullRequestCreationId, PullRequestLink, ReviewTarget, + StageId, StageOutcome, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index fc2e6c9c5..edf64822c 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -1036,12 +1036,14 @@ impl<'de> Deserialize<'de> for RunEvent { #[cfg(test)] mod tests { + use lithos_llm::catalog::builtin; + use lithos_llm::types::ReasoningOutput; use serde_json::json; use super::*; use crate::{ AuthMethod, BlobHash, CommandTermination, Edge, Graph, IdpIdentity, ModelRef, Node, - PendingReason, WorkflowSettings, fixtures, provider_ids, test_support, + PendingReason, WorkflowSettings, fixtures, test_support, }; fn user_principal(login: &str) -> Principal { @@ -2364,7 +2366,7 @@ mod tests { fn agent_message_omits_context_window_when_absent() { let body = EventBody::AgentMessage(AgentMessageProps { text: "ok".to_string(), - model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()), + model: ModelRef::new(builtin::openai(), "gpt-5.4".into()), billing: BilledTokenCounts::default(), cost_source: None, tool_call_count: 0, @@ -2391,7 +2393,7 @@ mod tests { fn agent_message_omits_reasoning_when_absent() { let body = EventBody::AgentMessage(AgentMessageProps { text: "ok".to_string(), - model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()), + model: ModelRef::new(builtin::openai(), "gpt-5.4".into()), billing: BilledTokenCounts::default(), cost_source: None, tool_call_count: 0, @@ -2415,14 +2417,14 @@ mod tests { fn agent_message_carries_reasoning_through_canonical_json() { let body = EventBody::AgentMessage(AgentMessageProps { text: String::new(), - model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()), + model: ModelRef::new(builtin::openai(), "gpt-5.4".into()), billing: BilledTokenCounts::default(), cost_source: None, tool_call_count: 1, visit: 1, message: None, context_window: None, - reasoning: Some(crate::ReasoningOutput::new( + reasoning: Some(ReasoningOutput::new( "inspect the implementation first", "read convert.rs, then the sink", )), @@ -2468,7 +2470,7 @@ mod tests { }; let body = EventBody::AgentMessage(AgentMessageProps { text: "ok".to_string(), - model: ModelRef::new(provider_ids::openai(), "gpt-5.4".into()), + model: ModelRef::new(builtin::openai(), "gpt-5.4".into()), billing: BilledTokenCounts::default(), cost_source: None, tool_call_count: 0, diff --git a/lib/foundation/fabro-types/src/run_event/session.rs b/lib/foundation/fabro-types/src/run_event/session.rs index 6f50a88f2..6cb2766c9 100644 --- a/lib/foundation/fabro-types/src/run_event/session.rs +++ b/lib/foundation/fabro-types/src/run_event/session.rs @@ -1,7 +1,8 @@ +use lithos_llm::catalog::ProviderId; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{ProviderId, TurnId}; +use crate::TurnId; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunSessionCreatedProps { diff --git a/lib/foundation/fabro-types/src/run_event/stage.rs b/lib/foundation/fabro-types/src/run_event/stage.rs index cc6aa69ab..422780c4d 100644 --- a/lib/foundation/fabro-types/src/run_event/stage.rs +++ b/lib/foundation/fabro-types/src/run_event/stage.rs @@ -1,12 +1,12 @@ use std::collections::BTreeMap; +use lithos_llm::types::{ReasoningEffort, Speed}; use serde::{Deserialize, Serialize}; use serde_json::Value; use super::ExecOutputTail; use crate::{ - BilledModelUsage, DiffSummary, FailureDetail, Outcome, ReasoningEffort, Speed, StageId, - StageOutcome, StageTiming, + BilledModelUsage, DiffSummary, FailureDetail, Outcome, StageId, StageOutcome, StageTiming, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index b05d249e3..fb727d9b5 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::num::NonZeroU32; use chrono::{DateTime, Utc}; +use lithos_llm::types::{ReasoningEffort, Speed}; use strum::{Display, EnumString, IntoStaticStr}; use crate::run_event::{AgentSessionActivatedProps, StagePromptProps}; @@ -10,9 +11,9 @@ use crate::{ AgentBackend, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolSummary, BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition, LlmOutputKind, ModelRef, ParallelBranchId, PermissionLevel, - PullRequestCreation, PullRequestLink, ReasoningEffort, RunApproval, RunControlAction, RunDiff, - RunId, RunSandbox, RunSpec, RunStatus, RunTiming, Speed, StageCompletion, StageHandler, - StageId, StageState, StageTiming, StartRecord, TodoListProjection, timing, + PullRequestCreation, PullRequestLink, RunApproval, RunControlAction, RunDiff, RunId, + RunSandbox, RunSpec, RunStatus, RunTiming, StageCompletion, StageHandler, StageId, StageState, + StageTiming, StartRecord, TodoListProjection, timing, }; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/lib/foundation/fabro-types/src/transcript.rs b/lib/foundation/fabro-types/src/transcript.rs index 32efda94f..4f1a44857 100644 --- a/lib/foundation/fabro-types/src/transcript.rs +++ b/lib/foundation/fabro-types/src/transcript.rs @@ -7,11 +7,7 @@ //! around lithos content parts. use chrono::{DateTime, Utc}; -pub use lithos_llm::types::{ - AudioContent, ContentPart, DocumentContent, ImageContent, MediaSource, Message, - ReasoningContent, Role, TokenCounts, ToolArgumentError, ToolArguments, ToolCall, ToolCallKind, - ToolChoice, ToolDefinition, ToolDefinitionKind, ToolInput, ToolResult, UnknownContent, -}; +use lithos_llm::types::{ContentPart, TokenCounts, ToolCall, ToolResult}; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr};