mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Resolve the model catalog table conflict in docs/public/core-concepts/models.mdx by keeping both changes: this branch's `kimi` -> `moonshot` provider rename for the Kimi rows, and main's new DeepSeek V4 rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
803 lines
29 KiB
Rust
803 lines
29 KiB
Rust
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "Live provider integration tests read required API keys from process env."
|
|
)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use fabro_auth::ApiCredential;
|
|
use fabro_llm::client::Client;
|
|
use fabro_llm::error::ProviderErrorKind;
|
|
use fabro_llm::model_test::{ModelTestStatus, run_model_test};
|
|
use fabro_llm::provider::ProviderAdapter;
|
|
use fabro_llm::providers::{
|
|
AnthropicAdapter, BedrockAdapter, GeminiAdapter, OpenAiAdapter, OpenAiCompatibleAdapter,
|
|
};
|
|
use fabro_llm::types::{
|
|
CostSource, FinishReason, Message, ReasoningEffort, Request, ToolChoice, ToolDefinition,
|
|
};
|
|
use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings};
|
|
use fabro_model::{Catalog, ModelTestMode, ProviderId};
|
|
use fabro_static::EnvVars;
|
|
|
|
fn make_request(model: &str) -> Request {
|
|
Request {
|
|
model: model.to_string(),
|
|
messages: vec![Message::user("Say hello in exactly one word")],
|
|
provider: None,
|
|
tools: None,
|
|
tool_choice: None,
|
|
response_format: None,
|
|
temperature: Some(0.0),
|
|
top_p: None,
|
|
max_tokens: Some(50),
|
|
stop_sequences: None,
|
|
reasoning_effort: None,
|
|
speed: None,
|
|
metadata: None,
|
|
provider_options: None,
|
|
}
|
|
}
|
|
|
|
/// Build the built-in catalog with `provider` enabled, plus an operator base
|
|
/// URL for providers such as Modal that do not ship one.
|
|
fn enabled_provider_catalog(provider: &ProviderId, base_url: Option<String>) -> Arc<Catalog> {
|
|
let mut settings = LlmCatalogSettings::default();
|
|
settings
|
|
.providers
|
|
.insert(provider.to_string(), ProviderCatalogSettings {
|
|
enabled: Some(true),
|
|
base_url,
|
|
..ProviderCatalogSettings::default()
|
|
});
|
|
Arc::new(
|
|
Catalog::from_builtin_with_overrides(&settings)
|
|
.unwrap_or_else(|err| panic!("enabled {provider} catalog should build: {err}")),
|
|
)
|
|
}
|
|
|
|
/// Drive the shared deep tool round trip for one catalog offering.
|
|
async fn assert_deep_tool_round_trip(
|
|
catalog: &Arc<Catalog>,
|
|
provider: &ProviderId,
|
|
model_id: &str,
|
|
credential: ApiCredential,
|
|
) {
|
|
let client = Arc::new(
|
|
Client::from_credentials(vec![credential], Arc::clone(catalog))
|
|
.await
|
|
.unwrap_or_else(|err| panic!("{provider} client should build from the catalog: {err}")),
|
|
);
|
|
let model = catalog
|
|
.get_on_provider(provider, model_id)
|
|
.unwrap_or_else(|| panic!("{provider} {model_id} should be present"));
|
|
|
|
let outcome = run_model_test(model, ModelTestMode::Deep, client).await;
|
|
assert_eq!(
|
|
outcome.status,
|
|
ModelTestStatus::Ok,
|
|
"{provider} {model_id} deep test failed: {:?}",
|
|
outcome.error_message
|
|
);
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
|
|
async fn anthropic_complete() {
|
|
let api_key = std::env::var(EnvVars::ANTHROPIC_API_KEY).expect("ANTHROPIC_API_KEY must be set");
|
|
let adapter = AnthropicAdapter::new(api_key);
|
|
let request = make_request("claude-haiku-4-5");
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert_eq!(response.finish_reason, FinishReason::Stop);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "anthropic");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin, live("OPENAI_API_KEY"))]
|
|
async fn openai_complete() {
|
|
let (base_url, api_key) = fabro_test::e2e_openai!();
|
|
let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url);
|
|
let request = Request {
|
|
temperature: None,
|
|
..make_request("gpt-5.2")
|
|
};
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert_eq!(response.finish_reason, FinishReason::Stop);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "openai");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin, live("OPENAI_API_KEY"))]
|
|
async fn openai_gpt_5_3_codex_complete() {
|
|
let (base_url, api_key) = fabro_test::e2e_openai!();
|
|
let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url);
|
|
let request = make_request("gpt-5.3-codex");
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "openai");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("OPENAI_API_KEY"))]
|
|
async fn openai_gpt_5_5_complete() {
|
|
let api_key = std::env::var(EnvVars::OPENAI_API_KEY).expect("OPENAI_API_KEY must be set");
|
|
let adapter = OpenAiAdapter::new(api_key);
|
|
let request = Request {
|
|
temperature: None,
|
|
..make_request("gpt-5.5")
|
|
};
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "openai");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("OPENAI_GPT_5_5_PRO_API_KEY"))]
|
|
async fn openai_gpt_5_5_pro_complete() {
|
|
let api_key = std::env::var("OPENAI_GPT_5_5_PRO_API_KEY")
|
|
.expect("OPENAI_GPT_5_5_PRO_API_KEY must be set");
|
|
let adapter = OpenAiAdapter::new(api_key);
|
|
let request = Request {
|
|
temperature: None,
|
|
..make_request("gpt-5.5-pro")
|
|
};
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "openai");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("KIMI_API_KEY"))]
|
|
async fn kimi_k3_reasoning_tool_round_trip() {
|
|
let api_key = std::env::var(EnvVars::KIMI_API_KEY).expect("KIMI_API_KEY must be set");
|
|
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.moonshot.ai/v1")
|
|
.with_name("moonshot")
|
|
.with_catalog(Arc::new(Catalog::from_builtin().unwrap()));
|
|
let tool = ToolDefinition::function(
|
|
"multiply",
|
|
"Multiply two integers",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"a": {"type": "integer"},
|
|
"b": {"type": "integer"}
|
|
},
|
|
"required": ["a", "b"]
|
|
}),
|
|
);
|
|
let request = Request {
|
|
model: "kimi-k3".to_string(),
|
|
messages: vec![Message::user(
|
|
"Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.",
|
|
)],
|
|
tools: Some(vec![tool]),
|
|
tool_choice: Some(ToolChoice::Required),
|
|
temperature: Some(0.0),
|
|
max_tokens: Some(4096),
|
|
reasoning_effort: Some(ReasoningEffort::Low),
|
|
..make_request("kimi-k3")
|
|
};
|
|
|
|
let tool_response = adapter.complete(&request).await.unwrap();
|
|
assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls);
|
|
assert!(
|
|
tool_response.reasoning().is_some(),
|
|
"K3 should return reasoning content before its tool call"
|
|
);
|
|
let tool_call = tool_response
|
|
.tool_calls()
|
|
.into_iter()
|
|
.next()
|
|
.expect("K3 should call the required tool");
|
|
assert_eq!(tool_call.name, "multiply");
|
|
|
|
let mut messages = request.messages.clone();
|
|
messages.push(tool_response.message);
|
|
messages.push(Message::tool_result(
|
|
tool_call.id,
|
|
serde_json::json!({"product": 437}),
|
|
false,
|
|
));
|
|
let final_request = Request {
|
|
model: "kimi-k3".to_string(),
|
|
messages,
|
|
temperature: Some(0.0),
|
|
max_tokens: Some(2048),
|
|
reasoning_effort: Some(ReasoningEffort::Low),
|
|
..make_request("kimi-k3")
|
|
};
|
|
|
|
let final_response = adapter.complete(&final_request).await.unwrap();
|
|
assert_eq!(final_response.finish_reason, FinishReason::Stop);
|
|
assert!(
|
|
final_response.text().contains("437"),
|
|
"K3 should incorporate the replayed tool result"
|
|
);
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(twin)]
|
|
async fn openai_server_error() {
|
|
let (base_url, api_key) = fabro_test::e2e_openai!();
|
|
let admin_url = base_url
|
|
.strip_suffix("/v1")
|
|
.expect("OpenAI base URL should end with /v1");
|
|
|
|
fabro_test::test_http_client()
|
|
.post(format!("{admin_url}/__admin/scenarios"))
|
|
.bearer_auth(&api_key)
|
|
.json(&serde_json::json!({
|
|
"scenarios": [{
|
|
"matcher": { "endpoint": "responses" },
|
|
"script": {
|
|
"kind": "error",
|
|
"status": 500,
|
|
"message": "internal server error",
|
|
"error_type": "server_error",
|
|
"code": "server_error"
|
|
}
|
|
}]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url);
|
|
let request = make_request("gpt-4o-mini");
|
|
let err = adapter.complete(&request).await.unwrap_err();
|
|
|
|
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::Server));
|
|
assert_eq!(err.status_code(), Some(500));
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("GEMINI_API_KEY"))]
|
|
async fn gemini_complete() {
|
|
let api_key = std::env::var(EnvVars::GEMINI_API_KEY).expect("GEMINI_API_KEY must be set");
|
|
let adapter = GeminiAdapter::new(api_key);
|
|
let request = make_request("gemini-2.5-flash");
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert_eq!(response.finish_reason, FinishReason::Stop);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "gemini");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("AWS_BEARER_TOKEN_BEDROCK"))]
|
|
async fn bedrock_complete_with_api_key() {
|
|
let token = std::env::var(EnvVars::AWS_BEARER_TOKEN_BEDROCK)
|
|
.expect("AWS_BEARER_TOKEN_BEDROCK must be set");
|
|
let adapter =
|
|
BedrockAdapter::new_api_key(token, "https://bedrock-runtime.us-east-1.amazonaws.com")
|
|
.unwrap()
|
|
.with_name("bedrock");
|
|
// Amazon Nova: first-party, no Anthropic-approval gate and no third-party
|
|
// marketplace subscription, so this runs on any Bedrock-enabled account.
|
|
let request = make_request("us.amazon.nova-2-lite-v1:0");
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "bedrock");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("AWS_ACCESS_KEY_ID"))]
|
|
async fn bedrock_complete_with_sigv4() {
|
|
let adapter = BedrockAdapter::new_sigv4("https://bedrock-runtime.us-east-1.amazonaws.com")
|
|
.unwrap()
|
|
.with_name("bedrock");
|
|
// First-party Nova — see bedrock_complete_with_api_key for why.
|
|
let request = make_request("us.amazon.nova-2-lite-v1:0");
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert_eq!(response.provider, "bedrock");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("AWS_BEARER_TOKEN_BEDROCK"))]
|
|
async fn bedrock_openai_frontier_complete() {
|
|
let token = std::env::var(EnvVars::AWS_BEARER_TOKEN_BEDROCK)
|
|
.expect("AWS_BEARER_TOKEN_BEDROCK must be set");
|
|
// GPT-5.x on Bedrock is the bedrock-mantle Responses surface: the plain
|
|
// openai adapter pointed at the mantle endpoint with the Bedrock key as
|
|
// the bearer token.
|
|
let adapter = OpenAiAdapter::new(token)
|
|
.with_base_url("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
|
|
.with_name("bedrock-openai");
|
|
let request = Request {
|
|
temperature: None,
|
|
..make_request("openai.gpt-5.5")
|
|
};
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert_eq!(response.provider, "bedrock-openai");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("POOLSIDE_API_KEY"))]
|
|
async fn poolside_laguna_xs_deep_tool_round_trip() {
|
|
let api_key = std::env::var(EnvVars::POOLSIDE_API_KEY).expect("POOLSIDE_API_KEY must be set");
|
|
let provider = ProviderId::new("poolside");
|
|
let catalog = enabled_provider_catalog(&provider, None);
|
|
let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
|
|
.expect("Poolside credential should resolve from the catalog");
|
|
|
|
assert_deep_tool_round_trip(&catalog, &provider, "laguna-xs-2.1", credential).await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))]
|
|
async fn openrouter_complete() {
|
|
let api_key =
|
|
std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set");
|
|
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1")
|
|
.with_name("openrouter");
|
|
let request = make_request("deepseek/deepseek-v4-flash-0731");
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "openrouter");
|
|
assert!(
|
|
response.cost_usd.is_some(),
|
|
"OpenRouter responses should carry an authoritative usage.cost",
|
|
);
|
|
assert_eq!(response.cost_source, Some(CostSource::Authoritative));
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("ZAI_API_KEY"))]
|
|
async fn zai_glm_5_2_reasoning_tool_round_trip() {
|
|
let api_key = std::env::var(EnvVars::ZAI_API_KEY).expect("ZAI_API_KEY must be set");
|
|
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.z.ai/api/coding/paas/v4")
|
|
.with_name("zai")
|
|
.with_catalog(Arc::new(Catalog::from_builtin().unwrap()));
|
|
let tool = ToolDefinition::function(
|
|
"multiply",
|
|
"Multiply two integers",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"a": {"type": "integer"},
|
|
"b": {"type": "integer"}
|
|
},
|
|
"required": ["a", "b"]
|
|
}),
|
|
);
|
|
let request = Request {
|
|
model: "glm-5.2".to_string(),
|
|
messages: vec![Message::user(
|
|
"Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.",
|
|
)],
|
|
tools: Some(vec![tool]),
|
|
tool_choice: Some(ToolChoice::Required),
|
|
temperature: Some(0.0),
|
|
max_tokens: Some(4096),
|
|
reasoning_effort: Some(ReasoningEffort::High),
|
|
..make_request("glm-5.2")
|
|
};
|
|
|
|
let tool_response = adapter.complete(&request).await.unwrap();
|
|
assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls);
|
|
let raw_message_keys = tool_response
|
|
.raw
|
|
.as_ref()
|
|
.and_then(|raw| raw.pointer("/choices/0/message"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.map(|message| message.keys().cloned().collect::<Vec<_>>())
|
|
.unwrap_or_default();
|
|
assert!(
|
|
tool_response.reasoning().is_some(),
|
|
"GLM 5.2 should return reasoning content before its tool call; raw message keys: \
|
|
{raw_message_keys:?}"
|
|
);
|
|
let tool_call = tool_response
|
|
.tool_calls()
|
|
.into_iter()
|
|
.next()
|
|
.expect("GLM 5.2 should call the required tool");
|
|
assert_eq!(tool_call.name, "multiply");
|
|
|
|
let mut messages = request.messages.clone();
|
|
messages.push(tool_response.message);
|
|
messages.push(Message::tool_result(
|
|
tool_call.id,
|
|
serde_json::json!({"product": 437}),
|
|
false,
|
|
));
|
|
let final_request = Request {
|
|
model: "glm-5.2".to_string(),
|
|
messages,
|
|
temperature: Some(0.0),
|
|
max_tokens: Some(2048),
|
|
reasoning_effort: Some(ReasoningEffort::High),
|
|
..make_request("glm-5.2")
|
|
};
|
|
|
|
let final_response = adapter.complete(&final_request).await.unwrap();
|
|
assert_eq!(final_response.finish_reason, FinishReason::Stop);
|
|
assert!(
|
|
final_response.text().contains("437"),
|
|
"GLM 5.2 should incorporate the replayed tool result"
|
|
);
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))]
|
|
async fn openrouter_glm_5_2_reasoning_tool_round_trip() {
|
|
let api_key =
|
|
std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set");
|
|
let overrides: LlmCatalogSettings = toml::from_str(
|
|
r"
|
|
[providers.openrouter]
|
|
enabled = true
|
|
",
|
|
)
|
|
.expect("OpenRouter catalog override should parse");
|
|
let catalog = Catalog::from_builtin_with_overrides(&overrides)
|
|
.expect("enabled OpenRouter catalog should build");
|
|
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1")
|
|
.with_name("openrouter")
|
|
.with_catalog(Arc::new(catalog));
|
|
let tool = ToolDefinition::function(
|
|
"multiply",
|
|
"Multiply two integers",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"a": {"type": "integer"},
|
|
"b": {"type": "integer"}
|
|
},
|
|
"required": ["a", "b"]
|
|
}),
|
|
);
|
|
let request = Request {
|
|
model: "z-ai/glm-5.2".to_string(),
|
|
messages: vec![Message::user(
|
|
"Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.",
|
|
)],
|
|
tools: Some(vec![tool]),
|
|
tool_choice: Some(ToolChoice::Required),
|
|
temperature: Some(0.0),
|
|
max_tokens: Some(4096),
|
|
reasoning_effort: Some(ReasoningEffort::High),
|
|
..make_request("z-ai/glm-5.2")
|
|
};
|
|
|
|
let tool_response = adapter.complete(&request).await.unwrap();
|
|
assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls);
|
|
let raw_message_keys = tool_response
|
|
.raw
|
|
.as_ref()
|
|
.and_then(|raw| raw.pointer("/choices/0/message"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.map(|message| message.keys().cloned().collect::<Vec<_>>())
|
|
.unwrap_or_default();
|
|
assert!(
|
|
tool_response.reasoning().is_some(),
|
|
"GLM 5.2 should return reasoning content before its tool call; raw message keys: \
|
|
{raw_message_keys:?}"
|
|
);
|
|
assert_eq!(tool_response.cost_source, Some(CostSource::Authoritative));
|
|
let tool_call = tool_response
|
|
.tool_calls()
|
|
.into_iter()
|
|
.next()
|
|
.expect("GLM 5.2 should call the required tool");
|
|
assert_eq!(tool_call.name, "multiply");
|
|
|
|
let mut messages = request.messages.clone();
|
|
messages.push(tool_response.message);
|
|
messages.push(Message::tool_result(
|
|
tool_call.id,
|
|
serde_json::json!({"product": 437}),
|
|
false,
|
|
));
|
|
let final_request = Request {
|
|
model: "z-ai/glm-5.2".to_string(),
|
|
messages,
|
|
temperature: Some(0.0),
|
|
max_tokens: Some(2048),
|
|
reasoning_effort: Some(ReasoningEffort::High),
|
|
..make_request("z-ai/glm-5.2")
|
|
};
|
|
|
|
let final_response = adapter.complete(&final_request).await.unwrap();
|
|
assert_eq!(final_response.finish_reason, FinishReason::Stop);
|
|
assert!(
|
|
final_response.text().contains("437"),
|
|
"GLM 5.2 should incorporate the replayed tool result"
|
|
);
|
|
assert_eq!(final_response.cost_source, Some(CostSource::Authoritative));
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))]
|
|
async fn openrouter_poolside_laguna_complete() {
|
|
let api_key =
|
|
std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set");
|
|
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1")
|
|
.with_name("openrouter");
|
|
let request = make_request("poolside/laguna-xs-2.1");
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "openrouter");
|
|
assert!(
|
|
response.cost_usd.is_some(),
|
|
"OpenRouter responses should carry an authoritative usage.cost",
|
|
);
|
|
assert_eq!(response.cost_source, Some(CostSource::Authoritative));
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))]
|
|
async fn fireworks_complete() {
|
|
let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set");
|
|
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.fireworks.ai/inference/v1")
|
|
.with_name("fireworks");
|
|
// gpt-oss models spend reasoning tokens before the final text, so the
|
|
// completion budget must cover both.
|
|
let request = Request {
|
|
max_tokens: Some(2048),
|
|
..make_request("accounts/fireworks/models/gpt-oss-20b")
|
|
};
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0);
|
|
assert_eq!(response.provider, "fireworks");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))]
|
|
async fn deepseek_complete() {
|
|
let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set");
|
|
let adapter =
|
|
OpenAiCompatibleAdapter::new(api_key, "https://api.deepseek.com").with_name("deepseek");
|
|
let request = Request {
|
|
// Thinking mode is enabled by default and shares this budget with the
|
|
// visible answer.
|
|
max_tokens: Some(1024),
|
|
..make_request("deepseek-v4-flash")
|
|
};
|
|
let response = adapter.complete(&request).await.unwrap();
|
|
|
|
assert!(
|
|
!response.text().is_empty(),
|
|
"response text should not be empty"
|
|
);
|
|
assert!(response.usage.input_tokens > 0);
|
|
assert!(response.usage.output_tokens > 0 || response.usage.reasoning_tokens > 0);
|
|
assert_eq!(response.provider, "deepseek");
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))]
|
|
async fn deepseek_v4_flash_deep_tool_round_trip() {
|
|
let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set");
|
|
let provider = ProviderId::new("deepseek");
|
|
let catalog = enabled_provider_catalog(&provider, None);
|
|
let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
|
|
.expect("DeepSeek credential should resolve from the catalog");
|
|
|
|
assert_deep_tool_round_trip(&catalog, &provider, "deepseek-v4-flash", credential).await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))]
|
|
async fn fireworks_kimi_k2_7_code_deep_tool_round_trip() {
|
|
let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set");
|
|
let provider = ProviderId::new("fireworks");
|
|
let catalog = enabled_provider_catalog(&provider, None);
|
|
let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
|
|
.expect("Fireworks credential should resolve from the catalog");
|
|
|
|
assert_deep_tool_round_trip(&catalog, &provider, "kimi-k2.7-code", credential).await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))]
|
|
async fn openrouter_kimi_k3_deep_tool_round_trip() {
|
|
let api_key =
|
|
std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set");
|
|
let provider = ProviderId::new("openrouter");
|
|
let catalog = enabled_provider_catalog(&provider, None);
|
|
let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog)
|
|
.expect("OpenRouter credential should resolve from the catalog");
|
|
|
|
assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3", credential).await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(
|
|
live("MODAL_KIMI_K3_BASE_URL"),
|
|
live("MODAL_TOKEN_ID"),
|
|
live("MODAL_TOKEN_SECRET")
|
|
)]
|
|
async fn modal_kimi_k3_deep_tool_round_trip() {
|
|
let base_url =
|
|
std::env::var("MODAL_KIMI_K3_BASE_URL").expect("MODAL_KIMI_K3_BASE_URL must be set");
|
|
let token_id = std::env::var(EnvVars::MODAL_TOKEN_ID).expect("MODAL_TOKEN_ID must be set");
|
|
let token_secret =
|
|
std::env::var(EnvVars::MODAL_TOKEN_SECRET).expect("MODAL_TOKEN_SECRET must be set");
|
|
let provider = ProviderId::new("modal");
|
|
let catalog = enabled_provider_catalog(&provider, Some(base_url));
|
|
let credential = ApiCredential::with_extra_headers(
|
|
provider.clone(),
|
|
HashMap::from([
|
|
("Modal-Key".to_string(), token_id),
|
|
("Modal-Secret".to_string(), token_secret),
|
|
]),
|
|
);
|
|
|
|
assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3", credential).await;
|
|
}
|
|
|
|
async fn run_multi_turn_cache_test(
|
|
adapter: &dyn ProviderAdapter,
|
|
model: &str,
|
|
min_cache_ratio: f64,
|
|
temperature: Option<f64>,
|
|
) {
|
|
// Claude Haiku 4.5 requires 4096 tokens minimum for prompt caching.
|
|
// Each repeat is ~78 tokens; 70 repeats ≈ 5460 tokens, safely above the
|
|
// threshold.
|
|
let padding = "This is a detailed context paragraph that provides background information \
|
|
about the conversation. It contains various facts and details that the model should \
|
|
remember throughout the multi-turn interaction. The purpose of this padding is to \
|
|
ensure the system prompt exceeds the minimum cache threshold for the provider. \
|
|
We include information about mathematics, science, history, and general knowledge. \
|
|
The model should use this context when answering questions. "
|
|
.repeat(70);
|
|
|
|
let system_message = Message::system(format!(
|
|
"You are a helpful math assistant. Answer briefly.\n\n{padding}"
|
|
));
|
|
|
|
let questions = [
|
|
"What is 1+1?",
|
|
"What is 2+2?",
|
|
"What is 3+3?",
|
|
"What is 4+4?",
|
|
"What is 5+5?",
|
|
"What is 6+6?",
|
|
];
|
|
|
|
let mut messages = vec![system_message, Message::user(questions[0])];
|
|
let mut best_cache_ratio = 0.0_f64;
|
|
|
|
for turn in 0..6 {
|
|
let request = Request {
|
|
model: model.to_string(),
|
|
messages: messages.clone(),
|
|
provider: None,
|
|
tools: None,
|
|
tool_choice: None,
|
|
response_format: None,
|
|
temperature,
|
|
top_p: None,
|
|
max_tokens: Some(100),
|
|
stop_sequences: None,
|
|
reasoning_effort: None,
|
|
speed: None,
|
|
metadata: None,
|
|
provider_options: None,
|
|
};
|
|
|
|
let response = adapter
|
|
.complete(&request)
|
|
.await
|
|
.expect("provider adapter should return a response");
|
|
let text = response.text();
|
|
assert!(
|
|
!text.is_empty(),
|
|
"response text should not be empty on turn {turn}"
|
|
);
|
|
|
|
let cache_read = response.usage.cache_read_tokens as f64;
|
|
let input = response.usage.input_tokens as f64;
|
|
let ratio = cache_read / input;
|
|
best_cache_ratio = best_cache_ratio.max(ratio);
|
|
|
|
messages.push(Message::assistant(text));
|
|
if turn < 5 {
|
|
messages.push(Message::user(questions[turn + 1]));
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
best_cache_ratio >= min_cache_ratio,
|
|
"best cache ratio {best_cache_ratio:.3} should be at least {min_cache_ratio} across all turns"
|
|
);
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
|
|
async fn anthropic_multi_turn_cache() {
|
|
let api_key = std::env::var(EnvVars::ANTHROPIC_API_KEY).expect("ANTHROPIC_API_KEY must be set");
|
|
let adapter =
|
|
AnthropicAdapter::new(api_key).with_catalog(Arc::new(Catalog::from_builtin().unwrap()));
|
|
run_multi_turn_cache_test(&adapter, "claude-haiku-4-5", 0.5, Some(0.0)).await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("OPENAI_API_KEY"))]
|
|
async fn openai_multi_turn_cache() {
|
|
let api_key = std::env::var(EnvVars::OPENAI_API_KEY).expect("OPENAI_API_KEY must be set");
|
|
let adapter = OpenAiAdapter::new(api_key);
|
|
run_multi_turn_cache_test(&adapter, "gpt-5.2", 0.5, None).await;
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("GEMINI_API_KEY"))]
|
|
async fn gemini_multi_turn_cache() {
|
|
let api_key = std::env::var(EnvVars::GEMINI_API_KEY).expect("GEMINI_API_KEY must be set");
|
|
let adapter = GeminiAdapter::new(api_key);
|
|
run_multi_turn_cache_test(&adapter, "gemini-2.5-flash", 0.5, Some(0.0)).await;
|
|
}
|
|
|
|
/// Prompt caching for Claude routed through OpenRouter: the catalog row opts
|
|
/// into explicit `cache_control` breakpoints, and OpenRouter must forward
|
|
/// them to Anthropic for cache reads to appear. Guards the end-to-end
|
|
/// passthrough the wire tests can't see.
|
|
#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))]
|
|
async fn openrouter_claude_multi_turn_cache() {
|
|
let api_key =
|
|
std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set");
|
|
let overrides: LlmCatalogSettings = toml::from_str(
|
|
r"
|
|
[providers.openrouter]
|
|
enabled = true
|
|
",
|
|
)
|
|
.expect("OpenRouter catalog override should parse");
|
|
let catalog = Catalog::from_builtin_with_overrides(&overrides)
|
|
.expect("enabled OpenRouter catalog should build");
|
|
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1")
|
|
.with_name("openrouter")
|
|
.with_catalog(Arc::new(catalog));
|
|
run_multi_turn_cache_test(&adapter, "claude-haiku-4-5", 0.5, Some(0.0)).await;
|
|
}
|