mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
refactor(cli): make model commands server-canonical
This commit is contained in:
parent
5b43bd7837
commit
d4b9331d2e
28 changed files with 986 additions and 1128 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
284
lib/crates/fabro-llm/src/model_test.rs
Normal file
284
lib/crates/fabro-llm/src/model_test.rs
Normal 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(()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `400`.
|
||||
* @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`.
|
||||
* @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 `basic`.
|
||||
* @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 `400`.
|
||||
* @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`.
|
||||
* @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 `basic`.
|
||||
* @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 `400`.
|
||||
* @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`.
|
||||
* @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 `basic`.
|
||||
* @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 `400`.
|
||||
* @param {string} [query] Case-insensitive substring search across `id`, `display_name`, and `aliases`.
|
||||
* @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 `basic`.
|
||||
* @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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
29
lib/packages/fabro-api-client/src/models/model-test-mode.ts
Normal file
29
lib/packages/fabro-api-client/src/models/model-test-mode.ts
Normal 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];
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue