diff --git a/Cargo.lock b/Cargo.lock index b037fa2a7..608789855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,6 +1717,7 @@ dependencies = [ "clap", "cli-table", "dialoguer", + "fabro-api", "fabro-macros", "fabro-model", "fabro-test", @@ -1726,6 +1727,7 @@ dependencies = [ "httpmock", "indicatif", "insta", + "progenitor-client", "rand 0.8.5", "reqwest 0.13.2", "serde", diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 245082025..85d0a6c93 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -1443,6 +1443,8 @@ paths: summary: List Models description: Returns a paginated list of available LLM models from the built-in catalog. parameters: + - $ref: "#/components/parameters/ModelProviderFilter" + - $ref: "#/components/parameters/ModelQueryFilter" - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: @@ -1452,6 +1454,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PaginatedModelList" + "400": + description: Invalid filter value + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" /api/v1/models/{id}/test: post: @@ -1466,6 +1474,7 @@ paths: schema: type: string description: The model identifier. + - $ref: "#/components/parameters/ModelTestModeParam" responses: "200": description: Test result @@ -1473,6 +1482,12 @@ paths: application/json: schema: $ref: "#/components/schemas/ModelTestResult" + "400": + description: Invalid test mode + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" "404": description: Model not found content: @@ -1751,6 +1766,33 @@ components: default: 0 example: 0 + ModelProviderFilter: + name: provider + in: query + required: false + description: Filter models by provider name. Invalid values return `400`. + schema: + type: string + example: anthropic + + ModelQueryFilter: + name: query + in: query + required: false + description: Case-insensitive substring search across `id`, `display_name`, and `aliases`. + schema: + type: string + example: opus + + ModelTestModeParam: + name: mode + in: query + required: false + description: Test mode for the single-model test endpoint. Defaults to `basic`. + schema: + $ref: "#/components/schemas/ModelTestMode" + example: basic + schemas: # ── Pagination ─────────────────────────────────────────────────────── @@ -1951,7 +1993,7 @@ components: description: Whether this is the default model for its provider. ModelTestResult: - description: Result of testing a model with a simple prompt. + description: Result of testing a model in `basic` or `deep` mode. type: object required: - model_id @@ -1972,6 +2014,13 @@ components: nullable: true description: Error details when status is "error". + ModelTestMode: + description: Single-model test mode. + type: string + enum: + - basic + - deep + # ── Completion Schemas ───────────────────────────────────────────── CompletionMessage: diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index cf15026e1..b455668c0 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use clap::{Args, Subcommand, ValueEnum}; use fabro_agent::cli::AgentArgs; use fabro_graphviz::render::GraphFormat; -use fabro_llm::cli::{ChatArgs, ModelsCommand, PromptArgs}; +use fabro_llm::cli::ModelsCommand; pub(crate) const LONG_VERSION: &str = concat!( env!("CARGO_PKG_VERSION"), @@ -731,9 +731,6 @@ impl RunsCommands { #[derive(Subcommand)] pub(crate) enum Commands { - /// LLM prompt operations - #[command(hide = true)] - Llm(LlmNamespace), /// Run an agentic coding session #[command(hide = true)] Exec(AgentArgs), @@ -831,10 +828,6 @@ pub(crate) enum Commands { impl Commands { pub(crate) fn name(&self) -> &'static str { match self { - Self::Llm(ns) => match &ns.command { - LlmCommand::Prompt(_) => "llm prompt", - LlmCommand::Chat(_) => "llm chat", - }, Self::Artifact(ns) => match &ns.command { ArtifactCommand::List(_) => "artifact list", ArtifactCommand::Cp(_) => "artifact cp", @@ -1079,20 +1072,6 @@ pub(crate) struct CompletionArgs { pub shell: clap_complete::Shell, } -#[derive(Args)] -pub(crate) struct LlmNamespace { - #[command(subcommand)] - pub(crate) command: LlmCommand, -} - -#[derive(Subcommand)] -pub(crate) enum LlmCommand { - /// Execute a prompt - Prompt(PromptArgs), - /// Interactive multi-turn chat - Chat(ChatArgs), -} - #[derive(Args)] pub(crate) struct SkillNamespace { #[command(subcommand)] diff --git a/lib/crates/fabro-cli/src/commands/llm/chat.rs b/lib/crates/fabro-cli/src/commands/llm/chat.rs deleted file mode 100644 index b63587cec..000000000 --- a/lib/crates/fabro-cli/src/commands/llm/chat.rs +++ /dev/null @@ -1,39 +0,0 @@ -use anyhow::Result; -use fabro_llm::cli::{ChatArgs, ServerConnection, run_chat, run_chat_via_server}; -use fabro_types::Settings; - -use crate::args::GlobalArgs; -use crate::user_config::{ExecutionMode, build_server_client, resolve_mode}; - -pub(super) async fn execute( - mut args: ChatArgs, - cli_settings: &Settings, - globals: &GlobalArgs, -) -> Result<()> { - globals.require_no_json()?; - let llm_defaults = cli_settings.llm.as_ref(); - if args.model.is_none() { - args.model = llm_defaults.and_then(|l| l.model.clone()); - } - - let resolved = resolve_mode( - globals.storage_dir.as_deref(), - globals.server_url.as_deref(), - cli_settings, - ); - match resolved.mode { - ExecutionMode::Server => { - let client = build_server_client(resolved.tls.as_ref())?; - let server = ServerConnection { - client, - base_url: resolved.server_base_url, - }; - run_chat_via_server(args, &server).await?; - } - ExecutionMode::Standalone => { - run_chat(args).await?; - } - } - - Ok(()) -} diff --git a/lib/crates/fabro-cli/src/commands/llm/mod.rs b/lib/crates/fabro-cli/src/commands/llm/mod.rs deleted file mode 100644 index e86c603fe..000000000 --- a/lib/crates/fabro-cli/src/commands/llm/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -mod chat; -mod prompt; - -use anyhow::Result; - -use crate::args::{GlobalArgs, LlmCommand, LlmNamespace}; -use crate::user_config::load_user_settings_with_globals; - -pub(crate) async fn dispatch(ns: LlmNamespace, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - - match ns.command { - LlmCommand::Prompt(args) => prompt::execute(args, &cli_settings, globals).await, - LlmCommand::Chat(args) => chat::execute(args, &cli_settings, globals).await, - } -} diff --git a/lib/crates/fabro-cli/src/commands/llm/prompt.rs b/lib/crates/fabro-cli/src/commands/llm/prompt.rs deleted file mode 100644 index e44c0a751..000000000 --- a/lib/crates/fabro-cli/src/commands/llm/prompt.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::Result; -use fabro_llm::cli::{PromptArgs, ServerConnection, run_prompt, run_prompt_via_server}; -use fabro_types::Settings; - -use crate::args::GlobalArgs; -use crate::user_config::{ExecutionMode, build_server_client, resolve_mode}; - -pub(super) async fn execute( - mut args: PromptArgs, - cli_settings: &Settings, - globals: &GlobalArgs, -) -> Result<()> { - let llm_defaults = cli_settings.llm.as_ref(); - if args.model.is_none() { - args.model = llm_defaults.and_then(|l| l.model.clone()); - } - - let resolved = resolve_mode( - globals.storage_dir.as_deref(), - globals.server_url.as_deref(), - cli_settings, - ); - match resolved.mode { - ExecutionMode::Server => { - let client = build_server_client(resolved.tls.as_ref())?; - let server = ServerConnection { - client, - base_url: resolved.server_base_url, - }; - run_prompt_via_server(args, &server, globals.json).await?; - } - ExecutionMode::Standalone => { - run_prompt(args, globals.json).await?; - } - } - - Ok(()) -} diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 2e4d171a6..aba2d0bfd 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -4,7 +4,6 @@ pub(crate) mod doctor; pub(crate) mod exec; pub(crate) mod graph; pub(crate) mod install; -pub(crate) mod llm; pub(crate) mod model; pub(crate) mod parse; pub(crate) mod pr; diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index e4a42fbad..b46328d82 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -1,26 +1,22 @@ use anyhow::Result; -use fabro_llm::cli::{ModelsCommand, ServerConnection, run_models}; +use fabro_llm::cli::{ModelsCommand, run_models}; use crate::args::GlobalArgs; +use crate::server_client; use crate::user_config; pub(crate) async fn execute(command: Option, globals: &GlobalArgs) -> Result<()> { let cli_settings = user_config::load_user_settings_with_globals(globals)?; - let resolved = user_config::resolve_mode( - globals.storage_dir.as_deref(), - globals.server_url.as_deref(), - &cli_settings, - ); - let server = match resolved.mode { - user_config::ExecutionMode::Server => { - let client = user_config::build_server_client(resolved.tls.as_ref())?; - Some(ServerConnection { - client, - base_url: resolved.server_base_url, - }) + let client = match globals.server_url.as_deref() { + Some(base_url) => { + let tls = cli_settings + .server + .as_ref() + .and_then(|server| server.tls.as_ref()); + server_client::connect_remote_api_client(base_url, tls)? } - user_config::ExecutionMode::Standalone => None, + None => server_client::connect_api_client(&cli_settings.storage_dir()).await?, }; - run_models(command, server, globals.json).await + run_models(command, client, globals.json).await } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 748f4146c..965231b80 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -160,7 +160,6 @@ async fn main_inner() -> (String, Result<()>) { let result = Box::pin(async move { match *command { - Commands::Llm(ns) => commands::llm::dispatch(ns, &globals).await?, Commands::Exec(args) => commands::exec::execute(args, &globals).await?, Commands::RunCmd(cmd) => Box::pin(commands::run::dispatch(cmd, &globals)).await?, Commands::Preflight(args) => commands::preflight::execute(args, &globals).await?, diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 3fa4e39f9..628b24262 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -15,6 +15,7 @@ use serde::de::DeserializeOwned; use tokio::time::sleep; use crate::commands::server::start; +use crate::user_config; pub(crate) struct ServerStoreClient { client: fabro_api::Client, @@ -98,6 +99,12 @@ impl RunProjection { } pub(crate) async fn connect_server(storage_dir: &Path) -> Result { + Ok(ServerStoreClient { + client: connect_api_client(storage_dir).await?, + }) +} + +pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result { let bind = start::ensure_server_running(storage_dir) .with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?; let socket_path = match bind { @@ -116,9 +123,18 @@ pub(crate) async fn connect_server(storage_dir: &Path) -> Result, +) -> Result { + let http_client = user_config::build_server_client(tls)?; + Ok(fabro_api::Client::new_with_client(base_url, http_client)) } async fn wait_for_server_ready(http_client: &reqwest::Client) -> Result<()> { diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index 371aba9a6..3c5fa073f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -59,3 +59,21 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn llm_namespace_is_not_available() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.arg("llm"); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 2 + ----- stdout ----- + ----- stderr ----- + error: unrecognized subcommand 'llm' + + Usage: fabro [OPTIONS] + + For more information, try '--help'. + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/llm.rs b/lib/crates/fabro-cli/tests/it/cmd/llm.rs deleted file mode 100644 index 1f8e7f2cc..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/llm.rs +++ /dev/null @@ -1,390 +0,0 @@ -use std::process::Output; - -use fabro_test::{TwinScenario, TwinScenarios, fabro_snapshot, test_context, twin_openai}; -use predicates::prelude::*; - -async fn run_success_output(mut cmd: assert_cmd::Command) -> Output { - tokio::task::spawn_blocking(move || cmd.assert().success().get_output().clone()) - .await - .expect("blocking command task should complete") -} - -#[test] -fn prompt_bad_option() { - let context = test_context!(); - let mut cmd = context.llm(); - cmd.args(["prompt", "-o", "bad_option", "hello"]); - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 2 - ----- stdout ----- - ----- stderr ----- - error: invalid value 'bad_option' for '--option