Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "nomi-providers"
|
||||
description = "LLM provider implementations for Nomi (Anthropic, OpenAI, Bedrock, Vertex)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomi-types.workspace = true
|
||||
nomi-config.workspace = true
|
||||
nomifun-net.workspace = true
|
||||
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
async-trait.workspace = true
|
||||
thiserror.workspace = true
|
||||
dirs.workspace = true
|
||||
base64.workspace = true
|
||||
uuid.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
|
||||
aws-sigv4.workspace = true
|
||||
aws-credential-types.workspace = true
|
||||
aws-config.workspace = true
|
||||
|
||||
tracing.workspace = true
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock.workspace = true
|
||||
tokio-test.workspace = true
|
||||
http.workspace = true
|
||||
@@ -0,0 +1,185 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use nomi_types::llm::{LlmEvent, LlmRequest, ThinkingConfig};
|
||||
|
||||
use super::anthropic_shared;
|
||||
use crate::{LlmProvider, ProviderError};
|
||||
use nomi_config::compat::ProviderCompat;
|
||||
|
||||
pub struct AnthropicProvider {
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
cache_enabled: bool,
|
||||
compat: ProviderCompat,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
pub fn new(api_key: &str, base_url: &str, compat: ProviderCompat) -> Self {
|
||||
Self {
|
||||
client: crate::http_client(),
|
||||
api_key: api_key.to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
cache_enabled: true,
|
||||
compat,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cache(mut self, enabled: bool) -> Self {
|
||||
self.cache_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
fn build_headers(&self) -> Result<HeaderMap, ProviderError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
let api_key = HeaderValue::from_str(&self.api_key)
|
||||
.map_err(|e| ProviderError::Connection(format!("Invalid x-api-key header: {}", e)))?;
|
||||
headers.insert("x-api-key", api_key);
|
||||
headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
if self.cache_enabled {
|
||||
headers.insert(
|
||||
"anthropic-beta",
|
||||
HeaderValue::from_static("prompt-caching-2024-07-31"),
|
||||
);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn build_request_body(&self, request: &LlmRequest) -> Value {
|
||||
// Build system prompt with optional cache_control
|
||||
let system = if self.cache_enabled {
|
||||
json!([{
|
||||
"type": "text",
|
||||
"text": &request.system,
|
||||
"cache_control": { "type": "ephemeral" }
|
||||
}])
|
||||
} else {
|
||||
json!(&request.system)
|
||||
};
|
||||
|
||||
let mut body = json!({
|
||||
"model": request.model,
|
||||
"max_tokens": request.max_tokens,
|
||||
"system": system,
|
||||
"messages": anthropic_shared::build_messages(&request.messages, &self.compat),
|
||||
"stream": true
|
||||
});
|
||||
|
||||
if !request.tools.is_empty() {
|
||||
let mut tools = anthropic_shared::build_tools(&request.tools);
|
||||
// Mark last tool with cache_control to cache the entire tools block
|
||||
if let Some(last) = tools.last_mut().filter(|_| self.cache_enabled) {
|
||||
last["cache_control"] = json!({ "type": "ephemeral" });
|
||||
}
|
||||
body["tools"] = json!(tools);
|
||||
}
|
||||
|
||||
if let Some(ThinkingConfig::Enabled { budget_tokens }) = &request.thinking {
|
||||
body["thinking"] = json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_tokens
|
||||
});
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for AnthropicProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
request: &LlmRequest,
|
||||
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
|
||||
let url = format!("{}/v1/messages", self.base_url);
|
||||
let body = self.build_request_body(request);
|
||||
|
||||
tracing::debug!(target: "nomi_providers", body = %serde_json::to_string_pretty(&body).unwrap_or_default(), "outgoing request");
|
||||
|
||||
let response = crate::retry::with_initial_connect_retry(|| async {
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(self.build_headers()?)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let retry_after_ms = crate::parse_retry_after_ms(response.headers()).unwrap_or(5000);
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 429 {
|
||||
return Err(ProviderError::RateLimited {
|
||||
retry_after_ms,
|
||||
message: crate::non_empty_rate_limit_message(body_text),
|
||||
});
|
||||
}
|
||||
return Err(ProviderError::Api {
|
||||
status: status.as_u16(),
|
||||
message: body_text,
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let client = self.client.clone();
|
||||
let headers = self.build_headers()?;
|
||||
let url_clone = url.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match anthropic_shared::process_sse_stream(response, &tx).await {
|
||||
anthropic_shared::StreamOutcome::Ok => {}
|
||||
anthropic_shared::StreamOutcome::FailedPartial(e) => {
|
||||
let _ = tx.send(LlmEvent::Error(e.to_string())).await;
|
||||
}
|
||||
anthropic_shared::StreamOutcome::FailedEmpty(e) => {
|
||||
if e.is_retryable() {
|
||||
let mut backoff = std::time::Duration::from_secs(1);
|
||||
let mut final_err = Some(e);
|
||||
for attempt in 1..=crate::retry::MAX_STREAM_RETRIES {
|
||||
backoff = crate::retry::backoff_sleep(attempt, backoff).await;
|
||||
match crate::retry::send_and_check(&client, &url_clone, &headers, &body)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let outcome =
|
||||
anthropic_shared::process_sse_stream(resp, &tx).await;
|
||||
match crate::retry::evaluate_outcome(outcome, attempt) {
|
||||
Ok(None) => {
|
||||
final_err = None;
|
||||
break;
|
||||
}
|
||||
Ok(Some(e)) => {
|
||||
final_err = Some(e);
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(e) if attempt == crate::retry::MAX_STREAM_RETRIES => {
|
||||
final_err = Some(e);
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
if let Some(err) = final_err {
|
||||
let _ = tx.send(LlmEvent::Error(err.to_string())).await;
|
||||
}
|
||||
} else {
|
||||
let _ = tx.send(LlmEvent::Error(e.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,548 @@
|
||||
// AWS Bedrock provider for Claude models.
|
||||
// Uses AWS SigV4 authentication and AWS event stream binary framing.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use aws_credential_types::Credentials;
|
||||
use aws_sigv4::http_request::{
|
||||
self as sigv4_http, PayloadChecksumKind, SignableBody, SignableRequest, SignatureLocation,
|
||||
SigningSettings,
|
||||
};
|
||||
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
use nomi_types::llm::{LlmEvent, LlmRequest, ThinkingConfig};
|
||||
use nomi_types::message::{StopReason, TokenUsage};
|
||||
|
||||
use super::anthropic_shared;
|
||||
use crate::{LlmProvider, ProviderError};
|
||||
use nomi_config::compat::{self, ProviderCompat};
|
||||
|
||||
pub struct BedrockProvider {
|
||||
client: reqwest::Client,
|
||||
region: String,
|
||||
credentials: AwsCredentials,
|
||||
cache_enabled: bool,
|
||||
compat: ProviderCompat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AwsCredentials {
|
||||
Explicit {
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
session_token: Option<String>,
|
||||
},
|
||||
Profile(String),
|
||||
Environment,
|
||||
}
|
||||
|
||||
impl BedrockProvider {
|
||||
pub fn new(
|
||||
region: &str,
|
||||
credentials: AwsCredentials,
|
||||
cache_enabled: bool,
|
||||
compat: ProviderCompat,
|
||||
) -> Self {
|
||||
Self {
|
||||
client: crate::http_client(),
|
||||
region: region.to_string(),
|
||||
credentials,
|
||||
cache_enabled,
|
||||
compat,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request_body(&self, request: &LlmRequest) -> Value {
|
||||
let system = if self.cache_enabled {
|
||||
json!([{
|
||||
"type": "text",
|
||||
"text": &request.system,
|
||||
"cache_control": { "type": "ephemeral" }
|
||||
}])
|
||||
} else {
|
||||
json!(&request.system)
|
||||
};
|
||||
|
||||
let mut body = json!({
|
||||
"anthropic_version": "bedrock-2023-05-31",
|
||||
"max_tokens": request.max_tokens,
|
||||
"system": system,
|
||||
"messages": anthropic_shared::build_messages(&request.messages, &self.compat)
|
||||
});
|
||||
|
||||
if !request.tools.is_empty() {
|
||||
let mut tools = anthropic_shared::build_tools(&request.tools);
|
||||
if self.compat.sanitize_schema() {
|
||||
for tool in &mut tools {
|
||||
if let Some(schema) = tool.get("input_schema").cloned() {
|
||||
tool["input_schema"] = compat::sanitize_json_schema(&schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.cache_enabled
|
||||
&& let Some(last) = tools.last_mut()
|
||||
{
|
||||
last["cache_control"] = json!({ "type": "ephemeral" });
|
||||
}
|
||||
body["tools"] = json!(tools);
|
||||
}
|
||||
|
||||
if let Some(ThinkingConfig::Enabled { budget_tokens }) = &request.thinking {
|
||||
body["thinking"] = json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_tokens
|
||||
});
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
fn build_url(&self, model: &str) -> String {
|
||||
format!(
|
||||
"https://bedrock-runtime.{}.amazonaws.com/model/{}/invoke-with-response-stream",
|
||||
self.region, model
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_credentials(&self) -> Result<Credentials, ProviderError> {
|
||||
match &self.credentials {
|
||||
AwsCredentials::Explicit {
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
} => Ok(Credentials::new(
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token.clone(),
|
||||
None,
|
||||
"nomi",
|
||||
)),
|
||||
AwsCredentials::Profile(profile) => Self::credentials_from_sdk(Some(profile.clone())),
|
||||
AwsCredentials::Environment => Self::credentials_from_sdk(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn credentials_from_sdk(profile: Option<String>) -> Result<Credentials, ProviderError> {
|
||||
// Use a short-lived tokio runtime to resolve credentials synchronously.
|
||||
// This is called once per LLM request so the overhead is acceptable.
|
||||
let rt = tokio::runtime::Handle::try_current();
|
||||
|
||||
let resolve = async move {
|
||||
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
|
||||
if let Some(p) = profile {
|
||||
loader = loader.profile_name(p);
|
||||
}
|
||||
let config = loader.load().await;
|
||||
let provider = config.credentials_provider().ok_or_else(|| {
|
||||
ProviderError::Connection(
|
||||
"No AWS credentials found. Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, \
|
||||
AWS_PROFILE, or configure credentials in ~/.aws/credentials"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
use aws_credential_types::provider::ProvideCredentials;
|
||||
let creds = provider
|
||||
.provide_credentials()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(format!("AWS credential error: {}", e)))?;
|
||||
|
||||
Ok(Credentials::new(
|
||||
creds.access_key_id(),
|
||||
creds.secret_access_key(),
|
||||
creds.session_token().map(|s| s.to_string()),
|
||||
creds.expiry(),
|
||||
"nomi-sdk",
|
||||
))
|
||||
};
|
||||
|
||||
match rt {
|
||||
Ok(_handle) => {
|
||||
// Already inside a tokio runtime — use spawn_blocking to avoid nested block_on
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
tokio::runtime::Runtime::new()
|
||||
.map_err(|e| {
|
||||
ProviderError::Connection(format!("Runtime error: {}", e))
|
||||
})?
|
||||
.block_on(resolve)
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
// No runtime — safe to create one
|
||||
tokio::runtime::Runtime::new()
|
||||
.map_err(|e| ProviderError::Connection(format!("Runtime error: {}", e)))?
|
||||
.block_on(resolve)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_bedrock_request(
|
||||
region: &str,
|
||||
method: &str,
|
||||
url: &str,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
credentials: &Credentials,
|
||||
) -> Result<HeaderMap, ProviderError> {
|
||||
let mut signing_settings = SigningSettings::default();
|
||||
signing_settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
|
||||
signing_settings.signature_location = SignatureLocation::Headers;
|
||||
|
||||
let identity = credentials.clone().into();
|
||||
let signing_params = aws_sigv4::sign::v4::SigningParams::builder()
|
||||
.identity(&identity)
|
||||
.region(region)
|
||||
.name("bedrock")
|
||||
.time(SystemTime::now())
|
||||
.settings(signing_settings)
|
||||
.build()
|
||||
.map_err(|e| ProviderError::Connection(format!("SigV4 params error: {}", e)))?;
|
||||
|
||||
// Build header pairs for signing
|
||||
let header_pairs: Vec<(&str, &str)> = headers
|
||||
.iter()
|
||||
.filter_map(|(name, value)| value.to_str().ok().map(|v| (name.as_str(), v)))
|
||||
.collect();
|
||||
|
||||
let signable_request = SignableRequest::new(
|
||||
method,
|
||||
url,
|
||||
header_pairs.into_iter(),
|
||||
SignableBody::Bytes(body),
|
||||
)
|
||||
.map_err(|e| ProviderError::Connection(format!("Signable request error: {}", e)))?;
|
||||
|
||||
let (signing_instructions, _signature) =
|
||||
sigv4_http::sign(signable_request, &signing_params.into())
|
||||
.map_err(|e| ProviderError::Connection(format!("SigV4 signing error: {}", e)))?
|
||||
.into_parts();
|
||||
|
||||
let mut signed_headers = headers.clone();
|
||||
for (name, value) in signing_instructions.headers() {
|
||||
signed_headers.insert(
|
||||
reqwest::header::HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|e| ProviderError::Connection(format!("Header name error: {}", e)))?,
|
||||
HeaderValue::from_str(value)
|
||||
.map_err(|e| ProviderError::Connection(format!("Header value error: {}", e)))?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(signed_headers)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for BedrockProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
request: &LlmRequest,
|
||||
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
|
||||
let url = self.build_url(&request.model);
|
||||
let body = self.build_request_body(request);
|
||||
|
||||
tracing::debug!(target: "nomi_providers", body = %serde_json::to_string_pretty(&body).unwrap_or_default(), "outgoing request");
|
||||
|
||||
let body_bytes = serde_json::to_vec(&body)
|
||||
.map_err(|e| ProviderError::Connection(format!("JSON serialize error: {}", e)))?;
|
||||
|
||||
let credentials = self.resolve_credentials()?;
|
||||
|
||||
// Each attempt re-signs: SigV4 signatures embed a timestamp and are only
|
||||
// valid within a short window, so a retried request needs a fresh
|
||||
// signature. `send_signed` builds headers, signs, sends, and maps a
|
||||
// non-2xx status — used for both the initial connect-retry and the
|
||||
// mid-stream retry loop (parity with the other providers, which retry via
|
||||
// crate::retry). (Phase 1 provider retry parity)
|
||||
let send_signed = |region: &str,
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
body_bytes: &[u8],
|
||||
credentials: &Credentials| {
|
||||
let region = region.to_owned();
|
||||
let client = client.clone();
|
||||
let url = url.to_owned();
|
||||
let body_bytes = body_bytes.to_vec();
|
||||
let credentials = credentials.clone();
|
||||
async move {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
let signed = Self::sign_bedrock_request(
|
||||
®ion, "POST", &url, &headers, &body_bytes, &credentials,
|
||||
)?;
|
||||
let response = client
|
||||
.post(&url)
|
||||
.headers(signed)
|
||||
.body(body_bytes.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(e.to_string()))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let retry_after_ms =
|
||||
crate::parse_retry_after_ms(response.headers()).unwrap_or(5000);
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 429 {
|
||||
return Err(ProviderError::RateLimited {
|
||||
retry_after_ms,
|
||||
message: crate::non_empty_rate_limit_message(body_text),
|
||||
});
|
||||
}
|
||||
return Err(ProviderError::Api {
|
||||
status: status.as_u16(),
|
||||
message: format_bedrock_error(status.as_u16(), &body_text),
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
};
|
||||
|
||||
// Initial request with connect-failure retry (status/rate-limit errors
|
||||
// are surfaced immediately, same as the other providers).
|
||||
let response = crate::retry::with_initial_connect_retry(|| {
|
||||
send_signed(&self.region, &self.client, &url, &body_bytes, &credentials)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
|
||||
// Owned copies for the spawned task's mid-stream retry loop (it outlives
|
||||
// `&self`, so it cannot borrow region/client/credentials).
|
||||
let region = self.region.clone();
|
||||
let client = self.client.clone();
|
||||
let url_owned = url.clone();
|
||||
|
||||
// AWS event stream uses binary framing.
|
||||
tokio::spawn(async move {
|
||||
match process_aws_event_stream(response, &tx).await {
|
||||
anthropic_shared::StreamOutcome::Ok => {}
|
||||
anthropic_shared::StreamOutcome::FailedPartial(e) => {
|
||||
// Content already emitted — replaying would duplicate it.
|
||||
let _ = tx.send(LlmEvent::Error(e.to_string())).await;
|
||||
}
|
||||
anthropic_shared::StreamOutcome::FailedEmpty(e) => {
|
||||
if e.is_retryable() {
|
||||
let mut backoff = std::time::Duration::from_secs(1);
|
||||
let mut final_err = Some(e);
|
||||
for attempt in 1..=crate::retry::MAX_STREAM_RETRIES {
|
||||
backoff = crate::retry::backoff_sleep(attempt, backoff).await;
|
||||
match send_signed(®ion, &client, &url_owned, &body_bytes, &credentials)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let outcome = process_aws_event_stream(resp, &tx).await;
|
||||
match crate::retry::evaluate_outcome(outcome, attempt) {
|
||||
Ok(None) => {
|
||||
final_err = None;
|
||||
break;
|
||||
}
|
||||
Ok(Some(err)) => {
|
||||
final_err = Some(err);
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(err) if attempt == crate::retry::MAX_STREAM_RETRIES => {
|
||||
final_err = Some(err);
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
if let Some(err) = final_err {
|
||||
let _ = tx.send(LlmEvent::Error(err.to_string())).await;
|
||||
}
|
||||
} else {
|
||||
let _ = tx.send(LlmEvent::Error(e.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the AWS event stream (binary framed) from Bedrock
|
||||
async fn process_aws_event_stream(
|
||||
response: reqwest::Response,
|
||||
tx: &mpsc::Sender<LlmEvent>,
|
||||
) -> anthropic_shared::StreamOutcome {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut state = anthropic_shared::StreamState::new();
|
||||
let mut buffer = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut emitted_content = false;
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = match chunk {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let err = ProviderError::Connection(e.to_string());
|
||||
return if emitted_content {
|
||||
anthropic_shared::StreamOutcome::FailedPartial(err)
|
||||
} else {
|
||||
anthropic_shared::StreamOutcome::FailedEmpty(err)
|
||||
};
|
||||
}
|
||||
};
|
||||
buffer.extend_from_slice(&chunk);
|
||||
|
||||
// Parse complete AWS event stream messages from buffer
|
||||
while let Some((event_data, consumed)) = parse_aws_event(&buffer) {
|
||||
buffer = buffer[consumed..].to_vec();
|
||||
|
||||
if let Some(payload) = event_data {
|
||||
// The payload contains an SSE-like structure with "bytes" field
|
||||
if let Ok(wrapper) = serde_json::from_slice::<Value>(&payload) {
|
||||
// Bedrock wraps the payload in {"bytes": "base64-encoded-data"}
|
||||
if let Some(b64) = wrapper["bytes"].as_str()
|
||||
&& let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64)
|
||||
&& let Ok(inner) = String::from_utf8(decoded)
|
||||
{
|
||||
tracing::debug!(target: "nomi_providers", chunk = %inner, "bedrock event chunk");
|
||||
// Inner payload is JSON with event type hints
|
||||
if let Ok(json_val) = serde_json::from_str::<Value>(&inner) {
|
||||
let event_type = json_val["type"].as_str().unwrap_or("");
|
||||
let events =
|
||||
anthropic_shared::parse_sse_data(event_type, &inner, &mut state);
|
||||
for event in events {
|
||||
if matches!(
|
||||
event,
|
||||
LlmEvent::TextDelta(_)
|
||||
| LlmEvent::ThinkingDelta(_)
|
||||
| LlmEvent::ThinkingSignature(_)
|
||||
| LlmEvent::ToolUse { .. }
|
||||
) {
|
||||
emitted_content = true;
|
||||
}
|
||||
if tx.send(event).await.is_err() {
|
||||
return anthropic_shared::StreamOutcome::Ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we haven't sent a Done event, send one now
|
||||
if state.input_tokens > 0 || state.output_tokens > 0 {
|
||||
let _ = tx
|
||||
.send(LlmEvent::Done {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: TokenUsage {
|
||||
input_tokens: state.input_tokens,
|
||||
output_tokens: state.output_tokens,
|
||||
cache_creation_tokens: state.cache_creation_tokens,
|
||||
cache_read_tokens: state.cache_read_tokens,
|
||||
},
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
anthropic_shared::StreamOutcome::Ok
|
||||
}
|
||||
|
||||
/// Parse one AWS event stream message from the buffer.
|
||||
/// Returns (Some(payload), bytes_consumed) if a complete message is found,
|
||||
/// or None if more data is needed.
|
||||
///
|
||||
/// AWS event stream binary format:
|
||||
/// - Prelude: total_len (4 bytes, big-endian) + headers_len (4 bytes) + prelude_crc (4 bytes)
|
||||
/// - Headers: variable length
|
||||
/// - Payload: variable length
|
||||
/// - Message CRC: 4 bytes
|
||||
fn parse_aws_event(buffer: &[u8]) -> Option<(Option<Vec<u8>>, usize)> {
|
||||
if buffer.len() < 12 {
|
||||
return None; // Need at least the prelude
|
||||
}
|
||||
|
||||
let total_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
|
||||
let headers_len = u32::from_be_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]) as usize;
|
||||
|
||||
if buffer.len() < total_len {
|
||||
return None; // Incomplete message
|
||||
}
|
||||
|
||||
// Prelude is 12 bytes (total_len + headers_len + prelude_crc)
|
||||
// Payload starts after prelude + headers
|
||||
let payload_start = 12 + headers_len;
|
||||
// Payload ends 4 bytes before total_len (message CRC)
|
||||
let payload_end = total_len - 4;
|
||||
|
||||
if payload_start <= payload_end {
|
||||
let payload = buffer[payload_start..payload_end].to_vec();
|
||||
Some((Some(payload), total_len))
|
||||
} else {
|
||||
// Empty payload (e.g., initial response event)
|
||||
Some((None, total_len))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format Bedrock error responses with actionable hints
|
||||
fn format_bedrock_error(status: u16, body: &str) -> String {
|
||||
// Try to extract the AWS error type from the response
|
||||
let error_type = serde_json::from_str::<Value>(body).ok().and_then(|v| {
|
||||
v.get("__type")
|
||||
.or_else(|| v.get("type"))
|
||||
.and_then(|t| t.as_str().map(String::from))
|
||||
});
|
||||
|
||||
let hint = match status {
|
||||
403 => Some(
|
||||
"Check IAM permissions: the role/user needs bedrock:InvokeModelWithResponseStream. \
|
||||
Also verify the model is enabled in the Bedrock console for your account.",
|
||||
),
|
||||
404 => Some(
|
||||
"Model not found in this region. Verify the model ID and that it's available in \
|
||||
your configured AWS region.",
|
||||
),
|
||||
400 => {
|
||||
if body.contains("schema") || body.contains("Schema") {
|
||||
Some(
|
||||
"Request schema validation failed. If using tools, try enabling sanitize_schema=true in [providers.bedrock.compat].",
|
||||
)
|
||||
} else {
|
||||
Some("Bad request — check model parameters and message format.")
|
||||
}
|
||||
}
|
||||
503 | 529 => Some(
|
||||
"Service overloaded or throttled. You may have exceeded your provisioned throughput quota. \
|
||||
Retry after a moment or request a quota increase.",
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let type_info = error_type.map(|t| format!(" [{}]", t)).unwrap_or_default();
|
||||
|
||||
match hint {
|
||||
Some(h) => format!("{}{}\nHint: {}", body, type_info, h),
|
||||
None => format!("{}{}", body, type_info),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build AwsCredentials from nomi-config's BedrockConfig
|
||||
pub fn credentials_from_config(bc: &nomi_config::config::BedrockConfig) -> AwsCredentials {
|
||||
if let (Some(key_id), Some(secret)) = (&bc.access_key_id, &bc.secret_access_key) {
|
||||
AwsCredentials::Explicit {
|
||||
access_key_id: key_id.clone(),
|
||||
secret_access_key: secret.clone(),
|
||||
session_token: bc.session_token.clone(),
|
||||
}
|
||||
} else if let Some(profile) = &bc.profile {
|
||||
AwsCredentials::Profile(profile.clone())
|
||||
} else {
|
||||
AwsCredentials::Environment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
pub mod anthropic;
|
||||
pub mod anthropic_shared;
|
||||
pub mod bedrock;
|
||||
pub mod openai;
|
||||
pub mod retry;
|
||||
pub mod vertex;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use nomi_config::config::{Config, ProviderType};
|
||||
use nomi_types::llm::{LlmEvent, LlmRequest};
|
||||
|
||||
/// Unified interface for LLM API providers
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
async fn stream(&self, request: &LlmRequest)
|
||||
-> Result<mpsc::Receiver<LlmEvent>, ProviderError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProviderError {
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("API error {status}: {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("SSE parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("Rate limited, retry after {retry_after_ms}ms: {message}")]
|
||||
RateLimited {
|
||||
retry_after_ms: u64,
|
||||
message: String,
|
||||
},
|
||||
#[error("Prompt too long: {0}")]
|
||||
PromptTooLong(String),
|
||||
#[error("Connection error: {0}")]
|
||||
Connection(String),
|
||||
}
|
||||
|
||||
impl ProviderError {
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
ProviderError::RateLimited { .. } | ProviderError::Connection(_) => true,
|
||||
// Transient server-side faults (500/502/503/504) from an overloaded
|
||||
// gateway are the most common spurious failure and are safe to retry
|
||||
// on the pre-response / empty-content paths. 4xx are terminal.
|
||||
ProviderError::Api { status, .. } => *status >= 500,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `Retry-After` HTTP header into milliseconds, honouring the provider's
|
||||
/// requested backoff instead of a fixed guess. Supports the delta-seconds form
|
||||
/// (what LLM gateways send); returns `None` for an absent, non-numeric, or
|
||||
/// HTTP-date value (caller falls back to its default). Clamped to 120s so a
|
||||
/// hostile/huge value can't wedge the agent.
|
||||
pub(crate) fn parse_retry_after_ms(headers: &reqwest::header::HeaderMap) -> Option<u64> {
|
||||
let secs: u64 = headers
|
||||
.get(reqwest::header::RETRY_AFTER)?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()?;
|
||||
Some(secs.saturating_mul(1000).min(120_000))
|
||||
}
|
||||
|
||||
/// Connection timeout for provider HTTP clients. Bounds the TCP/TLS connect
|
||||
/// phase so an unreachable or non-responsive gateway fails fast.
|
||||
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Idle read timeout for provider HTTP clients. Applies to each read of the
|
||||
/// (streaming) response, so a gateway that accepts the request but then stalls
|
||||
/// — sending no further bytes — surfaces an error instead of hanging the turn
|
||||
/// forever. Active streaming resets this on every chunk, so it only trips on a
|
||||
/// genuine stall. The health-check probe has its own 30s wrapper; the live
|
||||
/// conversation path previously had NO timeout at all, which turned an upstream
|
||||
/// stall into a silent freeze (no output, no error).
|
||||
const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Shared reqwest client for all LLM providers, configured with connection and
|
||||
/// idle-read timeouts. A stalled upstream produces a `reqwest` timeout error,
|
||||
/// which the SSE loop converts into `LlmEvent::Error` (surfaced to the user as
|
||||
/// `Nomi agent error: ...`) instead of an indefinite hang.
|
||||
pub(crate) fn http_client() -> reqwest::Client {
|
||||
let builder = reqwest::Client::builder()
|
||||
.connect_timeout(HTTP_CONNECT_TIMEOUT)
|
||||
.read_timeout(HTTP_READ_TIMEOUT);
|
||||
nomifun_net::proxy::apply_detected_proxy(builder)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
|
||||
pub(crate) fn non_empty_rate_limit_message(body: String) -> String {
|
||||
if body.trim().is_empty() {
|
||||
"HTTP 429 Too Many Requests".to_owned()
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a provider from resolved config
|
||||
pub fn create_provider(config: &Config) -> Arc<dyn LlmProvider> {
|
||||
let compat = config.compat.clone();
|
||||
|
||||
match config.provider {
|
||||
ProviderType::Anthropic => Arc::new(
|
||||
anthropic::AnthropicProvider::new(&config.api_key, &config.base_url, compat)
|
||||
.with_cache(config.prompt_caching),
|
||||
),
|
||||
ProviderType::OpenAI => Arc::new(openai::OpenAIProvider::new(
|
||||
&config.api_key,
|
||||
&config.base_url,
|
||||
compat,
|
||||
)),
|
||||
ProviderType::Bedrock => {
|
||||
let bc = config.bedrock.clone().unwrap_or_default();
|
||||
let region = bc
|
||||
.region
|
||||
.clone()
|
||||
.or_else(|| std::env::var("AWS_REGION").ok())
|
||||
.or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
|
||||
.unwrap_or_else(|| "us-east-1".to_string());
|
||||
let credentials = bedrock::credentials_from_config(&bc);
|
||||
Arc::new(bedrock::BedrockProvider::new(
|
||||
®ion,
|
||||
credentials,
|
||||
config.prompt_caching,
|
||||
compat,
|
||||
))
|
||||
}
|
||||
ProviderType::Vertex => {
|
||||
let vc = config.vertex.clone().unwrap_or_default();
|
||||
let project_id = vc.project_id.clone().unwrap_or_default();
|
||||
let region = vc
|
||||
.region
|
||||
.clone()
|
||||
.unwrap_or_else(|| "us-central1".to_string());
|
||||
let auth = vertex::auth_from_config(&vc);
|
||||
Arc::new(vertex::VertexProvider::new(
|
||||
&project_id,
|
||||
®ion,
|
||||
auth,
|
||||
config.prompt_caching,
|
||||
compat,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod retryable_tests {
|
||||
use super::ProviderError;
|
||||
use super::parse_retry_after_ms;
|
||||
|
||||
#[test]
|
||||
fn parse_retry_after_seconds_clamped() {
|
||||
use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER};
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(RETRY_AFTER, HeaderValue::from_static("30"));
|
||||
assert_eq!(parse_retry_after_ms(&h), Some(30_000));
|
||||
|
||||
let mut huge = HeaderMap::new();
|
||||
huge.insert(RETRY_AFTER, HeaderValue::from_static("99999"));
|
||||
assert_eq!(parse_retry_after_ms(&huge), Some(120_000)); // clamped
|
||||
|
||||
// Absent / non-numeric (HTTP-date) -> None (caller uses its default).
|
||||
assert_eq!(parse_retry_after_ms(&HeaderMap::new()), None);
|
||||
let mut date = HeaderMap::new();
|
||||
date.insert(RETRY_AFTER, HeaderValue::from_static("Wed, 21 Oct 2025 07:28:00 GMT"));
|
||||
assert_eq!(parse_retry_after_ms(&date), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_5xx_is_retryable_but_4xx_is_not() {
|
||||
// Transient server-side faults (overloaded gateways) are the most common
|
||||
// spurious failure and are safe to retry on the pre-response / empty
|
||||
// paths; client errors (4xx) are terminal. (Phase 1)
|
||||
let api = |status| ProviderError::Api {
|
||||
status,
|
||||
message: "x".to_string(),
|
||||
};
|
||||
assert!(api(500).is_retryable());
|
||||
assert!(api(502).is_retryable());
|
||||
assert!(api(503).is_retryable());
|
||||
assert!(api(504).is_retryable());
|
||||
assert!(!api(400).is_retryable());
|
||||
assert!(!api(404).is_retryable());
|
||||
assert!(!api(429).is_retryable(), "429 is surfaced as RateLimited, not Api");
|
||||
|
||||
assert!(
|
||||
ProviderError::RateLimited {
|
||||
retry_after_ms: 0,
|
||||
message: "x".to_string()
|
||||
}
|
||||
.is_retryable()
|
||||
);
|
||||
assert!(ProviderError::Connection("x".to_string()).is_retryable());
|
||||
assert!(!ProviderError::PromptTooLong("x".to_string()).is_retryable());
|
||||
assert!(!ProviderError::Parse("x".to_string()).is_retryable());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::ProviderError;
|
||||
use super::anthropic_shared::StreamOutcome;
|
||||
|
||||
pub const MAX_STREAM_RETRIES: u32 = 2;
|
||||
pub const MAX_INITIAL_CONNECT_RETRIES: u32 = 2;
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(15);
|
||||
const INITIAL_CONNECT_BACKOFF: Duration = Duration::from_millis(300);
|
||||
const MAX_INITIAL_CONNECT_BACKOFF: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Retry initial request failures that occur before an HTTP response exists.
|
||||
/// HTTP status errors and rate limits are intentionally not retried here.
|
||||
pub async fn with_initial_connect_retry<F, Fut, T>(f: F) -> Result<T, ProviderError>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<T, ProviderError>>,
|
||||
{
|
||||
let mut backoff = INITIAL_CONNECT_BACKOFF;
|
||||
for attempt in 0..=MAX_INITIAL_CONNECT_RETRIES {
|
||||
match f().await {
|
||||
Ok(val) => return Ok(val),
|
||||
Err(e) if is_initial_connect_error(&e) && attempt < MAX_INITIAL_CONNECT_RETRIES => {
|
||||
tracing::warn!(
|
||||
attempt = attempt + 1,
|
||||
max_retries = MAX_INITIAL_CONNECT_RETRIES,
|
||||
error = %e,
|
||||
"retrying initial provider request after connect failure"
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_INITIAL_CONNECT_BACKOFF);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
fn is_initial_connect_error(error: &ProviderError) -> bool {
|
||||
match error {
|
||||
ProviderError::Http(err) => err.is_connect(),
|
||||
ProviderError::Connection(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an HTTP request and check status, returning the response on success.
|
||||
/// Used by provider-specific retry loops to avoid duplicating request logic.
|
||||
pub async fn send_and_check(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
headers: &HeaderMap,
|
||||
body: &Value,
|
||||
) -> Result<reqwest::Response, ProviderError> {
|
||||
let response = client
|
||||
.post(url)
|
||||
.headers(headers.clone())
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
return Err(ProviderError::Api {
|
||||
status: status.as_u16(),
|
||||
message: body_text,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Sleep with exponential backoff and log the retry attempt.
|
||||
/// Returns the next backoff duration.
|
||||
pub async fn backoff_sleep(attempt: u32, current_backoff: Duration) -> Duration {
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
max = MAX_STREAM_RETRIES,
|
||||
"retrying stream after mid-stream disconnect"
|
||||
);
|
||||
tokio::time::sleep(current_backoff).await;
|
||||
(current_backoff * 2).min(MAX_BACKOFF)
|
||||
}
|
||||
|
||||
/// Evaluate a `StreamOutcome` within a retry loop. Returns:
|
||||
/// - `Ok(None)` — stream succeeded, stop retrying
|
||||
/// - `Ok(Some(err))` — non-retryable failure, caller should emit error
|
||||
/// - `Err(err)` — retryable failure, caller should continue loop
|
||||
pub fn evaluate_outcome(
|
||||
outcome: StreamOutcome,
|
||||
attempt: u32,
|
||||
) -> Result<Option<ProviderError>, ProviderError> {
|
||||
match outcome {
|
||||
StreamOutcome::Ok => Ok(None),
|
||||
StreamOutcome::FailedPartial(e) => Ok(Some(e)),
|
||||
StreamOutcome::FailedEmpty(e) => {
|
||||
if attempt == MAX_STREAM_RETRIES {
|
||||
Ok(Some(e))
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use super::*;
|
||||
use crate::ProviderError;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initial_connect_retry_succeeds_after_connection_failures() {
|
||||
tokio::time::pause();
|
||||
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let result = with_initial_connect_retry(|| {
|
||||
let counter = Arc::clone(&counter);
|
||||
async move {
|
||||
let attempt = counter.fetch_add(1, Ordering::SeqCst);
|
||||
if attempt < 2 {
|
||||
Err(ProviderError::Connection("connection refused".into()))
|
||||
} else {
|
||||
Ok(attempt)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(result.unwrap(), 2);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initial_connect_retry_does_not_retry_rate_limit() {
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let result = with_initial_connect_retry(|| {
|
||||
let counter = Arc::clone(&counter);
|
||||
async move {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
Err::<(), _>(ProviderError::RateLimited {
|
||||
retry_after_ms: 5000,
|
||||
message: "Too Many Requests".into(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
ProviderError::RateLimited {
|
||||
retry_after_ms: 5000,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// --- evaluate_outcome tests ---
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_outcome_ok_stops_retry() {
|
||||
let result = evaluate_outcome(StreamOutcome::Ok, 1);
|
||||
assert!(matches!(result, Ok(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_outcome_failed_partial_always_stops() {
|
||||
let err = ProviderError::Connection("disconnect".into());
|
||||
let result = evaluate_outcome(StreamOutcome::FailedPartial(err), 1);
|
||||
// FailedPartial means content was already emitted — cannot retry regardless of attempt
|
||||
let Ok(Some(e)) = result else {
|
||||
panic!("expected Ok(Some(err))")
|
||||
};
|
||||
assert!(matches!(e, ProviderError::Connection(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_outcome_failed_partial_on_last_attempt() {
|
||||
let err = ProviderError::Connection("disconnect".into());
|
||||
let result = evaluate_outcome(StreamOutcome::FailedPartial(err), MAX_STREAM_RETRIES);
|
||||
let Ok(Some(_)) = result else {
|
||||
panic!("expected Ok(Some(err))")
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_outcome_failed_empty_retries_when_not_exhausted() {
|
||||
let err = ProviderError::Connection("disconnect".into());
|
||||
// attempt 1 < MAX_STREAM_RETRIES(2), should signal "continue retrying"
|
||||
let result = evaluate_outcome(StreamOutcome::FailedEmpty(err), 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_outcome_failed_empty_stops_on_last_attempt() {
|
||||
let err = ProviderError::Connection("disconnect".into());
|
||||
// attempt == MAX_STREAM_RETRIES, should stop and return error
|
||||
let result = evaluate_outcome(StreamOutcome::FailedEmpty(err), MAX_STREAM_RETRIES);
|
||||
let Ok(Some(e)) = result else {
|
||||
panic!("expected Ok(Some(err))")
|
||||
};
|
||||
assert!(matches!(e, ProviderError::Connection(_)));
|
||||
}
|
||||
|
||||
// --- backoff_sleep tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_backoff_sleep_doubles_duration() {
|
||||
tokio::time::pause();
|
||||
|
||||
let next = backoff_sleep(1, Duration::from_secs(1)).await;
|
||||
assert_eq!(next, Duration::from_secs(2));
|
||||
|
||||
let next = backoff_sleep(2, Duration::from_secs(4)).await;
|
||||
assert_eq!(next, Duration::from_secs(8));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_backoff_sleep_caps_at_max() {
|
||||
tokio::time::pause();
|
||||
|
||||
// 10s * 2 = 20s, but MAX_BACKOFF is 15s
|
||||
let next = backoff_sleep(1, Duration::from_secs(10)).await;
|
||||
assert_eq!(next, Duration::from_secs(15));
|
||||
|
||||
// Already at max
|
||||
let next = backoff_sleep(2, Duration::from_secs(15)).await;
|
||||
assert_eq!(next, Duration::from_secs(15));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// Google Vertex AI provider for Claude models.
|
||||
// Uses GCP OAuth2 authentication. Response is standard SSE (same as Anthropic).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use nomi_types::llm::{LlmEvent, LlmRequest, ThinkingConfig};
|
||||
|
||||
use super::anthropic_shared;
|
||||
use crate::{LlmProvider, ProviderError};
|
||||
use nomi_config::compat::ProviderCompat;
|
||||
|
||||
pub struct VertexProvider {
|
||||
client: reqwest::Client,
|
||||
project_id: String,
|
||||
region: String,
|
||||
auth: GcpAuth,
|
||||
cache_enabled: bool,
|
||||
compat: ProviderCompat,
|
||||
/// Cached access token
|
||||
cached_token: Mutex<Option<CachedToken>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GcpAuth {
|
||||
ServiceAccount { key_file: String },
|
||||
ApplicationDefault,
|
||||
MetadataServer,
|
||||
}
|
||||
|
||||
struct CachedToken {
|
||||
token: String,
|
||||
expires_at: u64,
|
||||
}
|
||||
|
||||
impl VertexProvider {
|
||||
pub fn new(
|
||||
project_id: &str,
|
||||
region: &str,
|
||||
auth: GcpAuth,
|
||||
cache_enabled: bool,
|
||||
compat: ProviderCompat,
|
||||
) -> Self {
|
||||
Self {
|
||||
client: crate::http_client(),
|
||||
project_id: project_id.to_string(),
|
||||
region: region.to_string(),
|
||||
auth,
|
||||
cache_enabled,
|
||||
compat,
|
||||
cached_token: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_url(&self, model: &str) -> String {
|
||||
format!(
|
||||
"https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/anthropic/models/{}:streamRawPredict",
|
||||
self.region, self.project_id, self.region, model
|
||||
)
|
||||
}
|
||||
|
||||
fn build_request_body(&self, request: &LlmRequest) -> Value {
|
||||
let system = if self.cache_enabled {
|
||||
json!([{
|
||||
"type": "text",
|
||||
"text": &request.system,
|
||||
"cache_control": { "type": "ephemeral" }
|
||||
}])
|
||||
} else {
|
||||
json!(&request.system)
|
||||
};
|
||||
|
||||
let mut body = json!({
|
||||
"anthropic_version": "vertex-2023-10-16",
|
||||
"max_tokens": request.max_tokens,
|
||||
"system": system,
|
||||
"messages": anthropic_shared::build_messages(&request.messages, &self.compat),
|
||||
"stream": true
|
||||
});
|
||||
|
||||
if !request.tools.is_empty() {
|
||||
let mut tools = anthropic_shared::build_tools(&request.tools);
|
||||
if let Some(last) = tools.last_mut().filter(|_| self.cache_enabled) {
|
||||
last["cache_control"] = json!({ "type": "ephemeral" });
|
||||
}
|
||||
body["tools"] = json!(tools);
|
||||
}
|
||||
|
||||
if let Some(ThinkingConfig::Enabled { budget_tokens }) = &request.thinking {
|
||||
body["thinking"] = json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_tokens
|
||||
});
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
async fn get_access_token(&self) -> Result<String, ProviderError> {
|
||||
// Check cache first
|
||||
{
|
||||
let cached = self.cached_token.lock().map_err(|_| {
|
||||
ProviderError::Connection("Vertex token cache lock poisoned".to_string())
|
||||
})?;
|
||||
if let Some(token) = cached.as_ref() {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
if token.expires_at > now + 60 {
|
||||
return Ok(token.token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (token, expires_in) = match &self.auth {
|
||||
GcpAuth::ServiceAccount { key_file } => {
|
||||
self.get_service_account_token(key_file).await?
|
||||
}
|
||||
GcpAuth::ApplicationDefault => self.get_adc_token().await?,
|
||||
GcpAuth::MetadataServer => self.get_metadata_token().await?,
|
||||
};
|
||||
|
||||
// Cache the token
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let mut cached = self.cached_token.lock().map_err(|_| {
|
||||
ProviderError::Connection("Vertex token cache lock poisoned".to_string())
|
||||
})?;
|
||||
*cached = Some(CachedToken {
|
||||
token: token.clone(),
|
||||
expires_at: now + expires_in,
|
||||
});
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
async fn get_service_account_token(
|
||||
&self,
|
||||
key_file: &str,
|
||||
) -> Result<(String, u64), ProviderError> {
|
||||
let key_json = std::fs::read_to_string(key_file)
|
||||
.map_err(|e| ProviderError::Connection(format!("Failed to read key file: {}", e)))?;
|
||||
|
||||
let sa: ServiceAccountKey = serde_json::from_str(&key_json)
|
||||
.map_err(|e| ProviderError::Connection(format!("Failed to parse key file: {}", e)))?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let claims = JwtClaims {
|
||||
iss: sa.client_email.clone(),
|
||||
scope: "https://www.googleapis.com/auth/cloud-platform".to_string(),
|
||||
aud: sa.token_uri.clone(),
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
};
|
||||
|
||||
let encoding_key = EncodingKey::from_rsa_pem(sa.private_key.as_bytes())
|
||||
.map_err(|e| ProviderError::Connection(format!("Invalid RSA key: {}", e)))?;
|
||||
|
||||
let header = Header::new(Algorithm::RS256);
|
||||
let jwt = jsonwebtoken::encode(&header, &claims, &encoding_key)
|
||||
.map_err(|e| ProviderError::Connection(format!("JWT encode error: {}", e)))?;
|
||||
|
||||
// Exchange JWT for access token
|
||||
let resp = self
|
||||
.client
|
||||
.post(&sa.token_uri)
|
||||
.form(&[
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
|
||||
("assertion", &jwt),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(format!("Token exchange error: {}", e)))?;
|
||||
|
||||
let token_resp: GoogleTokenResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(format!("Token parse error: {}", e)))?;
|
||||
|
||||
Ok((token_resp.access_token, token_resp.expires_in))
|
||||
}
|
||||
|
||||
async fn get_adc_token(&self) -> Result<(String, u64), ProviderError> {
|
||||
// Read Application Default Credentials
|
||||
let adc_path = dirs::home_dir()
|
||||
.ok_or_else(|| ProviderError::Connection("Cannot determine home dir".into()))?
|
||||
.join(".config/gcloud/application_default_credentials.json");
|
||||
|
||||
let adc_json = std::fs::read_to_string(&adc_path).map_err(|e| {
|
||||
ProviderError::Connection(format!(
|
||||
"Failed to read ADC at {}: {}. Run 'gcloud auth application-default login'.",
|
||||
adc_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let adc: AdcCredentials = serde_json::from_str(&adc_json)
|
||||
.map_err(|e| ProviderError::Connection(format!("Failed to parse ADC: {}", e)))?;
|
||||
|
||||
// Use refresh token to get access token
|
||||
let resp = self
|
||||
.client
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.form(&[
|
||||
("client_id", adc.client_id.as_str()),
|
||||
("client_secret", adc.client_secret.as_str()),
|
||||
("refresh_token", adc.refresh_token.as_str()),
|
||||
("grant_type", "refresh_token"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(format!("ADC token refresh error: {}", e)))?;
|
||||
|
||||
let token_resp: GoogleTokenResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(format!("Token parse error: {}", e)))?;
|
||||
|
||||
Ok((token_resp.access_token, token_resp.expires_in))
|
||||
}
|
||||
|
||||
async fn get_metadata_token(&self) -> Result<(String, u64), ProviderError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token")
|
||||
.header("Metadata-Flavor", "Google")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(format!("Metadata server error: {}", e)))?;
|
||||
|
||||
let token_resp: GoogleTokenResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ProviderError::Connection(format!("Token parse error: {}", e)))?;
|
||||
|
||||
Ok((token_resp.access_token, token_resp.expires_in))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for VertexProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
request: &LlmRequest,
|
||||
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
|
||||
let url = self.build_url(&request.model);
|
||||
let body = self.build_request_body(request);
|
||||
|
||||
tracing::debug!(target: "nomi_providers", body = %serde_json::to_string_pretty(&body).unwrap_or_default(), "outgoing request");
|
||||
|
||||
let access_token = self.get_access_token().await?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {}", access_token))
|
||||
.map_err(|e| ProviderError::Connection(format!("Header error: {}", e)))?,
|
||||
);
|
||||
|
||||
let response = crate::retry::with_initial_connect_retry(|| async {
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(headers.clone())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let retry_after_ms = crate::parse_retry_after_ms(response.headers()).unwrap_or(5000);
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 429 {
|
||||
return Err(ProviderError::RateLimited {
|
||||
retry_after_ms,
|
||||
message: crate::non_empty_rate_limit_message(body_text),
|
||||
});
|
||||
}
|
||||
return Err(ProviderError::Api {
|
||||
status: status.as_u16(),
|
||||
message: body_text,
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let client = self.client.clone();
|
||||
let url_clone = url.clone();
|
||||
let headers_clone = {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
h.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {}", access_token))
|
||||
.map_err(|e| ProviderError::Connection(format!("Header error: {}", e)))?,
|
||||
);
|
||||
h
|
||||
};
|
||||
|
||||
// Vertex uses standard SSE (same as Anthropic)
|
||||
tokio::spawn(async move {
|
||||
match anthropic_shared::process_sse_stream(response, &tx).await {
|
||||
anthropic_shared::StreamOutcome::Ok => {}
|
||||
anthropic_shared::StreamOutcome::FailedPartial(e) => {
|
||||
let _ = tx.send(LlmEvent::Error(e.to_string())).await;
|
||||
}
|
||||
anthropic_shared::StreamOutcome::FailedEmpty(e) => {
|
||||
if e.is_retryable() {
|
||||
let mut backoff = std::time::Duration::from_secs(1);
|
||||
let mut final_err = Some(e);
|
||||
for attempt in 1..=crate::retry::MAX_STREAM_RETRIES {
|
||||
backoff = crate::retry::backoff_sleep(attempt, backoff).await;
|
||||
match crate::retry::send_and_check(
|
||||
&client,
|
||||
&url_clone,
|
||||
&headers_clone,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let outcome =
|
||||
anthropic_shared::process_sse_stream(resp, &tx).await;
|
||||
match crate::retry::evaluate_outcome(outcome, attempt) {
|
||||
Ok(None) => {
|
||||
final_err = None;
|
||||
break;
|
||||
}
|
||||
Ok(Some(e)) => {
|
||||
final_err = Some(e);
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(e) if attempt == crate::retry::MAX_STREAM_RETRIES => {
|
||||
final_err = Some(e);
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
if let Some(err) = final_err {
|
||||
let _ = tx.send(LlmEvent::Error(err.to_string())).await;
|
||||
}
|
||||
} else {
|
||||
let _ = tx.send(LlmEvent::Error(e.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal types ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServiceAccountKey {
|
||||
client_email: String,
|
||||
private_key: String,
|
||||
token_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JwtClaims {
|
||||
iss: String,
|
||||
scope: String,
|
||||
aud: String,
|
||||
iat: u64,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GoogleTokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default = "default_expires_in")]
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
fn default_expires_in() -> u64 {
|
||||
3600
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdcCredentials {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
/// Build GcpAuth from nomi-config's VertexConfig
|
||||
pub fn auth_from_config(vc: &nomi_config::config::VertexConfig) -> GcpAuth {
|
||||
if let Some(creds_file) = &vc.credentials_file {
|
||||
GcpAuth::ServiceAccount {
|
||||
key_file: creds_file.clone(),
|
||||
}
|
||||
} else {
|
||||
GcpAuth::ApplicationDefault
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
// Integration tests for AnthropicProvider using wiremock to mock the Anthropic API.
|
||||
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use nomi_config::compat::ProviderCompat;
|
||||
use nomi_providers::anthropic::AnthropicProvider;
|
||||
use nomi_providers::{LlmProvider, ProviderError};
|
||||
use nomi_types::llm::{LlmEvent, LlmRequest, ThinkingConfig};
|
||||
use nomi_types::message::{ContentBlock, Message, Role, StopReason};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn minimal_request() -> LlmRequest {
|
||||
LlmRequest {
|
||||
model: "claude-3-5-sonnet-20241022".to_string(),
|
||||
system: "You are helpful.".to_string(),
|
||||
messages: vec![Message::new(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
)],
|
||||
tools: vec![],
|
||||
max_tokens: 1024,
|
||||
thinking: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a complete SSE body for a simple text response.
|
||||
fn text_sse_body(text: &str) -> String {
|
||||
format!(
|
||||
"event: message_start\n\
|
||||
data: {{\"type\":\"message_start\",\"message\":{{\"id\":\"msg_test\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-5-sonnet-20241022\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{{\"input_tokens\":100,\"output_tokens\":1}}}}}}\n\n\
|
||||
event: content_block_start\n\
|
||||
data: {{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{{\"type\":\"text\",\"text\":\"\"}}}}\n\n\
|
||||
event: content_block_delta\n\
|
||||
data: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"text_delta\",\"text\":\"{text}\"}}}}\n\n\
|
||||
event: content_block_stop\n\
|
||||
data: {{\"type\":\"content_block_stop\",\"index\":0}}\n\n\
|
||||
event: message_delta\n\
|
||||
data: {{\"type\":\"message_delta\",\"delta\":{{\"stop_reason\":\"end_turn\",\"stop_sequence\":null}},\"usage\":{{\"output_tokens\":50}}}}\n\n\
|
||||
event: message_stop\n\
|
||||
data: {{\"type\":\"message_stop\"}}\n\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Collect all events from a receiver into a Vec, draining until closed.
|
||||
async fn collect_events(mut rx: tokio::sync::mpsc::Receiver<LlmEvent>) -> Vec<LlmEvent> {
|
||||
let mut events = Vec::new();
|
||||
while let Some(ev) = rx.recv().await {
|
||||
events.push(ev);
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_stream_text_response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A normal text SSE stream produces TextDelta events followed by a Done event.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_stream_text_response() {
|
||||
// Arrange: start a mock server
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_raw(text_sse_body("Hello, world!"), "text/event-stream"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-api-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(false);
|
||||
let request = minimal_request();
|
||||
|
||||
// Act
|
||||
let rx = provider
|
||||
.stream(&request)
|
||||
.await
|
||||
.expect("stream should succeed");
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
// Assert: at least one TextDelta and exactly one Done
|
||||
let text_deltas: Vec<&LlmEvent> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LlmEvent::TextDelta(_)))
|
||||
.collect();
|
||||
assert!(!text_deltas.is_empty(), "expected at least one TextDelta");
|
||||
|
||||
match &text_deltas[0] {
|
||||
LlmEvent::TextDelta(text) => assert_eq!(text, "Hello, world!"),
|
||||
_ => panic!("expected TextDelta"),
|
||||
}
|
||||
|
||||
let done_events: Vec<&LlmEvent> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LlmEvent::Done { .. }))
|
||||
.collect();
|
||||
assert_eq!(done_events.len(), 1, "expected exactly one Done event");
|
||||
|
||||
match done_events[0] {
|
||||
LlmEvent::Done { stop_reason, usage } => {
|
||||
assert_eq!(*stop_reason, StopReason::EndTurn);
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
}
|
||||
_ => panic!("expected Done"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_stream_text_response_crlf_framed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Some Anthropic-compatible gateways (e.g. new-api / one-api proxies) frame SSE
|
||||
/// events with CRLF ("\r\n\r\n") instead of the Anthropic API's "\n\n". The
|
||||
/// stream must still parse to TextDelta + Done rather than silently yielding an
|
||||
/// empty response. Regression test for the stepfun-proxy / new-api breakage.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_stream_text_response_crlf_framed() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// Convert the canonical LF-framed body into CRLF framing.
|
||||
let crlf_body = text_sse_body("Hello, world!").replace('\n', "\r\n");
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(crlf_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-api-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(false);
|
||||
|
||||
let rx = provider
|
||||
.stream(&minimal_request())
|
||||
.await
|
||||
.expect("stream should succeed");
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
let text: String = events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
LlmEvent::TextDelta(t) => Some(t.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(text, "Hello, world!", "CRLF-framed SSE must yield text");
|
||||
|
||||
let done = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LlmEvent::Done { .. }))
|
||||
.count();
|
||||
assert_eq!(done, 1, "expected exactly one Done event from CRLF stream");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_stream_tool_use
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An SSE stream containing a tool_use block produces a ToolUse event with
|
||||
/// accumulated JSON input.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_stream_tool_use() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let sse_body = "\
|
||||
event: message_start\n\
|
||||
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_tool\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-5-sonnet-20241022\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":80,\"output_tokens\":1}}}\n\n\
|
||||
event: content_block_start\n\
|
||||
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_abc\",\"name\":\"Read\",\"input\":{}}}\n\n\
|
||||
event: content_block_delta\n\
|
||||
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"file\"}}\n\n\
|
||||
event: content_block_delta\n\
|
||||
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"_path\\\":\\\"/tmp/test\\\"}\"}}\n\n\
|
||||
event: content_block_stop\n\
|
||||
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
|
||||
event: message_delta\n\
|
||||
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":30}}\n\n\
|
||||
event: message_stop\n\
|
||||
data: {\"type\":\"message_stop\"}\n\n";
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-api-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(false);
|
||||
let request = minimal_request();
|
||||
|
||||
// Act
|
||||
let rx = provider
|
||||
.stream(&request)
|
||||
.await
|
||||
.expect("stream should succeed");
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
// Assert: one ToolUse event with correct fields
|
||||
let tool_events: Vec<&LlmEvent> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LlmEvent::ToolUse { .. }))
|
||||
.collect();
|
||||
assert_eq!(tool_events.len(), 1, "expected exactly one ToolUse event");
|
||||
|
||||
match tool_events[0] {
|
||||
LlmEvent::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
assert_eq!(id, "toolu_abc");
|
||||
assert_eq!(name, "Read");
|
||||
assert_eq!(input["file_path"], "/tmp/test");
|
||||
}
|
||||
_ => panic!("expected ToolUse"),
|
||||
}
|
||||
|
||||
// Done event should reflect tool_use stop reason
|
||||
let done_events: Vec<&LlmEvent> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LlmEvent::Done { .. }))
|
||||
.collect();
|
||||
assert_eq!(done_events.len(), 1);
|
||||
match done_events[0] {
|
||||
LlmEvent::Done { stop_reason, .. } => {
|
||||
assert_eq!(*stop_reason, StopReason::ToolUse);
|
||||
}
|
||||
_ => panic!("expected Done"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_stream_with_thinking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An SSE stream containing a thinking block produces ThinkingDelta events.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_stream_with_thinking() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let sse_body = "\
|
||||
event: message_start\n\
|
||||
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_think\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-5-sonnet-20241022\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":90,\"output_tokens\":1}}}\n\n\
|
||||
event: content_block_start\n\
|
||||
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n\
|
||||
event: content_block_delta\n\
|
||||
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Let me think...\"}}\n\n\
|
||||
event: content_block_stop\n\
|
||||
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
|
||||
event: content_block_start\n\
|
||||
data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\
|
||||
event: content_block_delta\n\
|
||||
data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Answer.\"}}\n\n\
|
||||
event: content_block_stop\n\
|
||||
data: {\"type\":\"content_block_stop\",\"index\":1}\n\n\
|
||||
event: message_delta\n\
|
||||
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":20}}\n\n\
|
||||
event: message_stop\n\
|
||||
data: {\"type\":\"message_stop\"}\n\n";
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Enable thinking in the request
|
||||
let mut request = minimal_request();
|
||||
request.thinking = Some(ThinkingConfig::Enabled {
|
||||
budget_tokens: 5000,
|
||||
});
|
||||
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-api-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(false);
|
||||
|
||||
// Act
|
||||
let rx = provider
|
||||
.stream(&request)
|
||||
.await
|
||||
.expect("stream should succeed");
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
// Assert: ThinkingDelta event present with expected content
|
||||
let thinking_events: Vec<&LlmEvent> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LlmEvent::ThinkingDelta(_)))
|
||||
.collect();
|
||||
assert!(
|
||||
!thinking_events.is_empty(),
|
||||
"expected at least one ThinkingDelta"
|
||||
);
|
||||
|
||||
match thinking_events[0] {
|
||||
LlmEvent::ThinkingDelta(text) => assert_eq!(text, "Let me think..."),
|
||||
_ => panic!("expected ThinkingDelta"),
|
||||
}
|
||||
|
||||
// TextDelta should also be present
|
||||
let text_events: Vec<&LlmEvent> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LlmEvent::TextDelta(_)))
|
||||
.collect();
|
||||
assert!(
|
||||
!text_events.is_empty(),
|
||||
"expected at least one TextDelta after thinking"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_auth_error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A 401 response from the API should produce a ProviderError::Api with status 401.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_auth_error() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let error_body =
|
||||
r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string(error_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = AnthropicProvider::new(
|
||||
"bad-api-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(false);
|
||||
let request = minimal_request();
|
||||
|
||||
// Act
|
||||
let result = provider.stream(&request).await;
|
||||
|
||||
// Assert: returns an Api error with status 401
|
||||
match result {
|
||||
Err(ProviderError::Api { status, message }) => {
|
||||
assert_eq!(status, 401);
|
||||
assert!(
|
||||
message.contains("authentication_error") || message.contains("invalid x-api-key"),
|
||||
"unexpected error message: {message}"
|
||||
);
|
||||
}
|
||||
Err(other) => panic!("expected Api error, got: {other:?}"),
|
||||
Ok(_) => panic!("expected an error but stream succeeded"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_rate_limit_retryable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A 429 response from the API should produce a ProviderError::RateLimited.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_rate_limit_retryable() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(429).set_body_string(
|
||||
r#"{"type":"error","error":{"type":"rate_limit_error","message":"rate limit exceeded"}}"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-api-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(false);
|
||||
let request = minimal_request();
|
||||
|
||||
// Act
|
||||
let result = provider.stream(&request).await;
|
||||
|
||||
// Assert: RateLimited error, which is retryable
|
||||
match result {
|
||||
Err(ProviderError::RateLimited { retry_after_ms, .. }) => {
|
||||
assert!(retry_after_ms > 0, "retry_after_ms should be positive");
|
||||
}
|
||||
Err(other) => panic!("expected RateLimited error, got: {other:?}"),
|
||||
Ok(_) => panic!("expected an error but stream succeeded"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_request_headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The provider must send the correct HTTP headers: x-api-key, anthropic-version,
|
||||
/// and content-type. This test uses wiremock header matchers to verify them.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_request_headers() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// Register the mock with header matchers; only requests carrying the
|
||||
// correct headers will match and receive a 200 response.
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.and(header("x-api-key", "my-secret-key"))
|
||||
.and(header("anthropic-version", "2023-06-01"))
|
||||
.and(header("content-type", "application/json"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(text_sse_body("ok"), "text/event-stream"),
|
||||
)
|
||||
.expect(1) // exactly one matching request must arrive
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = AnthropicProvider::new(
|
||||
"my-secret-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(false);
|
||||
let request = minimal_request();
|
||||
|
||||
// Act — should succeed because the headers are correct
|
||||
let result = provider.stream(&request).await;
|
||||
assert!(result.is_ok(), "stream failed: {:?}", result.err());
|
||||
|
||||
// Drain the channel so the spawned task finishes
|
||||
if let Ok(rx) = result {
|
||||
collect_events(rx).await;
|
||||
}
|
||||
|
||||
// wiremock verifies the `expect(1)` assertion when MockServer is dropped;
|
||||
// if the header matcher was not satisfied the test will panic here.
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_prompt_caching_header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// When cache is enabled the provider must include the anthropic-beta header
|
||||
/// for prompt caching.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_prompt_caching_header() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.and(header("anthropic-beta", "prompt-caching-2024-07-31"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(text_sse_body("cached"), "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// with_cache(true) — default, but explicit here for clarity
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-api-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(true);
|
||||
let request = minimal_request();
|
||||
|
||||
let result = provider.stream(&request).await;
|
||||
assert!(result.is_ok(), "stream failed: {:?}", result.err());
|
||||
|
||||
if let Ok(rx) = result {
|
||||
collect_events(rx).await;
|
||||
}
|
||||
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_anthropic_no_prompt_caching_header_when_disabled
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// When cache is disabled the anthropic-beta header must NOT be present.
|
||||
/// We verify this by mounting a mock that matches only without that header and
|
||||
/// checking it receives exactly one request.
|
||||
#[tokio::test]
|
||||
async fn test_anthropic_no_prompt_caching_header_when_disabled() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// This mock matches any POST to /v1/messages (no anthropic-beta requirement).
|
||||
// We then confirm via received_requests that the header is absent.
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(text_sse_body("no cache"), "text/event-stream"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-api-key",
|
||||
&server.uri(),
|
||||
ProviderCompat::anthropic_defaults(),
|
||||
)
|
||||
.with_cache(false);
|
||||
let request = minimal_request();
|
||||
|
||||
let result = provider.stream(&request).await;
|
||||
assert!(result.is_ok(), "stream failed: {:?}", result.err());
|
||||
|
||||
if let Ok(rx) = result {
|
||||
collect_events(rx).await;
|
||||
}
|
||||
|
||||
// Inspect the captured request to assert that anthropic-beta is absent
|
||||
let received = server.received_requests().await.unwrap();
|
||||
assert_eq!(received.len(), 1, "expected exactly one request");
|
||||
let has_beta = received[0].headers.contains_key("anthropic-beta");
|
||||
assert!(
|
||||
!has_beta,
|
||||
"anthropic-beta header should not be present when cache is disabled"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_config::compat::ProviderCompat;
|
||||
use nomi_providers::LlmProvider;
|
||||
use nomi_providers::openai::OpenAIProvider;
|
||||
use nomi_types::llm::{LlmEvent, LlmRequest};
|
||||
use nomi_types::message::{ContentBlock, Message, Role, StopReason};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal LlmRequest suitable for all tests.
|
||||
fn make_request() -> LlmRequest {
|
||||
LlmRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
system: "You are a test assistant.".to_string(),
|
||||
messages: vec![Message::new(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
)],
|
||||
tools: vec![],
|
||||
max_tokens: 512,
|
||||
thinking: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all events from the receiver until the channel closes.
|
||||
async fn collect_events(mut rx: tokio::sync::mpsc::Receiver<LlmEvent>) -> Vec<LlmEvent> {
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = rx.recv().await {
|
||||
events.push(event);
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Build a raw SSE body string from a slice of JSON lines.
|
||||
/// Each line is wrapped in `data: ...\n\n` and a final `data: [DONE]\n\n` is appended.
|
||||
fn build_sse_body(data_lines: &[&str]) -> String {
|
||||
let mut body = String::new();
|
||||
for line in data_lines {
|
||||
body.push_str("data: ");
|
||||
body.push_str(line);
|
||||
body.push_str("\n\n");
|
||||
}
|
||||
body.push_str("data: [DONE]\n\n");
|
||||
body
|
||||
}
|
||||
|
||||
async fn start_server_after_initial_connect_refusal(sse_body: String) -> String {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = probe.local_addr().unwrap();
|
||||
drop(probe);
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let listener = TcpListener::bind(addr).await.unwrap();
|
||||
let (mut second, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0_u8; 4096];
|
||||
let _ = second.read(&mut buf).await.unwrap();
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
sse_body.len(),
|
||||
sse_body
|
||||
);
|
||||
second.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_stream_text_response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that a normal text response (multiple content deltas followed by a
|
||||
/// stop chunk with usage) is parsed into the correct sequence of TextDelta
|
||||
/// and Done events.
|
||||
#[tokio::test]
|
||||
async fn test_openai_stream_text_response() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// Chunk 1: first text delta
|
||||
let chunk1 = json!({
|
||||
"id": "chatcmpl-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": { "role": "assistant", "content": "Hello" },
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Chunk 2: second text delta
|
||||
let chunk2 = json!({
|
||||
"id": "chatcmpl-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": { "content": ", world!" },
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Chunk 3: finish_reason = "stop" with usage
|
||||
let chunk3 = json!({
|
||||
"id": "chatcmpl-001",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 25,
|
||||
"completion_tokens": 10
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse_body = build_sse_body(&[&chunk1, &chunk2, &chunk3]);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.and(header("authorization", "Bearer test-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider =
|
||||
OpenAIProvider::new("test-key", &server.uri(), ProviderCompat::openai_defaults());
|
||||
let rx = provider.stream(&make_request()).await.unwrap();
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
// Expect: TextDelta("Hello"), TextDelta(", world!"), Done{EndTurn}
|
||||
assert_eq!(events.len(), 3, "expected 3 events, got: {:?}", events);
|
||||
|
||||
match &events[0] {
|
||||
LlmEvent::TextDelta(text) => assert_eq!(text, "Hello"),
|
||||
e => panic!("expected TextDelta, got: {:?}", e),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
LlmEvent::TextDelta(text) => assert_eq!(text, ", world!"),
|
||||
e => panic!("expected TextDelta, got: {:?}", e),
|
||||
}
|
||||
|
||||
match &events[2] {
|
||||
LlmEvent::Done { stop_reason, usage } => {
|
||||
assert_eq!(*stop_reason, StopReason::EndTurn);
|
||||
assert_eq!(usage.input_tokens, 25);
|
||||
assert_eq!(usage.output_tokens, 10);
|
||||
}
|
||||
e => panic!("expected Done, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_initial_connect_error_is_retried
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that the provider retries when the initial HTTP request fails before
|
||||
/// receiving any response. This covers transient connect/TLS failures where no
|
||||
/// model output has been emitted yet.
|
||||
#[tokio::test]
|
||||
async fn test_openai_initial_connect_error_is_retried() {
|
||||
let chunk = json!({
|
||||
"id": "chatcmpl-retry",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": { "role": "assistant", "content": "Recovered" },
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let finish = json!({
|
||||
"id": "chatcmpl-retry",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let sse_body = build_sse_body(&[&chunk, &finish]);
|
||||
let base_url = start_server_after_initial_connect_refusal(sse_body).await;
|
||||
|
||||
let provider = OpenAIProvider::new("test-key", &base_url, ProviderCompat::openai_defaults());
|
||||
let rx = provider.stream(&make_request()).await.unwrap();
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
2,
|
||||
"expected retry success events, got: {:?}",
|
||||
events
|
||||
);
|
||||
match &events[0] {
|
||||
LlmEvent::TextDelta(text) => assert_eq!(text, "Recovered"),
|
||||
e => panic!("expected TextDelta, got: {:?}", e),
|
||||
}
|
||||
match &events[1] {
|
||||
LlmEvent::Done { stop_reason, .. } => assert_eq!(*stop_reason, StopReason::EndTurn),
|
||||
e => panic!("expected Done, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_stream_tool_call_aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that a tool call streamed in multiple delta chunks (id in first chunk,
|
||||
/// name in first chunk, arguments split across chunks) is correctly aggregated
|
||||
/// into a single ToolUse event.
|
||||
#[tokio::test]
|
||||
async fn test_openai_stream_tool_call_aggregation() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// Chunk 1: tool call header — id and function name arrive first
|
||||
let chunk1 = json!({
|
||||
"id": "chatcmpl-002",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\":"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Chunk 2: arguments continuation
|
||||
let chunk2 = json!({
|
||||
"id": "chatcmpl-002",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"function": {
|
||||
"arguments": "\"/tmp/test.txt\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Chunk 3: finish_reason = "tool_calls" with usage
|
||||
let chunk3 = json!({
|
||||
"id": "chatcmpl-002",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 40,
|
||||
"completion_tokens": 15
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse_body = build_sse_body(&[&chunk1, &chunk2, &chunk3]);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider =
|
||||
OpenAIProvider::new("test-key", &server.uri(), ProviderCompat::openai_defaults());
|
||||
let rx = provider.stream(&make_request()).await.unwrap();
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
// Expect: ToolUse, Done{ToolUse}
|
||||
assert_eq!(events.len(), 2, "expected 2 events, got: {:?}", events);
|
||||
|
||||
match &events[0] {
|
||||
LlmEvent::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
assert_eq!(id, "call_abc123");
|
||||
assert_eq!(name, "read_file");
|
||||
assert_eq!(input["path"], "/tmp/test.txt");
|
||||
}
|
||||
e => panic!("expected ToolUse, got: {:?}", e),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
LlmEvent::Done { stop_reason, usage } => {
|
||||
assert_eq!(*stop_reason, StopReason::ToolUse);
|
||||
assert_eq!(usage.input_tokens, 40);
|
||||
assert_eq!(usage.output_tokens, 15);
|
||||
}
|
||||
e => panic!("expected Done, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_multiple_tool_calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that when the API streams multiple parallel tool calls (different
|
||||
/// indices) they are all emitted as separate ToolUse events.
|
||||
#[tokio::test]
|
||||
async fn test_openai_multiple_tool_calls() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// Chunk 1: first tool call (index 0)
|
||||
let chunk1 = json!({
|
||||
"id": "chatcmpl-003",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": "call_tool0",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_files",
|
||||
"arguments": "{\"dir\": \"/tmp\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Chunk 2: second tool call (index 1)
|
||||
let chunk2 = json!({
|
||||
"id": "chatcmpl-003",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 1,
|
||||
"id": "call_tool1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\": \"/etc/hosts\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Chunk 3: finish_reason = "tool_calls"
|
||||
let chunk3 = json!({
|
||||
"id": "chatcmpl-003",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 60,
|
||||
"completion_tokens": 20
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse_body = build_sse_body(&[&chunk1, &chunk2, &chunk3]);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider =
|
||||
OpenAIProvider::new("test-key", &server.uri(), ProviderCompat::openai_defaults());
|
||||
let rx = provider.stream(&make_request()).await.unwrap();
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
// Expect: ToolUse (index 0), ToolUse (index 1), Done{ToolUse}
|
||||
assert_eq!(events.len(), 3, "expected 3 events, got: {:?}", events);
|
||||
|
||||
match &events[0] {
|
||||
LlmEvent::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
assert_eq!(id, "call_tool0");
|
||||
assert_eq!(name, "list_files");
|
||||
assert_eq!(input["dir"], "/tmp");
|
||||
}
|
||||
e => panic!("expected first ToolUse, got: {:?}", e),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
LlmEvent::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
assert_eq!(id, "call_tool1");
|
||||
assert_eq!(name, "read_file");
|
||||
assert_eq!(input["path"], "/etc/hosts");
|
||||
}
|
||||
e => panic!("expected second ToolUse, got: {:?}", e),
|
||||
}
|
||||
|
||||
match &events[2] {
|
||||
LlmEvent::Done { stop_reason, .. } => {
|
||||
assert_eq!(*stop_reason, StopReason::ToolUse);
|
||||
}
|
||||
e => panic!("expected Done, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_stream_state_transitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that the stream correctly stops processing events once it encounters
|
||||
/// the `[DONE]` sentinel — any data after [DONE] is ignored and the receiver
|
||||
/// channel closes cleanly.
|
||||
#[tokio::test]
|
||||
async fn test_openai_stream_state_transitions() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// A single text delta followed by a stop chunk, then the [DONE] sentinel.
|
||||
let chunk1 = json!({
|
||||
"id": "chatcmpl-004",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": { "content": "Transition test." },
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let chunk2 = json!({
|
||||
"id": "chatcmpl-004",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// Build SSE body manually: two data lines, then [DONE], then a stray line
|
||||
// that must NOT produce any events.
|
||||
let mut sse_body = String::new();
|
||||
sse_body.push_str("data: ");
|
||||
sse_body.push_str(&chunk1);
|
||||
sse_body.push_str("\n\n");
|
||||
sse_body.push_str("data: ");
|
||||
sse_body.push_str(&chunk2);
|
||||
sse_body.push_str("\n\n");
|
||||
sse_body.push_str("data: [DONE]\n\n");
|
||||
// Stray chunk after [DONE] — must be ignored
|
||||
sse_body.push_str("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ignored\"},\"finish_reason\":null}]}\n\n");
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider =
|
||||
OpenAIProvider::new("test-key", &server.uri(), ProviderCompat::openai_defaults());
|
||||
let rx = provider.stream(&make_request()).await.unwrap();
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
// Expect exactly: TextDelta, Done — the trailing chunk after [DONE] is discarded.
|
||||
assert_eq!(events.len(), 2, "expected 2 events, got: {:?}", events);
|
||||
|
||||
match &events[0] {
|
||||
LlmEvent::TextDelta(text) => assert_eq!(text, "Transition test."),
|
||||
e => panic!("expected TextDelta, got: {:?}", e),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
LlmEvent::Done { stop_reason, usage } => {
|
||||
assert_eq!(*stop_reason, StopReason::EndTurn);
|
||||
assert_eq!(usage.input_tokens, 10);
|
||||
assert_eq!(usage.output_tokens, 5);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
}
|
||||
e => panic!("expected Done, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_api_error_non_success_status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that a non-2xx HTTP response is surfaced as a ProviderError::Api.
|
||||
#[tokio::test]
|
||||
async fn test_openai_api_error_non_success_status() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string(
|
||||
r#"{"error":{"message":"Invalid API key","type":"invalid_request_error"}}"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = OpenAIProvider::new("bad-key", &server.uri(), ProviderCompat::openai_defaults());
|
||||
let result = provider.stream(&make_request()).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
nomi_providers::ProviderError::Api { status, .. } => {
|
||||
assert_eq!(status, 401);
|
||||
}
|
||||
e => panic!("expected Api error, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_rate_limited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that a 429 response is surfaced as ProviderError::RateLimited.
|
||||
#[tokio::test]
|
||||
async fn test_openai_rate_limited() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let body = r#"{"error":{"message":"You exceeded your current quota","type":"insufficient_quota","code":"insufficient_quota"}}"#;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(429).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider =
|
||||
OpenAIProvider::new("test-key", &server.uri(), ProviderCompat::openai_defaults());
|
||||
let result = provider.stream(&make_request()).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
let display = err.to_string();
|
||||
match err {
|
||||
nomi_providers::ProviderError::RateLimited { retry_after_ms, .. } => {
|
||||
assert_eq!(retry_after_ms, 5000);
|
||||
}
|
||||
e => panic!("expected RateLimited error, got: {:?}", e),
|
||||
}
|
||||
|
||||
assert!(
|
||||
display.contains("insufficient_quota"),
|
||||
"rate limit error should preserve provider body, got: {display}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_stream_max_tokens_stop_reason
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that finish_reason "length" maps to StopReason::MaxTokens.
|
||||
#[tokio::test]
|
||||
async fn test_openai_stream_max_tokens_stop_reason() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let chunk1 = json!({
|
||||
"id": "chatcmpl-005",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": { "content": "Truncated" },
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let chunk2 = json!({
|
||||
"id": "chatcmpl-005",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "length"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 512
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse_body = build_sse_body(&[&chunk1, &chunk2]);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider =
|
||||
OpenAIProvider::new("test-key", &server.uri(), ProviderCompat::openai_defaults());
|
||||
let rx = provider.stream(&make_request()).await.unwrap();
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
match &events[1] {
|
||||
LlmEvent::Done { stop_reason, usage } => {
|
||||
assert_eq!(*stop_reason, StopReason::MaxTokens);
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 512);
|
||||
}
|
||||
e => panic!("expected Done with MaxTokens, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// test_openai_stream_empty_content_delta_skipped
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that empty content strings in deltas do NOT produce TextDelta events
|
||||
/// (the provider filters them out).
|
||||
#[tokio::test]
|
||||
async fn test_openai_stream_empty_content_delta_skipped() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// Chunk with empty content — should be silently skipped
|
||||
let chunk_empty = json!({
|
||||
"id": "chatcmpl-006",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": { "content": "" },
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let chunk_text = json!({
|
||||
"id": "chatcmpl-006",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": { "content": "actual content" },
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let chunk_done = json!({
|
||||
"id": "chatcmpl-006",
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": { "prompt_tokens": 5, "completion_tokens": 3 }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let sse_body = build_sse_body(&[&chunk_empty, &chunk_text, &chunk_done]);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider =
|
||||
OpenAIProvider::new("test-key", &server.uri(), ProviderCompat::openai_defaults());
|
||||
let rx = provider.stream(&make_request()).await.unwrap();
|
||||
let events = collect_events(rx).await;
|
||||
|
||||
// Expect only TextDelta("actual content") and Done — no empty TextDelta
|
||||
assert_eq!(events.len(), 2, "expected 2 events, got: {:?}", events);
|
||||
|
||||
match &events[0] {
|
||||
LlmEvent::TextDelta(text) => assert_eq!(text, "actual content"),
|
||||
e => panic!("expected TextDelta with actual content, got: {:?}", e),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
LlmEvent::Done { stop_reason, .. } => assert_eq!(*stop_reason, StopReason::EndTurn),
|
||||
e => panic!("expected Done, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user