use std::collections::HashMap;
use std::sync::Arc;
use std::{pin::Pin, str};
use async_stream::try_stream;
use async_trait::async_trait;
use futures::{Stream, StreamExt};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::RwLock;
use tokio::time::{sleep, Duration};
use tokio_util::sync::CancellationToken;
use tandem_types::{ModelInfo, ProviderInfo, ToolMode, ToolSchema};
fn provider_max_tokens() -> u32 {
std::env::var("TANDEM_PROVIDER_MAX_TOKENS")
.ok()
.and_then(|raw| raw.trim().parse::<u32>().ok())
.filter(|value| *value >= 64)
.unwrap_or(16384)
}
fn protocol_title_header() -> String {
std::env::var("AGENT_PROTOCOL_TITLE")
.ok()
.or_else(|| std::env::var("TANDEM_PROTOCOL_TITLE").ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "Tandem".to_string())
}
fn sanitize_openai_function_name(name: &str) -> String {
let mut out = String::new();
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
} else {
out.push('_');
}
}
let cleaned = out.trim_matches('_');
if cleaned.is_empty() {
"tool".to_string()
} else {
cleaned.to_string()
}
}
fn build_openai_tool_aliases(
tools: &[ToolSchema],
) -> (HashMap<String, String>, HashMap<String, String>) {
let mut original_to_alias = HashMap::new();
let mut alias_to_original = HashMap::new();
for tool in tools {
let original = tool.name.trim();
if original.is_empty() {
continue;
}
let base = sanitize_openai_function_name(original);
let mut alias = base.clone();
let mut suffix = 2usize;
while alias_to_original.contains_key(&alias) {
alias = format!("{base}_{suffix}");
suffix = suffix.saturating_add(1);
}
original_to_alias.insert(original.to_string(), alias.clone());
alias_to_original.insert(alias, original.to_string());
}
(original_to_alias, alias_to_original)
}
fn normalize_openai_function_parameters(schema: serde_json::Value) -> serde_json::Value {
let mut schema = match schema {
serde_json::Value::Object(obj) => serde_json::Value::Object(obj),
_ => json!({}),
};
normalize_openai_schema_node(&mut schema);
let Some(obj) = schema.as_object_mut() else {
return json!({"type":"object","properties":{}});
};
if obj.get("type").and_then(|v| v.as_str()) != Some("object") {
obj.insert(
"type".to_string(),
serde_json::Value::String("object".to_string()),
);
}
if !obj.contains_key("properties") || !obj["properties"].is_object() {
obj.insert("properties".to_string(), json!({}));
}
schema
}
fn normalize_openai_schema_node(node: &mut serde_json::Value) {
let Some(obj) = node.as_object_mut() else {
return;
};
if obj.get("type").and_then(|v| v.as_str()) == Some("object") || obj.contains_key("properties")
{
if !obj.contains_key("properties") || !obj["properties"].is_object() {
obj.insert("properties".to_string(), json!({}));
}
}
if obj.get("type").and_then(|v| v.as_str()) == Some("array") && !obj.contains_key("items") {
obj.insert("items".to_string(), json!({}));
}
if let Some(items) = obj.get_mut("items") {
normalize_openai_items_schema(items);
}
if let Some(additional) = obj.get_mut("additionalProperties") {
normalize_openai_schema_or_bool(additional);
}
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for property_schema in properties.values_mut() {
normalize_openai_schema_or_bool(property_schema);
}
}
for key in ["anyOf", "oneOf", "allOf"] {
if let Some(variants) = obj.get_mut(key).and_then(|v| v.as_array_mut()) {
for variant in variants.iter_mut() {
normalize_openai_schema_or_bool(variant);
}
}
}
}
fn normalize_openai_schema_or_bool(node: &mut serde_json::Value) {
match node {
serde_json::Value::Object(_) => normalize_openai_schema_node(node),
serde_json::Value::Bool(_) => {}
_ => *node = json!({}),
}
}
fn normalize_openai_items_schema(items: &mut serde_json::Value) {
if let Some(tuple_items) = items.as_array_mut() {
let replacement = tuple_items
.iter()
.find(|candidate| candidate.is_object() || candidate.is_boolean())
.cloned()
.unwrap_or_else(|| json!({}));
*items = replacement;
}
normalize_openai_schema_or_bool(items);
}
fn openai_tool_choice(tool_mode: &ToolMode) -> &'static str {
match tool_mode {
ToolMode::Required => "required",
ToolMode::Auto | ToolMode::None => "auto",
}
}
#[derive(Debug, Clone)]
struct OpenAiToolCallChunk {
id: String,
name: String,
args_delta: String,
index: u64,
}
fn canonical_openai_tool_name(
raw_name: &str,
alias_to_original: &HashMap<String, String>,
) -> String {
alias_to_original
.get(raw_name)
.cloned()
.unwrap_or_else(|| raw_name.to_string())
}
fn push_openai_text_fragments(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::String(text) => {
if !text.is_empty() {
out.push(text.to_string());
}
}
serde_json::Value::Array(items) => {
for item in items {
push_openai_text_fragments(item, out);
}
}
serde_json::Value::Object(obj) => {
if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
if !text.is_empty() {
out.push(text.to_string());
}
}
if let Some(text) = obj
.get("text")
.and_then(|v| v.as_object())
.and_then(|nested| nested.get("value"))
.and_then(|v| v.as_str())
{
if !text.is_empty() {
out.push(text.to_string());
}
}
if let Some(text) = obj.get("content").and_then(|v| v.as_str()) {
if !text.is_empty() {
out.push(text.to_string());
}
}
if let Some(text) = obj.get("input_text").and_then(|v| v.as_str()) {
if !text.is_empty() {
out.push(text.to_string());
}
}
}
_ => {}
}
}
fn extract_openai_tool_call_chunk(
call: &serde_json::Value,
alias_to_original: &HashMap<String, String>,
fallback_id: String,
) -> Option<OpenAiToolCallChunk> {
let obj = call.as_object()?;
let function = obj.get("function").cloned().unwrap_or_default();
let index = obj
.get("index")
.and_then(|v| v.as_u64())
.unwrap_or_default();
let raw_name = obj
.get("name")
.and_then(|v| v.as_str())
.or_else(|| obj.get("tool_name").and_then(|v| v.as_str()))
.or_else(|| function.get("name").and_then(|v| v.as_str()))
.or_else(|| {
obj.get("call")
.and_then(|v| v.get("name"))
.and_then(|v| v.as_str())
})
.unwrap_or_default()
.trim()
.to_string();
let args_delta = obj
.get("arguments")
.and_then(|v| v.as_str())
.map(ToString::to_string)
.or_else(|| {
obj.get("args")
.and_then(|v| (!v.is_null()).then(|| v.to_string()))
})
.or_else(|| {
obj.get("input")
.and_then(|v| (!v.is_null()).then(|| v.to_string()))
})
.or_else(|| {
function
.get("arguments")
.and_then(|v| v.as_str())
.map(ToString::to_string)
})
.or_else(|| {
function
.get("arguments")
.and_then(|v| (!v.is_null()).then(|| v.to_string()))
})
.unwrap_or_default();
let id = obj
.get("id")
.and_then(|v| v.as_str())
.or_else(|| obj.get("tool_call_id").and_then(|v| v.as_str()))
.map(ToString::to_string)
.filter(|value| !value.is_empty())
.unwrap_or(fallback_id);
let canonical_name = if raw_name.is_empty() {
String::new()
} else {
canonical_openai_tool_name(&raw_name, alias_to_original)
};
if canonical_name.is_empty() && args_delta.is_empty() {
return None;
}
Some(OpenAiToolCallChunk {
id,
name: canonical_name,
args_delta,
index,
})
}
fn extract_openai_tool_call_chunks(
choice: &serde_json::Value,
alias_to_original: &HashMap<String, String>,
) -> Vec<OpenAiToolCallChunk> {
let delta = choice.get("delta").cloned().unwrap_or_default();
let message = choice.get("message").cloned().unwrap_or_default();
let mut calls = Vec::new();
let direct_lists = [
delta.get("tool_calls").and_then(|v| v.as_array()),
message.get("tool_calls").and_then(|v| v.as_array()),
choice.get("tool_calls").and_then(|v| v.as_array()),
];
for list in direct_lists.into_iter().flatten() {
for (idx, call) in list.iter().enumerate() {
let index = call
.get("index")
.and_then(|v| v.as_u64())
.unwrap_or(idx as u64);
if let Some(chunk) = extract_openai_tool_call_chunk(
call,
alias_to_original,
format!("tool_call_{index}"),
) {
calls.push(chunk);
}
}
}
for content in [
delta.get("content"),
message.get("content"),
choice.get("content"),
] {
let Some(items) = content.and_then(|v| v.as_array()) else {
continue;
};
for (idx, item) in items.iter().enumerate() {
let index = item
.get("index")
.and_then(|v| v.as_u64())
.unwrap_or(idx as u64);
let item_type = item
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(
item_type.as_str(),
"tool_call" | "function_call" | "tool_use" | "output_tool_call"
) {
if let Some(chunk) = extract_openai_tool_call_chunk(
item,
alias_to_original,
format!("content_tool_call_{index}"),
) {
calls.push(chunk);
}
}
}
}
calls
}
fn is_openai_tool_call_fallback_id(id: &str) -> bool {
id.starts_with("tool_call_") || id.starts_with("content_tool_call_")
}
fn resolve_openai_tool_call_stream_id(
call: &OpenAiToolCallChunk,
real_ids_by_index: &mut HashMap<u64, String>,
) -> String {
if !is_openai_tool_call_fallback_id(&call.id) {
real_ids_by_index.insert(call.index, call.id.clone());
return call.id.clone();
}
real_ids_by_index
.get(&call.index)
.cloned()
.unwrap_or_else(|| call.id.clone())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfig {
pub api_key: Option<String>,
pub url: Option<String>,
pub default_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppConfig {
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
pub default_provider: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MemoryConsolidationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: String,
pub content: String,
pub attachments: Vec<ChatAttachment>,
}
#[derive(Debug, Clone)]
pub enum ChatAttachment {
ImageUrl { url: String },
}
#[derive(Debug, Clone)]
pub enum StreamChunk {
TextDelta(String),
ReasoningDelta(String),
ToolCallStart {
id: String,
name: String,
},
ToolCallDelta {
id: String,
args_delta: String,
},
ToolCallEnd {
id: String,
},
Done {
finish_reason: String,
usage: Option<TokenUsage>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TokenUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
#[async_trait]
pub trait Provider: Send + Sync {
fn info(&self) -> ProviderInfo;
async fn complete(&self, prompt: &str, model_override: Option<&str>) -> anyhow::Result<String>;
async fn stream(
&self,
messages: Vec<ChatMessage>,
model_override: Option<&str>,
_tool_mode: ToolMode,
_tools: Option<Vec<ToolSchema>>,
_cancel: CancellationToken,
) -> anyhow::Result<Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>> {
let prompt = messages
.iter()
.map(|m| format!("{}: {}", m.role, m.content))
.collect::<Vec<_>>()
.join("\n");
let response = self.complete(&prompt, model_override).await?;
let stream = futures::stream::iter(vec![
Ok(StreamChunk::TextDelta(response)),
Ok(StreamChunk::Done {
finish_reason: "stop".to_string(),
usage: None,
}),
]);
Ok(Box::pin(stream))
}
}
#[derive(Clone)]
pub struct ProviderRegistry {
providers: Arc<RwLock<Vec<Arc<dyn Provider>>>>,
default_provider: Arc<RwLock<Option<String>>>,
}
impl ProviderRegistry {
pub fn new(config: AppConfig) -> Self {
let providers = build_providers(&config);
Self {
providers: Arc::new(RwLock::new(providers)),
default_provider: Arc::new(RwLock::new(config.default_provider)),
}
}
pub async fn reload(&self, config: AppConfig) {
let rebuilt = build_providers(&config);
*self.providers.write().await = rebuilt;
*self.default_provider.write().await = config.default_provider;
}
pub async fn list(&self) -> Vec<ProviderInfo> {
self.providers
.read()
.await
.iter()
.map(|p| p.info())
.collect()
}
pub async fn default_complete(&self, prompt: &str) -> anyhow::Result<String> {
let provider = self.select_provider(None).await?;
provider.complete(prompt, None).await
}
pub async fn complete_for_provider(
&self,
provider_id: Option<&str>,
prompt: &str,
model_id: Option<&str>,
) -> anyhow::Result<String> {
let provider = self.select_provider(provider_id).await?;
provider.complete(prompt, model_id).await
}
pub async fn complete_cheapest(
&self,
prompt: &str,
provider_override: Option<&str>,
model_override: Option<&str>,
) -> anyhow::Result<String> {
if let Some(pid) = provider_override {
return self
.complete_for_provider(Some(pid), prompt, model_override)
.await;
}
let best_provider = self.select_cheapest_provider_id().await;
let openrouter_free_model = "meta-llama/llama-3.3-70b-instruct:free";
match best_provider {
Some(pid @ "openrouter") if model_override.is_none() => {
self.complete_for_provider(Some(pid), prompt, Some(openrouter_free_model))
.await
}
Some(pid) => {
self.complete_for_provider(Some(pid), prompt, model_override)
.await
}
None => {
self.complete_for_provider(None, prompt, model_override)
.await
}
}
}
pub async fn select_cheapest_provider_id(&self) -> Option<&'static str> {
let providers = self.providers.read().await;
let configured_ids: Vec<String> = providers.iter().map(|p| p.info().id).collect();
drop(providers);
let priority_order = [
"ollama",
"groq",
"openrouter",
"together",
"mistral",
"openai",
"anthropic",
"cohere",
];
priority_order
.iter()
.find(|id| configured_ids.iter().any(|c| c == **id))
.copied()
}
pub async fn default_stream(
&self,
messages: Vec<ChatMessage>,
tool_mode: ToolMode,
tools: Option<Vec<ToolSchema>>,
cancel: CancellationToken,
) -> anyhow::Result<Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>> {
self.stream_for_provider(None, None, messages, tool_mode, tools, cancel)
.await
}
pub async fn stream_for_provider(
&self,
provider_id: Option<&str>,
model_id: Option<&str>,
messages: Vec<ChatMessage>,
tool_mode: ToolMode,
tools: Option<Vec<ToolSchema>>,
cancel: CancellationToken,
) -> anyhow::Result<Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>> {
let provider = self.select_provider(provider_id).await?;
provider
.stream(messages, model_id, tool_mode, tools, cancel)
.await
}
async fn select_provider(
&self,
provider_id: Option<&str>,
) -> anyhow::Result<Arc<dyn Provider>> {
let providers = self.providers.read().await;
let available = providers.iter().map(|p| p.info().id).collect::<Vec<_>>();
if let Some(id) = provider_id {
if let Some(provider) = providers.iter().find(|p| p.info().id == id) {
return Ok(provider.clone());
}
anyhow::bail!(
"provider `{}` is not configured. configured providers: {}",
id,
available.join(", ")
);
};
let configured_default = self.default_provider.read().await.clone();
if let Some(default_id) = configured_default {
if let Some(provider) = providers.iter().find(|p| p.info().id == default_id) {
return Ok(provider.clone());
}
};
let Some(provider) = providers.first() else {
anyhow::bail!("No provider configured.");
};
Ok(provider.clone())
}
}
fn build_providers(config: &AppConfig) -> Vec<Arc<dyn Provider>> {
let mut providers: Vec<Arc<dyn Provider>> = Vec::new();
add_openai_provider(
config,
&mut providers,
"ollama",
"Ollama",
"http://127.0.0.1:11434/v1",
"llama3.1:8b",
false,
);
add_openai_provider(
config,
&mut providers,
"openai",
"OpenAI",
"https://api.openai.com/v1",
"gpt-5.2",
true,
);
add_openai_provider(
config,
&mut providers,
"openrouter",
"OpenRouter",
"https://openrouter.ai/api/v1",
"openai/gpt-4o-mini",
true,
);
add_openai_provider(
config,
&mut providers,
"groq",
"Groq",
"https://api.groq.com/openai/v1",
"llama-3.1-8b-instant",
true,
);
add_openai_provider(
config,
&mut providers,
"mistral",
"Mistral",
"https://api.mistral.ai/v1",
"mistral-small-latest",
true,
);
add_openai_provider(
config,
&mut providers,
"together",
"Together",
"https://api.together.xyz/v1",
"meta-llama/Llama-3.1-8B-Instruct-Turbo",
true,
);
add_openai_provider(
config,
&mut providers,
"azure",
"Azure OpenAI-Compatible",
"https://example.openai.azure.com/openai/deployments/default",
"gpt-4o-mini",
true,
);
add_openai_provider(
config,
&mut providers,
"bedrock",
"Bedrock-Compatible",
"https://bedrock-runtime.us-east-1.amazonaws.com",
"anthropic.claude-3-5-sonnet-20240620-v1:0",
true,
);
add_openai_provider(
config,
&mut providers,
"vertex",
"Vertex-Compatible",
"https://aiplatform.googleapis.com/v1",
"gemini-1.5-flash",
true,
);
add_openai_provider(
config,
&mut providers,
"copilot",
"GitHub Copilot-Compatible",
"https://api.githubcopilot.com",
"gpt-4o-mini",
true,
);
if let Some(anthropic) = config.providers.get("anthropic") {
providers.push(Arc::new(AnthropicProvider {
api_key: anthropic
.api_key
.as_deref()
.filter(|key| !is_placeholder_api_key(key))
.map(|key| key.to_string())
.or_else(|| {
std::env::var("ANTHROPIC_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty())
}),
default_model: anthropic
.default_model
.clone()
.unwrap_or_else(|| "claude-sonnet-4-6".to_string()),
client: Client::new(),
}));
}
if let Some(cohere) = config.providers.get("cohere") {
providers.push(Arc::new(CohereProvider {
api_key: cohere
.api_key
.as_deref()
.filter(|key| !is_placeholder_api_key(key))
.map(|key| key.to_string())
.or_else(|| {
std::env::var("COHERE_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty())
}),
base_url: normalize_plain_base(
cohere.url.as_deref().unwrap_or("https://api.cohere.com/v2"),
),
default_model: cohere
.default_model
.clone()
.unwrap_or_else(|| "command-r-plus".to_string()),
client: Client::new(),
}));
}
for (id, entry) in &config.providers {
if is_known_provider_id(id) {
continue;
}
let provider_id = id.trim();
if provider_id.is_empty() {
continue;
}
providers.push(Arc::new(OpenAICompatibleProvider {
id: provider_id.to_string(),
name: humanize_provider_name(provider_id),
base_url: normalize_base(entry.url.as_deref().unwrap_or("https://api.openai.com/v1")),
api_key: entry
.api_key
.as_deref()
.filter(|key| !is_placeholder_api_key(key))
.map(|key| key.to_string())
.or_else(|| env_api_key_for_provider(provider_id)),
default_model: entry
.default_model
.clone()
.unwrap_or_else(|| "gpt-4o-mini".to_string()),
client: Client::new(),
}));
}
if providers.is_empty() {
providers.push(Arc::new(LocalEchoProvider));
}
providers
}
fn add_openai_provider(
config: &AppConfig,
providers: &mut Vec<Arc<dyn Provider>>,
id: &str,
name: &str,
default_url: &str,
default_model: &str,
use_api_key: bool,
) {
let Some(entry) = config.providers.get(id) else {
return;
};
providers.push(Arc::new(OpenAICompatibleProvider {
id: id.to_string(),
name: name.to_string(),
base_url: normalize_base(entry.url.as_deref().unwrap_or(default_url)),
api_key: if use_api_key {
entry
.api_key
.as_deref()
.filter(|key| !is_placeholder_api_key(key))
.map(|key| key.to_string())
.or_else(|| env_api_key_for_provider(id))
} else {
None
},
default_model: entry
.default_model
.clone()
.unwrap_or_else(|| default_model.to_string()),
client: Client::new(),
}));
}
fn is_placeholder_api_key(value: &str) -> bool {
let trimmed = value.trim();
trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("x")
|| trimmed.eq_ignore_ascii_case("placeholder")
}
fn is_known_provider_id(id: &str) -> bool {
matches!(
id.trim().to_ascii_lowercase().as_str(),
"ollama"
| "openai"
| "openrouter"
| "groq"
| "mistral"
| "together"
| "azure"
| "bedrock"
| "vertex"
| "copilot"
| "anthropic"
| "cohere"
)
}
fn humanize_provider_name(id: &str) -> String {
let mut words = Vec::new();
for segment in id.split(['_', '-']) {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let mut chars = segment.chars();
if let Some(first) = chars.next() {
let mut word = first.to_uppercase().collect::<String>();
word.push_str(chars.as_str());
words.push(word);
}
}
if words.is_empty() {
"Custom Provider".to_string()
} else {
words.join(" ")
}
}
fn env_api_key_for_provider(id: &str) -> Option<String> {
let explicit = match id {
"openai" => Some("OPENAI_API_KEY"),
"openrouter" => Some("OPENROUTER_API_KEY"),
"groq" => Some("GROQ_API_KEY"),
"mistral" => Some("MISTRAL_API_KEY"),
"together" => Some("TOGETHER_API_KEY"),
"copilot" => Some("GITHUB_TOKEN"),
_ => None,
};
if let Some(name) = explicit {
if let Some(value) = std::env::var(name).ok().filter(|v| !v.trim().is_empty()) {
return Some(value);
}
}
let normalized = id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
if normalized.is_empty() {
return None;
}
let env_name = format!("{}_API_KEY", normalized);
std::env::var(env_name)
.ok()
.filter(|v| !v.trim().is_empty())
}
fn provider_api_key_env_hint(id: &str) -> &'static str {
match id {
"openrouter" => "OPENROUTER_API_KEY",
"opencode" => "OPENCODE_ZEN_API_KEY",
"openai" => "OPENAI_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
"groq" => "GROQ_API_KEY",
"mistral" => "MISTRAL_API_KEY",
"cohere" => "COHERE_API_KEY",
_ => "provider API key",
}
}
struct LocalEchoProvider;
#[async_trait]
impl Provider for LocalEchoProvider {
fn info(&self) -> ProviderInfo {
ProviderInfo {
id: "local".to_string(),
name: "Local Echo".to_string(),
models: vec![ModelInfo {
id: "echo-1".to_string(),
provider_id: "local".to_string(),
display_name: "Echo Model".to_string(),
context_window: 8192,
}],
}
}
async fn complete(
&self,
prompt: &str,
_model_override: Option<&str>,
) -> anyhow::Result<String> {
Ok(format!("Echo: {prompt}"))
}
}
struct OpenAICompatibleProvider {
id: String,
name: String,
base_url: String,
api_key: Option<String>,
default_model: String,
client: Client,
}
#[async_trait]
impl Provider for OpenAICompatibleProvider {
fn info(&self) -> ProviderInfo {
ProviderInfo {
id: self.id.clone(),
name: self.name.clone(),
models: vec![ModelInfo {
id: self.default_model.clone(),
provider_id: self.id.clone(),
display_name: self.default_model.clone(),
context_window: 128_000,
}],
}
}
async fn complete(&self, prompt: &str, model_override: Option<&str>) -> anyhow::Result<String> {
let model = model_override
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(self.default_model.as_str());
let url = format!("{}/chat/completions", self.base_url);
let mut response_opt = None;
let mut last_send_err: Option<reqwest::Error> = None;
let max_tokens = provider_max_tokens();
for attempt in 0..3 {
let mut req = self.client.post(url.clone()).json(&json!({
"model": model,
"messages": [{"role":"user","content": prompt}],
"stream": false,
"max_tokens": max_tokens,
}));
if self.id == "openrouter" {
req = req
.header("HTTP-Referer", "https://tandem.frumu.ai")
.header("X-Title", protocol_title_header());
}
if let Some(api_key) = &self.api_key {
req = req.bearer_auth(api_key);
}
match req.send().await {
Ok(resp) => {
response_opt = Some(resp);
break;
}
Err(err) => {
let retryable = err.is_connect() || err.is_timeout();
if retryable && attempt < 2 {
sleep(Duration::from_millis(300 * (attempt + 1) as u64)).await;
last_send_err = Some(err);
continue;
}
last_send_err = Some(err);
break;
}
}
}
let response = if let Some(resp) = response_opt {
resp
} else {
let err = last_send_err.expect("send error should be captured");
let category = if err.is_connect() {
"connection error"
} else if err.is_timeout() {
"timeout"
} else {
"request error"
};
anyhow::bail!(
"failed to reach provider `{}` at {} ({}): {}. Verify endpoint is reachable and OpenAI-compatible.",
self.id,
self.base_url,
category,
err
);
};
let status = response.status();
let value: serde_json::Value = response.json().await?;
if !status.is_success() {
let detail = extract_openai_error(&value)
.unwrap_or_else(|| format!("provider request failed with status {}", status));
anyhow::bail!(detail);
}
if let Some(detail) = extract_openai_error(&value) {
anyhow::bail!(detail);
}
if let Some(text) = extract_openai_text(&value) {
return Ok(text);
}
let body_preview = truncate_for_error(&value.to_string(), 500);
anyhow::bail!(
"provider returned no completion content for model `{}` (response: {})",
model,
body_preview
);
}
async fn stream(
&self,
messages: Vec<ChatMessage>,
model_override: Option<&str>,
tool_mode: ToolMode,
tools: Option<Vec<ToolSchema>>,
cancel: CancellationToken,
) -> anyhow::Result<Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>> {
let model = model_override
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(self.default_model.as_str());
let url = format!("{}/chat/completions", self.base_url);
let has_image_inputs = messages.iter().any(|m| !m.attachments.is_empty());
if has_image_inputs && !model_supports_vision_input(model) {
anyhow::bail!(
"selected model `{}` does not appear to support image input. choose a vision-capable model.",
model
);
}
let wire_messages = messages
.into_iter()
.map(chat_message_to_openai_wire)
.collect::<Vec<_>>();
let tools = tools.unwrap_or_default();
let (original_to_alias, alias_to_original) = build_openai_tool_aliases(&tools);
let wire_tools = tools
.into_iter()
.map(|tool| {
let safe_name = original_to_alias
.get(tool.name.as_str())
.cloned()
.unwrap_or_else(|| sanitize_openai_function_name(&tool.name));
json!({
"type": "function",
"function": {
"name": safe_name,
"description": tool.description,
"parameters": normalize_openai_function_parameters(tool.input_schema),
}
})
})
.collect::<Vec<_>>();
let mut body = json!({
"model": model,
"messages": wire_messages,
"stream": true,
"max_tokens": provider_max_tokens(),
});
if !wire_tools.is_empty() {
body["tools"] = serde_json::Value::Array(wire_tools);
body["tool_choice"] = json!(openai_tool_choice(&tool_mode));
}
let mut resp_opt = None;
let mut last_send_err: Option<reqwest::Error> = None;
for attempt in 0..3 {
let mut req = self.client.post(url.clone()).json(&body);
if self.id == "openrouter" {
req = req
.header("HTTP-Referer", "https://tandem.frumu.ai")
.header("X-Title", protocol_title_header());
}
if let Some(api_key) = &self.api_key {
req = req.bearer_auth(api_key);
}
match req.send().await {
Ok(resp) => {
resp_opt = Some(resp);
break;
}
Err(err) => {
let retryable = err.is_connect() || err.is_timeout();
if retryable && attempt < 2 {
sleep(Duration::from_millis(300 * (attempt + 1) as u64)).await;
last_send_err = Some(err);
continue;
}
last_send_err = Some(err);
break;
}
}
}
let resp = if let Some(resp) = resp_opt {
resp
} else {
let err = last_send_err.expect("send error should be captured");
let category = if err.is_connect() {
"connection error"
} else if err.is_timeout() {
"timeout"
} else {
"request error"
};
anyhow::bail!(
"failed to reach provider `{}` at {} ({}): {}. Verify endpoint is reachable and OpenAI-compatible.",
self.id,
self.base_url,
category,
err
);
};
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
if text.contains("Failed to authenticate request with Clerk") {
let key_hint = provider_api_key_env_hint(&self.id);
anyhow::bail!(
"provider authentication failed ({}) for `{}`. Verify the provider API key (set `{}` or configure the key in Settings) and retry.",
status,
self.id,
key_hint
);
}
anyhow::bail!(
"provider stream request failed with status {}: {}",
status,
truncate_for_error(&text, 500)
);
}
let mut bytes = resp.bytes_stream();
let alias_to_original = alias_to_original.clone();
let stream = try_stream! {
let mut buffer = String::new();
let mut tool_call_real_ids = HashMap::new();
while let Some(chunk) = bytes.next().await {
if cancel.is_cancelled() {
yield StreamChunk::Done {
finish_reason: "cancelled".to_string(),
usage: None,
};
break;
}
let chunk = chunk?;
buffer.push_str(str::from_utf8(&chunk).unwrap_or_default());
while let Some(pos) = buffer.find("\n\n") {
let frame = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
for line in frame.lines() {
if !line.starts_with("data: ") {
continue;
}
let payload = line.trim_start_matches("data: ").trim();
if payload == "[DONE]" {
yield StreamChunk::Done {
finish_reason: "stop".to_string(),
usage: None,
};
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(payload) else {
continue;
};
if let Some(detail) = extract_openai_error(&value) {
Err(anyhow::anyhow!(detail))?;
}
let choices = value
.get("choices")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for choice in choices {
let delta = choice.get("delta").cloned().unwrap_or_default();
let message = choice.get("message").cloned().unwrap_or_default();
let mut emitted_text = false;
let mut text_fragments = Vec::new();
push_openai_text_fragments(&delta.get("content").cloned().unwrap_or_default(), &mut text_fragments);
if text_fragments.is_empty() {
push_openai_text_fragments(&message.get("content").cloned().unwrap_or_default(), &mut text_fragments);
}
for text in text_fragments {
if !text.is_empty() {
emitted_text = true;
yield StreamChunk::TextDelta(text);
}
}
if !emitted_text {
if let Some(text) = message.get("content").and_then(|v| v.as_str()) {
if !text.is_empty() {
yield StreamChunk::TextDelta(text.to_string());
}
}
}
for call in extract_openai_tool_call_chunks(&choice, &alias_to_original) {
let effective_id =
resolve_openai_tool_call_stream_id(&call, &mut tool_call_real_ids);
if !effective_id.is_empty() && !call.name.is_empty() {
yield StreamChunk::ToolCallStart {
id: effective_id.clone(),
name: call.name.clone(),
};
}
if !effective_id.is_empty() && !call.args_delta.is_empty() {
yield StreamChunk::ToolCallDelta {
id: effective_id.clone(),
args_delta: call.args_delta.clone(),
};
}
if !effective_id.is_empty() {
yield StreamChunk::ToolCallEnd { id: effective_id };
}
}
if let Some(reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
if !reason.is_empty() {
let usage = extract_usage(&value);
yield StreamChunk::Done {
finish_reason: reason.to_string(),
usage,
};
}
}
}
}
}
}
};
Ok(Box::pin(stream))
}
}
struct AnthropicProvider {
api_key: Option<String>,
default_model: String,
client: Client,
}
struct CohereProvider {
api_key: Option<String>,
base_url: String,
default_model: String,
client: Client,
}
#[async_trait]
impl Provider for AnthropicProvider {
fn info(&self) -> ProviderInfo {
ProviderInfo {
id: "anthropic".to_string(),
name: "Anthropic".to_string(),
models: vec![ModelInfo {
id: self.default_model.clone(),
provider_id: "anthropic".to_string(),
display_name: self.default_model.clone(),
context_window: 200_000,
}],
}
}
async fn complete(&self, prompt: &str, model_override: Option<&str>) -> anyhow::Result<String> {
let model = model_override
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(self.default_model.as_str());
let mut req = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("anthropic-version", "2023-06-01")
.json(&json!({
"model": model,
"max_tokens": 1024,
"messages": [{"role":"user","content": prompt}],
}));
if let Some(key) = &self.api_key {
req = req.header("x-api-key", key);
}
let value: serde_json::Value = req.send().await?.json().await?;
let text = value["content"][0]["text"]
.as_str()
.unwrap_or("No completion content.")
.to_string();
Ok(text)
}
async fn stream(
&self,
messages: Vec<ChatMessage>,
model_override: Option<&str>,
_tool_mode: ToolMode,
_tools: Option<Vec<ToolSchema>>,
cancel: CancellationToken,
) -> anyhow::Result<Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>> {
let model = model_override
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(self.default_model.as_str());
let mut req = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("anthropic-version", "2023-06-01")
.json(&json!({
"model": model,
"max_tokens": 1024,
"stream": true,
"messages": messages
.into_iter()
.map(|m| json!({"role": m.role, "content": m.content}))
.collect::<Vec<_>>(),
}));
if let Some(key) = &self.api_key {
req = req.header("x-api-key", key);
}
let resp = req.send().await?;
let mut bytes = resp.bytes_stream();
let stream = try_stream! {
let mut buffer = String::new();
while let Some(chunk) = bytes.next().await {
if cancel.is_cancelled() {
yield StreamChunk::Done {
finish_reason: "cancelled".to_string(),
usage: None,
};
break;
}
let chunk = chunk?;
buffer.push_str(str::from_utf8(&chunk).unwrap_or_default());
while let Some(pos) = buffer.find("\n\n") {
let frame = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
for line in frame.lines() {
if !line.starts_with("data: ") {
continue;
}
let payload = line.trim_start_matches("data: ").trim();
if payload == "[DONE]" {
yield StreamChunk::Done {
finish_reason: "stop".to_string(),
usage: None,
};
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(payload) else {
continue;
};
match value.get("type").and_then(|v| v.as_str()).unwrap_or_default() {
"content_block_delta" => {
if let Some(delta) = value.get("delta").and_then(|v| v.get("text")).and_then(|v| v.as_str()) {
yield StreamChunk::TextDelta(delta.to_string());
}
if let Some(reasoning) = value.get("delta").and_then(|v| v.get("thinking")).and_then(|v| v.as_str()) {
yield StreamChunk::ReasoningDelta(reasoning.to_string());
}
}
"message_stop" => {
yield StreamChunk::Done {
finish_reason: "stop".to_string(),
usage: None,
};
}
_ => {}
}
}
}
}
};
Ok(Box::pin(stream))
}
}
#[async_trait]
impl Provider for CohereProvider {
fn info(&self) -> ProviderInfo {
ProviderInfo {
id: "cohere".to_string(),
name: "Cohere".to_string(),
models: vec![ModelInfo {
id: self.default_model.clone(),
provider_id: "cohere".to_string(),
display_name: self.default_model.clone(),
context_window: 128_000,
}],
}
}
async fn complete(&self, prompt: &str, model_override: Option<&str>) -> anyhow::Result<String> {
let model = model_override
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(self.default_model.as_str());
let mut req = self
.client
.post(format!("{}/chat", self.base_url))
.json(&json!({
"model": model,
"messages": [{"role":"user","content": prompt}],
}));
if let Some(key) = &self.api_key {
req = req.bearer_auth(key);
}
let value: serde_json::Value = req.send().await?.json().await?;
let text = value["message"]["content"][0]["text"]
.as_str()
.or_else(|| value["text"].as_str())
.unwrap_or("No completion content.")
.to_string();
Ok(text)
}
}
fn chat_message_to_openai_wire(message: ChatMessage) -> serde_json::Value {
if message.attachments.is_empty() {
return json!({
"role": message.role,
"content": message.content
});
}
let mut content = Vec::new();
if !message.content.trim().is_empty() {
content.push(json!({
"type": "text",
"text": message.content
}));
}
for attachment in message.attachments {
match attachment {
ChatAttachment::ImageUrl { url } => content.push(json!({
"type": "image_url",
"image_url": { "url": url }
})),
}
}
if content.is_empty() {
content.push(json!({"type": "text", "text": ""}));
}
json!({
"role": message.role,
"content": content
})
}
fn model_supports_vision_input(model: &str) -> bool {
let lower = model.to_ascii_lowercase();
[
"vision", "gpt-4o", "gpt-4.1", "gpt-5", "omni", "gemini", "claude-3", "llava", "qwen-vl",
"pixtral",
]
.iter()
.any(|hint| lower.contains(hint))
}
fn normalize_base(input: &str) -> String {
let mut base = input.trim().trim_end_matches('/').to_string();
for suffix in ["/chat/completions", "/completions", "/models"] {
if let Some(stripped) = base.strip_suffix(suffix) {
base = stripped.trim_end_matches('/').to_string();
break;
}
}
while let Some(prefix) = base.strip_suffix("/v1") {
if prefix.ends_with("/v1") {
base = prefix.to_string();
continue;
}
break;
}
if base.ends_with("/v1") {
base
} else {
format!("{}/v1", base.trim_end_matches('/'))
}
}
fn normalize_plain_base(input: &str) -> String {
input.trim_end_matches('/').to_string()
}
fn truncate_for_error(input: &str, max_len: usize) -> String {
if input.len() <= max_len {
input.to_string()
} else {
format!("{}...", &input[..max_len])
}
}
fn extract_usage(value: &serde_json::Value) -> Option<TokenUsage> {
let usage = value.get("usage")?;
let prompt_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let completion_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let total_tokens = usage
.get("total_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(prompt_tokens.saturating_add(completion_tokens));
Some(TokenUsage {
prompt_tokens,
completion_tokens,
total_tokens,
})
}
fn collect_text_fragments(value: &serde_json::Value, out: &mut String) {
match value {
serde_json::Value::String(s) => out.push_str(s),
serde_json::Value::Array(arr) => {
for item in arr {
collect_text_fragments(item, out);
}
}
serde_json::Value::Object(map) => {
if let Some(text) = map.get("text").and_then(|v| v.as_str()) {
out.push_str(text);
}
if let Some(text) = map.get("output_text").and_then(|v| v.as_str()) {
out.push_str(text);
}
if let Some(content) = map.get("content") {
collect_text_fragments(content, out);
}
if let Some(delta) = map.get("delta") {
collect_text_fragments(delta, out);
}
if let Some(message) = map.get("message") {
collect_text_fragments(message, out);
}
}
_ => {}
}
}
fn extract_openai_text(value: &serde_json::Value) -> Option<String> {
let mut out = String::new();
if let Some(choice) = value.get("choices").and_then(|v| v.get(0)) {
collect_text_fragments(choice, &mut out);
if !out.trim().is_empty() {
return Some(out);
}
}
if let Some(text) = value
.get("choices")
.and_then(|v| v.get(0))
.and_then(|v| v.get("text"))
.and_then(|v| v.as_str())
{
return Some(text.to_string());
}
if let Some(output) = value.get("output") {
collect_text_fragments(output, &mut out);
if !out.trim().is_empty() {
return Some(out);
}
}
if let Some(content) = value.get("content") {
collect_text_fragments(content, &mut out);
if !out.trim().is_empty() {
return Some(out);
}
}
if let Some(text) = value.get("output_text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
None
}
fn extract_openai_error(value: &serde_json::Value) -> Option<String> {
value
.get("error")
.and_then(|v| v.get("message"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
value
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(
provider_ids: &[&str],
default_provider: Option<&str>,
include_openai_key: bool,
) -> AppConfig {
let mut providers = HashMap::new();
for id in provider_ids {
let api_key = if *id == "openai" && include_openai_key {
Some("sk-test".to_string())
} else {
None
};
providers.insert(
(*id).to_string(),
ProviderConfig {
api_key,
url: None,
default_model: Some(format!("{id}-model")),
},
);
}
AppConfig {
providers,
default_provider: default_provider.map(|s| s.to_string()),
}
}
#[tokio::test]
async fn explicit_provider_wins_over_default_provider() {
let registry = ProviderRegistry::new(cfg(&["openai", "openrouter"], Some("openai"), true));
let provider = registry
.select_provider(Some("openrouter"))
.await
.expect("provider");
assert_eq!(provider.info().id, "openrouter");
}
#[tokio::test]
async fn uses_default_provider_when_explicit_provider_missing() {
let registry =
ProviderRegistry::new(cfg(&["openai", "openrouter"], Some("openrouter"), true));
let provider = registry.select_provider(None).await.expect("provider");
assert_eq!(provider.info().id, "openrouter");
}
#[tokio::test]
async fn falls_back_to_first_provider_when_default_provider_missing() {
let registry = ProviderRegistry::new(cfg(&["openai"], Some("anthropic"), true));
let provider = registry.select_provider(None).await.expect("provider");
assert_eq!(provider.info().id, "openai");
}
#[tokio::test]
async fn explicit_unknown_provider_errors() {
let registry = ProviderRegistry::new(cfg(&["openai"], None, true));
let err = registry
.select_provider(Some("openruter"))
.await
.err()
.expect("expected error");
assert!(err
.to_string()
.contains("provider `openruter` is not configured"));
}
#[tokio::test]
async fn custom_provider_id_is_supported_from_config() {
let registry = ProviderRegistry::new(cfg(&["custom"], Some("custom"), false));
let provider = registry
.select_provider(Some("custom"))
.await
.expect("provider");
assert_eq!(provider.info().id, "custom");
}
#[test]
fn normalize_base_handles_common_openai_compatible_inputs() {
assert_eq!(
normalize_base("http://localhost:8080"),
"http://localhost:8080/v1"
);
assert_eq!(
normalize_base("http://localhost:8080/v1"),
"http://localhost:8080/v1"
);
assert_eq!(
normalize_base("http://localhost:8080/v1/"),
"http://localhost:8080/v1"
);
assert_eq!(
normalize_base("http://localhost:8080/v1/chat/completions"),
"http://localhost:8080/v1"
);
assert_eq!(
normalize_base("http://localhost:8080/v1/models"),
"http://localhost:8080/v1"
);
assert_eq!(
normalize_base("http://localhost:8080/v1/v1"),
"http://localhost:8080/v1"
);
}
#[tokio::test]
async fn complete_cheapest_picks_ollama_first() {
let registry = ProviderRegistry::new(cfg(&["openai", "groq", "ollama"], None, true));
let cheapest = registry.select_cheapest_provider_id().await;
assert_eq!(cheapest, Some("ollama"));
let registry = ProviderRegistry::new(cfg(&["openai", "openai", "openrouter"], None, true));
let cheapest = registry.select_cheapest_provider_id().await;
assert_eq!(cheapest, Some("openrouter"));
let registry = ProviderRegistry::new(cfg(&["unknown_provider"], None, true));
let cheapest = registry.select_cheapest_provider_id().await;
assert_eq!(cheapest, None);
}
#[test]
fn sanitize_openai_function_name_rewrites_invalid_chars() {
assert_eq!(
sanitize_openai_function_name("mcp.arcade.gmail_sendemail"),
"mcp_arcade_gmail_sendemail"
);
assert_eq!(sanitize_openai_function_name(" "), "tool");
assert_eq!(
sanitize_openai_function_name("clickup-getSpaces"),
"clickup-getSpaces"
);
}
#[test]
fn build_openai_tool_aliases_preserves_roundtrip_and_uniqueness() {
let tools = vec![
ToolSchema {
name: "mcp.arcade.gmail.send".to_string(),
description: "a".to_string(),
input_schema: json!({"type":"object"}),
},
ToolSchema {
name: "mcp_arcade_gmail_send".to_string(),
description: "b".to_string(),
input_schema: json!({"type":"object"}),
},
];
let (forward, reverse) = build_openai_tool_aliases(&tools);
let alias_a = forward
.get("mcp.arcade.gmail.send")
.expect("alias for dotted name");
let alias_b = forward
.get("mcp_arcade_gmail_send")
.expect("alias for underscore name");
assert_ne!(alias_a, alias_b, "aliases must be unique");
assert_eq!(
reverse.get(alias_a).map(String::as_str),
Some("mcp.arcade.gmail.send")
);
assert_eq!(
reverse.get(alias_b).map(String::as_str),
Some("mcp_arcade_gmail_send")
);
}
#[test]
fn extract_openai_tool_call_chunks_supports_content_array_tool_calls() {
let mut alias_to_original = HashMap::new();
alias_to_original.insert("write_alias".to_string(), "write".to_string());
let choice = json!({
"message": {
"content": [
{
"type": "tool_call",
"id": "call-1",
"function": {
"name": "write_alias",
"arguments": "{\"path\":\"README.md\",\"content\":\"hi\"}"
}
}
]
}
});
let calls = extract_openai_tool_call_chunks(&choice, &alias_to_original);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call-1");
assert_eq!(calls[0].name, "write");
assert!(calls[0].args_delta.contains("\"README.md\""));
}
#[test]
fn resolve_openai_tool_call_stream_id_keeps_multichunk_write_args_on_same_id() {
let mut alias_to_original = HashMap::new();
alias_to_original.insert("write_alias".to_string(), "write".to_string());
let first_choice = json!({
"delta": {
"tool_calls": [
{
"index": 2,
"id": "call_ghi",
"function": {
"name": "write_alias",
"arguments": ""
}
}
]
}
});
let continuation_choice = json!({
"delta": {
"tool_calls": [
{
"index": 2,
"function": {
"arguments": "{\"path\":\"game.html\",\"content\":\"hi\"}"
}
}
]
}
});
let first_calls = extract_openai_tool_call_chunks(&first_choice, &alias_to_original);
let continuation_calls =
extract_openai_tool_call_chunks(&continuation_choice, &alias_to_original);
assert_eq!(first_calls.len(), 1);
assert_eq!(first_calls[0].id, "call_ghi");
assert_eq!(first_calls[0].name, "write");
assert_eq!(first_calls[0].index, 2);
assert_eq!(continuation_calls.len(), 1);
assert_eq!(continuation_calls[0].id, "tool_call_2");
assert_eq!(continuation_calls[0].name, "");
assert_eq!(continuation_calls[0].index, 2);
let mut real_ids_by_index = HashMap::new();
let mut args_by_id = HashMap::<String, String>::new();
for call in first_calls.into_iter().chain(continuation_calls) {
let effective_id = resolve_openai_tool_call_stream_id(&call, &mut real_ids_by_index);
args_by_id
.entry(effective_id)
.or_default()
.push_str(&call.args_delta);
}
assert_eq!(
real_ids_by_index.get(&2).map(String::as_str),
Some("call_ghi")
);
assert_eq!(
args_by_id.get("call_ghi").map(String::as_str),
Some("{\"path\":\"game.html\",\"content\":\"hi\"}")
);
assert!(!args_by_id.contains_key("tool_call_2"));
}
#[test]
fn push_openai_text_fragments_reads_nested_text_parts() {
let value = json!([
{"type":"text","text":"first"},
{"type":"output_text","text":{"value":"second"}},
{"type":"text","content":"third"}
]);
let mut fragments = Vec::new();
push_openai_text_fragments(&value, &mut fragments);
assert_eq!(fragments, vec!["first", "second", "third"]);
}
#[test]
fn normalize_openai_function_parameters_adds_missing_properties() {
let normalized = normalize_openai_function_parameters(json!({"type":"object"}));
assert_eq!(
normalized
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_default(),
"object"
);
assert!(
normalized
.get("properties")
.and_then(|v| v.as_object())
.is_some(),
"properties object should exist"
);
}
#[test]
fn normalize_openai_function_parameters_recovers_non_object_schema() {
let normalized = normalize_openai_function_parameters(json!("bad"));
assert_eq!(
normalized
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_default(),
"object"
);
assert!(
normalized
.get("properties")
.and_then(|v| v.as_object())
.is_some(),
"properties object should exist"
);
}
#[test]
fn normalize_openai_function_parameters_rewrites_tuple_array_items() {
let normalized = normalize_openai_function_parameters(json!({
"type": "object",
"properties": {
"fieldIds": {
"type": "array",
"items": [
{ "$ref": "#/properties/fieldIds/items" }
]
}
}
}));
assert!(
normalized["properties"]["fieldIds"]["items"].is_object(),
"array items should be object/bool for OpenAI-compatible tools"
);
}
#[test]
fn normalize_openai_function_parameters_adds_nested_object_properties() {
let normalized = normalize_openai_function_parameters(json!({
"type": "object",
"properties": {
"filters": {
"type": "object"
}
}
}));
assert!(
normalized["properties"]["filters"]["properties"].is_object(),
"nested object schemas should include properties for OpenAI validation"
);
}
}