Appending /v1 by testing only the last path segment doubled Google's /v1beta/openai into /v1beta/openai/v1, where a POST hangs with zero bytes and GET /models still answers 200. Only a bare authority now gets the default; /v1, /v4 and /zen/go/v1 roots pass through.
1061 lines
35 KiB
Rust
1061 lines
35 KiB
Rust
#![allow(dead_code)] // WIP scaffolding not yet wired
|
|
use anyhow::{Context, Result};
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::time::Duration;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::bridge::provider::LlmProvider;
|
|
|
|
/// Bifrost Inference Client
|
|
///
|
|
/// Bifrost is an OpenAI-compatible API gateway: http://127.0.0.1:3360/v1
|
|
#[derive(Debug, Clone)]
|
|
pub struct BifrostClient {
|
|
/// Base URL including its version path (e.g. "http://127.0.0.1:3360/v1"
|
|
/// or "https://api.z.ai/api/coding/paas/v4").
|
|
base_url: String,
|
|
/// Provider identity label (logs/catalog). Defaults to "bifrost"; a
|
|
/// differently-branded OpenAI-compatible endpoint (e.g. z.ai) sets its own.
|
|
id: String,
|
|
/// Bearer token for auth
|
|
api_key: String,
|
|
/// Optional virtual key for x-bf-vk header
|
|
virtual_key: String,
|
|
/// Reqwest HTTP client
|
|
client: reqwest::Client,
|
|
/// Default model for chat
|
|
default_model: String,
|
|
/// Retry policy — configurable, eventually agent-adjustable.
|
|
retry_policy: RetryPolicy,
|
|
}
|
|
|
|
/// A single part in the OpenAI content array (for multimodal messages).
|
|
/// When content is an array, each element has a `type` discriminator.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ContentPart {
|
|
Text {
|
|
text: String,
|
|
},
|
|
#[serde(rename = "image_url")]
|
|
ImageUrl {
|
|
image_url: ImageUrlSource,
|
|
},
|
|
}
|
|
|
|
/// Source for an image URL content part — always a data URI.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ImageUrlSource {
|
|
pub url: String,
|
|
}
|
|
|
|
/// Content value that can be either a plain string or a multimodal part array.
|
|
/// Uses `#[serde(untagged)]` so both wire shapes deserialize correctly.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum ContentValue {
|
|
Text(String),
|
|
Parts(Vec<ContentPart>),
|
|
}
|
|
|
|
impl Default for ContentValue {
|
|
fn default() -> Self {
|
|
Self::Text(String::new())
|
|
}
|
|
}
|
|
|
|
impl ContentValue {
|
|
/// Extract the text content: for `Text` returns the string directly;
|
|
/// for `Parts`, joins all text parts together.
|
|
pub fn as_text(&self) -> String {
|
|
match self {
|
|
ContentValue::Text(t) => t.clone(),
|
|
ContentValue::Parts(parts) => {
|
|
let mut text = String::new();
|
|
for part in parts {
|
|
if let ContentPart::Text { text: t } = part {
|
|
text.push_str(t);
|
|
}
|
|
}
|
|
text
|
|
}
|
|
}
|
|
}
|
|
|
|
/// True if the content is empty (no text and no parts).
|
|
pub fn is_empty(&self) -> bool {
|
|
match self {
|
|
ContentValue::Text(t) => t.is_empty(),
|
|
ContentValue::Parts(parts) => parts.is_empty(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A message in OpenAI chat format.
|
|
///
|
|
/// For plain user/assistant/system turns, only `role` and `content` are set
|
|
/// and the wire shape matches `{role, content}`. For tool-calling turns the
|
|
/// optional fields engage:
|
|
///
|
|
/// - assistant calling tools: `tool_calls = Some(...)`, `content` usually `""`
|
|
/// - tool result: `role = "tool"`, `tool_call_id = Some(id)`, `name = Some(fn)`
|
|
///
|
|
/// Skipping the empty optionals on the wire keeps unrelated providers happy.
|
|
///
|
|
/// For multimodal messages, `content` may be `ContentValue::Parts` — an array of
|
|
/// `ContentPart` variants (text + image_url parts) in the OpenAI format.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Message {
|
|
pub role: String,
|
|
pub content: ContentValue,
|
|
/// Tool calls emitted by the assistant (OpenAI tool-use schema).
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub tool_calls: Option<Vec<MessageToolCall>>,
|
|
/// Links a `role: "tool"` message back to the assistant's call id.
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub tool_call_id: Option<String>,
|
|
/// Function name for `role: "tool"` messages (some providers require it).
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub name: Option<String>,
|
|
/// Signed thinking block from the assistant turn, carried so it can be
|
|
/// replayed. Anthropic requires the thinking block that preceded a
|
|
/// `tool_use` to come back with it, byte-identical and with its signature,
|
|
/// or the follow-up request is rejected. Only providers that return a
|
|
/// signed block populate this; it is skipped on the wire when absent, so
|
|
/// OpenAI-shaped gateways never see it.
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub thinking: Option<ThinkingBlock>,
|
|
}
|
|
|
|
/// An Anthropic thinking block preserved verbatim for replay. Both halves are
|
|
/// required — text without its signature cannot be replayed, so this is only
|
|
/// ever constructed when the provider returned both.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ThinkingBlock {
|
|
pub text: String,
|
|
pub signature: String,
|
|
}
|
|
|
|
impl Message {
|
|
/// Plain text message — system / user / assistant without tool use.
|
|
pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
|
|
Self {
|
|
role: role.into(),
|
|
content: ContentValue::Text(content.into()),
|
|
tool_calls: None,
|
|
tool_call_id: None,
|
|
name: None,
|
|
thinking: None,
|
|
}
|
|
}
|
|
|
|
/// Multimodal message with text + image content parts, under any role.
|
|
///
|
|
/// The role must be carried: an assistant message holding an image
|
|
/// replayed as `user` reads to the model as though the human said it.
|
|
pub fn multimodal(role: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
|
Self {
|
|
role: role.into(),
|
|
content: ContentValue::Parts(parts),
|
|
tool_calls: None,
|
|
tool_call_id: None,
|
|
name: None,
|
|
thinking: None,
|
|
}
|
|
}
|
|
|
|
/// Multimodal user message with text + image content parts.
|
|
///
|
|
/// The text argument is ignored — the parts array is the whole content.
|
|
pub fn multimodal_user(_text: impl Into<String>, parts: Vec<ContentPart>) -> Self {
|
|
Self::multimodal("user", parts)
|
|
}
|
|
|
|
/// Assistant message that called tools. `content` may be empty.
|
|
pub fn assistant_tool_calls(content: impl Into<String>, calls: Vec<MessageToolCall>) -> Self {
|
|
Self {
|
|
role: "assistant".to_string(),
|
|
content: ContentValue::Text(content.into()),
|
|
tool_calls: Some(calls),
|
|
tool_call_id: None,
|
|
name: None,
|
|
thinking: None,
|
|
}
|
|
}
|
|
|
|
/// Attach a signed thinking block for replay. No-op unless both the text
|
|
/// and the signature are present.
|
|
pub fn with_thinking(
|
|
mut self,
|
|
text: Option<impl Into<String>>,
|
|
signature: Option<impl Into<String>>,
|
|
) -> Self {
|
|
if let (Some(text), Some(signature)) = (text, signature) {
|
|
self.thinking = Some(ThinkingBlock {
|
|
text: text.into(),
|
|
signature: signature.into(),
|
|
});
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Tool-result message bound to a prior assistant tool_call by id.
|
|
pub fn tool_result(
|
|
tool_call_id: impl Into<String>,
|
|
name: impl Into<String>,
|
|
content: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
role: "tool".to_string(),
|
|
content: ContentValue::Text(content.into()),
|
|
tool_calls: None,
|
|
tool_call_id: Some(tool_call_id.into()),
|
|
name: Some(name.into()),
|
|
thinking: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tool call emitted by the assistant — serializable in OpenAI shape:
|
|
/// `{id, type: "function", function: {name, arguments: "<json-string>"}}`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MessageToolCall {
|
|
pub id: String,
|
|
#[serde(rename = "type")]
|
|
pub tool_type: String,
|
|
pub function: MessageToolCallFunction,
|
|
}
|
|
|
|
impl MessageToolCall {
|
|
pub fn function(
|
|
id: impl Into<String>,
|
|
name: impl Into<String>,
|
|
arguments: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
tool_type: "function".to_string(),
|
|
function: MessageToolCallFunction {
|
|
name: name.into(),
|
|
arguments: arguments.into(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MessageToolCallFunction {
|
|
pub name: String,
|
|
pub arguments: String,
|
|
}
|
|
|
|
/// A tool definition in OpenAI format
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ToolDefinition {
|
|
#[serde(rename = "type")]
|
|
pub tool_type: String,
|
|
pub function: ToolFunction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ToolFunction {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub parameters: serde_json::Value,
|
|
}
|
|
|
|
/// Chat completion request (OpenAI format)
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ChatCompletionRequest {
|
|
pub model: String,
|
|
pub messages: Vec<Message>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stream: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub max_tokens: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub temperature: Option<f32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tools: Option<Vec<ToolDefinition>>,
|
|
}
|
|
|
|
/// Response from a non-streaming chat completion
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ChatCompletionResponse {
|
|
pub id: String,
|
|
pub choices: Vec<Choice>,
|
|
pub usage: Option<Usage>,
|
|
#[serde(default)]
|
|
pub model: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Choice {
|
|
pub index: u32,
|
|
#[serde(default)]
|
|
pub finish_reason: Option<String>,
|
|
pub message: ResponseMessage,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ResponseMessage {
|
|
pub role: String,
|
|
#[serde(default)]
|
|
pub content: Option<String>,
|
|
// OpenRouter says "reasoning"; DeepSeek and z.ai say "reasoning_content".
|
|
#[serde(default, alias = "reasoning_content")]
|
|
pub reasoning: Option<String>,
|
|
#[serde(default)]
|
|
pub tool_calls: Option<Vec<ToolCall>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ToolCall {
|
|
pub id: String,
|
|
#[serde(rename = "type")]
|
|
pub tool_type: String,
|
|
pub function: ToolCallFunction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ToolCallFunction {
|
|
pub name: String,
|
|
pub arguments: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub struct Usage {
|
|
pub prompt_tokens: u32,
|
|
pub completion_tokens: u32,
|
|
#[serde(default)]
|
|
pub total_tokens: u32,
|
|
/// Prefix served from cache. Anthropic reports the uncached remainder as
|
|
/// `input_tokens`, so these two are the only way to tell a cache hit from a
|
|
/// short prompt — without them there is no way to know caching works.
|
|
#[serde(default)]
|
|
pub cache_read_tokens: u32,
|
|
#[serde(default)]
|
|
pub cache_write_tokens: u32,
|
|
}
|
|
|
|
/// Stream chunk from Bifrost (OpenAI SSE format)
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct StreamChunk {
|
|
pub choices: Vec<StreamChoice>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct StreamChoice {
|
|
pub index: u32,
|
|
pub delta: Delta,
|
|
#[serde(default)]
|
|
pub finish_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Delta {
|
|
#[serde(default)]
|
|
pub content: Option<String>,
|
|
// OpenRouter says "reasoning"; DeepSeek and z.ai say "reasoning_content".
|
|
#[serde(default, alias = "reasoning_content")]
|
|
pub reasoning: Option<String>,
|
|
#[serde(default)]
|
|
pub role: Option<String>,
|
|
#[serde(default)]
|
|
pub tool_calls: Option<Vec<StreamToolCall>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct StreamToolCall {
|
|
pub index: u32,
|
|
pub id: Option<String>,
|
|
#[serde(rename = "type")]
|
|
pub tool_type: Option<String>,
|
|
pub function: Option<StreamToolCallFunction>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct StreamToolCallFunction {
|
|
pub name: Option<String>,
|
|
pub arguments: Option<String>,
|
|
}
|
|
|
|
/// Parsed result from a chat completion (non-streaming)
|
|
#[derive(Debug, Clone)]
|
|
pub struct CompletionResult {
|
|
pub content: String,
|
|
pub reasoning: Option<String>,
|
|
/// Opaque signature for the thinking block in `reasoning`. Anthropic
|
|
/// returns it and requires it back verbatim when the same assistant turn
|
|
/// carried a `tool_use`. `None` for providers that don't sign thinking.
|
|
pub reasoning_signature: Option<String>,
|
|
pub tool_calls: Vec<ParsedToolCall>,
|
|
pub finish_reason: Option<String>,
|
|
pub usage: Option<Usage>,
|
|
}
|
|
|
|
/// A parsed tool call ready for execution
|
|
#[derive(Debug, Clone)]
|
|
pub struct ParsedToolCall {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub arguments: serde_json::Value,
|
|
}
|
|
|
|
/// Retry behavior for transient inference failures.
|
|
/// Defaults are conservative — the agent can request changes via
|
|
/// the memory system (e.g. writing to `system/dynamic/retry_policy.md`).
|
|
#[derive(Debug, Clone)]
|
|
pub struct RetryPolicy {
|
|
pub max_retries: u32,
|
|
pub base_delay_ms: u64,
|
|
pub max_delay_ms: u64,
|
|
pub fallback_models: Vec<String>,
|
|
}
|
|
|
|
impl Default for RetryPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_retries: 6,
|
|
base_delay_ms: 300,
|
|
max_delay_ms: 12000,
|
|
fallback_models: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum InferenceStrain {
|
|
Transient {
|
|
attempt: u32,
|
|
status: u16,
|
|
model: String,
|
|
delay_ms: u64,
|
|
},
|
|
Exhausted {
|
|
attempts: u32,
|
|
status: u16,
|
|
model: String,
|
|
body: String,
|
|
},
|
|
}
|
|
|
|
/// A Bifrost upstream timeout (`504` with `request_timed_out` / `"type":"timeout"`)
|
|
/// is *deterministic* — the same slow model on the same request will time out
|
|
/// again. Retrying it the full 6 times just multiplies one ~30s failure into a
|
|
/// multi-minute stall that was never going to succeed. Cap those at a single
|
|
/// retry (2 attempts total). Genuine transient blips — `503` overloaded,
|
|
/// connection resets, `429` — keep the full retry budget.
|
|
fn retry_cap(body: &str, max_retries: u32) -> u32 {
|
|
let b = body.to_ascii_lowercase();
|
|
if b.contains("request_timed_out") || b.contains("\"type\":\"timeout\"") {
|
|
1
|
|
} else {
|
|
max_retries
|
|
}
|
|
}
|
|
|
|
/// Longest `retry-after` still worth sleeping on rather than failing the call.
|
|
///
|
|
/// A window this long is a spent quota, not a burst limit, and it does not
|
|
/// clear inside a retry loop. Measured 2026-08-16: an `11997` header put the
|
|
/// subconscious pass to sleep for 3h20m with no cancel path and nothing on the
|
|
/// wire to say it was waiting rather than dead.
|
|
///
|
|
/// `claude_subscription.rs` learned this first and answers it by rotating to
|
|
/// another login, so there an *absent* `retry-after` also counts as exhausted.
|
|
/// Bifrost has nothing to rotate to, so an absent header keeps the ordinary
|
|
/// jittered backoff — only an explicitly long one fails fast.
|
|
const BURST_RETRY_CEILING_SECS: u64 = 60;
|
|
|
|
fn is_exhausted_window(status: reqwest::StatusCode, retry_after: Option<Duration>) -> bool {
|
|
status.as_u16() == 429
|
|
&& retry_after.is_some_and(|d| d.as_secs() > BURST_RETRY_CEILING_SECS)
|
|
}
|
|
|
|
/// Name the window in the error, so a failure says how long the provider
|
|
/// wanted rather than only that it refused.
|
|
fn reset_hint(retry_after: Option<Duration>) -> String {
|
|
match retry_after {
|
|
Some(d) => format!(" (provider asked for {}s)", d.as_secs()),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
fn classify_status(status: reqwest::StatusCode, body: &str) -> ErrorClass {
|
|
match status.as_u16() {
|
|
429 => {
|
|
if body.contains("quota") || body.contains("billing") || body.contains("exceeded") {
|
|
ErrorClass::Permanent
|
|
} else {
|
|
ErrorClass::Transient
|
|
}
|
|
}
|
|
500 | 502 | 503 | 504 => ErrorClass::Transient,
|
|
408 => ErrorClass::Transient,
|
|
_ => ErrorClass::Permanent,
|
|
}
|
|
}
|
|
|
|
fn jittered_delay(attempt: u32, policy: &RetryPolicy) -> Duration {
|
|
let base = policy.base_delay_ms * 2u64.pow(attempt);
|
|
let capped = base.min(policy.max_delay_ms);
|
|
let jitter = (capped as f64 * rand_jitter()) as u64;
|
|
Duration::from_millis(capped.saturating_sub(jitter / 2) + jitter)
|
|
}
|
|
|
|
fn rand_jitter() -> f64 {
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::hash::{Hash, Hasher};
|
|
let mut h = DefaultHasher::new();
|
|
std::time::SystemTime::now().hash(&mut h);
|
|
std::thread::current().id().hash(&mut h);
|
|
(h.finish() % 1000) as f64 / 1000.0
|
|
}
|
|
|
|
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
|
|
headers
|
|
.get("retry-after")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.map(Duration::from_secs)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum ErrorClass {
|
|
Transient,
|
|
Permanent,
|
|
}
|
|
|
|
/// True if the base URL carries a path past the authority. Such a base is the
|
|
/// complete API root; only a bare host gets the OpenAI default `/v1`.
|
|
///
|
|
/// Measured 2026-08-18: testing only the last segment for `v<digits>` appended
|
|
/// `/v1` to Google's `/v1beta/openai`, and a POST to the doubled path hangs
|
|
/// (40s, zero bytes) while `GET /models` returns 200 on both — so the mangled
|
|
/// base looks healthy on a listing.
|
|
fn has_own_path(url: &str) -> bool {
|
|
reqwest::Url::parse(url).is_ok_and(|u| u.path() != "/")
|
|
}
|
|
|
|
impl BifrostClient {
|
|
pub fn new(
|
|
base_url: &str,
|
|
api_key: &str,
|
|
virtual_key: &str,
|
|
default_model: &str,
|
|
timeout_secs: u64,
|
|
) -> Result<Self> {
|
|
let base = base_url.trim_end_matches('/').to_string();
|
|
let base_url = if has_own_path(&base) {
|
|
base
|
|
} else {
|
|
format!("{}/v1", base)
|
|
};
|
|
|
|
info!(
|
|
"🌉 Bifrost client initialized — model: {}, endpoint: {}, timeout: {}s",
|
|
default_model, base_url, timeout_secs
|
|
);
|
|
Ok(Self {
|
|
base_url,
|
|
id: "bifrost".to_string(),
|
|
api_key: api_key.to_string(),
|
|
virtual_key: virtual_key.to_string(),
|
|
client: reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(timeout_secs))
|
|
.build()
|
|
.context("building bifrost reqwest client")?,
|
|
default_model: default_model.to_string(),
|
|
retry_policy: RetryPolicy::default(),
|
|
})
|
|
}
|
|
|
|
/// Set the provider identity label (logs/catalog merge). Use when
|
|
/// constructing this generic OpenAI-compatible client for a non-Bifrost
|
|
/// provider such as z.ai.
|
|
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
|
self.id = id.into();
|
|
self
|
|
}
|
|
|
|
pub fn with_fallbacks(mut self, fallbacks: Vec<String>) -> Self {
|
|
self.retry_policy.fallback_models = fallbacks;
|
|
self
|
|
}
|
|
|
|
fn auth_headers(&self) -> reqwest::header::HeaderMap {
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
if !self.api_key.is_empty() {
|
|
let auth_val = format!("Bearer {}", self.api_key);
|
|
headers.insert(
|
|
reqwest::header::AUTHORIZATION,
|
|
reqwest::header::HeaderValue::from_str(&auth_val).unwrap(),
|
|
);
|
|
}
|
|
if !self.virtual_key.is_empty() {
|
|
headers.insert(
|
|
"x-bf-vk",
|
|
reqwest::header::HeaderValue::from_str(&self.virtual_key).unwrap(),
|
|
);
|
|
}
|
|
headers
|
|
}
|
|
|
|
/// List available models from Bifrost
|
|
pub async fn list_models(&self) -> Result<Vec<String>> {
|
|
let url = format!("{}/models", self.base_url);
|
|
let resp = self
|
|
.client
|
|
.get(&url)
|
|
.headers(self.auth_headers())
|
|
.send()
|
|
.await
|
|
.with_context(|| "Failed to fetch Bifrost models")?;
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let models = body["data"]
|
|
.as_array()
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|m| m["id"].as_str().map(String::from))
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Ok(models)
|
|
}
|
|
|
|
/// Send a non-streaming chat completion with retry on transient failures.
|
|
///
|
|
/// Returns the completion result plus any strain events that occurred.
|
|
/// Strain events are body-knowledge: the agent can feel when inference
|
|
/// was difficult, correlate it over time, notice patterns.
|
|
pub async fn chat_completion(
|
|
&self,
|
|
request: ChatCompletionRequest,
|
|
) -> Result<CompletionResult> {
|
|
let (result, _strain) = self.chat_completion_with_strain(request).await?;
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn chat_completion_with_strain(
|
|
&self,
|
|
request: ChatCompletionRequest,
|
|
) -> Result<(CompletionResult, Vec<InferenceStrain>)> {
|
|
let mut strain_events: Vec<InferenceStrain> = Vec::new();
|
|
|
|
// Try primary model
|
|
let fallbacks = self.retry_policy.fallback_models.clone();
|
|
|
|
match self
|
|
.try_model_with_retries(&request, &request.model, &mut strain_events)
|
|
.await
|
|
{
|
|
Ok(result) => return Ok((result, strain_events)),
|
|
Err(primary_err) => {
|
|
if fallbacks.is_empty() {
|
|
return Err(primary_err);
|
|
}
|
|
warn!(
|
|
"Primary model {} exhausted (error: {}), trying {} fallback(s)",
|
|
request.model,
|
|
primary_err,
|
|
fallbacks.len()
|
|
);
|
|
}
|
|
}
|
|
|
|
// Try each fallback model
|
|
for fallback in &fallbacks {
|
|
info!("Falling back to model: {}", fallback);
|
|
match self
|
|
.try_model_with_retries(&request, fallback, &mut strain_events)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
info!("Fallback to {} succeeded", fallback);
|
|
return Ok((result, strain_events));
|
|
}
|
|
Err(e) => {
|
|
warn!("Fallback model {} also failed: {}", fallback, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
anyhow::bail!(
|
|
"All models exhausted ({} + {} fallbacks). Last strain: {:?}",
|
|
request.model,
|
|
fallbacks.len(),
|
|
strain_events.last()
|
|
)
|
|
}
|
|
|
|
async fn try_model_with_retries(
|
|
&self,
|
|
request: &ChatCompletionRequest,
|
|
model: &str,
|
|
strain_events: &mut Vec<InferenceStrain>,
|
|
) -> Result<CompletionResult> {
|
|
let url = format!("{}/chat/completions", self.base_url);
|
|
let policy = &self.retry_policy;
|
|
|
|
let mut req_with_model = request.clone();
|
|
req_with_model.model = model.to_string();
|
|
|
|
for attempt in 0..=policy.max_retries {
|
|
debug!("POST {} — model: {} (attempt {})", url, model, attempt);
|
|
|
|
let resp = self
|
|
.client
|
|
.post(&url)
|
|
.headers(self.auth_headers())
|
|
.json(&req_with_model)
|
|
.send()
|
|
.await;
|
|
|
|
let resp = match resp {
|
|
Ok(r) => r,
|
|
Err(e) if e.is_timeout() || e.is_connect() => {
|
|
if attempt == policy.max_retries {
|
|
anyhow::bail!("Bifrost unreachable after {} attempts: {}", attempt + 1, e);
|
|
}
|
|
let delay = jittered_delay(attempt, policy);
|
|
warn!(
|
|
"Bifrost connection failed (attempt {}), retrying in {:?}: {}",
|
|
attempt, delay, e
|
|
);
|
|
strain_events.push(InferenceStrain::Transient {
|
|
attempt,
|
|
status: 0,
|
|
model: model.to_string(),
|
|
delay_ms: delay.as_millis() as u64,
|
|
});
|
|
tokio::time::sleep(delay).await;
|
|
continue;
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
|
|
let status = resp.status();
|
|
let retry_after = parse_retry_after(resp.headers());
|
|
|
|
if status.is_success() {
|
|
let body_text = resp
|
|
.text()
|
|
.await
|
|
.context("Failed to read Bifrost response body")?;
|
|
return Self::parse_completion_response(&body_text);
|
|
}
|
|
|
|
let body_text = resp
|
|
.text()
|
|
.await
|
|
.context("Failed to read Bifrost error body")?;
|
|
|
|
match classify_status(status, &body_text) {
|
|
ErrorClass::Transient
|
|
if attempt < retry_cap(&body_text, policy.max_retries)
|
|
&& !is_exhausted_window(status, retry_after) =>
|
|
{
|
|
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
|
|
warn!(
|
|
"Bifrost {} on {} (attempt {}), retrying in {:?}",
|
|
status.as_u16(),
|
|
model,
|
|
attempt,
|
|
delay
|
|
);
|
|
strain_events.push(InferenceStrain::Transient {
|
|
attempt,
|
|
status: status.as_u16(),
|
|
model: model.to_string(),
|
|
delay_ms: delay.as_millis() as u64,
|
|
});
|
|
tokio::time::sleep(delay).await;
|
|
}
|
|
_ => {
|
|
strain_events.push(InferenceStrain::Exhausted {
|
|
attempts: attempt + 1,
|
|
status: status.as_u16(),
|
|
model: model.to_string(),
|
|
body: body_text[..body_text.len().min(300)].to_string(),
|
|
});
|
|
anyhow::bail!(
|
|
"Bifrost returned {} after {} attempt(s) on {}{}: {}",
|
|
status,
|
|
attempt + 1,
|
|
model,
|
|
reset_hint(retry_after),
|
|
&body_text[..body_text.len().min(500)]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
anyhow::bail!("Bifrost retry loop exhausted without returning a result")
|
|
}
|
|
|
|
fn parse_completion_response(body_text: &str) -> Result<CompletionResult> {
|
|
let parsed: ChatCompletionResponse =
|
|
serde_json::from_str(body_text).with_context(|| {
|
|
let preview = &body_text[..body_text.len().min(200)];
|
|
format!("Failed to parse Bifrost response: {preview}")
|
|
})?;
|
|
|
|
let choice = parsed
|
|
.choices
|
|
.into_iter()
|
|
.next()
|
|
.context("Bifrost returned empty choices")?;
|
|
|
|
let content = choice.message.content.unwrap_or_default();
|
|
let reasoning = choice.message.reasoning;
|
|
let tool_calls = choice
|
|
.message
|
|
.tool_calls
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|tc| {
|
|
let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).ok()?;
|
|
Some(ParsedToolCall {
|
|
id: tc.id,
|
|
name: tc.function.name,
|
|
arguments: args,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(CompletionResult {
|
|
content,
|
|
reasoning,
|
|
// OpenAI-shaped gateways return reasoning text unsigned.
|
|
reasoning_signature: None,
|
|
tool_calls,
|
|
finish_reason: choice.finish_reason.clone(),
|
|
usage: parsed.usage,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// `BifrostClient` is the OpenAI-compatible gateway implementation of the
|
|
/// provider seam. The inherent methods do the work; the trait just exposes them
|
|
/// behind `dyn LlmProvider` so the engine can hold any provider uniformly.
|
|
#[async_trait]
|
|
impl LlmProvider for BifrostClient {
|
|
fn id(&self) -> &str {
|
|
&self.id
|
|
}
|
|
|
|
fn default_model(&self) -> &str {
|
|
&self.default_model
|
|
}
|
|
|
|
async fn list_models(&self) -> Result<Vec<String>> {
|
|
BifrostClient::list_models(self).await
|
|
}
|
|
|
|
async fn chat_completion_with_strain(
|
|
&self,
|
|
request: ChatCompletionRequest,
|
|
) -> Result<(CompletionResult, Vec<InferenceStrain>)> {
|
|
BifrostClient::chat_completion_with_strain(self, request).await
|
|
}
|
|
|
|
async fn chat_completion(&self, request: ChatCompletionRequest) -> Result<CompletionResult> {
|
|
BifrostClient::chat_completion(self, request).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn exhausted_window_rejects_the_measured_11997s_header() {
|
|
// The header that put the subconscious to sleep for 3h20m on
|
|
// 2026-08-16. It must not be slept on.
|
|
let long = Some(Duration::from_secs(11997));
|
|
assert!(is_exhausted_window(reqwest::StatusCode::TOO_MANY_REQUESTS, long));
|
|
}
|
|
|
|
#[test]
|
|
fn exhausted_window_still_sleeps_on_a_real_burst_limit() {
|
|
let short = Some(Duration::from_secs(30));
|
|
assert!(!is_exhausted_window(
|
|
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
|
short
|
|
));
|
|
// Exactly at the ceiling is still a burst, not a spent window.
|
|
let at_ceiling = Some(Duration::from_secs(BURST_RETRY_CEILING_SECS));
|
|
assert!(!is_exhausted_window(
|
|
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
|
at_ceiling
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn exhausted_window_ignores_absent_headers_and_other_statuses() {
|
|
// Bifrost has no account to rotate to, so a 429 with no header keeps
|
|
// the ordinary jittered backoff — this is where it diverges from
|
|
// `claude_subscription::is_exhausted_window` on purpose.
|
|
assert!(!is_exhausted_window(
|
|
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
|
None
|
|
));
|
|
assert!(!is_exhausted_window(
|
|
reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
|
Some(Duration::from_secs(11997))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn reset_hint_names_the_window_or_says_nothing() {
|
|
assert_eq!(
|
|
reset_hint(Some(Duration::from_secs(11997))),
|
|
" (provider asked for 11997s)"
|
|
);
|
|
assert_eq!(reset_hint(None), "");
|
|
}
|
|
|
|
/// Google's OpenAI-compatible root nests its version: `/v1beta/openai`.
|
|
/// Appending `/v1` there produced a path that hangs on POST.
|
|
#[test]
|
|
fn google_nested_version_path_is_not_doubled() {
|
|
let client = BifrostClient::new(
|
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
|
"",
|
|
"",
|
|
"gemini-3.6-flash",
|
|
300,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
client.base_url,
|
|
"https://generativelanguage.googleapis.com/v1beta/openai"
|
|
);
|
|
}
|
|
|
|
/// Every `[providers.*]` base URL on disk 2026-08-18 and the API root the
|
|
/// client must end up posting to.
|
|
#[test]
|
|
fn base_url_completion_across_configured_providers() {
|
|
let cases = [
|
|
// Paths of their own — taken as complete, nested version or not.
|
|
(
|
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
|
),
|
|
(
|
|
"https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
|
),
|
|
(
|
|
"https://api.z.ai/api/coding/paas/v4",
|
|
"https://api.z.ai/api/coding/paas/v4",
|
|
),
|
|
(
|
|
"https://token-plan-sgp.xiaomimimo.com/v1",
|
|
"https://token-plan-sgp.xiaomimimo.com/v1",
|
|
),
|
|
("https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1"),
|
|
// Bare hosts — get the OpenAI default.
|
|
("https://api.deepseek.com", "https://api.deepseek.com/v1"),
|
|
("https://api.deepseek.com/", "https://api.deepseek.com/v1"),
|
|
("http://10.10.20.120:3360", "http://10.10.20.120:3360/v1"),
|
|
("http://10.10.20.19:8080", "http://10.10.20.19:8080/v1"),
|
|
];
|
|
for (base, want) in cases {
|
|
let client = BifrostClient::new(base, "", "", "m", 30).unwrap();
|
|
assert_eq!(client.base_url, want, "base {base}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_client_creation() {
|
|
let client = BifrostClient::new(
|
|
"http://127.0.0.1:3360",
|
|
"sk-bf-test",
|
|
"",
|
|
"openai/deepseek-v4-pro",
|
|
120,
|
|
)
|
|
.unwrap();
|
|
assert!(client.base_url.ends_with("/v1"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_chat_request_serialization() {
|
|
let req = ChatCompletionRequest {
|
|
model: "openai/deepseek-v4-pro".to_string(),
|
|
messages: vec![Message::text("user", "Hello")],
|
|
stream: None,
|
|
max_tokens: None,
|
|
temperature: None,
|
|
tools: None,
|
|
};
|
|
let json = serde_json::to_string(&req).unwrap();
|
|
assert!(json.contains("openai/deepseek-v4-pro"));
|
|
assert!(json.contains("Hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_chat_response_deserialize() {
|
|
let json = r#"{
|
|
"id": "test",
|
|
"choices": [{
|
|
"index": 0,
|
|
"finish_reason": "stop",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "Hello!",
|
|
"reasoning": "The user greeted me."
|
|
}
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 5,
|
|
"total_tokens": 15
|
|
},
|
|
"model": "deepseek-v4-pro"
|
|
}"#;
|
|
let resp: ChatCompletionResponse = serde_json::from_str(json).unwrap();
|
|
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!"));
|
|
assert_eq!(
|
|
resp.choices[0].message.reasoning.as_deref(),
|
|
Some("The user greeted me.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_call_response_deserialize() {
|
|
let json = r#"{
|
|
"id": "test",
|
|
"choices": [{
|
|
"index": 0,
|
|
"finish_reason": "tool_calls",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [{
|
|
"index": 0,
|
|
"type": "function",
|
|
"id": "call_123",
|
|
"function": {
|
|
"name": "read",
|
|
"arguments": "{\"path\": \"/etc/hostname\"}"
|
|
}
|
|
}]
|
|
}
|
|
}],
|
|
"usage": null,
|
|
"model": "deepseek-v4-pro"
|
|
}"#;
|
|
let resp: ChatCompletionResponse = serde_json::from_str(json).unwrap();
|
|
let msg = &resp.choices[0].message;
|
|
assert!(msg.tool_calls.is_some());
|
|
let calls = msg.tool_calls.as_ref().unwrap();
|
|
assert_eq!(calls[0].function.name, "read");
|
|
}
|
|
}
|