refactor(cli): make model commands server-canonical

This commit is contained in:
Bryan Helmkamp 2026-04-05 14:09:24 -04:00
parent 5b43bd7837
commit d4b9331d2e
28 changed files with 986 additions and 1128 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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:

View file

@ -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)]

View file

@ -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(())
}

View file

@ -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,
}
}

View file

@ -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(())
}

View file

@ -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;

View file

@ -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<ModelsCommand>, 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
}

View file

@ -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?,

View file

@ -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<ServerStoreClient> {
Ok(ServerStoreClient {
client: connect_api_client(storage_dir).await?,
})
}
pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result<fabro_api::Client> {
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<ServerStoreClie
.context("Failed to build Unix-socket HTTP client for fabro server")?;
wait_for_server_ready(&http_client).await?;
Ok(ServerStoreClient {
client: fabro_api::Client::new_with_client("http://fabro", http_client),
})
Ok(fabro_api::Client::new_with_client(
"http://fabro",
http_client,
))
}
pub(crate) fn connect_remote_api_client(
base_url: &str,
tls: Option<&user_config::ClientTlsSettings>,
) -> Result<fabro_api::Client> {
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<()> {

View file

@ -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] <COMMAND>
For more information, try '--help'.
");
}

View file

@ -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 <OPTION>': expected key=value, got bad_option
For more information, try '--help'.
");
}
#[test]
fn prompt_no_text() {
let context = test_context!();
let mut cmd = context.llm();
cmd.arg("prompt");
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Error: no prompt provided. Pass a prompt as an argument or pipe text via stdin.
");
}
#[test]
fn prompt_schema_invalid() {
let context = test_context!();
let mut cmd = context.llm();
cmd.args([
"prompt",
"--no-stream",
"-m",
"test-model",
"--schema",
"not json",
"hello",
]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Using model: test-model
error: --schema must be valid JSON
> expected ident at line 1 column 2
");
}
#[test]
fn prompt_reads_from_stdin() {
let context = test_context!();
let result = context
.llm()
.args(["prompt", "--no-stream", "-m", "test-model"])
.write_stdin("hello from stdin")
.assert()
.failure();
// Should NOT complain about missing prompt
result.stderr(predicate::str::contains("no prompt provided").not());
}
#[test]
fn prompt_concatenates_stdin_and_arg() {
let context = test_context!();
let result = context
.llm()
.args([
"prompt",
"--no-stream",
"-m",
"test-model",
"summarize this",
])
.write_stdin("some input text")
.assert()
.failure();
result.stderr(predicate::str::contains("no prompt provided").not());
}
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
fn prompt_no_stream_generates_response() {
let context = test_context!();
context
.llm()
.args([
"prompt",
"--no-stream",
"-m",
"claude-sonnet-4-5",
"Say just the word 'hello'",
])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[fabro_macros::e2e_test(twin)]
async fn twin_prompt_no_stream() {
let context = test_context!();
let (base_url, api_key) = fabro_test::e2e_openai!();
let mut cmd = context.llm();
cmd.env("OPENAI_BASE_URL", base_url);
cmd.env("OPENAI_API_KEY", api_key);
cmd.args(["prompt", "--no-stream", "-m", "gpt-5.4-mini", "Say hello"]);
cmd.write_stdin("");
let output = run_success_output(cmd).await;
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"deterministic: Say hello"
);
}
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
fn prompt_stream_generates_response() {
let context = test_context!();
context
.llm()
.args([
"prompt",
"-m",
"claude-sonnet-4-5",
"Say just the word 'hello'",
])
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[fabro_macros::e2e_test(twin)]
async fn twin_prompt_stream() {
let context = test_context!();
let (base_url, api_key) = fabro_test::e2e_openai!();
let mut cmd = context.llm();
cmd.env("OPENAI_BASE_URL", base_url);
cmd.env("OPENAI_API_KEY", api_key);
cmd.args(["prompt", "-m", "gpt-5.4-mini", "Say hello"]);
cmd.write_stdin("");
let output = run_success_output(cmd).await;
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"deterministic: Say hello"
);
}
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
fn prompt_usage_shows_tokens() {
let context = test_context!();
context
.llm()
.args([
"prompt",
"--no-stream",
"-u",
"-m",
"claude-sonnet-4-5",
"Say just the word 'hello'",
])
.assert()
.success()
.stderr(predicate::str::contains("Tokens:"));
}
#[fabro_macros::e2e_test(twin)]
async fn twin_prompt_usage() {
let context = test_context!();
let (base_url, api_key) = fabro_test::e2e_openai!();
let mut cmd = context.llm();
cmd.env("OPENAI_BASE_URL", base_url);
cmd.env("OPENAI_API_KEY", api_key);
cmd.args([
"prompt",
"--no-stream",
"-u",
"-m",
"gpt-5.4-mini",
"Say hello",
]);
cmd.write_stdin("");
let output = run_success_output(cmd).await;
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"deterministic: Say hello"
);
assert!(
String::from_utf8(output.stderr)
.unwrap()
.contains("Tokens:"),
"stderr should include token usage"
);
}
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
fn prompt_schema_no_stream_generates_json() {
let context = test_context!();
let assert = context
.llm()
.args([
"prompt", "--no-stream", "-m", "claude-sonnet-4-5",
"--schema", r#"{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}"#,
"Return a JSON object with a greeting field set to hello",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");
assert!(
parsed.get("greeting").is_some(),
"expected 'greeting' key in output"
);
}
#[fabro_macros::e2e_test(twin)]
async fn twin_prompt_schema_no_stream() {
let context = test_context!();
let twin = twin_openai().await;
let namespace = format!("{}::{}", module_path!(), line!());
TwinScenarios::new(namespace.clone())
.scenario(
TwinScenario::responses("gpt-5.4-mini")
.stream(false)
.input_contains("Return JSON")
.text(r#"{"greeting":"hello"}"#),
)
.load(twin)
.await;
let mut cmd = context.llm();
twin.configure_command(&mut cmd, &namespace);
cmd.args([
"prompt",
"--no-stream",
"-m",
"gpt-5.4-mini",
"--schema",
r#"{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}"#,
"Return JSON",
]);
cmd.write_stdin("");
let output = run_success_output(cmd).await;
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");
assert_eq!(parsed["greeting"], "hello");
}
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
fn prompt_schema_stream_generates_json() {
let context = test_context!();
let assert = context
.llm()
.args([
"prompt", "-m", "claude-sonnet-4-5",
"--schema", r#"{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}"#,
"Return a JSON object with a greeting field set to hello",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");
assert!(
parsed.get("greeting").is_some(),
"expected 'greeting' key in output"
);
}
#[fabro_macros::e2e_test(twin)]
async fn twin_prompt_schema_stream() {
let context = test_context!();
let twin = twin_openai().await;
let namespace = format!("{}::{}", module_path!(), line!());
TwinScenarios::new(namespace.clone())
.scenario(
TwinScenario::responses("gpt-5.4-mini")
.stream(true)
.input_contains("Return JSON")
.text(r#"{"greeting":"hello"}"#),
)
.load(twin)
.await;
let mut cmd = context.llm();
twin.configure_command(&mut cmd, &namespace);
cmd.args([
"prompt",
"-m",
"gpt-5.4-mini",
"--schema",
r#"{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}"#,
"Return JSON",
]);
cmd.write_stdin("");
let output = run_success_output(cmd).await;
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");
assert_eq!(parsed["greeting"], "hello");
}
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
fn chat_multi_turn_with_system_prompt() {
let context = test_context!();
let assert = context
.command()
.args([
"llm",
"chat",
"-m",
"claude-haiku-4-5",
"-s",
"You are a pilot. End every response with 'Roger that.'",
])
.write_stdin("What is your profession?\nWhat did I just ask you?\n")
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();
// Verify model info printed to stderr
assert!(
stderr.contains("Using model:"),
"stderr should show model info"
);
// Verify the system prompt influenced the output
assert!(
stdout.to_lowercase().contains("roger that"),
"response should follow pilot system prompt, got: {stdout}"
);
// Verify multi-turn: the second response should reference the first question
assert!(
stdout.to_lowercase().contains("profession")
|| stdout.to_lowercase().contains("asked")
|| stdout.to_lowercase().contains("pilot"),
"second response should show multi-turn context, got: {stdout}"
);
}
#[fabro_macros::e2e_test(twin)]
async fn twin_chat_multi_turn() {
let context = test_context!();
let (base_url, api_key) = fabro_test::e2e_openai!();
let mut cmd = context.command();
cmd.env("OPENAI_BASE_URL", base_url);
cmd.env("OPENAI_API_KEY", api_key);
cmd.args([
"llm",
"chat",
"-m",
"gpt-5.4-mini",
"-s",
"You are a pilot. End every response with 'Roger that.'",
]);
cmd.write_stdin("What is your profession?\nWhat did I just ask you?\n");
let output = run_success_output(cmd).await;
let stdout = String::from_utf8(output.stdout).unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(!stdout.trim().is_empty(), "stdout should not be empty");
assert!(
stderr.contains("Using model:"),
"stderr should show model info"
);
}

View file

@ -1,87 +0,0 @@
use fabro_test::{fabro_snapshot, test_context};
use httpmock::prelude::*;
use serde_json::Value;
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["llm", "prompt", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Execute a prompt
Usage: fabro llm prompt [OPTIONS] [PROMPT]
Arguments:
[PROMPT] The prompt text (also accepts stdin)
Options:
--json Output as JSON [env: FABRO_JSON=]
-m, --model <MODEL> Model to use
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-s, --system <SYSTEM> System prompt
--no-stream Do not stream output
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
-u, --usage Show token usage
-S, --schema <SCHEMA> JSON schema for structured output (inline JSON string)
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-o, --option <OPTION> key=value options (temperature, `max_tokens`, `top_p`)
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--server-url <SERVER_URL> Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn prompt_json_streaming_server_reports_resolved_model() {
let context = test_context!();
let server = MockServer::start();
let sse_body = "\
event: stream_event\n\
data: {\"type\":\"text_delta\",\"delta\":\"Hi\",\"text_id\":null}\n\
\n\
event: stream_event\n\
data: {\"type\":\"finish\",\"finish_reason\":\"stop\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2,\"total_tokens\":7},\"response\":{\"id\":\"r1\",\"model\":\"resolved-model\",\"provider\":\"test-provider\",\"message\":{\"role\":\"assistant\",\"content\":[{\"kind\":\"text\",\"data\":\"Hi\"}],\"name\":null,\"tool_call_id\":null},\"finish_reason\":\"stop\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2,\"total_tokens\":7},\"raw\":null,\"warnings\":[],\"rate_limit\":null}}\n\
\n";
let mock = server.mock(|when, then| {
when.method(POST).path("/completions");
then.status(200)
.header("content-type", "text/event-stream")
.body(sse_body);
});
let output = context
.command()
.env_remove("FABRO_STORAGE_DIR")
.args([
"--server-url",
&server.url(""),
"--json",
"llm",
"prompt",
"Hello",
])
.output()
.expect("command should run");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let value: Value =
serde_json::from_slice(&output.stdout).expect("llm prompt JSON should parse");
assert_eq!(value["response"], "Hi");
assert_eq!(value["model"], "resolved-model");
assert_eq!(value["usage"]["input_tokens"], 5);
assert_eq!(value["usage"]["output_tokens"], 2);
mock.assert();
}

View file

@ -16,8 +16,6 @@ mod graph;
mod inspect;
mod install;
mod json_global;
mod llm;
mod llm_prompt;
mod logs;
mod model;
mod model_list;

View file

@ -161,3 +161,17 @@ fn list_query_case_insensitive() {
----- stderr -----
");
}
#[test]
fn list_invalid_provider_errors() {
let context = test_context!();
let mut cmd = context.model();
cmd.args(["list", "--provider", "not-a-provider"]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: unknown provider: not-a-provider
");
}

View file

@ -40,6 +40,7 @@ fn model_test_unknown_model_errors() {
exit_code: 1
----- stdout -----
----- stderr -----
Testing nonexistent-model-xyz... done
error: Unknown model: nonexistent-model-xyz
");
}

View file

@ -36,6 +36,8 @@ indicatif.workspace = true
clap.workspace = true
dialoguer.workspace = true
tracing.workspace = true
progenitor-client = "0.13"
fabro-api = { path = "../fabro-api" }
fabro-model = { path = "../fabro-model" }
fabro-util = { path = "../fabro-util" }

View file

@ -2,24 +2,23 @@ use std::io::{self, IsTerminal, Read, Write};
use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, Subcommand};
use cli_table::format::{Border, Justify, Separator};
use cli_table::{Cell, CellStruct, Color, Style, Table};
use futures::{StreamExt, stream};
use serde::{Deserialize, Serialize};
use futures::StreamExt;
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::task;
use tokio::time;
use fabro_api::{self, types as api_types};
use fabro_util::terminal::Styles;
use fabro_model::{Catalog, Model, Provider};
use crate::generate::{self, GenerateParams};
use crate::tools::Tool;
use crate::types::{ContentPart, GenerateResult, Message, ReasoningEffort, StreamEvent, Usage};
use crate::types::{Message, StreamEvent, Usage};
pub struct ServerConnection {
pub client: reqwest::Client,
@ -47,8 +46,6 @@ struct ModelTestRow {
#[derive(Serialize)]
struct ModelTestOutput {
#[serde(skip_serializing_if = "Option::is_none")]
deep_unsupported: Option<bool>,
results: Vec<ModelTestRow>,
total: usize,
failures: u32,
@ -932,186 +929,93 @@ pub async fn run_chat_via_server(args: ChatArgs, server: &ServerConnection) -> R
Ok(())
}
#[derive(Deserialize)]
struct PaginatedModelsResponse {
data: Vec<Model>,
fn map_api_error<E>(err: progenitor_client::Error<E>) -> anyhow::Error
where
E: serde::Serialize + std::fmt::Debug,
{
match err {
progenitor_client::Error::ErrorResponse(response) => {
if let Ok(value) = serde_json::to_value(response.into_inner()) {
if let Some(detail) = value
.get("errors")
.and_then(serde_json::Value::as_array)
.and_then(|errors| errors.first())
.and_then(|entry| entry.get("detail"))
.and_then(serde_json::Value::as_str)
{
return anyhow!("{detail}");
}
}
anyhow!("request failed")
}
progenitor_client::Error::UnexpectedResponse(response) => {
anyhow!("request failed with status {}", response.status())
}
other => anyhow!("{other}"),
}
}
#[derive(Deserialize)]
struct ModelTestResponse {
status: String,
error_message: Option<String>,
fn convert_type<TInput, TOutput>(value: TInput) -> Result<TOutput>
where
TInput: serde::Serialize,
TOutput: DeserializeOwned,
{
serde_json::from_value(serde_json::to_value(value)?).map_err(Into::into)
}
async fn fetch_models_from_server(
client: &reqwest::Client,
base_url: &str,
client: &fabro_api::Client,
provider: Option<&str>,
query: Option<&str>,
) -> Result<Vec<Model>> {
let url = format!("{base_url}/models?page[limit]=100");
tracing::debug!(url = %url, "Fetching models from server");
let mut offset = 0u64;
let mut models = Vec::new();
let response = client
.get(&url)
.send()
.await
.with_context(|| format!("Failed to connect to server at {base_url}"))?;
loop {
let mut request = client.list_models().page_limit(100u64).page_offset(offset);
if let Some(provider) = provider {
request = request.provider(provider.to_string());
}
if let Some(query) = query {
request = request.query(query.to_string());
}
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
bail!("Server returned {status}: {body}");
}
let parsed: PaginatedModelsResponse = response
.json()
.await
.context("Failed to parse models response from server")?;
let mut models = parsed.data;
tracing::debug!(model_count = models.len(), "Models received from server");
if let Some(p) = provider {
models.retain(|m| m.provider.as_str() == p);
let response = request.send().await.map_err(map_api_error)?;
let parsed = response.into_inner();
let count = parsed.data.len() as u64;
models.extend(convert_type::<_, Vec<Model>>(parsed.data)?);
if !parsed.meta.has_more {
break;
}
offset += count;
}
Ok(models)
}
async fn test_model_via_server(
client: &reqwest::Client,
base_url: &str,
client: &fabro_api::Client,
model_id: &str,
) -> Result<ModelTestResponse> {
let url = format!("{base_url}/models/{model_id}/test");
let response = client
.post(&url)
.send()
.await
.with_context(|| format!("Failed to connect to server at {base_url}"))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
bail!("Server returned {status}: {body}");
mode: Option<api_types::ModelTestMode>,
) -> Result<api_types::ModelTestResult> {
let mut request = client.test_model().id(model_id.to_string());
if let Some(mode) = mode {
request = request.mode(mode);
}
response
.json()
.await
.context("Failed to parse model test response from server")
}
fn build_deep_test_params(info: &Model) -> Option<GenerateParams> {
if !info.features.tools {
return None;
}
let add_tool = Tool::active(
"add",
"Add two integers and return the sum",
serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "integer", "description": "First number" },
"b": { "type": "integer", "description": "Second number" }
},
"required": ["a", "b"]
}),
|args, _ctx| async move {
let a = args
.get("a")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let b = args
.get("b")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
Ok(serde_json::json!(a + b))
},
);
let mut params = GenerateParams::new(&info.id)
.provider(info.provider.as_str())
.prompt(
"I have three numbers: 15, 27, and 42. \
First use the add tool to compute 15 + 27, \
then use the add tool to add that result to 42. \
Finally, tell me whether the grand total is even or odd and why.",
)
.tools(vec![add_tool])
.max_tool_rounds(5)
.max_tokens(1024);
if info.features.reasoning {
params = params.reasoning_effort(ReasoningEffort::High);
}
Some(params)
}
fn validate_deep_result(result: &GenerateResult, info: &Model) -> (cli_table::Color, String) {
// Check tool use: need at least 2 steps (tool call + follow-up)
if result.steps.len() < 2 {
return (
Color::Red,
"deep: fail (model did not call tool)".to_string(),
);
}
// Check that step 0 had tool results (tool was executed)
if result.steps[0].tool_results.is_empty() {
return (Color::Red, "deep: fail (tool not executed)".to_string());
}
// Check correctness: 15+27=42, 42+42=84 — final response should contain "84"
let final_text = result.response.text();
if !final_text.contains("84") {
return (Color::Red, "deep: fail (wrong answer)".to_string());
}
// Check reasoning if the model supports it
if info.features.reasoning {
let has_reasoning = result.steps.iter().any(|step| {
step.response.message.content.iter().any(|part| {
matches!(part, ContentPart::Thinking(_))
|| matches!(part, ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING)
})
});
if !has_reasoning {
return (Color::Yellow, "deep: ok (no reasoning)".to_string());
}
}
(Color::Green, "deep: ok".to_string())
let response = request.send().await.map_err(map_api_error)?;
Ok(response.into_inner())
}
#[allow(clippy::print_stdout, clippy::print_stderr)]
async fn test_models_via_server(
server: &ServerConnection,
client: &fabro_api::Client,
provider: Option<&str>,
model: Option<&str>,
deep: bool,
s: &Styles,
json_output: bool,
) -> Result<()> {
if deep && !json_output {
eprintln!("Warning: --deep is not supported in server mode");
}
let models_to_test = if let Some(model_id) = model {
let all = fetch_models_from_server(&server.client, &server.base_url, None).await?;
let found: Vec<_> = all.into_iter().filter(|m| m.id == model_id).collect();
if found.is_empty() {
bail!("Unknown model: {model_id}");
}
found
} else {
fetch_models_from_server(&server.client, &server.base_url, provider).await?
};
if models_to_test.is_empty() {
bail!("No models found");
}
let request_mode = deep.then_some(api_types::ModelTestMode::Deep);
let use_color = s.use_color;
let mut title = models_title(use_color);
@ -1120,31 +1024,47 @@ async fn test_models_via_server(
let mut rows: Vec<Vec<CellStruct>> = Vec::new();
let mut json_rows = Vec::new();
let mut failures = 0u32;
for info in &models_to_test {
if let Some(model_id) = model {
if !json_output {
eprint!("Testing {}...", info.id);
eprint!("Testing {model_id}...");
}
let result = test_model_via_server(&server.client, &server.base_url, &info.id).await;
let result = test_model_via_server(client, model_id, request_mode).await;
if !json_output {
eprintln!(" done");
}
let (result_color, status) = match result {
Ok(resp) if resp.status == "ok" => (Color::Green, "ok".to_string()),
let (info, result_color, status) = match result {
Ok(resp) => {
failures += 1;
let msg = resp
.error_message
.unwrap_or_else(|| "unknown error".to_string());
(Color::Red, format!("error: {msg}"))
let info = Catalog::builtin()
.get(&resp.model_id)
.cloned()
.with_context(|| {
format!("Unknown model returned by server: {}", resp.model_id)
})?;
if resp.status == api_types::ModelTestResultStatus::Ok {
(info, Color::Green, "ok".to_string())
} else {
failures += 1;
let message = resp
.error_message
.unwrap_or_else(|| "unknown error".to_string());
(info, Color::Red, format!("error: {message}"))
}
}
Err(e) => {
Err(err) if err.to_string().contains("Model not found") => {
bail!("Unknown model: {model_id}");
}
Err(err) => {
let info = Catalog::builtin()
.get(model_id)
.cloned()
.with_context(|| format!("Unknown model: {model_id}"))?;
failures += 1;
(Color::Red, format!("error: {e}"))
(info, Color::Red, format!("error: {err}"))
}
};
let mut row = model_row(info, use_color);
let mut row = model_row(&info, use_color);
row.push(
status
.clone()
@ -1152,14 +1072,55 @@ async fn test_models_via_server(
.foreground_color(color_if(use_color, result_color)),
);
rows.push(row);
json_rows.push(model_test_row_from_status(info, &status, result_color));
json_rows.push(model_test_row_from_status(&info, &status, result_color));
} else {
let models_to_test = fetch_models_from_server(client, provider, None).await?;
if models_to_test.is_empty() {
bail!("No models found");
}
for info in &models_to_test {
if !json_output {
eprint!("Testing {}...", info.id);
}
let result = test_model_via_server(client, &info.id, request_mode).await;
if !json_output {
eprintln!(" done");
}
let (result_color, status) = match result {
Ok(resp) if resp.status == api_types::ModelTestResultStatus::Ok => {
(Color::Green, "ok".to_string())
}
Ok(resp) => {
failures += 1;
let message = resp
.error_message
.unwrap_or_else(|| "unknown error".to_string());
(Color::Red, format!("error: {message}"))
}
Err(err) => {
failures += 1;
(Color::Red, format!("error: {err}"))
}
};
let mut row = model_row(info, use_color);
row.push(
status
.clone()
.cell()
.foreground_color(color_if(use_color, result_color)),
);
rows.push(row);
json_rows.push(model_test_row_from_status(info, &status, result_color));
}
}
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&ModelTestOutput {
deep_unsupported: deep.then_some(true),
total: json_rows.len(),
failures,
results: json_rows,
@ -1189,7 +1150,7 @@ async fn test_models_via_server(
#[allow(clippy::print_stdout)]
pub async fn run_models(
command: Option<ModelsCommand>,
server: Option<ServerConnection>,
client: fabro_api::Client,
json_output: bool,
) -> Result<()> {
let command = command.unwrap_or(ModelsCommand::List {
@ -1201,23 +1162,8 @@ pub async fn run_models(
match command {
ModelsCommand::List { provider, query } => {
let mut models = if let Some(s) = &server {
fetch_models_from_server(&s.client, &s.base_url, provider.as_deref()).await?
} else {
let p = provider.as_deref().and_then(|s| s.parse::<Provider>().ok());
Catalog::builtin().list(p).into_iter().cloned().collect()
};
if let Some(q) = &query {
let q_lower = q.to_lowercase();
models.retain(|m| {
m.id.to_lowercase().contains(&q_lower)
|| m.display_name.to_lowercase().contains(&q_lower)
|| m.aliases
.iter()
.any(|a| a.to_lowercase().contains(&q_lower))
});
}
let models =
fetch_models_from_server(&client, provider.as_deref(), query.as_deref()).await?;
if json_output {
println!("{}", serde_json::to_string_pretty(&models)?);
@ -1229,181 +1175,17 @@ pub async fn run_models(
provider,
model,
deep,
} => match &server {
Some(s) => {
test_models_via_server(
s,
provider.as_deref(),
model.as_deref(),
deep,
&styles,
json_output,
)
.await?;
}
None => {
test_models(
provider.as_deref(),
model.as_deref(),
deep,
&styles,
json_output,
)
.await?;
}
},
}
Ok(())
}
async fn test_one_model(info: &Model, deep: bool) -> (Color, String) {
if deep {
match build_deep_test_params(info) {
None => (Color::Yellow, "deep: skipped (no tool support)".to_string()),
Some(params) => {
let result =
time::timeout(Duration::from_secs(90), generate::generate(params)).await;
match result {
Ok(Ok(ref gen_result)) => validate_deep_result(gen_result, info),
Ok(Err(e)) => (Color::Red, format!("deep: error: {e}")),
Err(_) => (Color::Red, "deep: error: timeout (90s)".to_string()),
}
}
} => {
test_models_via_server(
&client,
provider.as_deref(),
model.as_deref(),
deep,
&styles,
json_output,
)
.await?;
}
} else {
let params = GenerateParams::new(&info.id)
.provider(info.provider.as_str())
.prompt("Say OK")
.max_tokens(16);
let result = time::timeout(Duration::from_secs(30), generate::generate(params)).await;
match result {
Ok(Ok(_)) => (Color::Green, "ok".to_string()),
Ok(Err(e)) => (Color::Red, format!("error: {e}")),
Err(_) => (Color::Red, "error: timeout (30s)".to_string()),
}
}
}
#[allow(clippy::print_stdout)]
async fn test_models(
provider: Option<&str>,
model: Option<&str>,
deep: bool,
s: &Styles,
json_output: bool,
) -> Result<()> {
use rand::seq::SliceRandom;
let models_to_test = if let Some(model_id) = model {
match Catalog::builtin().get(model_id) {
Some(info) => vec![info.clone()],
None => bail!("Unknown model: {model_id}"),
}
} else {
let p = provider.and_then(|s| s.parse::<Provider>().ok());
Catalog::builtin().list(p).into_iter().cloned().collect()
};
if models_to_test.is_empty() {
bail!("No models found");
}
let pb = if json_output {
None
} else {
let test_kind = if deep { "Deep testing" } else { "Testing" };
let pb = indicatif::ProgressBar::new(models_to_test.len() as u64);
pb.set_style(
indicatif::ProgressStyle::with_template(&format!(
"{{spinner:.green}} {test_kind} {{pos}}/{{len}} models {{wide_bar}} {{eta}}"
))
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
Some(pb)
};
// Build (original_index, model_info) pairs, then shuffle for provider spread
let mut indexed: Vec<(usize, &Model)> = models_to_test.iter().enumerate().collect();
indexed.shuffle(&mut rand::thread_rng());
// Run tests concurrently, 6 at a time
let results: Vec<(usize, Color, String)> = stream::iter(indexed)
.map(|(idx, info)| {
let pb = pb.clone();
async move {
let (color, status) = test_one_model(info, deep).await;
if let Some(pb) = pb {
pb.inc(1);
}
(idx, color, status)
}
})
.buffer_unordered(6)
.collect()
.await;
if let Some(pb) = pb {
pb.finish_and_clear();
}
// Sort back to original catalog order
let mut sorted_results = results;
sorted_results.sort_by_key(|(idx, _, _)| *idx);
let use_color = s.use_color;
let mut title = models_title(use_color);
title.push("RESULT".cell().bold(use_color));
let mut rows: Vec<Vec<CellStruct>> = Vec::new();
let mut json_rows = Vec::new();
let mut failures = 0u32;
for (idx, result_color, status) in &sorted_results {
if *result_color == Color::Red {
failures += 1;
}
json_rows.push(model_test_row_from_status(
&models_to_test[*idx],
status,
*result_color,
));
let mut row = model_row(&models_to_test[*idx], use_color);
row.push(
status
.cell()
.foreground_color(color_if(use_color, *result_color)),
);
rows.push(row);
}
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&ModelTestOutput {
deep_unsupported: None,
total: json_rows.len(),
failures,
results: json_rows,
})?
);
if failures > 0 {
bail!("{failures} model(s) failed");
}
return Ok(());
}
let table = rows
.table()
.title(title)
.color_choice(color_choice(use_color))
.border(Border::builder().build())
.separator(Separator::builder().build());
println!("{}", table.display()?);
if failures > 0 {
bail!("{failures} model(s) failed");
}
Ok(())
@ -1412,11 +1194,46 @@ async fn test_models(
#[cfg(test)]
mod tests {
use super::*;
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, Provider};
fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
}
fn test_api_client(base_url: &str) -> fabro_api::Client {
fabro_api::Client::new_with_client(base_url, test_http_client())
}
fn test_model_json(id: &str, provider: Provider) -> serde_json::Value {
serde_json::to_value(Model {
id: id.to_string(),
provider,
family: "test".to_string(),
display_name: format!("{id} display"),
limits: ModelLimits {
context_window: 128_000,
max_output: Some(4096),
},
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
effort: false,
},
costs: ModelCosts {
input_cost_per_mtok: Some(1.0),
output_cost_per_mtok: Some(2.0),
cache_input_cost_per_mtok: None,
},
estimated_output_tps: Some(100.0),
aliases: vec!["tm".to_string()],
default: false,
})
.unwrap()
}
// --- parse_option ---
#[test]
@ -1609,7 +1426,7 @@ mod tests {
let server = httpmock::MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method("POST").path("/models/test-model/test");
when.method("POST").path("/api/v1/models/test-model/test");
then.status(200)
.header("Content-Type", "application/json")
.body(
@ -1622,21 +1439,23 @@ mod tests {
})
.await;
let client = test_http_client();
let resp = test_model_via_server(&client, &server.url(""), "test-model")
let client = test_api_client(&server.url(""));
let resp = test_model_via_server(&client, "test-model", None)
.await
.unwrap();
assert_eq!(resp.status, "ok");
assert_eq!(resp.status, api_types::ModelTestResultStatus::Ok);
assert!(resp.error_message.is_none());
}
#[tokio::test]
async fn test_model_via_server_parses_error() {
async fn test_model_via_server_passes_mode_and_parses_error() {
let server = httpmock::MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method("POST").path("/models/test-model/test");
when.method("POST")
.path("/api/v1/models/test-model/test")
.query_param("mode", "deep");
then.status(200)
.header("Content-Type", "application/json")
.body(
@ -1650,12 +1469,13 @@ mod tests {
})
.await;
let client = test_http_client();
let resp = test_model_via_server(&client, &server.url(""), "test-model")
.await
.unwrap();
let client = test_api_client(&server.url(""));
let resp =
test_model_via_server(&client, "test-model", Some(api_types::ModelTestMode::Deep))
.await
.unwrap();
assert_eq!(resp.status, "error");
assert_eq!(resp.status, api_types::ModelTestResultStatus::Error);
assert_eq!(resp.error_message.as_deref(), Some("timeout"));
}
@ -1664,7 +1484,7 @@ mod tests {
let server = httpmock::MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method("POST").path("/models/bad-model/test");
when.method("POST").path("/api/v1/models/bad-model/test");
then.status(404)
.header("Content-Type", "application/json")
.body(serde_json::json!({
@ -1673,9 +1493,10 @@ mod tests {
})
.await;
let client = test_http_client();
let result = test_model_via_server(&client, &server.url(""), "bad-model").await;
let client = test_api_client(&server.url(""));
let result = test_model_via_server(&client, "bad-model", None).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Model not found"));
}
// --- fetch_models_from_server ---
@ -1683,32 +1504,26 @@ mod tests {
#[tokio::test]
async fn fetch_models_from_server_parses_response() {
let server = httpmock::MockServer::start_async().await;
let mock = server.mock_async(|when, then| {
when.method("GET").path("/models").query_param("page[limit]", "100");
then.status(200)
.header("Content-Type", "application/json")
.body(serde_json::json!({
"data": [{
"id": "test-model",
"provider": "anthropic",
"family": "test",
"display_name": "Test Model",
"limits": { "context_window": 128_000, "max_output": 4096 },
"training": null,
"features": { "tools": true, "vision": false, "reasoning": false },
"costs": { "input_cost_per_mtok": 1.0, "output_cost_per_mtok": 2.0, "cache_input_cost_per_mtok": null },
"estimated_output_tps": 100.0,
"aliases": ["tm"],
"default": false
}],
"meta": { "has_more": false }
}).to_string());
}).await;
let mock = server
.mock_async(|when, then| {
when.method("GET")
.path("/api/v1/models")
.query_param("page[limit]", "100")
.query_param("page[offset]", "0");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("test-model", Provider::Anthropic)],
"meta": { "has_more": false }
})
.to_string(),
);
})
.await;
let client = test_http_client();
let models = fetch_models_from_server(&client, &server.url(""), None)
.await
.unwrap();
let client = test_api_client(&server.url(""));
let models = fetch_models_from_server(&client, None, None).await.unwrap();
mock.assert_async().await;
assert_eq!(models.len(), 1);
@ -1721,44 +1536,25 @@ mod tests {
let server = httpmock::MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method("GET").path("/models");
when.method("GET")
.path("/api/v1/models")
.query_param("page[limit]", "100")
.query_param("page[offset]", "0")
.query_param("provider", "anthropic");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [
{
"id": "model-a",
"provider": "anthropic",
"family": "a",
"display_name": "Model A",
"limits": { "context_window": 8000 },
"features": { "tools": false, "vision": false, "reasoning": false },
"costs": {},
"aliases": [],
"default": false
},
{
"id": "model-b",
"provider": "openai",
"family": "b",
"display_name": "Model B",
"limits": { "context_window": 8000 },
"features": { "tools": false, "vision": false, "reasoning": false },
"costs": {},
"aliases": [],
"default": false
}
],
"meta": { "has_more": false }
})
.to_string(),
);
serde_json::json!({
"data": [test_model_json("model-a", Provider::Anthropic)],
"meta": { "has_more": false }
})
.to_string(),
);
})
.await;
let client = test_http_client();
let models = fetch_models_from_server(&client, &server.url(""), Some("anthropic"))
let client = test_api_client(&server.url(""));
let models = fetch_models_from_server(&client, Some("anthropic"), None)
.await
.unwrap();
@ -1766,18 +1562,101 @@ mod tests {
assert_eq!(models[0].id, "model-a");
}
#[tokio::test]
async fn fetch_models_from_server_passes_query_param() {
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method("GET")
.path("/api/v1/models")
.query_param("page[limit]", "100")
.query_param("page[offset]", "0")
.query_param("query", "sonnet");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("claude-sonnet-4-5", Provider::Anthropic)],
"meta": { "has_more": false }
})
.to_string(),
);
})
.await;
let client = test_api_client(&server.url(""));
let models = fetch_models_from_server(&client, None, Some("sonnet"))
.await
.unwrap();
mock.assert_async().await;
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "claude-sonnet-4-5");
}
#[tokio::test]
async fn fetch_models_from_server_follows_pagination() {
let server = httpmock::MockServer::start_async().await;
let first_page = server
.mock_async(|when, then| {
when.method("GET")
.path("/api/v1/models")
.query_param("page[limit]", "100")
.query_param("page[offset]", "0");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("model-a", Provider::Anthropic)],
"meta": { "has_more": true }
})
.to_string(),
);
})
.await;
let second_page = server
.mock_async(|when, then| {
when.method("GET")
.path("/api/v1/models")
.query_param("page[limit]", "100")
.query_param("page[offset]", "1");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("model-b", Provider::OpenAi)],
"meta": { "has_more": false }
})
.to_string(),
);
})
.await;
let client = test_api_client(&server.url(""));
let models = fetch_models_from_server(&client, None, None).await.unwrap();
first_page.assert_async().await;
second_page.assert_async().await;
assert_eq!(models.len(), 2);
assert_eq!(models[0].id, "model-a");
assert_eq!(models[1].id, "model-b");
}
#[tokio::test]
async fn fetch_models_from_server_error_on_failure() {
let server = httpmock::MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method("GET").path("/models");
when.method("GET")
.path("/api/v1/models")
.query_param("page[limit]", "100")
.query_param("page[offset]", "0");
then.status(500).body("internal error");
})
.await;
let client = test_http_client();
let result = fetch_models_from_server(&client, &server.url(""), None).await;
let client = test_api_client(&server.url(""));
let result = fetch_models_from_server(&client, None, None).await;
assert!(result.is_err());
}

