mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
Merge pull request #848 from fabro-sh/rust-boundary
Some checks failed
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Rust / Format (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
Some checks failed
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Rust / Format (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
Adopt the lithos-llm Rust boundary API
This commit is contained in:
commit
c3ba5ce666
161 changed files with 1481 additions and 2969 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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<dyn CredentialSource>` and `Arc<Catalog>`, 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<dyn CredentialProvider>` and `Arc<Catalog>`, 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<Catalog>` locally, then pass it explicitly.
|
||||
- `GenerateParams::new(model, client)` always receives an explicit `Arc<Client>`.
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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<dyn std::error::Error>> {
|
||||
|
|
@ -48,7 +49,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let profile: Arc<dyn AgentProfile> = 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<T, AgentError>`:
|
|||
|
||||
| Variant | Description |
|
||||
|---|---|
|
||||
| `Llm(LlmError)` | An error from the LLM provider (the stored form of a lithos `Error`). |
|
||||
| `Llm(Box<ErrorData>)` | 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<T, fabro_llm::Error>`, 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<T, fabro_llm::Error>`, 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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<fabro_types::ProviderId>,
|
||||
pub(crate) llm_provider: Option<ProviderId>,
|
||||
|
||||
#[arg(long, hide = true)]
|
||||
pub(crate) llm_api_key_stdin: bool,
|
||||
|
|
|
|||
|
|
@ -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<Arc<Client>>,
|
||||
llm_source: OnceCell<Arc<dyn CredentialSource>>,
|
||||
llm_source: OnceCell<Arc<dyn CredentialProvider>>,
|
||||
catalog: OnceLock<Arc<Catalog>>,
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +164,7 @@ impl CommandContext {
|
|||
Ok(Arc::clone(client))
|
||||
}
|
||||
|
||||
pub(crate) async fn llm_source(&self) -> Result<Arc<dyn CredentialSource>> {
|
||||
pub(crate) async fn llm_source(&self) -> Result<Arc<dyn CredentialProvider>> {
|
||||
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<dyn CredentialSource> =
|
||||
let source: Arc<dyn CredentialProvider> =
|
||||
Arc::new(SqlVaultCredentialSource::new(Arc::new(store)));
|
||||
Ok::<Arc<dyn CredentialSource>, anyhow::Error>(source)
|
||||
Ok::<Arc<dyn CredentialProvider>, anyhow::Error>(source)
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<ProviderId> {
|
||||
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<ProviderId> {
|
|||
}
|
||||
|
||||
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()),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Catalog> {
|
|||
}
|
||||
|
||||
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<AuthMethod> {
|
||||
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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<Provi
|
|||
.auth_issues
|
||||
.iter()
|
||||
.find(|(issue_provider, _)| issue_provider == &provider)
|
||||
.map(|(_, issue)| redact_string(&auth_issue_message(&provider, issue)));
|
||||
.map(|(_, issue)| redact_string(&issue.to_string()));
|
||||
let registration_issue = result
|
||||
.build_issues
|
||||
.iter()
|
||||
|
|
@ -242,7 +241,7 @@ async fn probe_single_provider(
|
|||
registration_issue: Option<String>,
|
||||
) -> 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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<ProviderId> {
|
||||
fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog())
|
||||
fabro_llm::test_support::test_catalog()
|
||||
.enabled_provider_ids()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<dyn CredentialSource>,
|
||||
pub(crate) llm_source: Arc<dyn CredentialProvider>,
|
||||
manifest_run_defaults: RwLock<Arc<RunLayer>>,
|
||||
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
|
||||
pub(crate) server_settings: RwLock<Arc<ServerSettings>>,
|
||||
|
|
@ -1401,7 +1402,7 @@ impl AppState {
|
|||
|
||||
pub(crate) async fn configured_llm_provider_ids(&self) -> Vec<ProviderId> {
|
||||
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::<HashSet<_>>();
|
||||
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<dyn CredentialSource>,
|
||||
source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
) -> anyhow::Result<FabroClient> {
|
||||
|
|
@ -2455,7 +2456,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
// Read vault secrets needed for synchronous setup before we wrap the vault in
|
||||
// an async lock for the rest of AppState.
|
||||
let daytona_api_key = vault.get(EnvVars::DAYTONA_API_KEY).map(str::to_string);
|
||||
let llm_source: Arc<dyn CredentialSource> = Arc::new(SqlVaultCredentialSource::vault_only(
|
||||
let llm_source: Arc<dyn CredentialProvider> = Arc::new(SqlVaultCredentialSource::vault_only(
|
||||
Arc::clone(&secret_store),
|
||||
));
|
||||
let (global_event_tx, _) = broadcast::channel(4096);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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::<std::collections::HashSet<_>>();
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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::<std::collections::HashSet<_>>();
|
||||
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<String, toml::Value>,
|
||||
client: LlmClient,
|
||||
model_id: String,
|
||||
provider_id: fabro_types::ProviderId,
|
||||
provider_id: ProviderId,
|
||||
}
|
||||
|
||||
fn spawn_generated_title_task(task: GeneratedTitleTask) {
|
||||
|
|
|
|||
|
|
@ -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<ProviderId>,
|
||||
) -> 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<ProviderId> {
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<fabro_llm::credentials::Credentials, fabro_auth::ResolveError> {
|
||||
Err(fabro_auth::ResolveError::NotConfigured(
|
||||
provider.id().clone(),
|
||||
))
|
||||
) -> Result<fabro_llm::credentials::Credentials, fabro_llm::credentials::CredentialError> {
|
||||
Err(fabro_llm::credentials::CredentialError::NotConfigured {
|
||||
provider: provider.id().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn configured_providers(
|
||||
&self,
|
||||
catalog: &fabro_llm::lithos_catalog::Catalog,
|
||||
) -> Vec<fabro_types::ProviderId> {
|
||||
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::<HashSet<_>>();
|
||||
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",
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("")),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<dyn fabro_auth::CredentialSource> =
|
||||
let llm_source: Arc<dyn fabro_llm::credentials::CredentialProvider> =
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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<ModelEntry<'_>> {
|
||||
let catalog = self.catalog()?;
|
||||
catalog::model_on_provider(catalog, self.provider_id().as_str(), self.model())
|
||||
fn catalog_model(&self) -> Option<Offering<'_>> {
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Arc<dyn CredentialSource>> {
|
||||
/// 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<Arc<dyn CredentialProvider>> {
|
||||
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<dyn CredentialSource>,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
mcp_servers: Vec<McpServerSettings>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> 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<ProviderId> {
|
||||
catalog.enabled_provider_ids().into_iter().collect()
|
||||
}
|
||||
|
||||
fn test_catalog() -> Arc<Catalog> {
|
||||
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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<Offering<'a>> {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ErrorData>),
|
||||
|
||||
#[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<ErrorData>),
|
||||
|
||||
#[error("Context compaction failed: {0}")]
|
||||
Compaction(#[from] CompactionError),
|
||||
|
|
@ -54,15 +55,27 @@ pub enum Error {
|
|||
Interrupted(InterruptReason),
|
||||
}
|
||||
|
||||
impl From<ErrorData> for Error {
|
||||
fn from(error: ErrorData) -> Self {
|
||||
Self::Llm(Box::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<fabro_llm::Error> for Error {
|
||||
fn from(error: fabro_llm::Error) -> Self {
|
||||
Self::Llm(LlmError::from(error))
|
||||
Self::from(ErrorData::from(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ErrorData> for CompactionError {
|
||||
fn from(error: ErrorData) -> Self {
|
||||
Self::Llm(Box::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<fabro_llm::Error> 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<T> = std::result::Result<T, Error>;
|
|||
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<Error> = 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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<LlmOutputKind> {
|
|||
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<ToolCall> = 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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OutputCaptureStats>,
|
||||
) -> 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::*;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<dyn AgentProfile> = AgentProfileBuilder::new(
|
||||
default.agent_profile(),
|
||||
catalog::offering_agent_profile(&default),
|
||||
provider_id.clone(),
|
||||
model.as_str(),
|
||||
Arc::clone(&catalog),
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<dyn Sandbox>,
|
||||
execution_context: &HookExecutionContext,
|
||||
_llm_source: Arc<dyn fabro_auth::CredentialSource>,
|
||||
_llm_source: Arc<dyn CredentialProvider>,
|
||||
_catalog: Arc<Catalog>,
|
||||
) -> HookResult {
|
||||
self.captured_contexts.lock().unwrap().push(context.clone());
|
||||
|
|
|
|||
|
|
@ -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<dyn Sandbox>,
|
||||
execution_context: &HookExecutionContext,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> HookResult;
|
||||
}
|
||||
|
|
@ -281,7 +282,7 @@ impl HookExecutorImpl {
|
|||
prompt: &InterpString,
|
||||
model: Option<&InterpString>,
|
||||
context: &HookContext,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> 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<u32>,
|
||||
context: &HookContext,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> 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<Catalog>,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
) -> Result<Client, fabro_llm::LlmSetupError> {
|
||||
fabro_llm::build_client(
|
||||
Catalog::clone(&catalog),
|
||||
|
|
@ -662,7 +659,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
context: &HookContext,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
execution_context: &HookExecutionContext,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> 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<dyn CredentialSource> {
|
||||
fn test_llm_source() -> Arc<dyn CredentialProvider> {
|
||||
test_support::vault_only_credential_source()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<dyn HookExecutor>,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
/// Pre-compiled regexes keyed by matcher pattern string.
|
||||
compiled_matchers: HashMap<String, regex::Regex>,
|
||||
|
|
@ -26,7 +26,7 @@ impl HookRunner {
|
|||
#[must_use]
|
||||
pub fn new(
|
||||
config: HookSettings,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
llm_source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> Self {
|
||||
let compiled_matchers = Self::compile_matchers(&config);
|
||||
|
|
@ -256,7 +256,7 @@ mod tests {
|
|||
_context: &HookContext,
|
||||
_sandbox: Arc<dyn Sandbox>,
|
||||
_execution_context: &HookExecutionContext,
|
||||
_llm_source: Arc<dyn CredentialSource>,
|
||||
_llm_source: Arc<dyn CredentialProvider>,
|
||||
_catalog: Arc<Catalog>,
|
||||
) -> HookResult {
|
||||
HookResult {
|
||||
|
|
@ -277,7 +277,7 @@ mod tests {
|
|||
HookContext::new(event, fixtures::RUN_1, "test-wf".into())
|
||||
}
|
||||
|
||||
fn test_llm_source() -> Arc<dyn CredentialSource> {
|
||||
fn test_llm_source() -> Arc<dyn CredentialProvider> {
|
||||
test_support::vault_only_credential_source()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<dyn CredentialSource> {
|
||||
fn test_llm_source() -> Arc<dyn CredentialProvider> {
|
||||
test_support::vault_only_credential_source()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<ProviderId>) -> Vec<Model> {
|
||||
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<ProviderId>) -> Vec<Provider> {
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
||||
/// Middleware that inlines local-path media parts.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct InlineLocalAttachments {
|
||||
env_lookup: Option<EnvLookup>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<MediaSource> {
|
||||
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<ContentPart> {
|
||||
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<Message>) -> Option<Request> {
|
||||
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<Output, Error> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Cost> {
|
||||
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<ProviderId> {
|
||||
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<ProviderId> {
|
||||
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<ModelEntry<'_>> {
|
||||
provider
|
||||
.models()
|
||||
.map(|model| ModelEntry { provider, model })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every model across listed providers, provider priority order.
|
||||
#[must_use]
|
||||
pub fn models(catalog: &Catalog) -> Vec<ModelEntry<'_>> {
|
||||
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<ModelEntry<'a>> {
|
||||
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<ModelEntry<'a>> {
|
||||
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<ModelEntry<'a>> {
|
||||
let provider = provider(catalog, provider_selector)?;
|
||||
let default = provider.default_model()?;
|
||||
model_on_provider(catalog, provider.id().as_str(), default)
|
||||
}
|
||||
|
||||
/// The model Fabro probes a provider with: the `probe` model, else the
|
||||
/// provider default.
|
||||
#[must_use]
|
||||
pub fn probe_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option<ModelEntry<'a>> {
|
||||
let 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<ProviderId>,
|
||||
) -> Option<ModelEntry<'a>> {
|
||||
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<ProviderId>,
|
||||
) -> Option<ModelEntry<'a>> {
|
||||
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<AgentProfileKind> {
|
||||
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<ModelEntry<'a>> {
|
||||
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<ProviderId, Vec<ModelId>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<RetryListener>() {
|
||||
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<ProviderId>,
|
||||
/// 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<ProviderBuildIssue>,
|
||||
}
|
||||
|
|
@ -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<dyn CredentialSource>,
|
||||
credentials: Arc<dyn CredentialProvider>,
|
||||
options: ClientOptions,
|
||||
) -> Result<FabroClient, LlmSetupError> {
|
||||
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<ProviderId> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<u16>;
|
||||
fn retry_classification(&self) -> RetryClassification;
|
||||
|
||||
/// The delay the classification advises, when repeating is safe after
|
||||
/// a wait.
|
||||
fn retry_after(&self) -> Option<Duration> {
|
||||
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<u16> {
|
||||
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<u16> {
|
||||
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<ErrorData>);
|
||||
|
||||
impl LlmError {
|
||||
/// A failure Fabro itself raises, never retried.
|
||||
#[must_use]
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> 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<Duration> {
|
||||
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<u16> {
|
||||
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<Error> 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<ErrorData> 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<E: ErrorFacts + ?Sized>(error: &E) -> bool {
|
||||
!matches!(error.retry_classification(), RetryClassification::Never)
|
||||
}
|
||||
|
||||
/// Whether the failure came from a credential problem.
|
||||
#[must_use]
|
||||
pub fn is_auth_error<E: ErrorFacts + ?Sized>(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<E: ErrorFacts + ?Sized>(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<E: ErrorFacts + ?Sized>(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<E: ErrorFacts + ?Sized>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ModelTestOutcome, ApiKeyProbeError> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ReasoningOutput> {
|
||||
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<String> {
|
||||
(!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<ReasoningOutput> {
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProviderId, ModelSelectionError> {
|
||||
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<ModelEntry<'a>, ModelSelectionError> {
|
||||
) -> Result<Offering<'a>, 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<ProviderId>,
|
||||
) -> Result<ModelEntry<'a>, ModelSelectionError> {
|
||||
) -> Result<Offering<'a>, 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<ProviderId>,
|
||||
) -> Result<ModelEntry<'a>, ModelSelectionError> {
|
||||
) -> Result<Offering<'a>, 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<ProviderId>) -> HashSet<ProviderId> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<StructuredCompletion, Error> {
|
||||
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<StructuredCompletion, Error> {
|
||||
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<serde_json::Value, Error> {
|
||||
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<ContentPart>) -> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
) -> Option<Diagnostic> {
|
||||
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<String>,
|
||||
) -> Option<Diagnostic> {
|
||||
if catalog::is_provider_selector(catalog, provider) {
|
||||
if catalog.enabled_provider(provider).is_some() {
|
||||
return None;
|
||||
}
|
||||
let valid: Vec<String> = catalog::listed_providers(catalog)
|
||||
let valid: Vec<String> = catalog
|
||||
.listed_providers()
|
||||
.iter()
|
||||
.map(|provider| provider.id().to_string())
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<E: ErrorFacts + ?Sized>(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<ErrorData>),
|
||||
|
||||
#[error("Checkpoint error: {0}")]
|
||||
Checkpoint(String),
|
||||
|
|
@ -583,7 +583,7 @@ impl Error {
|
|||
#[must_use]
|
||||
pub fn failure_signature_hint(&self) -> Option<FailureSignature> {
|
||||
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<std::io::Error> for Error {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<LlmError> for Error {
|
||||
fn from(err: LlmError) -> Self {
|
||||
Self::Llm(err)
|
||||
impl From<ErrorData> for Error {
|
||||
fn from(err: ErrorData) -> Self {
|
||||
Self::Llm(Box::new(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<fabro_llm::Error> 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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -30,11 +30,10 @@ pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result<Ev
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ::fabro_types::{
|
||||
ModelId, ModelRef, ReasoningOutput, TokenCounts as LlmTokenCounts, fixtures, provider_ids,
|
||||
run_event as fabro_types,
|
||||
};
|
||||
use ::fabro_types::{ModelRef, fixtures, run_event as fabro_types};
|
||||
use fabro_agent::AgentEvent;
|
||||
use lithos_llm::catalog::{ModelId, builtin};
|
||||
use lithos_llm::types::{ReasoningOutput, TokenCounts as LlmTokenCounts};
|
||||
|
||||
use super::*;
|
||||
use crate::event::{Event, to_run_event};
|
||||
|
|
@ -125,7 +124,7 @@ mod tests {
|
|||
visit: 1,
|
||||
event: AgentEvent::AssistantMessage {
|
||||
text: "done".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,
|
||||
|
|
|
|||
|
|
@ -296,8 +296,10 @@ mod tests {
|
|||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use ::fabro_types::{Graph, ModelId, RunNoticeLevel, WorkflowSettings, fixtures};
|
||||
use ::fabro_types::{Graph, RunNoticeLevel, WorkflowSettings, fixtures};
|
||||
use fabro_types::test_support;
|
||||
use lithos_llm::catalog::ModelId;
|
||||
use lithos_llm::types::{ReasoningOutput, TokenCounts};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -390,14 +392,14 @@ mod tests {
|
|||
event: fabro_agent::AgentEvent::AssistantMessage {
|
||||
text: String::new(),
|
||||
model: ::fabro_types::ModelRef::new(
|
||||
::fabro_types::provider_ids::openai(),
|
||||
::lithos_llm::catalog::builtin::openai(),
|
||||
ModelId::new("gpt-5.4"),
|
||||
),
|
||||
usage: ::fabro_types::TokenCounts::default(),
|
||||
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",
|
||||
"write the line, then read it back",
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -446,7 +446,8 @@ 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::types::{ReasoningEffort, Speed};
|
||||
use object_store::memory::InMemory;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use fabro_types::{PermissionLevel, ReasoningEffort, SessionCapability, Speed, StageId};
|
||||
use fabro_types::{PermissionLevel, SessionCapability, StageId};
|
||||
use lithos_llm::types::{ReasoningEffort, Speed};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::event::{Emitter, Event};
|
||||
|
|
|
|||
|
|
@ -10,18 +10,20 @@ use fabro_agent::{
|
|||
Sandbox, Session, SessionOptions, SessionShutdownReason, StaticEnvProvider, ToolEnvProvider,
|
||||
ToolSecrets, WebFetchSummarizer, canonical_tool_name, register_question_tools,
|
||||
};
|
||||
use fabro_auth::CredentialSource;
|
||||
use fabro_graphviz::graph::{AttrValue, Node};
|
||||
use fabro_llm::error::failover_eligible;
|
||||
use fabro_llm::credentials::CredentialProvider;
|
||||
use fabro_llm::lithos_catalog::Catalog;
|
||||
use fabro_llm::types::ResponseFormat;
|
||||
use fabro_llm::{Client, ClientOptions, FallbackTarget, LlmError, Request, Response, catalog};
|
||||
use fabro_llm::{Client, ClientOptions, ErrorData, FallbackTarget, Request, Response};
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
use fabro_types::settings::run::RunModelControls;
|
||||
use fabro_types::{
|
||||
AgentProfileKind, FailoverProps, Message, ModelHandle, ModelId, ModelRef, PermissionLevel,
|
||||
ProviderId, ReasoningEffort, Role, RunId, SessionCapability, Speed, StageId, StageTiming,
|
||||
TokenCounts, ToolDefinition as LlmToolDefinition, UsdMicros, billing, controls,
|
||||
AgentProfileKind, FailoverProps, ModelRef, PermissionLevel, RunId, SessionCapability, StageId,
|
||||
StageTiming, UsdMicros, billing,
|
||||
};
|
||||
use lithos_llm::catalog::{ModelHandle, ModelId, ProviderId};
|
||||
use lithos_llm::types::{
|
||||
Message, ReasoningEffort, Role, Speed, TokenCounts, ToolDefinition as LlmToolDefinition,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::mpsc;
|
||||
|
|
@ -40,7 +42,7 @@ use crate::context::WorkflowContext;
|
|||
use crate::context::keys::Fidelity;
|
||||
use crate::error::Error;
|
||||
use crate::event::{Emitter, Event, StageScope};
|
||||
use crate::model_fallback::{ModelFallbackNotice, ModelFallbackPolicy};
|
||||
use crate::model_fallback::{ModelFallbackNotice, ModelFallbackPolicy, canonical_model_id};
|
||||
use crate::outcome::billed_model_usage_from_llm;
|
||||
use crate::services::FabroRunToolServices;
|
||||
use crate::steering_hub::{ActiveControlHandle, SteeringHub};
|
||||
|
|
@ -110,7 +112,7 @@ enum AgentApiErrorDisposition {
|
|||
/// Session was interrupted via cancellation; surface as `Error::Cancelled`.
|
||||
Cancelled,
|
||||
/// Underlying LLM error eligible for provider failover.
|
||||
FailoverEligible(LlmError),
|
||||
FailoverEligible(ErrorData),
|
||||
/// Terminal error; abort the invocation with this workflow `Error`.
|
||||
Terminal(Error),
|
||||
}
|
||||
|
|
@ -132,7 +134,7 @@ fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> 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<McpServerSettings>,
|
||||
tool_secrets: ToolSecrets,
|
||||
run_model_controls: RunModelControls,
|
||||
source: Arc<dyn CredentialSource>,
|
||||
source: Arc<dyn CredentialProvider>,
|
||||
steering_hub: Arc<SteeringHub>,
|
||||
catalog: Arc<Catalog>,
|
||||
fabro_run_tools: Option<FabroRunToolServices>,
|
||||
|
|
@ -755,7 +757,7 @@ impl LiveAgentInvocation {
|
|||
error: fabro_agent::Error,
|
||||
allow_failover: bool,
|
||||
emitter: &Arc<Emitter>,
|
||||
) -> Result<LlmError, Error> {
|
||||
) -> Result<ErrorData, Error> {
|
||||
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<ProviderId>,
|
||||
fallbacks: ModelFallbackPolicy,
|
||||
source: Arc<dyn CredentialSource>,
|
||||
source: Arc<dyn CredentialProvider>,
|
||||
steering_hub: Arc<SteeringHub>,
|
||||
) -> Self {
|
||||
let catalog = Arc::new(fabro_llm::default_catalog());
|
||||
|
|
@ -803,7 +805,7 @@ impl AgentApiBackend {
|
|||
model: String,
|
||||
provider_id: ProviderId,
|
||||
fallbacks: ModelFallbackPolicy,
|
||||
source: Arc<dyn CredentialSource>,
|
||||
source: Arc<dyn CredentialProvider>,
|
||||
steering_hub: Arc<SteeringHub>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> 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<ModelFallbackNotice>) {
|
||||
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<dyn Sandbox>,
|
||||
source: Arc<dyn CredentialSource>,
|
||||
source: Arc<dyn CredentialProvider>,
|
||||
catalog: Arc<Catalog>,
|
||||
tool_env: Option<&Arc<dyn ToolEnvProvider>>,
|
||||
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
|
||||
|
|
@ -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<u32> {
|
||||
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<Catalog>,
|
||||
source: Arc<dyn CredentialSource>,
|
||||
source: Arc<dyn CredentialProvider>,
|
||||
) -> Result<Client, Error> {
|
||||
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<_>>(),
|
||||
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"));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue