use std::fmt;
use std::num::NonZeroU64;
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use super::{Completion, GatewayEndpoint, Message, SecretString, ToolSchema};
use crate::dialects::{DialectRequest, ToolDialectRegistry};
use crate::model::{CompletionError, CompletionOptions};
use crate::{Error, Result};
#[derive(Clone)]
#[non_exhaustive]
pub struct GatewayClient {
transport: GatewayTransport,
base_url: String,
key: SecretString,
dialect_registry: Arc<ToolDialectRegistry>,
request_timeout: Duration,
max_response_bytes: u64,
}
pub(crate) const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
const DEFAULT_MAX_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
#[derive(Clone)]
enum GatewayTransport {
Http(reqwest::Client),
Disabled,
}
impl fmt::Debug for GatewayClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GatewayClient")
.field("base_url", &self.base_url)
.field("key", &"<redacted>")
.finish_non_exhaustive()
}
}
impl GatewayClient {
#[must_use]
pub fn new(endpoint: GatewayEndpoint, key: SecretString) -> GatewayClient {
GatewayClient {
transport: GatewayTransport::Http(reqwest::Client::new()),
base_url: endpoint.url,
key,
dialect_registry: Arc::new(ToolDialectRegistry::builtin()),
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
}
}
#[must_use]
pub fn disabled() -> GatewayClient {
GatewayClient {
transport: GatewayTransport::Disabled,
base_url: String::new(),
key: SecretString::disabled_placeholder(),
dialect_registry: Arc::new(ToolDialectRegistry::builtin()),
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
}
}
#[must_use]
pub fn with_request_limits(
mut self,
request_timeout: Duration,
max_response_bytes: NonZeroU64,
) -> GatewayClient {
self.request_timeout = request_timeout;
self.max_response_bytes = max_response_bytes.get();
self
}
pub fn from_env() -> std::result::Result<GatewayClient, CompletionError> {
from_env_with(|name| match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(Error::InvalidEnv(name.to_owned())),
})
.map_err(CompletionError::from)
}
pub async fn complete(
&self,
messages: &[Message],
tools: Option<&[ToolSchema]>,
options: &CompletionOptions,
) -> std::result::Result<Completion, CompletionError> {
let GatewayTransport::Http(http) = &self.transport else {
return Err(CompletionError::from(Error::GatewayDisabled));
};
let mut request_body = serde_json::json!({
"model": options.model,
"messages": messages,
});
if let Some(tools) = tools.filter(|tools| !tools.is_empty()) {
let wrapped: Vec<Value> = tools
.iter()
.map(|tool| {
serde_json::json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
})
})
.collect();
request_body["tools"] = Value::Array(wrapped);
request_body["tool_choice"] = Value::String("auto".into());
}
if let Some(temperature) = options.temperature {
request_body["temperature"] = serde_json::json!(temperature.get());
}
if let Some(max_tokens) = options.max_tokens {
request_body["max_tokens"] = serde_json::json!(max_tokens.get());
}
if let Some(thinking) = options.thinking {
request_body["chat_template_kwargs"] = serde_json::json!({
"enable_thinking": thinking,
});
}
let dialect = self
.dialect_registry
.get(options.tool_dialect)
.ok_or(Error::UnknownDialect(options.tool_dialect))?;
let mut dr = DialectRequest::new(&mut request_body);
dialect.prepare_request(&mut dr)?;
let response = http
.post(format!("{}/chat/completions", self.base_url))
.bearer_auth(self.key.expose())
.timeout(self.request_timeout)
.json(&request_body)
.send()
.await
.map_err(Error::http)?;
let status = response.status();
let raw_body = read_body_capped(response, self.max_response_bytes).await?;
if !status.is_success() {
let body = String::from_utf8_lossy(&raw_body);
let body = escape_controls(&body, 2000);
return Err(CompletionError::from(Error::Backend {
status: status.as_u16(),
body,
}));
}
let response_body: Value = serde_json::from_slice(&raw_body).map_err(|error| {
Error::MalformedResponseSource {
message: "completion response was not valid JSON".to_owned(),
source: Box::new(error),
}
})?;
let turn = dialect.parse_turn(&response_body)?;
Ok(Completion {
result: turn.outcome,
finish_reason: turn.finish_reason,
reasoning_content: turn.reasoning_content,
request_body,
response_body,
})
}
}
pub(crate) fn escape_controls(body: &str, max: usize) -> String {
if body.is_empty() {
return "(empty body)".to_owned();
}
let mut escaped = String::with_capacity(body.len());
for ch in body.chars().take(max) {
if ch.is_control() {
for part in ch.escape_default() {
escaped.push(part);
}
} else {
escaped.push(ch);
}
}
escaped
}
async fn read_body_capped(mut response: reqwest::Response, cap: u64) -> Result<Vec<u8>> {
if let Some(len) = response.content_length()
&& len > cap
{
return Err(Error::MalformedResponse(format!(
"response body of {len} bytes exceeds the {cap}-byte limit"
)));
}
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(Error::http)? {
if body.len() as u64 + chunk.len() as u64 > cap {
return Err(Error::MalformedResponse(format!(
"response body exceeds the {cap}-byte limit"
)));
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
pub(crate) fn from_env_with(
lookup: impl Fn(&str) -> std::result::Result<Option<String>, Error>,
) -> Result<GatewayClient> {
let base_url = lookup("PROMPTFORGE_GATEWAY_URL")?
.ok_or_else(|| Error::MissingEnv("PROMPTFORGE_GATEWAY_URL".into()))?;
let key = lookup("PROMPTFORGE_GATEWAY_KEY")?
.ok_or_else(|| Error::MissingEnv("PROMPTFORGE_GATEWAY_KEY".into()))?;
let endpoint = GatewayEndpoint::new(&base_url).map_err(Error::from)?;
let key = SecretString::new(key)
.map_err(|_| Error::MissingEnv("PROMPTFORGE_GATEWAY_KEY must not be empty".into()))?;
Ok(GatewayClient::new(endpoint, key))
}