View file

@ -3,6 +3,7 @@ pub mod client;
pub mod error;
pub mod generate;
pub mod middleware;
pub mod model_test;
pub mod provider;
pub mod providers;
pub mod retry;

View file

@ -0,0 +1,284 @@
use std::str::FromStr;
use std::time::Duration;
use tokio::time;
use crate::generate::{self, GenerateParams};
use crate::tools::Tool;
use crate::types::GenerateResult;
use crate::types::ReasoningEffort;
use fabro_model::Model;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ModelTestMode {
#[default]
Basic,
Deep,
}
impl ModelTestMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Basic => "basic",
Self::Deep => "deep",
}
}
#[must_use]
pub const fn timeout_secs(self) -> u64 {
match self {
Self::Basic => 30,
Self::Deep => 90,
}
}
}
impl FromStr for ModelTestMode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"basic" => Ok(Self::Basic),
"deep" => Ok(Self::Deep),
other => Err(format!("invalid model test mode: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelTestStatus {
Ok,
Error,
}
impl ModelTestStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelTestOutcome {
pub status: ModelTestStatus,
pub error_message: Option<String>,
}
impl ModelTestOutcome {
#[must_use]
pub fn ok() -> Self {
Self {
status: ModelTestStatus::Ok,
error_message: None,
}
}
#[must_use]
pub fn error(message: impl Into<String>) -> Self {
Self {
status: ModelTestStatus::Error,
error_message: Some(message.into()),
}
}
}
pub async fn run_model_test(info: &Model, mode: ModelTestMode) -> ModelTestOutcome {
match mode {
ModelTestMode::Basic => run_basic_test(info).await,
ModelTestMode::Deep => run_deep_test(info).await,
}
}
async fn run_basic_test(info: &Model) -> ModelTestOutcome {
let params = GenerateParams::new(&info.id)
.provider(info.provider.as_str())
.prompt("Say OK")
.max_tokens(16);
let result = time::timeout(
Duration::from_secs(ModelTestMode::Basic.timeout_secs()),
generate::generate(params),
)
.await;
match result {
Ok(Ok(_)) => ModelTestOutcome::ok(),
Ok(Err(err)) => ModelTestOutcome::error(err.to_string()),
Err(_) => ModelTestOutcome::error("timeout (30s)"),
}
}
async fn run_deep_test(info: &Model) -> ModelTestOutcome {
let Some(params) = build_deep_test_params(info) else {
return ModelTestOutcome::error("model does not support tools");
};
let result = time::timeout(
Duration::from_secs(ModelTestMode::Deep.timeout_secs()),
generate::generate(params),
)
.await;
match result {
Ok(Ok(gen_result)) => match validate_deep_result(&gen_result) {
Ok(()) => ModelTestOutcome::ok(),
Err(message) => ModelTestOutcome::error(message),
},
Ok(Err(err)) => ModelTestOutcome::error(err.to_string()),
Err(_) => ModelTestOutcome::error("timeout (90s)"),
}
}
fn build_deep_test_params(info: &Model) -> Option<GenerateParams> {
if !info.features.tools {
return None;
}
let add_tool = Tool::active(
"add",
"Add two integers and return the sum",
serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "integer", "description": "First number" },
"b": { "type": "integer", "description": "Second number" }
},
"required": ["a", "b"]
}),
|args, _ctx| async move {
let a = args
.get("a")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let b = args
.get("b")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
Ok(serde_json::json!(a + b))
},
);
let mut params = GenerateParams::new(&info.id)
.provider(info.provider.as_str())
.prompt(
"Use the add tool twice: first add 15 and 27, then add that result to 42. \
Finally, tell me whether the grand total is even or odd and why.",
)
.tools(vec![add_tool])
.max_tool_rounds(5)
.max_tokens(1024);
if info.features.reasoning {
params = params.reasoning_effort(ReasoningEffort::High);
}
Some(params)
}
fn validate_deep_result(result: &GenerateResult) -> Result<(), String> {
if result.steps.len() < 2 {
return Err("model did not call tool".to_string());
}
if result.steps[0].tool_results.is_empty() {
return Err("tool was not executed".to_string());
}
if !result.response.text().contains("84") {
return Err("wrong answer".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{FinishReason, Message, Response, StepResult, Usage};
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, Provider};
fn test_model_with(features: ModelFeatures) -> Model {
Model {
id: "test-model".to_string(),
provider: Provider::Anthropic,
family: "test".to_string(),
display_name: "Test Model".to_string(),
limits: ModelLimits {
context_window: 200_000,
max_output: Some(8_000),
},
training: None,
knowledge_cutoff: None,
features,
costs: ModelCosts {
input_cost_per_mtok: None,
output_cost_per_mtok: None,
cache_input_cost_per_mtok: None,
},
estimated_output_tps: None,
aliases: vec![],
default: false,
}
}
fn response_with_text(text: &str) -> Response {
Response {
id: "resp_1".to_string(),
model: "test-model".to_string(),
provider: "anthropic".to_string(),
message: Message::assistant(text),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
raw: None,
warnings: vec![],
rate_limit: None,
}
}
#[tokio::test]
async fn run_model_test_deep_errors_when_model_lacks_tools() {
let info = test_model_with(ModelFeatures {
tools: false,
vision: false,
reasoning: true,
effort: true,
});
let outcome = run_model_test(&info, ModelTestMode::Deep).await;
assert_eq!(outcome.status, ModelTestStatus::Error);
assert_eq!(
outcome.error_message.as_deref(),
Some("model does not support tools")
);
}
#[test]
fn validate_deep_result_does_not_fail_only_for_missing_reasoning() {
let tool_results = vec![crate::types::ToolResult::success(
"call_1",
serde_json::json!(42),
)];
let first_step = StepResult {
response: response_with_text("tool step"),
tool_results: tool_results.clone(),
};
let second_step = StepResult {
response: response_with_text("84 is even"),
tool_results: vec![],
};
let result = GenerateResult {
response: response_with_text("84 is even"),
tool_results,
total_usage: Usage::default(),
steps: vec![first_step, second_step],
output: None,
};
assert_eq!(validate_deep_result(&result), Ok(()));
}
}

View file

@ -578,23 +578,6 @@ pub(crate) async fn list_query_history(
paginated_response(insights::history(), &pagination)
}
// ── Models ────────────────────────────────────────────────────────────
pub(crate) async fn list_models(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
paginated_response(
fabro_model::Catalog::builtin()
.list(None)
.into_iter()
.cloned()
.collect::<Vec<_>>(),
&pagination,
)
}
// ── Settings ───────────────────────────────────────────────────────────
pub(crate) async fn get_server_settings(

View file

@ -16,7 +16,8 @@ use axum::{Json, Router};
use axum_extra::extract::cookie::Key;
use bytes::Bytes;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate, generate_object};
use fabro_llm::generate::{GenerateParams, generate_object};
use fabro_llm::model_test::{ModelTestMode, run_model_test};
use fabro_llm::types::{
ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest,
Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage,
@ -32,7 +33,7 @@ use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::{Notify, OnceCell};
use tokio::task::spawn_blocking;
use tokio::time::{sleep, timeout};
use tokio::time::sleep;
use tokio_stream::StreamExt;
use tower::{ServiceExt, service_fn};
use ulid::Ulid;
@ -77,6 +78,24 @@ pub struct PaginationParams {
pub offset: u32,
}
#[derive(serde::Deserialize)]
struct ModelListParams {
#[serde(rename = "page[limit]", default = "default_page_limit")]
limit: u32,
#[serde(rename = "page[offset]", default)]
offset: u32,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
query: Option<String>,
}
#[derive(serde::Deserialize)]
struct ModelTestParams {
#[serde(default)]
mode: Option<String>,
}
#[derive(serde::Deserialize)]
struct EventListParams {
#[serde(default)]
@ -325,7 +344,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
)
.route("/insights/execute", post(demo::execute_query_stub))
.route("/insights/history", get(demo::list_query_history))
.route("/models", get(demo::list_models))
.route("/models", get(list_models))
.route("/models/{id}/test", post(test_model))
.route("/completions", post(create_completion))
.route("/settings", get(demo::get_server_settings))
@ -405,7 +424,7 @@ fn real_routes() -> Router<Arc<AppState>> {
)
.route("/insights/execute", post(not_implemented))
.route("/insights/history", get(not_implemented))
.route("/models", get(demo::list_models))
.route("/models", get(list_models))
.route("/models/{id}/test", post(test_model))
.route("/completions", post(create_completion))
.route("/settings", get(not_implemented))
@ -1818,49 +1837,85 @@ async fn unpause_run(
}
}
async fn list_models(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Query(params): Query<ModelListParams>,
) -> Response {
let provider = match params.provider.as_deref() {
Some(value) => match fabro_model::Provider::from_str(value) {
Ok(provider) => Some(provider),
Err(err) => return ApiError::new(StatusCode::BAD_REQUEST, err).into_response(),
},
None => None,
};
let query = params.query.as_ref().map(|value| value.to_lowercase());
let limit = params.limit.clamp(1, 100) as usize;
let offset = params.offset as usize;
let mut models = fabro_model::Catalog::builtin()
.list(provider)
.into_iter()
.filter(|model| match &query {
Some(query) => {
model.id.to_lowercase().contains(query)
|| model.display_name.to_lowercase().contains(query)
|| model
.aliases
.iter()
.any(|alias| alias.to_lowercase().contains(query))
}
None => true,
})
.cloned()
.collect::<Vec<_>>();
let has_more = models.len() > offset.saturating_add(limit);
let data = models.drain(offset..models.len().min(offset.saturating_add(limit)));
(
StatusCode::OK,
Json(serde_json::json!({
"data": data.collect::<Vec<_>>(),
"meta": { "has_more": has_more }
})),
)
.into_response()
}
async fn test_model(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<ModelTestParams>,
) -> Response {
let mode = match params.mode.as_deref() {
Some(value) => match ModelTestMode::from_str(value) {
Ok(mode) => mode,
Err(err) => return ApiError::new(StatusCode::BAD_REQUEST, err).into_response(),
},
None => ModelTestMode::Basic,
};
let Some(info) = fabro_model::Catalog::builtin().get(&id) else {
return ApiError::not_found(format!("Model not found: {id}")).into_response();
};
if state.dry_run() {
return Json(serde_json::json!({
"model_id": id,
"model_id": info.id,
"status": "ok",
}))
.into_response();
}
let params = GenerateParams::new(&info.id)
.provider(info.provider.as_str())
.prompt("Say OK")
.max_tokens(16);
let result = timeout(Duration::from_secs(30), generate(params)).await;
match result {
Ok(Ok(_)) => Json(serde_json::json!({
"model_id": id,
"status": "ok",
}))
.into_response(),
Ok(Err(e)) => Json(serde_json::json!({
"model_id": id,
"status": "error",
"error_message": e.to_string(),
}))
.into_response(),
Err(_) => Json(serde_json::json!({
"model_id": id,
"status": "error",
"error_message": "timeout (30s)",
}))
.into_response(),
}
let outcome = run_model_test(info, mode).await;
Json(serde_json::json!({
"model_id": info.id,
"status": outcome.status.as_str(),
"error_message": outcome.error_message,
}))
.into_response()
}
fn finish_reason_to_api_stop_reason(reason: &FinishReason) -> String {
@ -2346,6 +2401,109 @@ mod tests {
assert!(body["status"] == "ok" || body["status"] == "error");
}
#[tokio::test]
async fn test_model_alias_returns_canonical_model_id() {
let state = create_app_state_with_options(dry_run_settings(), 5);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri(api("/models/sonnet/test"))
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["model_id"], "claude-sonnet-4-6");
assert_eq!(body["status"], "ok");
}
#[tokio::test]
async fn test_model_invalid_mode_returns_400() {
let state = create_app_state_with_options(dry_run_settings(), 5);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri(api("/models/claude-opus-4-6/test?mode=bogus"))
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn list_models_filters_by_provider() {
let app = test_app_with();
let req = Request::builder()
.method("GET")
.uri(api("/models?provider=anthropic"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let models = body["data"].as_array().unwrap();
assert!(!models.is_empty());
assert!(
models
.iter()
.all(|model| model["provider"] == serde_json::Value::String("anthropic".into()))
);
}
#[tokio::test]
async fn list_models_filters_by_query_across_aliases() {
let app = test_app_with();
let req = Request::builder()
.method("GET")
.uri(api("/models?query=codex"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let model_ids = body["data"]
.as_array()
.unwrap()
.iter()
.map(|model| model["id"].as_str().unwrap().to_string())
.collect::<Vec<_>>();
assert_eq!(
model_ids,
vec![
"gpt-5.2-codex".to_string(),
"gpt-5.3-codex".to_string(),
"gpt-5.3-codex-spark".to_string()
]
);
}
#[tokio::test]
async fn list_models_invalid_provider_returns_400() {
let app = test_app_with();
let req = Request::builder()
.method("GET")
.uri(api("/models?provider=not-a-provider"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn auth_login_github_redirects_to_github() {
let mut settings = Settings::default();

View file

@ -561,13 +561,6 @@ impl TestContext {
cmd
}
/// Build a `llm` subcommand.
pub fn llm(&self) -> Command {
let mut cmd = self.command();
cmd.arg("llm");
cmd
}
/// Build a `settings` subcommand.
pub fn settings(&self) -> Command {
let mut cmd = self.command();

View file

@ -86,6 +86,7 @@ models/model-costs.ts
models/model-features.ts
models/model-limits.ts
models/model-reference.ts
models/model-test-mode.ts
models/model-test-result.ts
models/model.ts
models/node-state.ts

View file

@ -24,6 +24,8 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { ModelTestMode } from '../models';
// @ts-ignore
import type { ModelTestResult } from '../models';
// @ts-ignore
import type { PaginatedModelList } from '../models';
@ -35,12 +37,14 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio
/**
* Returns a paginated list of available LLM models from the built-in catalog.
* @summary List Models
* @param {string} [provider] Filter models by provider name. Invalid values return &#x60;400&#x60;.
* @param {string} [query] Case-insensitive substring search across &#x60;id&#x60;, &#x60;display_name&#x60;, and &#x60;aliases&#x60;.
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listModels: async (pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listModels: async (provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/models`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -60,6 +64,14 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (provider !== undefined) {
localVarQueryParameter['provider'] = provider;
}
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
if (pageLimit !== undefined) {
localVarQueryParameter['page[limit]'] = pageLimit;
}
@ -83,10 +95,11 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio
* Tests a model by sending a simple prompt and reporting pass/fail.
* @summary Test Model
* @param {string} id The model identifier.
* @param {ModelTestMode} [mode] Test mode for the single-model test endpoint. Defaults to &#x60;basic&#x60;.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
testModel: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
testModel: async (id: string, mode?: ModelTestMode, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('testModel', 'id', id)
const localVarPath = `/api/v1/models/{id}/test`
@ -109,6 +122,10 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (mode !== undefined) {
localVarQueryParameter['mode'] = mode;
}
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -132,13 +149,15 @@ export const ModelsApiFp = function(configuration?: Configuration) {
/**
* Returns a paginated list of available LLM models from the built-in catalog.
* @summary List Models
* @param {string} [provider] Filter models by provider name. Invalid values return &#x60;400&#x60;.
* @param {string} [query] Case-insensitive substring search across &#x60;id&#x60;, &#x60;display_name&#x60;, and &#x60;aliases&#x60;.
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listModels(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedModelList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listModels(pageLimit, pageOffset, options);
async listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedModelList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listModels(provider, query, pageLimit, pageOffset, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ModelsApi.listModels']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -147,11 +166,12 @@ export const ModelsApiFp = function(configuration?: Configuration) {
* Tests a model by sending a simple prompt and reporting pass/fail.
* @summary Test Model
* @param {string} id The model identifier.
* @param {ModelTestMode} [mode] Test mode for the single-model test endpoint. Defaults to &#x60;basic&#x60;.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async testModel(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ModelTestResult>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.testModel(id, options);
async testModel(id: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ModelTestResult>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.testModel(id, mode, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ModelsApi.testModel']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -168,23 +188,26 @@ export const ModelsApiFactory = function (configuration?: Configuration, basePat
/**
* Returns a paginated list of available LLM models from the built-in catalog.
* @summary List Models
* @param {string} [provider] Filter models by provider name. Invalid values return &#x60;400&#x60;.
* @param {string} [query] Case-insensitive substring search across &#x60;id&#x60;, &#x60;display_name&#x60;, and &#x60;aliases&#x60;.
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listModels(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedModelList> {
return localVarFp.listModels(pageLimit, pageOffset, options).then((request) => request(axios, basePath));
listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedModelList> {
return localVarFp.listModels(provider, query, pageLimit, pageOffset, options).then((request) => request(axios, basePath));
},
/**
* Tests a model by sending a simple prompt and reporting pass/fail.
* @summary Test Model
* @param {string} id The model identifier.
* @param {ModelTestMode} [mode] Test mode for the single-model test endpoint. Defaults to &#x60;basic&#x60;.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
testModel(id: string, options?: RawAxiosRequestConfig): AxiosPromise<ModelTestResult> {
return localVarFp.testModel(id, options).then((request) => request(axios, basePath));
testModel(id: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig): AxiosPromise<ModelTestResult> {
return localVarFp.testModel(id, mode, options).then((request) => request(axios, basePath));
},
};
};
@ -196,24 +219,27 @@ export class ModelsApi extends BaseAPI {
/**
* Returns a paginated list of available LLM models from the built-in catalog.
* @summary List Models
* @param {string} [provider] Filter models by provider name. Invalid values return &#x60;400&#x60;.
* @param {string} [query] Case-insensitive substring search across &#x60;id&#x60;, &#x60;display_name&#x60;, and &#x60;aliases&#x60;.
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listModels(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
return ModelsApiFp(this.configuration).listModels(pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath));
public listModels(provider?: string, query?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
return ModelsApiFp(this.configuration).listModels(provider, query, pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath));
}
/**
* Tests a model by sending a simple prompt and reporting pass/fail.
* @summary Test Model
* @param {string} id The model identifier.
* @param {ModelTestMode} [mode] Test mode for the single-model test endpoint. Defaults to &#x60;basic&#x60;.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public testModel(id: string, options?: RawAxiosRequestConfig) {
return ModelsApiFp(this.configuration).testModel(id, options).then((request) => request(this.axios, this.basePath));
public testModel(id: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig) {
return ModelsApiFp(this.configuration).testModel(id, mode, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -67,6 +67,7 @@ export * from './model-costs';
export * from './model-features';
export * from './model-limits';
export * from './model-reference';
export * from './model-test-mode';
export * from './model-test-result';
export * from './node-state';
export * from './node-status-record';

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Single-model test mode.
*/
export const ModelTestMode = {
BASIC: 'basic',
DEEP: 'deep'
} as const;
export type ModelTestMode = typeof ModelTestMode[keyof typeof ModelTestMode];

View file

@ -15,7 +15,7 @@
/**
* Result of testing a model with a simple prompt.
* Result of testing a model in `basic` or `deep` mode.
*/
export interface ModelTestResult {
/**