use crate::openai::{OpenAIReasoningEffort, convert};
use crate::retry::{RetryConfig, execute_with_retry, is_retryable_model_error};
use crate::sse::take_line;
use adk_core::{
AdkError, Content, ErrorCategory, ErrorComponent, FinishReason, GenericSchemaAdapter, Llm,
LlmRequest, LlmResponse, LlmResponseStream, Part, SchemaAdapter, SchemaCache, UsageMetadata,
};
use async_openai::types::chat::{
CreateChatCompletionRequestArgs, ReasoningEffort as OaiReasoningEffort, ResponseFormat,
ResponseFormatJsonSchema,
};
use async_stream::try_stream;
use async_trait::async_trait;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReasoningReplayField {
ReasoningContent,
Reasoning,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct OpenAICompatibleConfig {
pub provider_name: String,
#[serde(skip_serializing, default)]
pub api_key: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<OaiReasoningEffort>,
pub parallel_tool_calls: bool,
}
impl std::fmt::Debug for OpenAICompatibleConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenAICompatibleConfig")
.field("provider_name", &self.provider_name)
.field("api_key", &"[REDACTED]")
.field("model", &self.model)
.field("base_url", &self.base_url)
.field("organization_id", &self.organization_id)
.field("project_id", &self.project_id)
.field("reasoning_effort", &self.reasoning_effort)
.field("parallel_tool_calls", &self.parallel_tool_calls)
.finish()
}
}
impl OpenAICompatibleConfig {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
provider_name: "openai-compatible".to_string(),
api_key: api_key.into(),
model: model.into(),
base_url: None,
organization_id: None,
project_id: None,
reasoning_effort: None,
parallel_tool_calls: true,
}
}
pub fn with_provider_name(mut self, provider_name: impl Into<String>) -> Self {
self.provider_name = provider_name.into();
self
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn with_organization(mut self, organization_id: impl Into<String>) -> Self {
self.organization_id = Some(organization_id.into());
self
}
pub fn with_project(mut self, project_id: impl Into<String>) -> Self {
self.project_id = Some(project_id.into());
self
}
pub fn with_reasoning_effort(mut self, effort: OaiReasoningEffort) -> Self {
self.reasoning_effort = Some(effort);
self
}
pub fn with_parallel_tool_calls(mut self, parallel_tool_calls: bool) -> Self {
self.parallel_tool_calls = parallel_tool_calls;
self
}
pub fn fireworks(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("fireworks")
.with_base_url("https://api.fireworks.ai/inference/v1")
}
pub fn together(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("together")
.with_base_url("https://api.together.xyz/v1")
}
pub fn mistral(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("mistral")
.with_base_url("https://api.mistral.ai/v1")
}
pub fn perplexity(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("perplexity")
.with_base_url("https://api.perplexity.ai")
}
pub fn cerebras(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("cerebras")
.with_base_url("https://api.cerebras.ai/v1")
}
pub fn sambanova(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("sambanova")
.with_base_url("https://api.sambanova.ai/v1")
}
pub fn xai(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model).with_provider_name("xai").with_base_url("https://api.x.ai/v1")
}
pub fn orcarouter(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("orcarouter")
.with_base_url("https://api.orcarouter.ai/v1")
}
pub fn gemini(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("gemini")
.with_base_url("https://generativelanguage.googleapis.com/v1beta/openai")
}
pub fn minimax(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("minimax")
.with_base_url("https://api.minimax.chat/v1")
}
pub fn bytedance(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("bytedance")
.with_base_url("https://ark.cn-beijing.volces.com/api/v3")
}
pub fn zhipu(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("zhipu")
.with_base_url("https://open.bigmodel.cn/api/paas/v4")
}
pub fn baidu(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("baidu")
.with_base_url("https://qianfan.baidubce.com/v2")
}
pub fn cohere(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model)
.with_provider_name("cohere")
.with_base_url("https://api.cohere.com/compatibility/v1")
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ErrorCodes {
pub(crate) request: &'static str,
pub(crate) api_error: &'static str,
pub(crate) parse: &'static str,
pub(crate) invalid_tool_arguments: &'static str,
pub(crate) status: &'static [(u16, &'static str)],
}
impl ErrorCodes {
pub(crate) const COMPATIBLE: Self = Self {
request: "model.openai_compat.request",
api_error: "model.openai_compat.api_error",
parse: "model.openai_compat.parse",
invalid_tool_arguments: "model.openai_compat.invalid_tool_arguments",
status: &[],
};
fn for_status(&self, status: u16) -> &'static str {
self.status
.iter()
.find_map(|(candidate, code)| (*candidate == status).then_some(*code))
.unwrap_or(self.api_error)
}
}
pub struct OpenAICompatible {
completion_url: Option<String>,
http: reqwest::Client,
default_headers: http::HeaderMap,
request_adapter: Option<crate::openai::RequestAdapter>,
api_key: String,
base_url: String,
model: String,
provider_name: String,
retry_config: RetryConfig,
reasoning_effort: Option<OpenAIReasoningEffort>,
organization_id: Option<String>,
parallel_tool_calls: bool,
reasoning_replay_field: Option<ReasoningReplayField>,
error_codes: ErrorCodes,
}
impl OpenAICompatible {
pub fn new(config: OpenAICompatibleConfig) -> Result<Self, AdkError> {
let reasoning_effort = config.reasoning_effort.clone().map(|effort| match effort {
OaiReasoningEffort::None => OpenAIReasoningEffort::None,
OaiReasoningEffort::Minimal => OpenAIReasoningEffort::Minimal,
OaiReasoningEffort::Low => OpenAIReasoningEffort::Low,
OaiReasoningEffort::Medium => OpenAIReasoningEffort::Medium,
OaiReasoningEffort::High => OpenAIReasoningEffort::High,
OaiReasoningEffort::Xhigh => OpenAIReasoningEffort::XHigh,
});
Self::new_with_reasoning_effort(config, reasoning_effort)
}
pub fn new_with_reasoning_effort(
config: OpenAICompatibleConfig,
reasoning_effort: Option<OpenAIReasoningEffort>,
) -> Result<Self, AdkError> {
crate::catalog::warn_if_obsolete(&config.provider_name, &config.model);
let base_url = config.base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
Ok(Self {
completion_url: None,
http: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| AdkError::model("failed to initialize compatible HTTP transport"))?,
default_headers: http::HeaderMap::new(),
request_adapter: None,
api_key: config.api_key,
base_url,
model: config.model,
provider_name: config.provider_name,
retry_config: RetryConfig::default(),
reasoning_effort,
organization_id: config.organization_id,
parallel_tool_calls: config.parallel_tool_calls,
reasoning_replay_field: None,
error_codes: ErrorCodes::COMPATIBLE,
})
}
pub fn with_default_headers(mut self, headers: http::HeaderMap) -> Result<Self, AdkError> {
self.default_headers.extend(headers);
self.http = reqwest::Client::builder()
.default_headers(self.default_headers.clone())
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|err| {
AdkError::new(
ErrorComponent::Model,
ErrorCategory::Internal,
"model.openai.http_client_init",
format!("failed to initialize the {} HTTP client: {err}", self.provider_name),
)
.with_provider(&self.provider_name)
.with_source(err)
})?;
Ok(self)
}
#[must_use]
pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
self.retry_config = retry_config;
self
}
#[must_use]
pub fn with_request_adapter(mut self, adapter: crate::openai::RequestAdapter) -> Self {
self.request_adapter = Some(adapter);
self
}
pub(crate) fn with_completion_url(mut self, url: String) -> Self {
self.completion_url = Some(url);
self
}
pub(crate) fn with_reasoning_effort(mut self, effort: Option<OpenAIReasoningEffort>) -> Self {
self.reasoning_effort = effort;
self
}
pub(crate) fn with_error_codes(mut self, error_codes: ErrorCodes) -> Self {
self.error_codes = error_codes;
self
}
pub fn set_retry_config(&mut self, retry_config: RetryConfig) {
self.retry_config = retry_config;
}
pub fn retry_config(&self) -> &RetryConfig {
&self.retry_config
}
#[must_use]
pub fn with_reasoning_replay(mut self, enabled: bool) -> Self {
self.reasoning_replay_field = enabled.then_some(ReasoningReplayField::ReasoningContent);
self
}
#[must_use]
pub fn with_reasoning_replay_field(mut self, field: ReasoningReplayField) -> Self {
self.reasoning_replay_field = Some(field);
self
}
}
fn inject_assistant_reasoning(
body: &mut serde_json::Value,
contents: &[Content],
field: Option<ReasoningReplayField>,
) {
let Some(field) = field else {
return;
};
let Some(messages) = body.get_mut("messages").and_then(serde_json::Value::as_array_mut) else {
return;
};
let field_name = match field {
ReasoningReplayField::ReasoningContent => "reasoning_content",
ReasoningReplayField::Reasoning => "reasoning",
};
for (message, content) in messages.iter_mut().zip(contents) {
if !matches!(content.role.as_str(), "model" | "assistant") {
continue;
}
let Some(reasoning) = convert::extract_reasoning_content(&content.parts) else {
continue;
};
let Some(message) = message.as_object_mut() else {
continue;
};
message.insert(field_name.to_string(), serde_json::Value::String(reasoning));
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ChatCapabilities {
pub(crate) parallel_tool_calls: bool,
pub(crate) native_pdf: bool,
}
pub(crate) fn build_request_json(
model: &str,
request: &LlmRequest,
reasoning_effort: &Option<OpenAIReasoningEffort>,
capabilities: ChatCapabilities,
reasoning_replay_field: Option<ReasoningReplayField>,
adapter: &dyn SchemaAdapter,
cache: &SchemaCache,
) -> Result<serde_json::Value, AdkError> {
let bare_model = model.strip_prefix("models/").unwrap_or(model);
if matches!(bare_model, "gemini-3.6-flash" | "gemini-3.7-flash")
&& request.config.as_ref().is_some_and(|config| {
config.temperature.is_some() || config.top_p.is_some() || config.top_k.is_some()
})
{
return Err(AdkError::new(
ErrorComponent::Model,
ErrorCategory::InvalidInput,
"model.gemini.sampling_unsupported",
format!(
"{bare_model} does not accept temperature, top_p, or top_k; remove explicit sampling parameters"
),
)
.with_provider("gemini"));
}
let contents = crate::tool_result::with_images(&request.contents);
let messages: Vec<_> = contents
.iter()
.map(|content| convert::content_to_message(content, capabilities.native_pdf))
.collect();
let mut request_builder = CreateChatCompletionRequestArgs::default();
request_builder.model(model).messages(messages);
if !request.tools.is_empty() {
let tools = convert::convert_tools(&request.tools, adapter, cache);
request_builder.tools(tools);
request_builder.parallel_tool_calls(capabilities.parallel_tool_calls);
}
if let Some(effort) = reasoning_effort.and_then(to_oai_reasoning_effort) {
request_builder.reasoning_effort(effort);
}
if let Some(config) = &request.config {
if let Some(temp) = config.temperature {
request_builder.temperature(temp);
}
if let Some(top_p) = config.top_p {
request_builder.top_p(top_p);
}
if let Some(max_tokens) = config.max_output_tokens {
request_builder.max_completion_tokens(max_tokens as u32);
}
if let Some(schema) = &config.response_schema {
let mut schema_with_strict = schema.clone();
if let Some(obj) = schema_with_strict.as_object_mut() {
obj.insert("additionalProperties".to_string(), serde_json::json!(false));
}
let json_schema = ResponseFormatJsonSchema {
name: request.model.replace(['-', '.', '/'], "_"),
description: None,
schema: schema_with_strict,
strict: Some(true),
};
request_builder.response_format(ResponseFormat::JsonSchema { json_schema });
}
}
let openai_request = request_builder
.build()
.map_err(|e| AdkError::model(format!("failed to build request: {e}")))?;
let mut body = serde_json::to_value(&openai_request)
.map_err(|e| AdkError::model(format!("failed to serialize request: {e}")))?;
if matches!(reasoning_effort, Some(OpenAIReasoningEffort::Max)) {
body["reasoning_effort"] = serde_json::Value::String("max".to_string());
}
inject_assistant_reasoning(&mut body, &contents, reasoning_replay_field);
if let Some(config) = &request.config
&& let Some(openai_ext) = config.extensions.get("openai")
&& let (Some(body_obj), Some(ext_obj)) = (body.as_object_mut(), openai_ext.as_object())
{
for (key, value) in ext_obj {
body_obj.insert(key.clone(), value.clone());
}
}
Ok(body)
}
fn to_oai_reasoning_effort(effort: OpenAIReasoningEffort) -> Option<OaiReasoningEffort> {
match effort {
OpenAIReasoningEffort::None => Some(OaiReasoningEffort::None),
OpenAIReasoningEffort::Minimal => Some(OaiReasoningEffort::Minimal),
OpenAIReasoningEffort::Low => Some(OaiReasoningEffort::Low),
OpenAIReasoningEffort::Medium => Some(OaiReasoningEffort::Medium),
OpenAIReasoningEffort::High => Some(OaiReasoningEffort::High),
OpenAIReasoningEffort::XHigh => Some(OaiReasoningEffort::Xhigh),
OpenAIReasoningEffort::Max => None,
}
}
#[derive(Clone)]
struct RequestTarget {
url: String,
api_key: String,
organization_id: Option<String>,
provider_name: String,
adapter: Option<crate::openai::RequestAdapter>,
error_codes: ErrorCodes,
}
async fn send_request(
http: &reqwest::Client,
target: &RequestTarget,
body: &serde_json::Value,
) -> Result<reqwest::Response, AdkError> {
let RequestTarget { url, api_key, organization_id, provider_name, adapter, error_codes } =
target;
let mut http_req = http.post(url);
if !api_key.is_empty() {
http_req = http_req.bearer_auth(api_key);
}
if let Some(adapter) = adapter {
let mut body = body.clone();
let mut headers = reqwest::header::HeaderMap::new();
adapter(&mut body, &mut headers)?;
http_req = http_req.headers(headers).json(&body);
} else {
http_req = http_req.json(body);
}
if let Some(org_id) = organization_id {
http_req = http_req.header("OpenAI-Organization", org_id);
}
let http_resp = http_req.send().await.map_err(|e| {
AdkError::new(
ErrorComponent::Model,
ErrorCategory::Unavailable,
error_codes.request,
format!("{provider_name} request error: {e}"),
)
.with_provider(provider_name)
})?;
if !http_resp.status().is_success() {
let status = http_resp.status();
let status_code = status.as_u16();
let retry_after = http_resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(crate::retry::parse_retry_after);
let body = http_resp.text().await.unwrap_or_default();
let error = AdkError::new(
ErrorComponent::Model,
crate::retry::category_for_status_code(status_code),
error_codes.for_status(status_code),
format!("{provider_name} API error (HTTP {status}): {body}"),
)
.with_upstream_status(status_code)
.with_provider(provider_name);
return Err(crate::retry::with_retry_after(error, retry_after));
}
Ok(http_resp)
}
fn parse_finish_reason(fr: &str) -> FinishReason {
match fr {
"stop" => FinishReason::Stop,
"length" => FinishReason::MaxTokens,
"tool_calls" => FinishReason::Stop,
"content_filter" => FinishReason::Safety,
"function_call" => FinishReason::Stop,
_ => FinishReason::Stop,
}
}
fn append_tool_call_arguments(accumulator: &mut String, arguments: &serde_json::Value) {
match arguments {
serde_json::Value::String(fragment) => accumulator.push_str(fragment),
serde_json::Value::Null => {}
structured => {
let empty_snapshot = matches!(structured, serde_json::Value::Object(fields) if fields.is_empty())
|| matches!(structured, serde_json::Value::Array(items) if items.is_empty());
if !empty_snapshot {
accumulator.clear();
accumulator.push_str(&structured.to_string());
}
}
}
}
fn parse_tool_call_arguments(
provider_name: &str,
error_codes: ErrorCodes,
tool_name: &str,
arguments: &str,
) -> Result<serde_json::Value, AdkError> {
crate::tool_args::parse_streamed_tool_arguments(
provider_name,
error_codes.invalid_tool_arguments,
tool_name,
arguments,
)
}
fn parse_usage_from_chunk(chunk: &serde_json::Value) -> Option<UsageMetadata> {
chunk.get("usage").and_then(convert::usage_metadata_from_raw)
}
#[async_trait]
impl Llm for OpenAICompatible {
fn name(&self) -> &str {
&self.model
}
#[tracing::instrument(
name = "model.generate_content",
skip_all,
fields(
model.name = %self.name(),
stream = %stream,
request.contents_count = %request.contents.len(),
request.tools_count = %request.tools.len()
)
)]
async fn generate_content(
&self,
request: LlmRequest,
stream: bool,
) -> Result<LlmResponseStream, AdkError> {
let model = self.model.clone();
let provider_name = self.provider_name.clone();
let http = self.http.clone();
let retry_config = self.retry_config.clone();
let reasoning_effort = self.reasoning_effort;
let reasoning_replay_field = self.reasoning_replay_field;
let error_codes = self.error_codes;
let target = RequestTarget {
url: self
.completion_url
.clone()
.unwrap_or_else(|| format!("{}/chat/completions", self.base_url)),
api_key: self.api_key.clone(),
organization_id: self.organization_id.clone(),
provider_name: provider_name.clone(),
adapter: self.request_adapter.clone(),
error_codes,
};
let declared_tools: std::collections::HashSet<String> =
request.tools.keys().cloned().collect();
let adapter = self.schema_adapter();
use std::sync::LazyLock;
static SCHEMA_CACHE: LazyLock<SchemaCache> =
LazyLock::new(|| SchemaCache::for_adapter(std::sync::Arc::new(GenericSchemaAdapter)));
let request_body = build_request_json(
&model,
&request,
&reasoning_effort,
ChatCapabilities {
parallel_tool_calls: self.parallel_tool_calls,
native_pdf: matches!(provider_name.as_str(), "openai" | "azure-openai"),
},
reasoning_replay_field,
adapter,
&SCHEMA_CACHE,
)?;
let usage_span = adk_telemetry::llm_generate_span(&provider_name, &model, stream);
if stream {
let response_stream = try_stream! {
let mut body = request_body.clone();
if let Some(obj) = body.as_object_mut() {
obj.insert("stream".to_string(), serde_json::json!(true));
obj.insert(
"stream_options".to_string(),
serde_json::json!({"include_usage": true}),
);
}
let response = execute_with_retry(&retry_config, is_retryable_model_error, || {
let http = http.clone();
let target = target.clone();
let body = body.clone();
async move { send_request(&http, &target, &body).await }
})
.await?;
let mut byte_stream = response.bytes_stream();
let mut buffer = Vec::new();
let mut tool_call_accumulators: HashMap<u32, (String, String, String)> =
HashMap::new();
let mut text_tool_buffer =
crate::tool_call_parser::ToolCallBuffer::for_declared_tools(declared_tools);
let mut pending_final_response: Option<LlmResponse> = None;
while let Some(chunk_result) = byte_stream.next().await {
let chunk = chunk_result.map_err(|e| {
AdkError::model(format!("stream read error: {e}"))
})?;
buffer.extend_from_slice(&chunk);
while let Some(line) = take_line(&mut buffer)? {
if line.is_empty() {
continue;
}
if line == "data: [DONE]" {
if let Some(response) = pending_final_response.take() {
yield response;
}
continue;
}
if let Some(data) = line.strip_prefix("data: ") {
let chunk_json: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"failed to parse SSE chunk: {e} - {data}"
);
continue;
}
};
let usage_metadata = parse_usage_from_chunk(&chunk_json);
let choice = match chunk_json.get("choices").and_then(|c| c.get(0)) {
Some(c) => c,
None => {
if let Some(usage_metadata) = usage_metadata
&& let Some(mut response) = pending_final_response.take()
{
response.usage_metadata = Some(usage_metadata);
yield response;
}
continue;
}
};
let delta = match choice.get("delta") {
Some(d) => d,
None => continue,
};
let finish_reason_str = choice
.get("finish_reason")
.and_then(|v| v.as_str())
.map(String::from);
if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
for tc in tool_calls {
let index = tc.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let entry = tool_call_accumulators
.entry(index)
.or_insert_with(|| {
let call_id = tc
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(call_id, String::new(), String::new())
});
if let Some(id) = tc.get("id").and_then(|v| v.as_str())
&& !id.is_empty() {
entry.0 = id.to_string();
}
if let Some(func) = tc.get("function") {
if let Some(name) = func.get("name").and_then(|v| v.as_str()) {
entry.1 = name.to_string();
}
if let Some(arguments) = func.get("arguments") {
append_tool_call_arguments(&mut entry.2, arguments);
}
}
}
}
if let Some(ref fr) = finish_reason_str {
let finish_reason = Some(parse_finish_reason(fr));
if !tool_call_accumulators.is_empty() {
let mut sorted_calls: Vec<_> =
tool_call_accumulators.drain().collect();
sorted_calls.sort_by_key(|(idx, _)| *idx);
let parts: Vec<Part> = sorted_calls
.into_iter()
.map(|(_, (id, name, args_str))| {
let args = parse_tool_call_arguments(
&provider_name,
error_codes,
&name,
&args_str,
)?;
Ok(Part::FunctionCall {
name,
args,
id: Some(id),
thought_signature: None,
})
})
.collect::<Result<Vec<_>, AdkError>>()?;
let response = LlmResponse {
content: Some(Content {
role: "model".to_string(),
parts,
}),
usage_metadata,
finish_reason,
citation_metadata: None,
partial: false,
turn_complete: false,
interrupted: false,
error_code: None,
error_message: None,
provider_metadata: None,
interaction_id: None,
};
if response.usage_metadata.is_some() {
yield response;
} else {
pending_final_response = Some(response);
}
continue;
}
let mut parts = Vec::new();
if let Some(text) = delta.get("content").and_then(|v| v.as_str())
&& !text.is_empty() {
parts.push(Part::Text { text: text.to_string() });
}
let response = LlmResponse {
content: if parts.is_empty() { None } else {
Some(Content {
role: "model".to_string(),
parts,
})
},
usage_metadata,
finish_reason,
citation_metadata: None,
partial: false,
turn_complete: true,
interrupted: false,
error_code: None,
error_message: None,
provider_metadata: None,
interaction_id: None,
};
if response.usage_metadata.is_some() {
yield response;
} else {
pending_final_response = Some(response);
}
continue;
}
let reasoning = delta.get("reasoning_content")
.or_else(|| delta.get("reasoning"))
.and_then(|v| v.as_str());
if let Some(reasoning) = reasoning
&& !reasoning.is_empty() {
yield LlmResponse {
content: Some(Content {
role: "model".to_string(),
parts: vec![Part::Thinking {
thinking: reasoning.to_string(),
signature: None,
}],
}),
usage_metadata: None,
finish_reason: None,
citation_metadata: None,
partial: true,
turn_complete: false,
interrupted: false,
error_code: None,
error_message: None,
provider_metadata: None,
interaction_id: None,
};
}
if let Some(text) = delta.get("content").and_then(|v| v.as_str())
&& !text.is_empty() {
match text_tool_buffer.push(text) {
crate::tool_call_parser::BufferAction::Emit(parts) => {
for part in parts {
let is_tool = matches!(part, Part::FunctionCall { .. });
yield LlmResponse {
content: Some(Content {
role: "model".to_string(),
parts: vec![part],
}),
usage_metadata: None,
finish_reason: None,
citation_metadata: None,
partial: !is_tool,
turn_complete: false,
interrupted: false,
error_code: None,
error_message: None,
provider_metadata: None,
interaction_id: None,
};
}
}
crate::tool_call_parser::BufferAction::Buffering => {
}
}
}
}
}
}
if let Some(response) = pending_final_response.take() {
yield response;
}
for part in text_tool_buffer.flush() {
let is_tool = matches!(part, Part::FunctionCall { .. });
yield LlmResponse {
content: Some(Content {
role: "model".to_string(),
parts: vec![part],
}),
usage_metadata: None,
finish_reason: if is_tool { Some(adk_core::FinishReason::Stop) } else { None },
citation_metadata: None,
partial: !is_tool,
turn_complete: false,
interrupted: false,
error_code: None,
error_message: None,
provider_metadata: None,
interaction_id: None,
};
}
};
Ok(crate::usage_tracking::with_usage_tracking(Box::pin(response_stream), usage_span))
} else {
let response_stream = try_stream! {
let response = execute_with_retry(&retry_config, is_retryable_model_error, || {
let model = model.clone();
let provider_name = provider_name.clone();
let http = http.clone();
let target = target.clone();
let body = request_body.clone();
async move {
let http_resp = send_request(&http, &target, &body).await?;
let raw_json: serde_json::Value = http_resp.json().await.map_err(|e| {
AdkError::new(
ErrorComponent::Model,
ErrorCategory::Internal,
error_codes.parse,
format!("{provider_name} response parse error: {e}"),
)
.with_provider(&provider_name)
})?;
tracing::debug!(
provider = %provider_name,
model = %model,
has_reasoning = raw_json
.pointer("/choices/0/message/reasoning_content")
.is_some(),
"openai chat completion response"
);
Ok(raw_json)
}
})
.await?;
let adk_response = convert::from_raw_openai_response(&response, &declared_tools).map_err(|error| {
AdkError::new(
ErrorComponent::Model,
ErrorCategory::Internal,
error_codes.invalid_tool_arguments,
format!("{provider_name} returned {error}"),
)
.with_provider(&provider_name)
})?;
yield adk_response;
};
Ok(crate::usage_tracking::with_usage_tracking(Box::pin(response_stream), usage_span))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use adk_core::{GenericSchemaAdapter, LlmRequest, SchemaCache};
use std::sync::Arc;
mod sse_lines {
use super::take_line;
#[test]
fn preserves_every_split() {
let expected = r#"data: {"text":"Ã©ä¸æ–‡ðŸ™‚�","arguments":"{\"path\":\"资料.txt\",\"text\":\"改动🙂\"}"}"#;
let framed = format!("{expected}\r\n");
for split in 0..framed.len() {
let mut buffer = framed.as_bytes()[..split].to_vec();
assert_eq!(take_line(&mut buffer).unwrap(), None);
buffer.extend_from_slice(&framed.as_bytes()[split..]);
assert_eq!(take_line(&mut buffer).unwrap().as_deref(), Some(expected));
assert!(buffer.is_empty());
}
}
#[test]
fn accepts_bytewise_delivery() {
let expected = "data: 䏿–‡ðŸ™‚é";
let mut buffer = Vec::new();
for byte in expected.as_bytes() {
buffer.push(*byte);
assert_eq!(take_line(&mut buffer).unwrap(), None);
}
buffer.push(b'\n');
assert_eq!(take_line(&mut buffer).unwrap().as_deref(), Some(expected));
assert!(buffer.is_empty());
}
#[test]
fn leaves_following_lines() {
let mut buffer = "data: 第一行\r\n\r\ndata: 第二行🙂\n".as_bytes().to_vec();
assert_eq!(take_line(&mut buffer).unwrap().as_deref(), Some("data: 第一行"));
assert_eq!(take_line(&mut buffer).unwrap().as_deref(), Some(""));
assert_eq!(take_line(&mut buffer).unwrap().as_deref(), Some("data: 第二行🙂"));
assert_eq!(take_line(&mut buffer).unwrap(), None);
}
#[test]
fn rejects_invalid_utf8() {
for invalid in [b"data: \xc3(\n".as_slice(), b"data: \xf0\x9f\n".as_slice()] {
assert!(take_line(&mut invalid.to_vec()).is_err());
}
}
}
#[test]
fn test_parallel_tool_calls_config() {
let config =
OpenAICompatibleConfig::new("test-key", "test-model").with_parallel_tool_calls(false);
assert!(!config.parallel_tool_calls, "parallel_tool_calls should be false in config");
let client = OpenAICompatible::new(config).expect("client creation failed");
assert!(!client.parallel_tool_calls, "parallel_tool_calls should be false in client");
}
#[test]
fn test_parallel_tool_calls_default() {
let config = OpenAICompatibleConfig::new("test-key", "test-model");
assert!(config.parallel_tool_calls, "parallel_tool_calls should default to true");
let client = OpenAICompatible::new(config).expect("client creation failed");
assert!(client.parallel_tool_calls, "parallel_tool_calls should default to true in client");
}
#[test]
fn streamed_tool_arguments_require_valid_json() {
let parsed = parse_tool_call_arguments(
"compatible-provider",
ErrorCodes::COMPATIBLE,
"bash",
r#"{"command":"pwd"}"#,
)
.expect("valid arguments should parse");
assert_eq!(parsed["command"], "pwd");
assert_eq!(
parse_tool_call_arguments("compatible-provider", ErrorCodes::COMPATIBLE, "no_args", "")
.expect("an empty compatible payload should normalize"),
serde_json::json!({})
);
assert_eq!(
parse_tool_call_arguments(
"compatible-provider",
ErrorCodes::COMPATIBLE,
"no_args",
" "
)
.expect("a whitespace-only compatible payload should normalize"),
serde_json::json!({})
);
assert_eq!(
parse_tool_call_arguments(
"compatible-provider",
ErrorCodes::COMPATIBLE,
"no_args",
"[]"
)
.expect("an empty array compatible payload should normalize"),
serde_json::json!({})
);
let error = parse_tool_call_arguments(
"compatible-provider",
ErrorCodes::COMPATIBLE,
"bash",
r#"{"command":"#,
)
.expect_err("truncated arguments must remain invalid");
assert_eq!(error.component, ErrorComponent::Model);
assert_eq!(error.category, ErrorCategory::Internal);
assert_eq!(error.code, "model.openai_compat.invalid_tool_arguments");
assert_eq!(error.details.provider.as_deref(), Some("compatible-provider"));
let error = parse_tool_call_arguments(
"compatible-provider",
ErrorCodes::COMPATIBLE,
"bash",
r#"["pwd"]"#,
)
.expect_err("non-empty array arguments must remain invalid");
assert_eq!(error.code, "model.openai_compat.invalid_tool_arguments");
}
#[test]
fn streamed_structured_tool_arguments_are_canonicalized() {
let mut arguments = String::new();
append_tool_call_arguments(&mut arguments, &serde_json::json!({"command": "pwd"}));
assert_eq!(
parse_tool_call_arguments(
"compatible-provider",
ErrorCodes::COMPATIBLE,
"bash",
&arguments
)
.expect("a structured object should normalize"),
serde_json::json!({"command": "pwd"})
);
}
#[test]
fn streamed_empty_snapshot_before_string_fragments_is_ignored() {
for placeholder in [serde_json::json!({}), serde_json::json!([])] {
let mut arguments = String::new();
append_tool_call_arguments(&mut arguments, &placeholder);
assert_eq!(
parse_tool_call_arguments(
"compatible-provider",
ErrorCodes::COMPATIBLE,
"no_args",
&arguments
)
.expect("an empty snapshot should remain a no-argument call"),
serde_json::json!({})
);
append_tool_call_arguments(&mut arguments, &serde_json::json!(r#"{"command""#));
append_tool_call_arguments(&mut arguments, &serde_json::json!(r#": "pwd"}"#));
assert_eq!(
parse_tool_call_arguments(
"compatible-provider",
ErrorCodes::COMPATIBLE,
"bash",
&arguments
)
.expect("empty snapshots must not prefix string fragments"),
serde_json::json!({"command": "pwd"})
);
}
}
#[test]
fn gpt_56_chat_tools_preserve_configured_reasoning() {
let adapter = GenericSchemaAdapter;
let cache = SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter));
let mut request = LlmRequest {
model: crate::catalog::OPENAI_DEFAULT.to_string(),
contents: Vec::new(),
config: None,
tools: HashMap::new(),
previous_response_id: None,
};
request.tools.insert(
"lookup".to_string(),
serde_json::json!({
"description": "Look up a record",
"parameters": {"type": "object", "properties": {}}
}),
);
for (configured_effort, expected) in [
(None, None),
(Some(OpenAIReasoningEffort::Medium), Some("medium")),
(Some(OpenAIReasoningEffort::Max), Some("max")),
] {
let body = build_request_json(
crate::catalog::OPENAI_DEFAULT,
&request,
&configured_effort,
ChatCapabilities { parallel_tool_calls: true, native_pdf: false },
None,
&adapter,
&cache,
)
.expect("request should build");
assert_eq!(body.get("reasoning_effort").and_then(serde_json::Value::as_str), expected);
}
}
#[test]
fn gpt_56_without_tools_keeps_server_reasoning_default() {
let adapter = GenericSchemaAdapter;
let cache = SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter));
let request = LlmRequest {
model: crate::catalog::OPENAI_DEFAULT.to_string(),
contents: Vec::new(),
config: None,
tools: HashMap::new(),
previous_response_id: None,
};
let body = build_request_json(
crate::catalog::OPENAI_DEFAULT,
&request,
&None,
ChatCapabilities { parallel_tool_calls: true, native_pdf: false },
None,
&adapter,
&cache,
)
.expect("request should build");
assert!(body.get("reasoning_effort").is_none());
}
#[test]
fn gpt_56_chat_serializes_max_reasoning() {
let adapter = GenericSchemaAdapter;
let cache = SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter));
let request = LlmRequest {
model: crate::catalog::OPENAI_DEFAULT.to_string(),
contents: Vec::new(),
config: None,
tools: HashMap::new(),
previous_response_id: None,
};
let body = build_request_json(
crate::catalog::OPENAI_DEFAULT,
&request,
&Some(OpenAIReasoningEffort::Max),
ChatCapabilities { parallel_tool_calls: true, native_pdf: false },
None,
&adapter,
&cache,
)
.expect("request should build");
assert_eq!(body["reasoning_effort"], "max");
}
#[test]
fn gemini_37_compatible_path_rejects_sampling_locally() {
let mut request = LlmRequest::new("gemini-3.7-flash", Vec::new());
request.config =
Some(adk_core::GenerateContentConfig { top_p: Some(0.9), ..Default::default() });
let cache = SchemaCache::for_adapter(Arc::new(GenericSchemaAdapter));
let error = build_request_json(
"gemini-3.7-flash",
&request,
&None,
ChatCapabilities { parallel_tool_calls: true, native_pdf: false },
None,
&GenericSchemaAdapter,
&cache,
)
.expect_err("sampling should be rejected before network I/O");
assert_eq!(error.code, "model.gemini.sampling_unsupported");
}
#[test]
fn orcarouter_preset_sets_endpoint_and_provider() {
let config = OpenAICompatibleConfig::orcarouter("test-key", "openai/gpt-5.6-terra");
assert_eq!(config.provider_name, "orcarouter");
assert_eq!(config.model, "openai/gpt-5.6-terra");
assert_eq!(config.base_url.as_deref(), Some("https://api.orcarouter.ai/v1"));
assert_eq!(config.api_key, "test-key");
let client = OpenAICompatible::new(config).expect("client builds");
assert_eq!(client.name(), "openai/gpt-5.6-terra");
}
#[test]
fn gemini_preset_sets_endpoint_and_provider() {
let config = OpenAICompatibleConfig::gemini("test-key", "gemini-3.5-flash");
assert_eq!(config.provider_name, "gemini");
assert_eq!(config.model, "gemini-3.5-flash");
assert_eq!(
config.base_url.as_deref(),
Some("https://generativelanguage.googleapis.com/v1beta/openai")
);
assert_eq!(config.api_key, "test-key");
}
#[test]
fn gemini_preset_supports_reasoning_effort() {
let config = OpenAICompatibleConfig::gemini("k", "gemini-3.5-flash")
.with_reasoning_effort(OaiReasoningEffort::Low);
assert_eq!(config.reasoning_effort, Some(OaiReasoningEffort::Low));
}
#[test]
fn gemini_preset_builds_client() {
let config = OpenAICompatibleConfig::gemini("k", "gemini-3.5-flash");
let client = OpenAICompatible::new(config).expect("client builds");
assert_eq!(client.name(), "gemini-3.5-flash");
}
}