mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
Extract fabro-model crate from fabro-llm (#109)
This PR extracts a new `fabro-model` crate from `fabro-llm`, isolating
the model catalog, provider identity types, and model metadata into a
focused, lightweight package. The new crate contains `Provider`,
`ModelId`, `ModelInfo` (and related structs), and the full catalog API
(`get_model_info`, `list_models`, `default_model_from_env`,
`build_fallback_chain`, etc.) — with only `serde` and `serde_json` as
runtime dependencies, no async runtime required.
The extraction follows a clean boundary: `fabro-llm` now depends on
`fabro-model` and re-exports `Provider` and `ModelId` at its root so
existing `fabro_llm::Provider` usage remains valid without a migration.
The `ProviderAdapter` trait, `StreamEventStream`, and
`validate_tool_choice` stay in `fabro-llm` since they depend on async
streaming types. All seven consumer crates (`fabro-agent`, `fabro-api`,
`fabro-cli`, `fabro-hooks`, `fabro-workflows`, `fabro-validate`, and
`fabro-llm` itself) are updated to import catalog functions and provider
types directly from `fabro_model` rather than `fabro_llm::catalog` or
`fabro_llm::provider`.
The most impactful dependency reduction is in `fabro-validate`, which
previously pulled in all of `fabro-llm` (tokio, reqwest, the full async
runtime) just to perform catalog lookups and validate provider names. It
now depends solely on `fabro-model`, eliminating that heavyweight
transitive closure. All other consumers retain their `fabro-llm`
dependency for client and streaming functionality, but gain a precise
import path for catalog and identity concerns.
### Fabro Details
<details>
<summary>Ran 9 stages in 21m 16s for $5.47</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 1m 10s | – | 0 |
| preflight_lint | 12s | – | 0 |
| implement | 13m 29s | $3.81 | 1 |
| simplify_opus | 5m 41s | $1.66 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 15s | – | 0 |
| fmt | 1s | – | 0 |
| **Total** | **21m 16s** | **$5.47** | **1** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (12 nodes and 15
edges)</summary>
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=success"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
parent
35df8583db
commit
62c310bcec
49 changed files with 455 additions and 395 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -1248,6 +1248,7 @@ dependencies = [
|
|||
"fabro-config",
|
||||
"fabro-llm",
|
||||
"fabro-mcp",
|
||||
"fabro-model",
|
||||
"fabro-sandbox",
|
||||
"fabro-util",
|
||||
"futures",
|
||||
|
|
@ -1287,6 +1288,7 @@ dependencies = [
|
|||
"fabro-hooks",
|
||||
"fabro-interview",
|
||||
"fabro-llm",
|
||||
"fabro-model",
|
||||
"fabro-retro",
|
||||
"fabro-sandbox",
|
||||
"fabro-types",
|
||||
|
|
@ -1360,6 +1362,7 @@ dependencies = [
|
|||
"fabro-interview",
|
||||
"fabro-llm",
|
||||
"fabro-mcp",
|
||||
"fabro-model",
|
||||
"fabro-openai-oauth",
|
||||
"fabro-retro",
|
||||
"fabro-sandbox",
|
||||
|
|
@ -1487,6 +1490,7 @@ dependencies = [
|
|||
"fabro-agent",
|
||||
"fabro-config",
|
||||
"fabro-llm",
|
||||
"fabro-model",
|
||||
"fabro-util",
|
||||
"mockito",
|
||||
"regex",
|
||||
|
|
@ -1524,6 +1528,7 @@ dependencies = [
|
|||
"cli-table",
|
||||
"dialoguer",
|
||||
"dotenvy",
|
||||
"fabro-model",
|
||||
"fabro-util",
|
||||
"futures",
|
||||
"http",
|
||||
|
|
@ -1556,6 +1561,15 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.176.2"
|
||||
dependencies = [
|
||||
"insta",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-openai-oauth"
|
||||
version = "0.176.2"
|
||||
|
|
@ -1715,7 +1729,7 @@ name = "fabro-validate"
|
|||
version = "0.176.2"
|
||||
dependencies = [
|
||||
"fabro-graphviz",
|
||||
"fabro-llm",
|
||||
"fabro-model",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
|
@ -1741,6 +1755,7 @@ dependencies = [
|
|||
"fabro-interview",
|
||||
"fabro-llm",
|
||||
"fabro-mcp",
|
||||
"fabro-model",
|
||||
"fabro-retro",
|
||||
"fabro-sandbox",
|
||||
"fabro-util",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ anyhow.workspace = true
|
|||
dotenvy.workspace = true
|
||||
fabro-config = { path = "../fabro-config", features = ["clap"] }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
fabro-sandbox = { path = "../fabro-sandbox" }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::{
|
|||
};
|
||||
use clap::{Args, Parser};
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::provider::{ModelId, Provider};
|
||||
use fabro_model::{ModelId, Provider};
|
||||
use fabro_util::terminal::Styles;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -386,8 +386,8 @@ pub async fn run_with_args_and_client(
|
|||
|
||||
// Resolve model and build profile
|
||||
let model = args.model.unwrap_or_else(|| {
|
||||
fabro_llm::catalog::default_model_for_provider(provider.as_str())
|
||||
.unwrap_or_else(fabro_llm::catalog::default_model_from_env)
|
||||
fabro_model::default_model_for_provider(provider.as_str())
|
||||
.unwrap_or_else(fabro_model::default_model_from_env)
|
||||
.id
|
||||
});
|
||||
eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}")));
|
||||
|
|
@ -634,7 +634,7 @@ pub async fn run() -> anyhow::Result<()> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
use serde_json::json;
|
||||
|
||||
static NO_COLOR: std::sync::LazyLock<Styles> = std::sync::LazyLock::new(|| Styles::new(false));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::sandbox::Sandbox;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
use std::collections::HashSet;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::sandbox::Sandbox;
|
|||
use crate::skills::Skill;
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{make_edit_file_tool, register_core_tools, WebFetchSummarizer};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
|
||||
use super::EnvContext;
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ in the project. Keep changes minimal and focused on the task.";
|
|||
}
|
||||
|
||||
fn capabilities(&self) -> ProfileCapabilities {
|
||||
let context_window_size = fabro_llm::catalog::get_model_info(self.model())
|
||||
let context_window_size = fabro_model::get_model_info(self.model())
|
||||
.map(|info| info.limits.context_window as usize)
|
||||
.unwrap_or_else(|| {
|
||||
if self.model().contains("opus-4-6") {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::tools::{
|
|||
make_edit_file_tool, make_list_dir_tool, make_read_many_files_tool, register_core_tools,
|
||||
WebFetchSummarizer,
|
||||
};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
|
||||
use super::EnvContext;
|
||||
|
||||
|
|
@ -199,7 +199,7 @@ in the project.";
|
|||
}
|
||||
|
||||
fn capabilities(&self) -> ProfileCapabilities {
|
||||
let context_window_size = fabro_llm::catalog::get_model_info(self.model())
|
||||
let context_window_size = fabro_model::get_model_info(self.model())
|
||||
.map(|info| info.limits.context_window as usize)
|
||||
.unwrap_or(1_000_000);
|
||||
ProfileCapabilities {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ pub use openai::OpenAiProfile;
|
|||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::{format_skills_prompt_section, Skill};
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
|
||||
/// Common fields shared by all provider profiles.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use crate::skills::Skill;
|
|||
use crate::tool_registry::ToolRegistry;
|
||||
use crate::tools::{register_core_tools, WebFetchSummarizer};
|
||||
use crate::v4a_patch::make_apply_patch_tool;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
|
||||
use super::EnvContext;
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ in the project.";
|
|||
}
|
||||
|
||||
fn capabilities(&self) -> ProfileCapabilities {
|
||||
let context_window_size = fabro_llm::catalog::get_model_info(self.model())
|
||||
let context_window_size = fabro_model::get_model_info(self.model())
|
||||
.map(|info| info.limits.context_window as usize)
|
||||
.unwrap_or(128_000);
|
||||
ProfileCapabilities {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ use crate::subagent::{
|
|||
SubAgentManager,
|
||||
};
|
||||
use crate::tool_registry::ToolRegistry;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_llm::types::ToolDefinition;
|
||||
use fabro_model::Provider;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Static capabilities of a provider profile.
|
||||
|
|
@ -82,7 +82,7 @@ pub trait ProviderProfile: Send + Sync {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::{MockSandbox, TestProfile};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
|
||||
#[test]
|
||||
fn profile_provider_and_model() {
|
||||
|
|
|
|||
|
|
@ -901,7 +901,7 @@ impl Session {
|
|||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens: self.config.max_tokens.or_else(|| {
|
||||
fabro_llm::catalog::get_model_info(self.provider_profile.model())
|
||||
fabro_model::get_model_info(self.provider_profile.model())
|
||||
.and_then(|m| m.limits.max_output)
|
||||
}),
|
||||
stop_sequences: None,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ use crate::tool_registry::ToolRegistry;
|
|||
use async_trait::async_trait;
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::error::SdkError;
|
||||
use fabro_llm::provider::{Provider, ProviderAdapter, StreamEventStream};
|
||||
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
|
||||
use fabro_llm::types::{ContentPart, FinishReason, Message, Request, Response, StreamEvent, Usage};
|
||||
use fabro_model::Provider;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ use crate::config::SessionConfig;
|
|||
use crate::sandbox::GrepOptions;
|
||||
use crate::tool_registry::RegisteredTool;
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::provider::ModelId;
|
||||
use fabro_llm::types::{Message, Request, ToolDefinition};
|
||||
use fabro_model::ModelId;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -1249,7 +1249,7 @@ mod tests {
|
|||
let client = make_client(provider).await;
|
||||
let summarizer = WebFetchSummarizer {
|
||||
client,
|
||||
model_id: ModelId::new(fabro_llm::provider::Provider::Anthropic, "mock-model"),
|
||||
model_id: ModelId::new(fabro_model::Provider::Anthropic, "mock-model"),
|
||||
};
|
||||
|
||||
let tool = make_web_fetch_tool(Some(summarizer));
|
||||
|
|
@ -1342,7 +1342,7 @@ mod tests {
|
|||
|
||||
let summarizer = WebFetchSummarizer {
|
||||
client,
|
||||
model_id: ModelId::new(fabro_llm::provider::Provider::Anthropic, "target-model"),
|
||||
model_id: ModelId::new(fabro_model::Provider::Anthropic, "target-model"),
|
||||
};
|
||||
|
||||
let tool = make_web_fetch_tool(Some(summarizer));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use fabro_agent::{AnthropicProfile, GeminiProfile, OpenAiProfile, ProviderProfile};
|
||||
use fabro_llm::catalog;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model as catalog;
|
||||
use fabro_model::Provider;
|
||||
|
||||
#[test]
|
||||
fn profile_context_window_matches_catalog_for_default_models() {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] }
|
|||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-retro = { path = "../fabro-retro" }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
fabro-db = { path = "../fabro-db" }
|
||||
|
|
@ -62,4 +63,4 @@ http-body-util = "0.1"
|
|||
tempfile = "3"
|
||||
openapiv3 = "2"
|
||||
serde_yaml = "0.9"
|
||||
fabro-sandbox = { path = "../fabro-sandbox", features = ["exe"] }
|
||||
fabro-sandbox = { path = "../fabro-sandbox", features = ["exe"] }
|
||||
|
|
@ -576,7 +576,7 @@ pub async fn list_models(
|
|||
State(_state): State<Arc<AppState>>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> Response {
|
||||
paginated_response(fabro_llm::catalog::list_models(None), &pagination)
|
||||
paginated_response(fabro_model::list_models(None), &pagination)
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{error, info, warn};
|
||||
|
|
@ -291,13 +291,13 @@ fn resolve_model_provider(
|
|||
// Look up default model from catalog for the given provider,
|
||||
// falling back to the best provider with an API key configured.
|
||||
provider_str
|
||||
.and_then(fabro_llm::catalog::default_model_for_provider)
|
||||
.unwrap_or_else(fabro_llm::catalog::default_model_from_env)
|
||||
.and_then(fabro_model::default_model_for_provider)
|
||||
.unwrap_or_else(fabro_model::default_model_from_env)
|
||||
.id
|
||||
});
|
||||
|
||||
// Resolve model alias through catalog
|
||||
let (model, provider_str) = match fabro_llm::catalog::get_model_info(&model) {
|
||||
let (model, provider_str) = match fabro_model::get_model_info(&model) {
|
||||
Some(info) => (
|
||||
info.id,
|
||||
provider_str.map(|s| s.to_string()).or(Some(info.provider)),
|
||||
|
|
|
|||
|
|
@ -1053,7 +1053,7 @@ async fn test_model(
|
|||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let Some(info) = fabro_llm::catalog::get_model_info(&id) else {
|
||||
let Some(info) = fabro_model::get_model_info(&id) else {
|
||||
return ApiError::not_found(format!("Model not found: {id}")).into_response();
|
||||
};
|
||||
|
||||
|
|
@ -1163,12 +1163,12 @@ async fn create_completion(
|
|||
) -> Response {
|
||||
// Resolve model
|
||||
let model_id = req.model.unwrap_or_else(|| {
|
||||
fabro_llm::catalog::list_models(None)
|
||||
fabro_model::list_models(None)
|
||||
.first()
|
||||
.map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone())
|
||||
});
|
||||
|
||||
let catalog_info = fabro_llm::catalog::get_model_info(&model_id);
|
||||
let catalog_info = fabro_model::get_model_info(&model_id);
|
||||
|
||||
// Resolve provider: explicit request > catalog > None
|
||||
let provider_name = req
|
||||
|
|
|
|||
|
|
@ -61,11 +61,11 @@ fn generate_title(content: &str) -> String {
|
|||
|
||||
fn resolve_model(model_arg: Option<String>) -> (String, Option<String>) {
|
||||
let raw = model_arg.unwrap_or_else(|| {
|
||||
fabro_llm::catalog::list_models(None)
|
||||
fabro_model::list_models(None)
|
||||
.first()
|
||||
.map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone())
|
||||
});
|
||||
match fabro_llm::catalog::get_model_info(&raw) {
|
||||
match fabro_model::get_model_info(&raw) {
|
||||
Some(info) => (info.id, Some(info.provider)),
|
||||
None => (raw, None),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ sleep_inhibitor = ["dep:fabro-beastie"]
|
|||
[dependencies]
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-openai-oauth = { path = "../fabro-openai-oauth" }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
|
|
@ -87,4 +88,4 @@ predicates = "3"
|
|||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
httpmock = "0.8"
|
||||
trycmd = "0.15"
|
||||
trycmd = "0.15"
|
||||
|
|
@ -385,7 +385,7 @@ async fn create_from(
|
|||
|
||||
let model = args
|
||||
.model
|
||||
.unwrap_or_else(|| fabro_llm::catalog::default_model_from_env().id);
|
||||
.unwrap_or_else(|| fabro_model::default_model_from_env().id);
|
||||
|
||||
let record = fabro_workflows::pull_request::maybe_open_pull_request(
|
||||
&creds,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::provider_auth;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox};
|
|||
use fabro_config::run::{RunDefaults, WorkflowRunConfig};
|
||||
use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config};
|
||||
use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_validate::Severity;
|
||||
use fabro_workflows::backend::{AgentApiBackend, AgentCliBackend, BackendRouter};
|
||||
|
|
@ -208,13 +208,13 @@ fn resolve_model_provider(
|
|||
.unwrap_or_else(|| {
|
||||
provider
|
||||
.as_deref()
|
||||
.and_then(fabro_llm::catalog::default_model_for_provider)
|
||||
.unwrap_or_else(fabro_llm::catalog::default_model_from_env)
|
||||
.and_then(fabro_model::default_model_for_provider)
|
||||
.unwrap_or_else(fabro_model::default_model_from_env)
|
||||
.id
|
||||
});
|
||||
|
||||
// Resolve model alias through catalog
|
||||
match fabro_llm::catalog::get_model_info(&model) {
|
||||
match fabro_model::get_model_info(&model) {
|
||||
Some(info) => (info.id, provider.or(Some(info.provider))),
|
||||
None => (model, provider),
|
||||
}
|
||||
|
|
@ -357,13 +357,13 @@ fn resolve_fallback_chain(
|
|||
provider: Provider,
|
||||
model: &str,
|
||||
run_cfg: Option<&WorkflowRunConfig>,
|
||||
) -> Vec<fabro_llm::catalog::FallbackTarget> {
|
||||
) -> Vec<fabro_model::FallbackTarget> {
|
||||
let fallbacks = run_cfg
|
||||
.and_then(|c| c.llm.as_ref())
|
||||
.and_then(|l| l.fallbacks.as_ref());
|
||||
|
||||
match fallbacks {
|
||||
Some(map) => fabro_llm::catalog::build_fallback_chain(provider.as_str(), model, map),
|
||||
Some(map) => fabro_model::build_fallback_chain(provider.as_str(), model, map),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1893,14 +1893,14 @@ async fn run_from_branch(
|
|||
|
||||
let model = args
|
||||
.model
|
||||
.unwrap_or_else(|| fabro_llm::catalog::default_model_from_env().id);
|
||||
.unwrap_or_else(|| fabro_model::default_model_from_env().id);
|
||||
let provider_enum = args
|
||||
.provider
|
||||
.as_deref()
|
||||
.map(|s| s.parse::<fabro_llm::provider::Provider>())
|
||||
.map(|s| s.parse::<fabro_model::Provider>())
|
||||
.transpose()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
.unwrap_or_else(fabro_llm::provider::Provider::default_from_env);
|
||||
.unwrap_or_else(fabro_model::Provider::default_from_env);
|
||||
|
||||
// No fallback config available for branch resume; use empty chain.
|
||||
let fallback_chain = Vec::new();
|
||||
|
|
@ -2281,7 +2281,7 @@ async fn run_preflight(
|
|||
|
||||
// Resolve through catalog to get canonical model ID and provider
|
||||
let (resolved_model, resolved_provider) =
|
||||
if let Some(info) = fabro_llm::catalog::get_model_info(node_model) {
|
||||
if let Some(info) = fabro_model::get_model_info(node_model) {
|
||||
(info.id, info.provider)
|
||||
} else {
|
||||
(node_model.to_string(), node_provider.to_string())
|
||||
|
|
@ -2300,7 +2300,7 @@ async fn run_preflight(
|
|||
// If no LLM nodes found, fall back to the default model/provider
|
||||
if model_providers.is_empty() {
|
||||
let (resolved_model, resolved_provider) =
|
||||
if let Some(info) = fabro_llm::catalog::get_model_info(&model) {
|
||||
if let Some(info) = fabro_model::get_model_info(&model) {
|
||||
(info.id, info.provider)
|
||||
} else {
|
||||
(model.clone(), default_provider.to_string())
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::sync::LazyLock;
|
|||
|
||||
#[cfg(feature = "server")]
|
||||
use fabro_config::server::{ApiAuthStrategy, AuthProvider};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
pub use fabro_util::check_report::{
|
||||
CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus,
|
||||
};
|
||||
|
|
@ -856,7 +856,7 @@ async fn probe_daytona() -> Option<Result<(), String>> {
|
|||
}
|
||||
|
||||
pub(crate) fn probe_model(provider: Provider) -> String {
|
||||
fabro_llm::catalog::probe_model_for_provider(provider.as_str())
|
||||
fabro_model::probe_model_for_provider(provider.as_str())
|
||||
.map(|m| m.id)
|
||||
.unwrap_or_else(|| format!("unknown-{}", provider.as_str()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use axum::extract::Query;
|
|||
use axum::response::Html;
|
||||
use axum::routing::get;
|
||||
use dialoguer::{MultiSelect, Select};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
use rand::Rng;
|
||||
use tokio::net::TcpListener;
|
||||
|
|
|
|||
|
|
@ -952,7 +952,7 @@ mod tests {
|
|||
Command::Provider {
|
||||
command: ProviderCommand::Login(args),
|
||||
} => {
|
||||
assert_eq!(args.provider, fabro_llm::provider::Provider::OpenAi);
|
||||
assert_eq!(args.provider, fabro_model::Provider::OpenAi);
|
||||
}
|
||||
_ => panic!("unexpected command variant"),
|
||||
}
|
||||
|
|
@ -966,7 +966,7 @@ mod tests {
|
|||
Command::Provider {
|
||||
command: ProviderCommand::Login(args),
|
||||
} => {
|
||||
assert_eq!(args.provider, fabro_llm::provider::Provider::Anthropic);
|
||||
assert_eq!(args.provider, fabro_model::Provider::Anthropic);
|
||||
}
|
||||
_ => panic!("unexpected command variant"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::path::Path;
|
|||
|
||||
use anyhow::Result;
|
||||
use dialoguer::{Confirm, Password};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::doctor;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ doctest = false
|
|||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
@ -25,4 +26,4 @@ tokio-util.workspace = true
|
|||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
toml.workspace = true
|
||||
toml.workspace = true
|
||||
|
|
@ -218,7 +218,7 @@ impl HookExecutorImpl {
|
|||
/// Resolve a model alias (e.g. "haiku") to a concrete model ID.
|
||||
fn resolve_model(model: &Option<String>) -> String {
|
||||
let model_id = model.as_deref().unwrap_or("haiku");
|
||||
let model_info = fabro_llm::catalog::get_model_info(model_id);
|
||||
let model_info = fabro_model::get_model_info(model_id);
|
||||
model_info
|
||||
.as_ref()
|
||||
.map_or(model_id, |m| m.id.as_str())
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ cli-table.workspace = true
|
|||
clap.workspace = true
|
||||
dialoguer.workspace = true
|
||||
tracing.workspace = true
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
@ -39,4 +40,4 @@ insta = { workspace = true }
|
|||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
dotenvy.workspace = true
|
||||
httpmock = "0.8"
|
||||
serde_json.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
@ -13,9 +13,11 @@ use serde::Deserialize;
|
|||
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::catalog;
|
||||
use fabro_model as catalog;
|
||||
|
||||
use crate::generate::{self, GenerateParams};
|
||||
use crate::types::{Message, ModelInfo};
|
||||
use crate::types::Message;
|
||||
use fabro_model::ModelInfo;
|
||||
|
||||
pub struct ServerConnection {
|
||||
pub client: reqwest::Client,
|
||||
|
|
@ -117,7 +119,7 @@ fn color_if(use_color: bool, color: Color) -> Option<Color> {
|
|||
}
|
||||
}
|
||||
|
||||
fn model_row(model: &crate::types::ModelInfo, use_color: bool) -> Vec<CellStruct> {
|
||||
fn model_row(model: &ModelInfo, use_color: bool) -> Vec<CellStruct> {
|
||||
let aliases = model.aliases.join(", ");
|
||||
let cost = format!(
|
||||
"{} / {}",
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ impl Client {
|
|||
/// Resolve the provider for a request.
|
||||
fn resolve_provider(&self, request: &Request) -> Result<Arc<dyn ProviderAdapter>, SdkError> {
|
||||
let catalog_provider =
|
||||
crate::catalog::get_model_info(&request.model).map(|info| info.provider);
|
||||
fabro_model::get_model_info(&request.model).map(|info| info.provider);
|
||||
|
||||
let provider_name = request
|
||||
.provider
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
|
|
@ -11,5 +10,5 @@ pub mod tools;
|
|||
pub mod types;
|
||||
|
||||
// Re-export module-level default client helpers (Section 2.5).
|
||||
pub use fabro_model::{ModelId, Provider};
|
||||
pub use generate::set_default_client;
|
||||
pub use provider::{ModelId, Provider};
|
||||
|
|
|
|||
|
|
@ -1,149 +1,9 @@
|
|||
use crate::error::SdkError;
|
||||
use crate::types::{Request, Response, StreamEvent, ToolChoice};
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::pin::Pin;
|
||||
use std::str::FromStr;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider enum — compile-time safe provider identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Known LLM provider variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Provider {
|
||||
Anthropic,
|
||||
OpenAi,
|
||||
Gemini,
|
||||
Kimi,
|
||||
Zai,
|
||||
Minimax,
|
||||
Inception,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// All known provider variants, for use in guardrail tests and iteration.
|
||||
pub const ALL: &[Provider] = &[
|
||||
Provider::Anthropic,
|
||||
Provider::OpenAi,
|
||||
Provider::Gemini,
|
||||
Provider::Kimi,
|
||||
Provider::Zai,
|
||||
Provider::Minimax,
|
||||
Provider::Inception,
|
||||
];
|
||||
|
||||
/// Environment variable names that can provide the API key for this provider.
|
||||
/// Gemini accepts either `GEMINI_API_KEY` or `GOOGLE_API_KEY`.
|
||||
#[must_use]
|
||||
pub fn api_key_env_vars(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Anthropic => &["ANTHROPIC_API_KEY"],
|
||||
Self::OpenAi => &["OPENAI_API_KEY"],
|
||||
Self::Gemini => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
||||
Self::Kimi => &["KIMI_API_KEY"],
|
||||
Self::Zai => &["ZAI_API_KEY"],
|
||||
Self::Minimax => &["MINIMAX_API_KEY"],
|
||||
Self::Inception => &["INCEPTION_API_KEY"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if at least one of the provider's API key env vars is set.
|
||||
#[must_use]
|
||||
pub fn has_api_key(self) -> bool {
|
||||
self.api_key_env_vars()
|
||||
.iter()
|
||||
.any(|var| std::env::var(var).is_ok())
|
||||
}
|
||||
|
||||
/// Pick the best default provider based on which API keys are available.
|
||||
///
|
||||
/// Checks Anthropic → OpenAI → Gemini; falls back to Anthropic if none
|
||||
/// have a key configured.
|
||||
#[must_use]
|
||||
pub fn default_from_env() -> Self {
|
||||
Self::default_with(Self::has_api_key)
|
||||
}
|
||||
|
||||
/// Testable core of [`default_from_env`]: walks the precedence list and
|
||||
/// returns the first provider for which `is_configured` returns `true`.
|
||||
fn default_with(is_configured: impl Fn(Self) -> bool) -> Self {
|
||||
const PRECEDENCE: [Provider; 3] = [Provider::Anthropic, Provider::OpenAi, Provider::Gemini];
|
||||
PRECEDENCE
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&p| is_configured(p))
|
||||
.unwrap_or(Provider::Anthropic)
|
||||
}
|
||||
|
||||
/// Stable lowercase string representation used in `Request.provider`,
|
||||
/// adapter names, and other serialization boundaries.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::OpenAi => "openai",
|
||||
Self::Gemini => "gemini",
|
||||
Self::Kimi => "kimi",
|
||||
Self::Zai => "zai",
|
||||
Self::Minimax => "minimax",
|
||||
Self::Inception => "inception",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Provider {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Provider {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"anthropic" => Ok(Self::Anthropic),
|
||||
"openai" | "open_ai" => Ok(Self::OpenAi),
|
||||
"gemini" => Ok(Self::Gemini),
|
||||
"kimi" => Ok(Self::Kimi),
|
||||
"zai" => Ok(Self::Zai),
|
||||
"minimax" => Ok(Self::Minimax),
|
||||
"inception" | "inception_labs" => Ok(Self::Inception),
|
||||
other => Err(format!("unknown provider: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModelId — bundles a provider with a model name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A model identifier that pairs a [`Provider`] with the provider-specific
|
||||
/// model name (e.g. `"claude-opus-4-6"` or `"gpt-4o-mini"`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ModelId {
|
||||
pub provider: Provider,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl ModelId {
|
||||
#[must_use]
|
||||
pub fn new(provider: Provider, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
model: model.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ModelId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}", self.provider, self.model)
|
||||
}
|
||||
}
|
||||
pub use fabro_model::{ModelId, Provider};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProviderAdapter trait
|
||||
|
|
@ -209,145 +69,6 @@ pub fn validate_tool_choice(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_kimi() {
|
||||
assert_eq!("kimi".parse::<Provider>().unwrap(), Provider::Kimi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_zai() {
|
||||
assert_eq!("zai".parse::<Provider>().unwrap(), Provider::Zai);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimax() {
|
||||
assert_eq!("minimax".parse::<Provider>().unwrap(), Provider::Minimax);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_as_str() {
|
||||
assert_eq!(Provider::Kimi.as_str(), "kimi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zai_as_str() {
|
||||
assert_eq!(Provider::Zai.as_str(), "zai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_as_str() {
|
||||
assert_eq!(Provider::Minimax.as_str(), "minimax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_inception() {
|
||||
assert_eq!(
|
||||
"inception".parse::<Provider>().unwrap(),
|
||||
Provider::Inception
|
||||
);
|
||||
assert_eq!(
|
||||
"inception_labs".parse::<Provider>().unwrap(),
|
||||
Provider::Inception
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inception_as_str() {
|
||||
assert_eq!(Provider::Inception.as_str(), "inception");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_all_configured_prefers_anthropic() {
|
||||
assert_eq!(Provider::default_with(|_| true), Provider::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_only_openai() {
|
||||
assert_eq!(
|
||||
Provider::default_with(|p| p == Provider::OpenAi),
|
||||
Provider::OpenAi
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_only_gemini() {
|
||||
assert_eq!(
|
||||
Provider::default_with(|p| p == Provider::Gemini),
|
||||
Provider::Gemini
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_openai_and_gemini_prefers_openai() {
|
||||
assert_eq!(
|
||||
Provider::default_with(|p| p == Provider::OpenAi || p == Provider::Gemini),
|
||||
Provider::OpenAi,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_none_configured_falls_back_to_anthropic() {
|
||||
assert_eq!(Provider::default_with(|_| false), Provider::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_only_kimi_falls_back_to_anthropic() {
|
||||
assert_eq!(
|
||||
Provider::default_with(|p| p == Provider::Kimi),
|
||||
Provider::Anthropic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_anthropic() {
|
||||
assert_eq!(
|
||||
Provider::Anthropic.api_key_env_vars(),
|
||||
&["ANTHROPIC_API_KEY"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_openai() {
|
||||
assert_eq!(Provider::OpenAi.api_key_env_vars(), &["OPENAI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_gemini_has_two() {
|
||||
let vars = Provider::Gemini.api_key_env_vars();
|
||||
assert_eq!(vars.len(), 2);
|
||||
assert_eq!(vars, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_kimi() {
|
||||
assert_eq!(Provider::Kimi.api_key_env_vars(), &["KIMI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_zai() {
|
||||
assert_eq!(Provider::Zai.api_key_env_vars(), &["ZAI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_minimax() {
|
||||
assert_eq!(Provider::Minimax.api_key_env_vars(), &["MINIMAX_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_inception() {
|
||||
assert_eq!(
|
||||
Provider::Inception.api_key_env_vars(),
|
||||
&["INCEPTION_API_KEY"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_provider_has_at_least_one_env_var() {
|
||||
assert!(Provider::ALL
|
||||
.iter()
|
||||
.all(|p| !p.api_key_env_vars().is_empty()));
|
||||
}
|
||||
|
||||
// Mock adapter that supports all tool choices
|
||||
struct MockAdapter;
|
||||
|
||||
|
|
|
|||
|
|
@ -1078,7 +1078,7 @@ fn build_api_request(
|
|||
max_tokens: request
|
||||
.max_tokens
|
||||
.or_else(|| {
|
||||
crate::catalog::get_model_info(&request.model).and_then(|m| m.limits.max_output)
|
||||
fabro_model::get_model_info(&request.model).and_then(|m| m.limits.max_output)
|
||||
})
|
||||
.unwrap_or(65536),
|
||||
system: system_value,
|
||||
|
|
|
|||
|
|
@ -636,43 +636,9 @@ impl StreamEvent {
|
|||
}
|
||||
}
|
||||
|
||||
// --- 2.9 ModelInfo ---
|
||||
// --- 2.9 ModelInfo (re-exported from fabro-model) ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelLimits {
|
||||
pub context_window: i64,
|
||||
pub max_output: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelFeatures {
|
||||
pub tools: bool,
|
||||
pub vision: bool,
|
||||
pub reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelCosts {
|
||||
pub input_cost_per_mtok: Option<f64>,
|
||||
pub output_cost_per_mtok: Option<f64>,
|
||||
pub cache_input_cost_per_mtok: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub family: String,
|
||||
pub display_name: String,
|
||||
pub limits: ModelLimits,
|
||||
pub training: Option<String>,
|
||||
pub features: ModelFeatures,
|
||||
pub costs: ModelCosts,
|
||||
pub estimated_output_tps: Option<f64>,
|
||||
pub aliases: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub default: bool,
|
||||
}
|
||||
pub use fabro_model::{ModelCosts, ModelFeatures, ModelInfo, ModelLimits};
|
||||
|
||||
// --- 4.7 Timeouts ---
|
||||
|
||||
|
|
|
|||
16
lib/crates/fabro-model/Cargo.toml
Normal file
16
lib/crates/fabro-model/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "fabro-model"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
description = "LLM model catalog: provider identity, model metadata, and resolution"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
insta.workspace = true
|
||||
10
lib/crates/fabro-model/src/lib.rs
Normal file
10
lib/crates/fabro-model/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
pub mod catalog;
|
||||
pub mod provider;
|
||||
pub mod types;
|
||||
|
||||
pub use catalog::{
|
||||
build_fallback_chain, closest_model, default_model, default_model_for_provider,
|
||||
default_model_from_env, get_model_info, list_models, probe_model_for_provider, FallbackTarget,
|
||||
};
|
||||
pub use provider::{ModelId, Provider};
|
||||
pub use types::{ModelCosts, ModelFeatures, ModelInfo, ModelLimits};
|
||||
286
lib/crates/fabro-model/src/provider.rs
Normal file
286
lib/crates/fabro-model/src/provider.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider enum — compile-time safe provider identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Known LLM provider variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Provider {
|
||||
Anthropic,
|
||||
OpenAi,
|
||||
Gemini,
|
||||
Kimi,
|
||||
Zai,
|
||||
Minimax,
|
||||
Inception,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// All known provider variants, for use in guardrail tests and iteration.
|
||||
pub const ALL: &[Provider] = &[
|
||||
Provider::Anthropic,
|
||||
Provider::OpenAi,
|
||||
Provider::Gemini,
|
||||
Provider::Kimi,
|
||||
Provider::Zai,
|
||||
Provider::Minimax,
|
||||
Provider::Inception,
|
||||
];
|
||||
|
||||
/// Environment variable names that can provide the API key for this provider.
|
||||
/// Gemini accepts either `GEMINI_API_KEY` or `GOOGLE_API_KEY`.
|
||||
#[must_use]
|
||||
pub fn api_key_env_vars(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Anthropic => &["ANTHROPIC_API_KEY"],
|
||||
Self::OpenAi => &["OPENAI_API_KEY"],
|
||||
Self::Gemini => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
||||
Self::Kimi => &["KIMI_API_KEY"],
|
||||
Self::Zai => &["ZAI_API_KEY"],
|
||||
Self::Minimax => &["MINIMAX_API_KEY"],
|
||||
Self::Inception => &["INCEPTION_API_KEY"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if at least one of the provider's API key env vars is set.
|
||||
#[must_use]
|
||||
pub fn has_api_key(self) -> bool {
|
||||
self.api_key_env_vars()
|
||||
.iter()
|
||||
.any(|var| std::env::var(var).is_ok())
|
||||
}
|
||||
|
||||
/// Pick the best default provider based on which API keys are available.
|
||||
///
|
||||
/// Checks Anthropic → OpenAI → Gemini; falls back to Anthropic if none
|
||||
/// have a key configured.
|
||||
#[must_use]
|
||||
pub fn default_from_env() -> Self {
|
||||
Self::default_with(Self::has_api_key)
|
||||
}
|
||||
|
||||
/// Testable core of [`default_from_env`]: walks the precedence list and
|
||||
/// returns the first provider for which `is_configured` returns `true`.
|
||||
fn default_with(is_configured: impl Fn(Self) -> bool) -> Self {
|
||||
const PRECEDENCE: [Provider; 3] = [Provider::Anthropic, Provider::OpenAi, Provider::Gemini];
|
||||
PRECEDENCE
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&p| is_configured(p))
|
||||
.unwrap_or(Provider::Anthropic)
|
||||
}
|
||||
|
||||
/// Stable lowercase string representation used in `Request.provider`,
|
||||
/// adapter names, and other serialization boundaries.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::OpenAi => "openai",
|
||||
Self::Gemini => "gemini",
|
||||
Self::Kimi => "kimi",
|
||||
Self::Zai => "zai",
|
||||
Self::Minimax => "minimax",
|
||||
Self::Inception => "inception",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Provider {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Provider {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"anthropic" => Ok(Self::Anthropic),
|
||||
"openai" | "open_ai" => Ok(Self::OpenAi),
|
||||
"gemini" => Ok(Self::Gemini),
|
||||
"kimi" => Ok(Self::Kimi),
|
||||
"zai" => Ok(Self::Zai),
|
||||
"minimax" => Ok(Self::Minimax),
|
||||
"inception" | "inception_labs" => Ok(Self::Inception),
|
||||
other => Err(format!("unknown provider: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModelId — bundles a provider with a model name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A model identifier that pairs a [`Provider`] with the provider-specific
|
||||
/// model name (e.g. `"claude-opus-4-6"` or `"gpt-4o-mini"`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ModelId {
|
||||
pub provider: Provider,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl ModelId {
|
||||
#[must_use]
|
||||
pub fn new(provider: Provider, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
model: model.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ModelId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}", self.provider, self.model)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_kimi() {
|
||||
assert_eq!("kimi".parse::<Provider>().unwrap(), Provider::Kimi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_zai() {
|
||||
assert_eq!("zai".parse::<Provider>().unwrap(), Provider::Zai);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimax() {
|
||||
assert_eq!("minimax".parse::<Provider>().unwrap(), Provider::Minimax);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_as_str() {
|
||||
assert_eq!(Provider::Kimi.as_str(), "kimi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zai_as_str() {
|
||||
assert_eq!(Provider::Zai.as_str(), "zai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_as_str() {
|
||||
assert_eq!(Provider::Minimax.as_str(), "minimax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_inception() {
|
||||
assert_eq!(
|
||||
"inception".parse::<Provider>().unwrap(),
|
||||
Provider::Inception
|
||||
);
|
||||
assert_eq!(
|
||||
"inception_labs".parse::<Provider>().unwrap(),
|
||||
Provider::Inception
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inception_as_str() {
|
||||
assert_eq!(Provider::Inception.as_str(), "inception");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_all_configured_prefers_anthropic() {
|
||||
assert_eq!(Provider::default_with(|_| true), Provider::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_only_openai() {
|
||||
assert_eq!(
|
||||
Provider::default_with(|p| p == Provider::OpenAi),
|
||||
Provider::OpenAi
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_only_gemini() {
|
||||
assert_eq!(
|
||||
Provider::default_with(|p| p == Provider::Gemini),
|
||||
Provider::Gemini
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_openai_and_gemini_prefers_openai() {
|
||||
assert_eq!(
|
||||
Provider::default_with(|p| p == Provider::OpenAi || p == Provider::Gemini),
|
||||
Provider::OpenAi,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_none_configured_falls_back_to_anthropic() {
|
||||
assert_eq!(Provider::default_with(|_| false), Provider::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_with_only_kimi_falls_back_to_anthropic() {
|
||||
assert_eq!(
|
||||
Provider::default_with(|p| p == Provider::Kimi),
|
||||
Provider::Anthropic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_anthropic() {
|
||||
assert_eq!(
|
||||
Provider::Anthropic.api_key_env_vars(),
|
||||
&["ANTHROPIC_API_KEY"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_openai() {
|
||||
assert_eq!(Provider::OpenAi.api_key_env_vars(), &["OPENAI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_gemini_has_two() {
|
||||
let vars = Provider::Gemini.api_key_env_vars();
|
||||
assert_eq!(vars.len(), 2);
|
||||
assert_eq!(vars, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_kimi() {
|
||||
assert_eq!(Provider::Kimi.api_key_env_vars(), &["KIMI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_zai() {
|
||||
assert_eq!(Provider::Zai.api_key_env_vars(), &["ZAI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_minimax() {
|
||||
assert_eq!(Provider::Minimax.api_key_env_vars(), &["MINIMAX_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_inception() {
|
||||
assert_eq!(
|
||||
Provider::Inception.api_key_env_vars(),
|
||||
&["INCEPTION_API_KEY"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_provider_has_at_least_one_env_var() {
|
||||
assert!(Provider::ALL
|
||||
.iter()
|
||||
.all(|p| !p.api_key_env_vars().is_empty()));
|
||||
}
|
||||
}
|
||||
39
lib/crates/fabro-model/src/types.rs
Normal file
39
lib/crates/fabro-model/src/types.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// --- 2.9 ModelInfo ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelLimits {
|
||||
pub context_window: i64,
|
||||
pub max_output: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelFeatures {
|
||||
pub tools: bool,
|
||||
pub vision: bool,
|
||||
pub reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelCosts {
|
||||
pub input_cost_per_mtok: Option<f64>,
|
||||
pub output_cost_per_mtok: Option<f64>,
|
||||
pub cache_input_cost_per_mtok: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub family: String,
|
||||
pub display_name: String,
|
||||
pub limits: ModelLimits,
|
||||
pub training: Option<String>,
|
||||
pub features: ModelFeatures,
|
||||
pub costs: ModelCosts,
|
||||
pub estimated_output_tps: Option<f64>,
|
||||
pub aliases: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub default: bool,
|
||||
}
|
||||
|
|
@ -10,6 +10,6 @@ doctest = false
|
|||
|
||||
[dependencies]
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
@ -934,7 +934,7 @@ impl LintRule for StylesheetModelKnownRule {
|
|||
for decl in &rule.declarations {
|
||||
match decl.property.as_str() {
|
||||
"model" => {
|
||||
if fabro_llm::catalog::get_model_info(&decl.value).is_none() {
|
||||
if fabro_model::get_model_info(&decl.value).is_none() {
|
||||
diagnostics.push(Diagnostic {
|
||||
rule: self.name().to_string(),
|
||||
severity: Severity::Warning,
|
||||
|
|
@ -949,8 +949,8 @@ impl LintRule for StylesheetModelKnownRule {
|
|||
}
|
||||
}
|
||||
"provider" => {
|
||||
if fabro_llm::Provider::from_str(&decl.value).is_err() {
|
||||
let valid: Vec<&str> = fabro_llm::Provider::ALL
|
||||
if fabro_model::Provider::from_str(&decl.value).is_err() {
|
||||
let valid: Vec<&str> = fabro_model::Provider::ALL
|
||||
.iter()
|
||||
.map(|p| p.as_str())
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ fabro-interview = { path = "../fabro-interview" }
|
|||
fabro-util = { path = "../fabro-util" }
|
||||
fabro-git-storage = { path = "../fabro-git-storage" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-retro = { path = "../fabro-retro" }
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ use fabro_agent::{
|
|||
AgentEvent, AnthropicProfile, GeminiProfile, OpenAiProfile, ProviderProfile, Sandbox, Session,
|
||||
SessionConfig, Turn,
|
||||
};
|
||||
use fabro_llm::catalog::FallbackTarget;
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::FallbackTarget;
|
||||
use fabro_model::Provider;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::cost::compute_stage_cost;
|
||||
|
|
@ -268,9 +268,9 @@ impl CodergenBackend for AgentApiBackend {
|
|||
.map(String::from)
|
||||
.or_else(|| Some(self.provider.as_str().to_string()));
|
||||
|
||||
let max_tokens = node.max_tokens().or_else(|| {
|
||||
fabro_llm::catalog::get_model_info(model).and_then(|m| m.limits.max_output)
|
||||
});
|
||||
let max_tokens = node
|
||||
.max_tokens()
|
||||
.or_else(|| fabro_model::get_model_info(model).and_then(|m| m.limits.max_output));
|
||||
|
||||
let mut messages = Vec::new();
|
||||
if let Some(sys) = system_prompt {
|
||||
|
|
@ -343,8 +343,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
);
|
||||
|
||||
let max_tokens = node.max_tokens().or_else(|| {
|
||||
fabro_llm::catalog::get_model_info(&target.model)
|
||||
.and_then(|m| m.limits.max_output)
|
||||
fabro_model::get_model_info(&target.model).and_then(|m| m.limits.max_output)
|
||||
});
|
||||
|
||||
let fallback_request = fabro_llm::types::Request {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::sync::Arc;
|
|||
use async_trait::async_trait;
|
||||
use fabro_agent::sandbox::ExecResult;
|
||||
use fabro_agent::Sandbox;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::cost::compute_stage_cost;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use crate::outcome::StageUsage;
|
|||
/// Compute the dollar cost for a stage's token usage, if pricing is available.
|
||||
#[must_use]
|
||||
pub fn compute_stage_cost(usage: &StageUsage) -> Option<f64> {
|
||||
let info = fabro_llm::catalog::get_model_info(&usage.model)?;
|
||||
let info = fabro_model::get_model_info(&usage.model)?;
|
||||
let input_rate = info.costs.input_cost_per_mtok?;
|
||||
let output_rate = info.costs.output_cost_per_mtok?;
|
||||
Some(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::path::Path;
|
|||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Provider;
|
||||
|
||||
use crate::context::keys;
|
||||
use crate::context::Context;
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ impl Transform for ProviderInferenceTransform {
|
|||
.map(String::from);
|
||||
if let Some(model) = model {
|
||||
if !node.attrs.contains_key("provider") {
|
||||
if let Some(info) = fabro_llm::catalog::get_model_info(&model) {
|
||||
if let Some(info) = fabro_model::get_model_info(&model) {
|
||||
node.attrs
|
||||
.insert("provider".to_string(), AttrValue::String(info.provider));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue