Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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(
&region, "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(&region, &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(
&region,
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,
&region,
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
}
}