Files
tkmind_go/crates/goose/src/providers/openai_compatible.rs
T

240 lines
7.3 KiB
Rust

use anyhow::Error;
use async_stream::try_stream;
use futures::TryStreamExt;
use reqwest::Response;
#[cfg(test)]
use reqwest::StatusCode;
use serde_json::Value;
use tokio::pin;
use tokio_stream::StreamExt;
use tokio_util::codec::{FramedRead, LinesCodec};
use tokio_util::io::StreamReader;
use super::api_client::ApiClient;
use super::base::{MessageStream, Provider};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::{ImageFormat, RequestLog};
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use crate::providers::formats::openai::{create_request, response_to_streaming_message};
use rmcp::model::Tool;
pub struct OpenAiCompatibleProvider {
name: String,
/// Client targeted at the base URL (e.g. `https://api.x.ai/v1`)
api_client: ApiClient,
model: ModelConfig,
/// Path prefix prepended to `chat/completions` (e.g. `"deployments/{name}/"` for Azure).
completions_prefix: String,
}
impl OpenAiCompatibleProvider {
pub fn new(
name: String,
api_client: ApiClient,
model: ModelConfig,
completions_prefix: String,
) -> Self {
Self {
name,
api_client,
model,
completions_prefix,
}
}
fn build_request(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
for_streaming: bool,
) -> Result<Value, ProviderError> {
create_request(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
for_streaming,
)
.map_err(|e| ProviderError::RequestFailed(format!("Failed to create request: {}", e)))
}
}
#[async_trait::async_trait]
impl Provider for OpenAiCompatibleProvider {
fn get_name(&self) -> &str {
&self.name
}
fn get_model_config(&self) -> ModelConfig {
self.model.clone()
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
let response = self
.api_client
.response_get(None, "models")
.await
.map_err(|e| ProviderError::RequestFailed(e.to_string()))?;
let json = handle_response_openai_compat(response).await?;
if let Some(err_obj) = json.get("error") {
let msg = err_obj
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(ProviderError::Authentication(msg.to_string()));
}
let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
ProviderError::RequestFailed("Missing 'data' array in models response".to_string())
})?;
let mut models: Vec<String> = arr
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.collect();
models.sort();
Ok(models)
}
async fn stream(
&self,
model_config: &ModelConfig,
session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let payload = self.build_request(model_config, system, messages, tools, true)?;
let mut log = RequestLog::start(model_config, &payload)?;
let completions_path = format!("{}chat/completions", self.completions_prefix);
let response = self
.with_retry(|| async {
let resp = self
.api_client
.response_post(Some(session_id), &completions_path, &payload)
.await?;
handle_status(resp).await
})
.await
.inspect_err(|e| {
let _ = log.error(e);
})?;
stream_openai_compat(response, log)
}
}
// Re-exported from the dedicated `http_status` module — these helpers are
// format-agnostic and used across all provider families.
pub use super::http_status::{handle_response, handle_status, map_http_error_to_provider_error};
// Legacy alias kept for callers that haven't migrated their import path yet.
pub use super::http_status::handle_response as handle_response_openai_compat;
pub fn stream_openai_compat(
response: Response,
mut log: RequestLog,
) -> Result<MessageStream, ProviderError> {
let stream = response.bytes_stream().map_err(std::io::Error::other);
Ok(Box::pin(try_stream! {
let stream_reader = StreamReader::new(stream);
let framed = FramedRead::new(stream_reader, LinesCodec::new())
.map_err(Error::from);
let message_stream = response_to_streaming_message(framed);
pin!(message_stream);
while let Some(message) = message_stream.next().await {
let (message, usage) = message.map_err(|e|
e.downcast::<ProviderError>()
.unwrap_or_else(|e| ProviderError::RequestFailed(format!("Stream decode error: {e}")))
)?;
log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?;
yield (message, usage);
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use test_case::test_case;
#[test_case(
StatusCode::PAYMENT_REQUIRED,
Some(json!({"error": {"message": "Insufficient credits to complete this request"}})),
"CreditsExhausted"
; "402 with payload"
)]
#[test_case(
StatusCode::PAYMENT_REQUIRED,
None,
"CreditsExhausted"
; "402 without payload"
)]
#[test_case(
StatusCode::TOO_MANY_REQUESTS,
Some(json!({"error": {"message": "Rate limit exceeded"}})),
"RateLimitExceeded"
; "429 rate limit"
)]
#[test_case(
StatusCode::UNAUTHORIZED,
None,
"Authentication"
; "401 unauthorized"
)]
#[test_case(
StatusCode::BAD_REQUEST,
Some(json!({"error": {"message": "This request exceeds the maximum context length"}})),
"ContextLengthExceeded"
; "400 context length"
)]
#[test_case(
StatusCode::INTERNAL_SERVER_ERROR,
None,
"ServerError"
; "500 server error"
)]
#[test_case(
StatusCode::NOT_FOUND,
None,
"RequestFailed"
; "404 not found"
)]
#[test_case(
StatusCode::NOT_FOUND,
Some(json!({"error": {"message": "model not available"}})),
"RequestFailed"
; "404 with error payload"
)]
fn http_status_maps_to_expected_error(
status: StatusCode,
payload: Option<Value>,
expected_variant: &str,
) {
let err = map_http_error_to_provider_error(status, payload);
let actual = err.telemetry_type();
let expected_telemetry = match expected_variant {
"CreditsExhausted" => "credits_exhausted",
"RateLimitExceeded" => "rate_limit",
"Authentication" => "auth",
"ContextLengthExceeded" => "context_length",
"ServerError" => "server",
"RequestFailed" => "request",
other => panic!("Unknown variant: {other}"),
};
assert_eq!(
actual, expected_telemetry,
"Expected {expected_variant}, got error: {err:?}"
);
}
}