feat(gateway): native OpenAI chat completions with SSE passthrough (#46813)

Add the openai provider to /v1/chat/completions. The caller's body is passed
through with only the model renamed and LiteLLM-owned keys removed, and the
provider's JSON or SSE bytes are relayed as received. Streams have no total
timeout, only a per-read idle limit, so 30+ minute generations are not cut off.
openai_like and the translated providers keep their existing path.
This commit is contained in:
Nate Armstrong 2026-10-16 12:43:00 -07:00 • committed by GitHub
parent a38288c146
commit bce2ced779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1454 additions and 62 deletions

View file

@ -3997,6 +3997,7 @@ dependencies = [
name = "litellm-inference-chat"
version = "0.1.0"
dependencies = [
"bytes",
"futures-util",
"litellm-auth",
"litellm-cache-memory",
@ -4011,6 +4012,7 @@ dependencies = [
"litellm-llms-types",
"litellm-secrets",
"litellm-tracing",
"reqwest 0.12.28",
"rstest",
"serde_json",
"tokio",

View file

@ -3,13 +3,20 @@ use std::sync::Arc;
use axum::{
Json,
body::Bytes,
extract::{Path, State},
http::header::CONTENT_TYPE,
response::{IntoResponse, Response},
};
use litellm_inference_chat::types::ChatCompletionsCall;
use litellm_host_http::Sse;
use litellm_inference_chat::{
ChatCompletionsRoute,
openai::{OpenAiChatCall, OpenAiChatCompletions},
types::ChatCompletionsCall,
};
use serde_json::{Map, Value};
use crate::{Error, Gateway, JsonObject, request};
use crate::{Deployment, Error, Gateway, JsonObject, request};
pub(crate) async fn create(
State(gateway): State<Arc<Gateway>>,
@ -43,6 +50,12 @@ async fn handle(
let deployment = request::resolve_deployment(gateway, &body)?;
request::authorize_model(identity, deployment, &body).await?;
let (body, cache_options) = crate::caching::prepare(identity, body)?;
if ChatCompletionsRoute::serves_openai(
&deployment.model,
deployment.custom_llm_provider.as_deref(),
) {
return openai(gateway, deployment, body).await;
}
let route = gateway.chat_completions.clone();
let route = match &gateway.cache {
Some(cache) => route.with_cache(litellm_cache_response::ScopedCache::new(
@ -79,3 +92,27 @@ async fn handle(
.await?;
Ok(headers.apply(response))
}
async fn openai(
gateway: &Gateway,
deployment: &Deployment,
body: Map<String, Value>,
) -> Result<Response, Error> {
let machine = gateway.chat_completions.clone().openai_machine(
OpenAiChatCall {
model: deployment.model.clone(),
body,
api_key: deployment.api_key.clone(),
api_base: deployment.api_base.clone(),
custom_llm_provider: deployment.custom_llm_provider.clone(),
extra_headers: None,
timeout: deployment.timeout,
},
None,
);
let encoder = Sse::<OpenAiChatCompletions, _, _>::new(
|body: Bytes| ([(CONTENT_TYPE, "application/json")], body),
|error| Bytes::from(Error::from(error).openai_sse_frame()),
);
Ok(litellm_host_http::serve(machine, (), (), encoder, None).await?)
}

View file

@ -76,21 +76,30 @@ impl Error {
}
pub fn openai_response(self) -> Response {
(self.status(), Json(self.openai_body())).into_response()
}
/// An OpenAI error event, for a Chat Completions stream that fails after its headers
/// went out.
pub fn openai_sse_frame(&self) -> String {
format!("data: {}\n\n", self.openai_body())
}
fn openai_body(&self) -> Value {
let status = self.status();
let message = match &self {
let message = match self {
Self::UnknownModel(model) => format!("Invalid model name passed in model={model}"),
Self::Route(RouteError::Transport(TransportError::Http { body, .. })) => {
openai_error_message(body).unwrap_or_else(|| self.to_string())
}
_ => self.to_string(),
};
(
status,
Json(json!({"error": {
"message": message,
"type": error_type(status),
"param": null,
"code": status.as_u16(),
}})),
)
.into_response()
json!({"error": {
"message": message,
"type": error_type(status),
"param": null,
"code": status.as_u16(),
}})
}
/// The Anthropic error envelope Python's `AnthropicExceptionMapping` builds: an upstream
@ -148,6 +157,14 @@ fn is_anthropic_error(object: &Map<String, Value>) -> bool {
.is_some_and(|error| error.contains_key("type") && error.contains_key("message"))
}
fn openai_error_message(body: &str) -> Option<String> {
let body: Value = serde_json::from_str(body).ok()?;
body.get("error")?
.get("message")?
.as_str()
.map(str::to_owned)
}
fn provider_message(object: &Map<String, Value>) -> Option<&str> {
if let Some(detail) = object.get("detail").and_then(Value::as_object) {
return detail.get("message").and_then(Value::as_str);
@ -223,6 +240,24 @@ mod tests {
assert_eq!(error.body(request_id), expected);
}
#[rstest]
#[case::openai_body(
upstream(429, r#"{"error":{"message":"slow down","type":"requests","code":"rate_limit"}}"#),
json!({"error": {"message": "slow down", "type": "rate_limit_error", "param": null, "code": 429}}),
)]
#[case::plain_text(
upstream(502, "bad gateway"),
json!({"error": {
"message": "upstream request failed with status 502: bad gateway",
"type": "api_error", "param": null, "code": 502,
}}),
)]
fn openai_errors_carry_the_provider_message(#[case] error: Error, #[case] expected: Value) {
let frame = error.openai_sse_frame();
assert_eq!(error.openai_body(), expected);
assert_eq!(frame, format!("data: {expected}\n\n"));
}
#[rstest]
#[case::upstream_status(upstream(429, ""), StatusCode::TOO_MANY_REQUESTS)]
#[case::rejected_request(Error::Route(RouteError::InvalidRequest("top_k".into())), StatusCode::BAD_REQUEST)]

View file

@ -0,0 +1,190 @@
mod support;
use std::time::Duration;
use axum::body::to_bytes;
use futures_util::StreamExt;
use rstest::rstest;
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_json, header, method, path},
};
const COMPLETION: &str = r#"{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-6-astra","choices":[{"index":0,"message":{"role":"assistant","content":"hi","refusal":null},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4},"system_fingerprint":"fp"}"#;
const STREAM: &str = "data: {\"id\":\"c\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\ndata: {\"id\":\"c\",\"object\":\"chat.completion.chunk\",\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}\n\ndata: [DONE]\n\n";
fn request(stream: bool) -> Value {
json!({
"model": "public/model",
"messages": [{"role": "user", "content": "hi"}],
"stream": stream,
"stream_options": stream.then(|| json!({"include_usage": true})),
"caching": false,
"metadata": {"tag": "x"}
})
}
#[rstest]
#[case::json(false, COMPLETION, "application/json")]
#[case::sse(true, STREAM, "text/event-stream")]
#[tokio::test]
async fn openai_chat_passes_the_provider_response_through(
#[case] stream: bool,
#[case] upstream_body: &'static str,
#[case] content_type: &str,
#[values("/v1/chat/completions", "/engines/public%2Fmodel/chat/completions")] route: &str,
) {
let upstream = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(header("authorization", "Bearer test-key"))
.and(body_json(json!({
"model": "gpt-6-astra",
"messages": [{"role": "user", "content": "hi"}],
"stream": stream,
"stream_options": stream.then(|| json!({"include_usage": true})),
})))
.respond_with(ResponseTemplate::new(200).set_body_raw(upstream_body, content_type))
.expect(1)
.mount(&upstream)
.await;
let response = support::post(
support::app("openai/gpt-6-astra", &format!("{}/v1", upstream.uri())),
route,
request(stream),
)
.await;
assert_eq!(response.status(), 200);
assert_eq!(response.headers()["content-type"], content_type);
assert_eq!(
to_bytes(response.into_body(), 1 << 20).await.unwrap(),
upstream_body
);
}
#[rstest]
#[case::json(false)]
#[case::sse(true)]
#[tokio::test]
async fn an_openai_error_keeps_its_status_and_message(#[case] stream: bool) {
let upstream = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(429).set_body_raw(
r#"{"error":{"message":"Rate limit reached","type":"requests","code":"rate_limit_exceeded"}}"#,
"application/json",
))
.mount(&upstream)
.await;
let response = support::post(
support::app("openai/gpt-6-astra", &upstream.uri()),
"/v1/chat/completions",
request(stream),
)
.await;
assert_eq!(response.status(), 429);
let body = support::json(response).await;
assert_eq!(body["error"]["message"], "Rate limit reached");
assert_eq!(body["error"]["type"], "rate_limit_error");
}
/// Sends the SSE head and one event, then holds the connection open until told to send the
/// rest, or drops it short of the end when `truncate` is set.
async fn paused_upstream(release: tokio::sync::oneshot::Receiver<()>, truncate: bool) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = vec![0; 64 * 1024];
let _ = socket.read(&mut request).await;
socket
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n{:x}\r\n{FIRST_EVENT}\r\n",
FIRST_EVENT.len()
)
.as_bytes(),
)
.await
.unwrap();
let _ = release.await;
if truncate {
return;
}
let _ = socket
.write_all(format!("{:x}\r\n{DONE}\r\n0\r\n\r\n", DONE.len()).as_bytes())
.await;
});
base
}
const FIRST_EVENT: &str = "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"a\"}}]}\n\n";
const DONE: &str = "data: [DONE]\n\n";
#[rstest]
#[tokio::test]
async fn the_gateway_forwards_each_event_while_the_provider_is_still_streaming() {
let (release, released) = tokio::sync::oneshot::channel();
let base = paused_upstream(released, false).await;
let response = support::post(
support::app("openai/gpt-6-astra", &base),
"/v1/chat/completions",
request(true),
)
.await;
assert_eq!(response.status(), 200);
let mut body = response.into_body().into_data_stream();
let first = tokio::time::timeout(Duration::from_secs(2), body.next())
.await
.expect("the first event reaches the client before the provider finishes")
.unwrap()
.unwrap();
assert_eq!(first, FIRST_EVENT);
release.send(()).unwrap();
assert_eq!(body.next().await.unwrap().unwrap(), DONE);
assert!(body.next().await.is_none());
}
#[rstest]
#[tokio::test]
async fn a_stream_cut_by_the_provider_ends_with_an_openai_error_event() {
let (release, released) = tokio::sync::oneshot::channel();
let base = paused_upstream(released, true).await;
let response = support::post(
support::app("openai/gpt-6-astra", &base),
"/v1/chat/completions",
request(true),
)
.await;
release.send(()).unwrap();
let body = to_bytes(response.into_body(), 1 << 20).await.unwrap();
let text = std::str::from_utf8(&body).unwrap();
let error = text
.strip_prefix(FIRST_EVENT)
.and_then(|rest| rest.strip_prefix("data: "))
.and_then(|rest| rest.strip_suffix("\n\n"))
.unwrap_or_else(|| panic!("expected the first event then an error event, got {text:?}"));
let error: Value = serde_json::from_str(error).unwrap();
assert_eq!(error["error"]["code"], 500);
}
#[rstest]
#[tokio::test]
async fn openai_like_chat_streaming_is_still_declined() {
let upstream = MockServer::start().await;
let response = support::post(
support::app("openai_like/gpt-6-astra", &upstream.uri()),
"/v1/chat/completions",
json!({"model": "public/model", "messages": [{"role": "user", "content": "hi"}], "stream": true}),
)
.await;
assert_eq!(response.status(), 501);
assert!(upstream.received_requests().await.unwrap().is_empty());
}

View file

@ -1,3 +1,4 @@
- Shared base and layering rules: [`../inference/AGENTS.md`](../inference/AGENTS.md)
- This crate owns Chat Completions call orchestration. Payloads belong in `litellm-llms-types`, transformations in `llms/src/base_llm/chat` and `llms/src/<provider>/chat`
- Chat Completions is currently non-streaming: the route returns its completed response, cached through `litellm_inference::caching::execute_unary`
- Translated providers (`ChatCompletions` in `src/route.rs`) are non-streaming: the route returns its completed response, cached through `litellm_inference::caching::execute_unary`
- OpenAI (`OpenAiChatCompletions` in `src/openai.rs`) is a passthrough machine: the provider's JSON body or SSE bytes go to the host as received, with no total timeout on a stream, only a per-read idle limit

View file

@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
bytes.workspace = true
futures-util.workspace = true
litellm-auth = { workspace = true, features = ["aws", "azure", "gcp"] }
litellm-cache-response.workspace = true
@ -16,7 +17,9 @@ litellm-inference.workspace = true
litellm-llms.workspace = true
litellm-llms-types.workspace = true
litellm-secrets.workspace = true
reqwest.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }
tokio = { workspace = true, features = ["time"] }
tracing.workspace = true
[dev-dependencies]
@ -26,5 +29,5 @@ litellm-http = { workspace = true, features = ["test-support"] }
litellm-inference-testing.workspace = true
litellm-tracing.workspace = true
rstest.workspace = true
tokio.workspace = true
tokio = { workspace = true, features = ["io-util", "sync"] }
wiremock.workspace = true

View file

@ -5,6 +5,7 @@ pub use litellm_inference::RouteError as Error;
mod common_utils;
pub mod constants;
pub(crate) mod handler;
pub mod openai;
mod prepare;
use litellm_llms_types::formats::chat_completions::ChatCompletionsResponse;
use prepare::{prepare_provider_request, resolve_request};

View file

@ -0,0 +1,610 @@
use std::{convert::Infallible, time::Duration};
use bytes::Bytes;
use futures_util::{StreamExt, stream::BoxStream};
use litellm_core_utils::get_llm_provider_logic::LlmProviders;
use litellm_host::{
call::{CallOutput, HostedMachine, hosted_call},
interceptors::{
Cost, ExecutionFacts, Interceptors, ProviderIdentity, RequestContext, ResultSource,
WireRequest,
},
observation::ObservationSender,
protocol::Protocol,
};
use litellm_http::{outbound::OutboundRequest, request::truncate_error_body, transport};
use litellm_inference::{CallOptions, context::CallContext, provider::resolve_llm_provider};
use litellm_llms::{
base_llm::auth::resolve_auth,
openai::chat::transformation::{OPENAI_CHAT_COMPLETIONS_CONFIG, is_stream},
};
use serde_json::{Map, Value};
use crate::{
ChatCompletionsRoute, Error, common_utils::string_headers,
constants::CHAT_COMPLETIONS_TIMEOUT_SECS,
};
pub struct OpenAiChatCompletions;
impl Protocol for OpenAiChatCompletions {
type Request = OpenAiChatCall;
/// The provider's JSON body, as it sent it.
type Response = Bytes;
type Error = Error;
type HostCall = Infallible;
type Chunk = Bytes;
type StreamHead = ();
}
pub type OpenAiChatOutput = CallOutput<Bytes, (), Bytes, Error>;
/// A Chat Completions body bound for an OpenAI deployment. `body` is the caller's request;
/// `model` is the deployment's, with or without its `openai/` prefix.
pub struct OpenAiChatCall {
pub model: String,
pub body: Map<String, Value>,
pub api_key: Option<String>,
pub api_base: Option<String>,
pub custom_llm_provider: Option<String>,
pub extra_headers: Option<Map<String, Value>>,
pub timeout: Option<Duration>,
}
impl ChatCompletionsRoute {
pub fn serves_openai(model: &str, custom_llm_provider: Option<&str>) -> bool {
resolve_llm_provider(model, custom_llm_provider, "chat completions")
.is_ok_and(|resolved| resolved.provider == LlmProviders::Openai)
}
pub fn openai_machine(
self,
call: OpenAiChatCall,
observers: Option<ObservationSender>,
) -> HostedMachine<OpenAiChatCompletions> {
hosted_call(
call,
observers,
move |call, _, interceptors, observers| async move {
let context = CallContext::new(
&interceptors,
CallOptions {
cache: None,
observers,
},
);
Box::pin(self.run_openai(call, context)).await
},
)
}
pub async fn execute_openai(
&self,
call: OpenAiChatCall,
interceptors: &impl Interceptors<Error>,
observers: Option<ObservationSender>,
) -> Result<OpenAiChatOutput, Error> {
let context = CallContext::new(
interceptors,
CallOptions {
cache: None,
observers,
},
);
litellm_host::lifecycle::observe_call(
context.observers.clone(),
Box::pin(self.run_openai(call, context)),
)
.await
}
#[tracing::instrument(name = "litellm.route", skip_all, fields(
route = "chat_completions",
model = %call.model,
provider,
resolved_model,
stream,
outcome
))]
async fn run_openai(
&self,
call: OpenAiChatCall,
context: CallContext<'_, impl Interceptors<Error>>,
) -> Result<OpenAiChatOutput, Error> {
litellm_inference::diagnostic::call(async {
let resolved = resolve_llm_provider(
&call.model,
call.custom_llm_provider.as_deref(),
"chat completions",
)?;
if resolved.provider != LlmProviders::Openai {
return Err(Error::InvalidProvider(
<&str>::from(resolved.provider).to_string(),
));
}
let model = resolved.model.to_string();
let provider = <&str>::from(LlmProviders::Openai);
litellm_inference::diagnostic::provider(&model, provider);
let config = &OPENAI_CHAT_COMPLETIONS_CONFIG;
let secrets = self
.secrets
.resolve(config.secret_names(call.api_key.as_deref(), call.api_base.as_deref()))
.await?;
let lookup = |key: &str| secrets.get(key);
let body = config.transform_request(&model, call.body)?;
let environment = config.validate_environment(
string_headers(call.extra_headers)?,
call.api_key.as_deref(),
&lookup,
)?;
let url = config.get_complete_url(call.api_base.as_deref(), &lookup);
let authenticated = resolve_auth(&self.auth, environment, &lookup).await?;
let request_context = RequestContext {
model: model.clone(),
custom_llm_provider: provider.to_string(),
optional_params: Value::Object(
body.iter()
.filter(|(name, _)| !matches!(name.as_str(), "model" | "messages"))
.map(|(name, value)| (name.clone(), value.clone()))
.collect(),
),
secret_fields: Vec::new(),
api_key: call.api_key.map(litellm_auth::SecretValue::new),
};
let wire = context
.interceptors
.before_provider_request(
WireRequest {
url,
headers: authenticated.headers,
body: Value::Object(body),
},
request_context,
)
.await?;
let stream = wire
.body
.as_object()
.map(is_stream)
.transpose()?
.unwrap_or(false);
let timeout = call
.timeout
.unwrap_or(Duration::from_secs(CHAT_COMPLETIONS_TIMEOUT_SECS));
let response = send(&self.http, wire, stream, timeout).await?;
let output = if stream {
CallOutput::Stream {
head: (),
chunks: chunks(response, timeout),
}
} else {
let body = response.bytes().await.map_err(network)?;
context
.response_received(&String::from_utf8_lossy(&body))
.await?;
CallOutput::Complete(body)
};
context
.result_ready(ExecutionFacts {
provider: ProviderIdentity {
model,
provider: provider.to_string(),
},
source: ResultSource::Provider,
cost: Cost::Deferred,
})
.await?;
Ok(output)
})
.await
}
}
/// A non-streaming call keeps the usual total timeout. A stream has no total limit, so a
/// 30 minute generation is not cut off: `timeout` bounds the wait for the response head
/// here and each read after it in [`chunks`].
async fn send(
http: &litellm_http::Client,
wire: WireRequest,
stream: bool,
timeout: Duration,
) -> Result<reqwest::Response, Error> {
let request = OutboundRequest::json(
wire.url,
wire.headers,
&wire.body,
(!stream).then_some(timeout),
)?;
let response = tokio::time::timeout(timeout, litellm_inference::outbound::send(request, http))
.await
.map_err(|_| silent_provider(timeout))?
.map_err(|error| Error::Transport(transport::Error::from_reqwest_before_dispatch(error)))?;
let status = response.status();
if status.is_success() {
return Ok(response);
}
let text = response.text().await.map_err(network)?;
Err(Error::Transport(transport::Error::Http {
status: status.as_u16(),
body: truncate_error_body(&text),
}))
}
fn chunks(response: reqwest::Response, idle: Duration) -> BoxStream<'static, Result<Bytes, Error>> {
futures_util::stream::try_unfold(response, move |mut response| async move {
let chunk = tokio::time::timeout(idle, response.chunk())
.await
.map_err(|_| silent_provider(idle))?
.map_err(network)?;
Ok(chunk.map(|chunk| (chunk, response)))
})
.boxed()
}
fn network(error: reqwest::Error) -> Error {
Error::Transport(transport::Error::from(error))
}
fn silent_provider(timeout: Duration) -> Error {
Error::Transport(transport::Error::Network(format!(
"the provider sent nothing for {}s",
timeout.as_secs_f64()
)))
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use futures_util::TryStreamExt;
use litellm_host::interceptors::RawResponse;
use rstest::{fixture, rstest};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use wiremock::{
Mock, MockServer, Request, ResponseTemplate,
matchers::{header, method, path},
};
use super::*;
const COMPLETION: &str = r#"{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-6-astra","system_fingerprint":"fp","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"f","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}"#;
const STREAM: &str = "data: {\"id\":\"c\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\ndata: {\"id\":\"c\",\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}\n\ndata: [DONE]\n\n";
#[derive(Default)]
struct Recording {
contexts: Mutex<Vec<RequestContext>>,
raw: Mutex<Vec<String>>,
results: Mutex<Vec<ExecutionFacts>>,
}
impl Interceptors<Error> for Recording {
async fn before_provider_request(
&self,
wire: WireRequest,
context: RequestContext,
) -> Result<WireRequest, Error> {
self.contexts.lock().unwrap().push(context);
Ok(wire)
}
async fn after_provider_response(&self, raw: RawResponse) -> Result<(), Error> {
self.raw.lock().unwrap().push(raw.body);
Ok(())
}
async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> {
self.results.lock().unwrap().push(facts);
Ok(())
}
}
#[fixture]
fn route() -> ChatCompletionsRoute {
ChatCompletionsRoute::new(
litellm_http::Client::plain_for_test(),
Arc::new(litellm_auth::AuthServices::default()),
litellm_inference_testing::no_secrets(),
)
}
fn call(api_base: &str, body: Value) -> OpenAiChatCall {
let Value::Object(body) = body else {
panic!("the body is an object");
};
OpenAiChatCall {
model: "openai/gpt-6-astra".into(),
body,
api_key: Some("sk-deployment".into()),
api_base: Some(format!("{api_base}/v1")),
custom_llm_provider: None,
extra_headers: None,
timeout: Some(Duration::from_secs(5)),
}
}
async fn run(
route: &ChatCompletionsRoute,
call: OpenAiChatCall,
interceptors: &Recording,
) -> Result<OpenAiChatOutput, Error> {
route
.run_openai(call, CallContext::new(interceptors, CallOptions::default()))
.await
}
async fn drain(chunks: BoxStream<'static, Result<Bytes, Error>>) -> Result<Vec<u8>, Error> {
Ok(chunks.try_collect::<Vec<_>>().await?.concat())
}
async fn collect(output: OpenAiChatOutput) -> Result<Vec<u8>, Error> {
match output {
CallOutput::Complete(body) => panic!("expected a stream, got {body:?}"),
CallOutput::Stream { chunks, .. } => drain(chunks).await,
}
}
#[rstest]
#[case::openai_prefix("openai/gpt-6-astra", None, true)]
#[case::explicit_provider("gpt-6-astra", Some("openai"), true)]
#[case::openai_like("openai_like/gpt-6-astra", None, false)]
#[case::anthropic("anthropic/claude", None, false)]
#[case::unprefixed("gpt-6-astra", None, false)]
fn only_openai_deployments_take_the_passthrough(
#[case] model: &str,
#[case] provider: Option<&str>,
#[case] expected: bool,
) {
assert_eq!(
ChatCompletionsRoute::serves_openai(model, provider),
expected
);
}
#[rstest]
#[tokio::test]
async fn a_completion_returns_the_provider_body_byte_for_byte(route: ChatCompletionsRoute) {
let upstream = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(header("authorization", "Bearer sk-deployment"))
.respond_with(ResponseTemplate::new(200).set_body_raw(COMPLETION, "application/json"))
.expect(1)
.mount(&upstream)
.await;
let interceptors = Recording::default();
let output = run(
&route,
call(
&upstream.uri(),
json!({"model": "public", "messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "f"}}], "metadata": {"k": "v"}}),
),
&interceptors,
)
.await
.unwrap();
let CallOutput::Complete(body) = output else {
panic!("a non-streaming call completes");
};
assert_eq!(body, COMPLETION.as_bytes());
let requests = upstream.received_requests().await.unwrap();
let sent: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(
sent,
json!({"model": "gpt-6-astra", "messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "f"}}]})
);
assert_eq!(*interceptors.raw.lock().unwrap(), [COMPLETION]);
let contexts = interceptors.contexts.lock().unwrap();
assert_eq!(
(
contexts[0].model.as_str(),
contexts[0].custom_llm_provider.as_str()
),
("gpt-6-astra", "openai")
);
assert_eq!(
contexts[0].optional_params,
json!({"tools": [{"type": "function", "function": {"name": "f"}}]})
);
assert_eq!(interceptors.results.lock().unwrap().len(), 1);
}
#[rstest]
#[tokio::test]
async fn a_stream_relays_every_byte_including_the_usage_chunk(route: ChatCompletionsRoute) {
let upstream = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_raw(STREAM, "text/event-stream"))
.mount(&upstream)
.await;
let interceptors = Recording::default();
let output = run(
&route,
call(
&upstream.uri(),
json!({"model": "public", "messages": [{"role": "user", "content": "hi"}],
"stream": true, "stream_options": {"include_usage": true}}),
),
&interceptors,
)
.await
.unwrap();
assert_eq!(collect(output).await.unwrap(), STREAM.as_bytes());
let request: &Request = &upstream.received_requests().await.unwrap()[0];
let sent: Value = serde_json::from_slice(&request.body).unwrap();
assert_eq!(sent["stream_options"], json!({"include_usage": true}));
assert!(interceptors.raw.lock().unwrap().is_empty());
}
/// Serves an SSE head and the first event, then waits for `release` before the rest.
async fn paused_upstream(release: tokio::sync::oneshot::Receiver<()>) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = vec![0; 64 * 1024];
let _ = socket.read(&mut request).await;
socket
.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n")
.await
.unwrap();
let event = "data: {\"n\":1}\n\n";
socket
.write_all(format!("{:x}\r\n{event}\r\n", event.len()).as_bytes())
.await
.unwrap();
let _ = release.await;
let done = "data: [DONE]\n\n";
let _ = socket
.write_all(format!("{:x}\r\n{done}\r\n0\r\n\r\n", done.len()).as_bytes())
.await;
});
base
}
#[rstest]
#[tokio::test]
async fn each_event_is_delivered_before_the_stream_finishes(route: ChatCompletionsRoute) {
let (release, released) = tokio::sync::oneshot::channel();
let base = paused_upstream(released).await;
let output = run(
&route,
call(&base, json!({"model": "m", "messages": [], "stream": true})),
&Recording::default(),
)
.await
.unwrap();
let CallOutput::Stream { mut chunks, .. } = output else {
panic!("a streaming call streams");
};
let first = tokio::time::timeout(Duration::from_secs(2), chunks.try_next())
.await
.expect("the first event arrives while the provider is still streaming")
.unwrap()
.unwrap();
assert_eq!(first, "data: {\"n\":1}\n\n");
release.send(()).unwrap();
assert_eq!(drain(chunks).await.unwrap(), b"data: [DONE]\n\n");
}
#[rstest]
#[tokio::test]
async fn a_stream_outlives_the_request_timeout_while_data_keeps_coming(
route: ChatCompletionsRoute,
) {
let (release, released) = tokio::sync::oneshot::channel();
let base = paused_upstream(released).await;
let mut streaming = call(&base, json!({"model": "m", "messages": [], "stream": true}));
streaming.timeout = Some(Duration::from_millis(300));
let output = run(&route, streaming, &Recording::default()).await.unwrap();
let CallOutput::Stream { mut chunks, .. } = output else {
panic!("a streaming call streams");
};
chunks.try_next().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
release.send(()).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(drain(chunks).await.unwrap(), b"data: [DONE]\n\n");
}
#[rstest]
#[tokio::test]
async fn a_stream_that_goes_silent_past_the_timeout_fails(route: ChatCompletionsRoute) {
let (_release, released) = tokio::sync::oneshot::channel();
let base = paused_upstream(released).await;
let mut streaming = call(&base, json!({"model": "m", "messages": [], "stream": true}));
streaming.timeout = Some(Duration::from_millis(100));
let output = run(&route, streaming, &Recording::default()).await.unwrap();
assert!(matches!(
collect(output).await,
Err(Error::Transport(transport::Error::Network(_)))
));
}
#[rstest]
#[tokio::test]
async fn an_upstream_error_keeps_its_status(route: ChatCompletionsRoute) {
let upstream = MockServer::start().await;
let body = r#"{"error":{"message":"slow down","type":"rate_limit_error"}}"#;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(429).set_body_raw(body, "application/json"))
.mount(&upstream)
.await;
let error = run(
&route,
call(
&upstream.uri(),
json!({"model": "m", "messages": [], "stream": true}),
),
&Recording::default(),
)
.await
.err()
.expect("the upstream failure fails the call");
assert_eq!(
error,
Error::Transport(transport::Error::Http {
status: 429,
body: body.into()
})
);
}
#[rstest]
#[tokio::test]
async fn the_key_falls_back_to_the_environment(route: ChatCompletionsRoute) {
let upstream = MockServer::start().await;
Mock::given(header("authorization", "Bearer sk-env"))
.respond_with(ResponseTemplate::new(200).set_body_raw(COMPLETION, "application/json"))
.expect(1)
.mount(&upstream)
.await;
let route = ChatCompletionsRoute {
secrets: Arc::new(litellm_inference_testing::RecordingSecrets::new([(
"OPENAI_API_KEY",
"sk-env",
)])),
..route
};
let mut without_key = call(&upstream.uri(), json!({"model": "m", "messages": []}));
without_key.api_key = None;
run(&route, without_key, &Recording::default())
.await
.unwrap();
}
#[rstest]
#[tokio::test]
async fn a_missing_key_fails_before_any_request(route: ChatCompletionsRoute) {
let mut without_key = call("http://127.0.0.1:1", json!({"model": "m", "messages": []}));
without_key.api_key = None;
assert!(matches!(
run(&route, without_key, &Recording::default()).await,
Err(Error::Auth(litellm_auth::Error::MissingApiKey { .. }))
));
}
#[rstest]
#[tokio::test]
async fn a_non_openai_deployment_is_refused(route: ChatCompletionsRoute) {
let mut other = call("http://127.0.0.1:1", json!({"model": "m", "messages": []}));
other.model = "openai_like/gpt".into();
assert_eq!(
run(&route, other, &Recording::default()).await.err(),
Some(Error::InvalidProvider("openai_like".into()))
);
}
}

View file

@ -0,0 +1,4 @@
//! Real provider calls, one module per provider. Each test skips when its provider's key is not
//! in the environment, so `cargo test` stays offline by default.
mod openai;

View file

@ -0,0 +1,108 @@
use std::time::Duration;
use futures_util::TryStreamExt;
use litellm_host::call::CallOutput;
use litellm_inference_chat::{
ChatCompletionsRoute,
openai::{OpenAiChatCall, OpenAiChatOutput},
};
use litellm_inference_testing::live::{LiveResources, within_deadline};
use rstest::{fixture, rstest};
use serde_json::{Map, Value, json};
const DEFAULT_MODEL: &str = "openai/gpt-6-astra";
/// A route reading `OPENAI_API_KEY` from the environment; tests skip when it is unset.
#[fixture]
fn route() -> Option<ChatCompletionsRoute> {
std::env::var("OPENAI_API_KEY").ok()?;
let resources = LiveResources::default();
Some(ChatCompletionsRoute::new(
resources.http,
resources.auth,
resources.secrets,
))
}
fn call(extra: Value) -> OpenAiChatCall {
let Value::Object(extra) = extra else {
panic!("extra params are an object");
};
let body: Map<String, Value> = json!({
"model": "public",
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
"max_completion_tokens": 256,
})
.as_object()
.unwrap()
.clone()
.into_iter()
.chain(extra)
.collect();
OpenAiChatCall {
model: std::env::var("OPENAI_LIVE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()),
body,
api_key: None,
api_base: None,
custom_llm_provider: None,
extra_headers: None,
timeout: Some(Duration::from_secs(60)),
}
}
async fn execute(route: &ChatCompletionsRoute, call: OpenAiChatCall) -> OpenAiChatOutput {
within_deadline(route.execute_openai(call, &(), None))
.await
.unwrap()
}
#[rstest]
#[tokio::test]
async fn a_completion_returns_openai_json(route: Option<ChatCompletionsRoute>) {
let Some(route) = route else { return };
let CallOutput::Complete(body) = execute(&route, call(json!({}))).await else {
panic!("expected a complete response");
};
let body: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["object"], "chat.completion", "{body}");
assert!(
body["choices"][0]["message"]["content"]
.as_str()
.is_some_and(|text| text.to_ascii_lowercase().contains("pong")),
"{body}"
);
assert!(
body["usage"]["total_tokens"].as_u64().unwrap() > 0,
"{body}"
);
}
#[rstest]
#[tokio::test]
async fn a_stream_ends_with_usage_and_done(route: Option<ChatCompletionsRoute>) {
let Some(route) = route else { return };
let output = execute(
&route,
call(json!({"stream": true, "stream_options": {"include_usage": true}})),
)
.await;
let CallOutput::Stream { chunks, .. } = output else {
panic!("expected SSE");
};
let wire = within_deadline(chunks.try_collect::<Vec<_>>())
.await
.unwrap()
.concat();
let text = std::str::from_utf8(&wire).unwrap();
let events: Vec<&str> = text
.split("\n\n")
.filter_map(|event| event.trim().strip_prefix("data:"))
.map(str::trim)
.collect();
assert_eq!(events.last(), Some(&"[DONE]"), "{text}");
let usage: Value = serde_json::from_str(events[events.len() - 2]).unwrap();
assert!(
usage["usage"]["total_tokens"].as_u64().unwrap() > 0,
"{usage}"
);
}

View file

@ -1,7 +1,7 @@
# rules
- Chat Completions originated at OpenAI and is the most widely cloned LLM API. Types here describe the format, not OpenAI's deployment of it
- Spoken natively by OpenAI and OpenAI-compatible hosts, served by `llms/src/openai_like/chat/`
- Spoken natively by OpenAI, passed through by `llms/src/openai/chat/`, and by OpenAI-compatible hosts, served by `llms/src/openai_like/chat/`
- Other providers translate into it from their own API (Anthropic Messages, Bedrock Converse) in `llms/src/<provider>/chat/`
- Compatible hosts commonly
- accept a subset of parameters, which is recorded per provider in `supported_openai_param_mappings`, not by trimming the type

View file

@ -0,0 +1,6 @@
# rules
- Format spec lives in `llms-types/src/formats/chat_completions/AGENTS.md`
- Passthrough, not a `BaseConfig`: that contract parses messages and normalizes the response into `ChatCompletionsResponse`, which drops tool calls, ids and every field it does not type. OpenAI's own body and SSE bytes reach the caller as sent
- The request changes only by the deployment model name, the `extra_body` merge the OpenAI SDK does, and dropping the LiteLLM-owned keys Python consumes before the call
- Endpoint and credential fallbacks are shared with Responses in `../common_utils.rs`

View file

@ -0,0 +1 @@
pub mod transformation;

View file

@ -0,0 +1,214 @@
//! `litellm/llms/openai/chat/gpt_transformation.py` as a passthrough: the caller's body is
//! already OpenAI-shaped, so only the model name and LiteLLM's own request keys change, and
//! the response bytes reach the caller untouched.
use serde_json::{Map, Value};
use crate::{
Error, ErrorDetail,
base_llm::auth::{Headers, ValidatedEnvironment},
openai::common_utils,
};
pub const OPENAI_CHAT_COMPLETIONS_PATH: &str = "/chat/completions";
/// The keys of Python's `all_litellm_params` a proxy caller can put in a chat request body.
/// Python consumes them before the OpenAI call, so they never reach the provider.
const LITELLM_OWNED_PARAMS: &[&str] = &[
"api_base",
"api_key",
"api_version",
"base_model",
"cache",
"caching",
"context_window_fallback_dict",
"custom_llm_provider",
"extra_headers",
"fallbacks",
"guardrails",
"litellm_metadata",
"litellm_session_id",
"litellm_trace_id",
"max_retries",
"metadata",
"mock_response",
"mock_timeout",
"model_info",
"no-log",
"num_retries",
"prompt_id",
"prompt_variables",
"prompt_version",
"request_timeout",
"stream_timeout",
"tags",
"timeout",
];
const INTERNAL_PARAM_PREFIX: &str = "_litellm_";
const EXTRA_BODY: &str = "extra_body";
pub struct OpenAiChatConfig;
pub const OPENAI_CHAT_COMPLETIONS_CONFIG: OpenAiChatConfig = OpenAiChatConfig;
impl OpenAiChatConfig {
pub fn secret_names(
&self,
api_key: Option<&str>,
api_base: Option<&str>,
) -> &'static [&'static str] {
common_utils::secret_names(api_key, api_base)
}
pub fn validate_environment(
&self,
headers: Headers,
api_key: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<ValidatedEnvironment, Error> {
common_utils::bearer_environment(headers, api_key, lookup)
}
pub fn get_complete_url(
&self,
api_base: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> String {
common_utils::complete_url(api_base, OPENAI_CHAT_COMPLETIONS_PATH, lookup)
}
/// The OpenAI SDK merges `extra_body` over the body, and the deployment's model name
/// replaces the public one.
pub fn transform_request(
&self,
model: &str,
body: Map<String, Value>,
) -> Result<Map<String, Value>, Error> {
let mut body = body;
let extra_body = match body.remove(EXTRA_BODY) {
None | Some(Value::Null) => Map::new(),
Some(Value::Object(extra)) => extra,
Some(other) => {
return Err(Error::InvalidRequest(ErrorDetail::InvalidValue {
field: EXTRA_BODY,
expected: "an object",
actual: other,
}));
}
};
let mut body: Map<String, Value> = body
.into_iter()
.chain(extra_body)
.filter(|(name, _)| !is_litellm_owned(name))
.collect();
body.insert("model".into(), Value::String(model.into()));
Ok(body)
}
}
/// Whether the body asks for SSE. Anything but a boolean or null is the caller's mistake,
/// since the call cannot tell which response shape to expect.
pub fn is_stream(body: &Map<String, Value>) -> Result<bool, Error> {
match body.get("stream") {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(stream)) => Ok(*stream),
Some(other) => Err(Error::InvalidRequest(ErrorDetail::InvalidValue {
field: "stream",
expected: "a boolean",
actual: other.clone(),
})),
}
}
fn is_litellm_owned(name: &str) -> bool {
LITELLM_OWNED_PARAMS.contains(&name) || name.starts_with(INTERNAL_PARAM_PREFIX)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use serde_json::json;
use super::*;
fn object(value: Value) -> Map<String, Value> {
let Value::Object(map) = value else {
panic!("expected an object, got {value}");
};
map
}
#[rstest]
fn the_body_passes_through_with_only_the_model_renamed() {
let body = json!({
"model": "public/gpt",
"messages": [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "u"}}]}],
"tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}],
"stream": true,
"stream_options": {"include_usage": true},
"reasoning_effort": "high",
"a_future_param": {"nested": [1, 2]},
"user": "end-user"
});
let sent = OPENAI_CHAT_COMPLETIONS_CONFIG
.transform_request("gpt-6-astra", object(body.clone()))
.unwrap();
let mut expected = object(body);
expected.insert("model".into(), json!("gpt-6-astra"));
assert_eq!(sent, expected);
}
#[rstest]
#[case::metadata("metadata")]
#[case::mock_response("mock_response")]
#[case::fallbacks("fallbacks")]
#[case::credentials("api_key")]
#[case::timeout("timeout")]
#[case::no_log("no-log")]
#[case::internal("_litellm_control")]
fn litellm_owned_keys_never_reach_openai(#[case] name: &str) {
let body = object(json!({"model": "m", "messages": [], name: "x"}));
let sent = OPENAI_CHAT_COMPLETIONS_CONFIG
.transform_request("gpt", body)
.unwrap();
assert_eq!(sent, object(json!({"model": "gpt", "messages": []})));
}
#[rstest]
fn extra_body_is_merged_over_the_body_but_not_over_the_model() {
let body = object(json!({
"model": "m",
"temperature": 1,
"extra_body": {"temperature": 0, "top_k": 5, "model": "other", "metadata": {}}
}));
let sent = OPENAI_CHAT_COMPLETIONS_CONFIG
.transform_request("gpt", body)
.unwrap();
assert_eq!(
sent,
object(json!({"model": "gpt", "temperature": 0, "top_k": 5}))
);
}
#[rstest]
fn a_non_object_extra_body_is_rejected() {
let body = object(json!({"model": "m", "extra_body": "x"}));
assert!(matches!(
OPENAI_CHAT_COMPLETIONS_CONFIG.transform_request("gpt", body),
Err(Error::InvalidRequest(ErrorDetail::InvalidValue {
field: "extra_body",
..
}))
));
}
#[rstest]
#[case::absent(json!({}), Some(false))]
#[case::null(json!({"stream": null}), Some(false))]
#[case::off(json!({"stream": false}), Some(false))]
#[case::on(json!({"stream": true}), Some(true))]
#[case::string(json!({"stream": "true"}), None)]
fn the_stream_flag_must_be_a_boolean(#[case] body: Value, #[case] expected: Option<bool>) {
assert_eq!(is_stream(&object(body)).ok(), expected);
}
}

View file

@ -0,0 +1,137 @@
//! Endpoint and credential resolution every OpenAI API config shares, mirroring the
//! `api_base` / `api_key` fallbacks in `litellm/llms/openai/openai.py`.
use litellm_auth::{CredentialPlacement, SecretValue};
use crate::{
Error,
base_llm::auth::{AuthScheme, Headers, ValidatedEnvironment},
};
pub const OPENAI_DEFAULT_API_BASE: &str = "https://api.openai.com/v1";
pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
const OPENAI_API_BASE_ENVS: [&str; 2] = ["OPENAI_BASE_URL", "OPENAI_API_BASE"];
/// The secrets a call has to resolve: only the ones its deployment leaves unset.
pub fn secret_names(api_key: Option<&str>, api_base: Option<&str>) -> &'static [&'static str] {
match (
api_key.is_some_and(|key| !key.is_empty()),
api_base.is_some_and(|base| !base.is_empty()),
) {
(true, true) => &[],
(true, false) => &["OPENAI_BASE_URL", "OPENAI_API_BASE"],
(false, true) => &["OPENAI_API_KEY"],
(false, false) => &["OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE"],
}
}
/// The deployment's `api_base`, else `OPENAI_BASE_URL` or `OPENAI_API_BASE`, else OpenAI's
/// own endpoint, with `path` appended.
pub fn complete_url(
api_base: Option<&str>,
path: &str,
lookup: &dyn Fn(&str) -> Option<String>,
) -> String {
let base = api_base
.filter(|base| !base.is_empty())
.map(str::to_owned)
.or_else(|| OPENAI_API_BASE_ENVS.into_iter().find_map(lookup))
.unwrap_or_else(|| OPENAI_DEFAULT_API_BASE.into());
format!("{}{path}", base.trim_end_matches('/'))
}
/// The deployment's key, else `OPENAI_API_KEY`, sent as a bearer token. The configured key
/// replaces any `authorization` the caller forwarded.
pub fn bearer_environment(
headers: Headers,
api_key: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<ValidatedEnvironment, Error> {
let key = api_key
.filter(|key| !key.is_empty())
.map(str::to_owned)
.or_else(|| lookup(OPENAI_API_KEY_ENV))
.filter(|key| !key.is_empty())
.ok_or(litellm_auth::Error::MissingApiKey {
provider: "OpenAI",
environment_variable: OPENAI_API_KEY_ENV,
})?;
Ok(ValidatedEnvironment {
headers: litellm_http::request::with_default_headers(
headers,
&[("content-type", "application/json")],
),
auth: AuthScheme::Credential {
placement: CredentialPlacement::Bearer,
secret: SecretValue::new(key),
},
})
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn env(name: &str) -> Option<String> {
match name {
"OPENAI_API_BASE" => Some("http://env-base/v1/".into()),
"OPENAI_API_KEY" => Some("sk-env".into()),
_ => None,
}
}
fn no_env(_: &str) -> Option<String> {
None
}
#[rstest]
#[case::default(None, no_env, "https://api.openai.com/v1/chat/completions")]
#[case::empty_deployment_base(Some(""), no_env, "https://api.openai.com/v1/chat/completions")]
#[case::environment(None, env, "http://env-base/v1/chat/completions")]
#[case::deployment_wins(
Some("http://mock/d/1/v1/"),
env,
"http://mock/d/1/v1/chat/completions"
)]
fn the_url_prefers_the_deployment_then_the_environment(
#[case] api_base: Option<&str>,
#[case] lookup: fn(&str) -> Option<String>,
#[case] expected: &str,
) {
assert_eq!(
complete_url(api_base, "/chat/completions", &lookup),
expected
);
}
#[rstest]
#[case::deployment(Some("sk-deployment"), no_env, "sk-deployment")]
#[case::deployment_wins(Some("sk-deployment"), env, "sk-deployment")]
#[case::environment(None, env, "sk-env")]
#[case::empty_deployment_key(Some(""), env, "sk-env")]
fn the_key_prefers_the_deployment_then_the_environment(
#[case] api_key: Option<&str>,
#[case] lookup: fn(&str) -> Option<String>,
#[case] expected: &str,
) {
let validated = bearer_environment(Vec::new(), api_key, &lookup).unwrap();
assert!(matches!(
validated.auth,
AuthScheme::Credential { placement: CredentialPlacement::Bearer, secret }
if secret.expose() == expected
));
}
#[rstest]
fn a_missing_key_names_the_environment_variable() {
assert_eq!(
bearer_environment(Vec::new(), None, &no_env).unwrap_err(),
Error::Auth(litellm_auth::Error::MissingApiKey {
provider: "OpenAI",
environment_variable: "OPENAI_API_KEY",
})
);
}
}

View file

@ -1 +1,3 @@
pub mod chat;
pub mod common_utils;
pub mod responses;

View file

@ -3,20 +3,19 @@ use litellm_llms_types::formats::responses::{
};
use serde_json::{Map, Value};
use litellm_auth::{CredentialPlacement, SecretValue};
use crate::{
Error,
base_llm::{
auth::{AuthScheme, ValidatedEnvironment},
auth::ValidatedEnvironment,
responses::transformation::{
BaseResponsesApiConfig, ResponsesWebSocketProviderConfig, ResponsesWsTransformResult,
enforce_model,
},
},
openai::common_utils::{self, OPENAI_DEFAULT_API_BASE},
};
pub const OPENAI_RESPONSES_DEFAULT_API_BASE: &str = "https://api.openai.com/v1";
pub const OPENAI_RESPONSES_DEFAULT_API_BASE: &str = OPENAI_DEFAULT_API_BASE;
pub const OPENAI_RESPONSES_PATH: &str = "/responses";
pub struct OpenAiResponsesApiConfig;
@ -107,15 +106,7 @@ impl BaseResponsesApiConfig for OpenAiResponsesApiConfig {
api_key: Option<&str>,
api_base: Option<&str>,
) -> &'static [&'static str] {
match (
api_key.is_some_and(|key| !key.is_empty()),
api_base.is_some_and(|base| !base.is_empty()),
) {
(true, true) => &[],
(true, false) => &["OPENAI_BASE_URL", "OPENAI_API_BASE"],
(false, true) => &["OPENAI_API_KEY"],
(false, false) => &["OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_BASE"],
}
common_utils::secret_names(api_key, api_base)
}
fn validate_environment(
@ -124,25 +115,7 @@ impl BaseResponsesApiConfig for OpenAiResponsesApiConfig {
api_key: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<ValidatedEnvironment, Error> {
let key = api_key
.filter(|key| !key.is_empty())
.map(str::to_owned)
.or_else(|| lookup("OPENAI_API_KEY"))
.filter(|key| !key.is_empty())
.ok_or(litellm_auth::Error::MissingApiKey {
provider: "OpenAI",
environment_variable: "OPENAI_API_KEY",
})?;
Ok(ValidatedEnvironment {
headers: litellm_http::request::with_default_headers(
headers,
&[("content-type", "application/json")],
),
auth: AuthScheme::Credential {
placement: CredentialPlacement::Bearer,
secret: SecretValue::new(key),
},
})
common_utils::bearer_environment(headers, api_key, lookup)
}
fn get_complete_url(
@ -150,13 +123,7 @@ impl BaseResponsesApiConfig for OpenAiResponsesApiConfig {
api_base: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> String {
let base = api_base
.filter(|base| !base.is_empty())
.map(str::to_owned)
.or_else(|| lookup("OPENAI_BASE_URL"))
.or_else(|| lookup("OPENAI_API_BASE"))
.unwrap_or_else(|| OPENAI_RESPONSES_DEFAULT_API_BASE.into());
format!("{}/responses", base.trim_end_matches('/'))
common_utils::complete_url(api_base, OPENAI_RESPONSES_PATH, lookup)
}
fn transform_responses_api_request(

View file

@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="640" viewBox="0 0 980 640" font-family="Helvetica, Arial, sans-serif" font-size="13">
<defs>
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#333"/>
</marker>
</defs>
<rect x="0" y="0" width="980" height="640" fill="#ffffff"/>
<text x="20" y="30" font-size="17" font-weight="bold" fill="#111">POST /v1/chat/completions to an openai/ deployment</text>
<!-- client -->
<rect x="20" y="60" width="150" height="56" rx="6" fill="#eef3fb" stroke="#4a6fa5"/>
<text x="95" y="84" text-anchor="middle" fill="#111" font-weight="bold">Client</text>
<text x="95" y="102" text-anchor="middle" fill="#333">OpenAI SDK body</text>
<!-- gateway handle -->
<rect x="220" y="50" width="300" height="76" rx="6" fill="#f5f5f5" stroke="#666"/>
<text x="370" y="72" text-anchor="middle" fill="#111" font-weight="bold">gateway-inference chat_completions::handle</text>
<text x="370" y="92" text-anchor="middle" fill="#333">virtual key auth, resolve deployment,</text>
<text x="370" y="110" text-anchor="middle" fill="#333">authorize model, caching::prepare</text>
<line x1="170" y1="88" x2="218" y2="88" stroke="#333" marker-end="url(#a)"/>
<!-- decision -->
<polygon points="370,150 490,190 370,230 250,190" fill="#fff8e6" stroke="#b8860b"/>
<text x="370" y="186" text-anchor="middle" fill="#111">serves_openai(model,</text>
<text x="370" y="202" text-anchor="middle" fill="#111">custom_llm_provider)?</text>
<line x1="370" y1="126" x2="370" y2="148" stroke="#333" marker-end="url(#a)"/>
<!-- no branch -->
<rect x="580" y="160" width="380" height="60" rx="6" fill="#f5f5f5" stroke="#999" stroke-dasharray="5,4"/>
<text x="770" y="184" text-anchor="middle" fill="#111">Existing ChatCompletions route (unchanged)</text>
<text x="770" y="202" text-anchor="middle" fill="#555">openai_like, anthropic, bedrock: non-stream, stream = 501</text>
<line x1="490" y1="190" x2="578" y2="190" stroke="#333" marker-end="url(#a)"/>
<text x="530" y="182" text-anchor="middle" fill="#555">no</text>
<!-- run_openai -->
<rect x="160" y="262" width="420" height="168" rx="6" fill="#eef7ee" stroke="#3c8d3c"/>
<text x="370" y="284" text-anchor="middle" fill="#111" font-weight="bold">inference-chat openai::run_openai (HostedMachine)</text>
<text x="176" y="308" fill="#333">1. api_key: deployment, else OPENAI_API_KEY (Bearer)</text>
<text x="176" y="328" fill="#333">2. api_base: deployment, else OPENAI_BASE_URL / OPENAI_API_BASE,</text>
<text x="194" y="346" fill="#333">else https://api.openai.com/v1, + /chat/completions</text>
<text x="176" y="366" fill="#333">3. body: merge extra_body, drop LiteLLM-owned keys</text>
<text x="194" y="384" fill="#333">(metadata, fallbacks, _litellm_*, ...), set model</text>
<text x="176" y="404" fill="#333">4. before_provider_request interceptor, then send</text>
<text x="176" y="422" fill="#555">every other field, incl. stream_options, passes verbatim</text>
<line x1="370" y1="230" x2="370" y2="260" stroke="#333" marker-end="url(#a)"/>
<text x="382" y="250" fill="#555">yes</text>
<!-- upstream -->
<rect x="680" y="300" width="280" height="90" rx="6" fill="#eef3fb" stroke="#4a6fa5"/>
<text x="820" y="326" text-anchor="middle" fill="#111" font-weight="bold">OpenAI (or api_base)</text>
<text x="820" y="346" text-anchor="middle" fill="#333">application/json or</text>
<text x="820" y="364" text-anchor="middle" fill="#333">text/event-stream</text>
<line x1="580" y1="330" x2="678" y2="330" stroke="#333" marker-end="url(#a)"/>
<line x1="678" y1="364" x2="580" y2="364" stroke="#333" marker-end="url(#a)"/>
<!-- timeouts -->
<rect x="20" y="460" width="440" height="160" rx="6" fill="#ffffff" stroke="#b8860b"/>
<text x="240" y="482" text-anchor="middle" fill="#111" font-weight="bold">stream: false</text>
<text x="36" y="506" fill="#333">reqwest total timeout = deployment timeout (default 600s)</text>
<text x="36" y="526" fill="#333">body read once, after_provider_response sees it</text>
<text x="36" y="546" fill="#333">CallOutput::Complete(bytes) sent as application/json,</text>
<text x="36" y="566" fill="#333">byte for byte</text>
<text x="36" y="594" fill="#333">upstream 4xx/5xx: same status, OpenAI error envelope</text>
<text x="36" y="612" fill="#333">carrying the provider's error.message</text>
<rect x="500" y="460" width="460" height="160" rx="6" fill="#ffffff" stroke="#3c8d3c"/>
<text x="730" y="482" text-anchor="middle" fill="#111" font-weight="bold">stream: true</text>
<text x="516" y="506" fill="#333">no total timeout; the timeout bounds the wait for the</text>
<text x="516" y="526" fill="#333">response head and each chunk read (idle limit)</text>
<text x="516" y="546" fill="#333">CallOutput::Stream: each reqwest chunk goes to the</text>
<text x="516" y="566" fill="#333">host-http Sse encoder as is, no buffering or re-framing</text>
<text x="516" y="594" fill="#333">upstream cut or idle past the limit after headers:</text>
<text x="516" y="612" fill="#333">a final data: {"error": ...} event, then close</text>
<line x1="300" y1="430" x2="240" y2="458" stroke="#333" marker-end="url(#a)"/>
<line x1="440" y1="430" x2="730" y2="458" stroke="#333" marker-end="url(#a)"/>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -30,7 +30,7 @@ Images are tagged `customer-proxy-<sha>` so every result maps to a commit.
```sh
mkdir -p out
docker run --rm -v "$PWD/out:/work/out" litellm-loadtest:<tag> genconfig -out out \
-mocks http://mockllm:9000 -chat-provider openai_like -keys 100
-mocks http://mockllm:9000 -keys 100
export GATEWAY_IMAGE=litellm-rust-gateway:<tag> TOOLS_IMAGE=litellm-loadtest:<tag>
docker compose up -d gateway mockllm procmon
docker compose run --rm loadgen loadgen -workload /workloads/smoke.json \
@ -93,8 +93,6 @@ gateway samples and mock hit counts, and prints the summary.
(`KEY=` for `cloud-run.sh`). Once keys are read from Postgres, drop it and set `create_postgres`.
- `/v1/messages` auth: `-messages-auth x-api-key` sends the key the way the Anthropic SDK does.
The gateway reads only `Authorization: Bearer` today.
- Chat: deployments use `openai_like/` because the gateway rejects `openai/`, and chat streaming
returns 501.
- Bedrock: `/v1/messages` to a `bedrock/` deployment returns 501 ("Bedrock invoke messages request
shaping" is unimplemented in Rust). mockllm already serves Bedrock's `/invoke` and
`/invoke-with-response-stream` (AWS event-stream framing), so set `-bedrock-fraction 0.5`

View file

@ -31,7 +31,7 @@ func main() {
chatGroups := flag.Int("chat-groups", 4, "chat model groups")
messagesGroups := flag.Int("messages-groups", 4, "messages model groups")
deployments := flag.Int("deployments", 4, "deployments per model group")
chatProvider := flag.String("chat-provider", "openai", "provider prefix for chat deployments (openai_like until native openai lands in Rust)")
chatProvider := flag.String("chat-provider", "openai", "provider prefix for chat deployments (openai is served natively, streaming included; openai_like is non-streaming only)")
keys := flag.Int("keys", 1000, "virtual keys to generate")
keySeed := flag.String("key-seed", "", "derive keys from this seed so every machine generates the same set (default: random keys)")
masterKey := flag.String("master-key", "sk-loadtest-master", "gateway master key")

View file

@ -2,7 +2,7 @@
# containers. Not a perf number (one host), but the same images and workloads as the cloud rig.
#
# docker run --rm -v "$PWD/out:/work/out" litellm-loadtest genconfig -out out \
# -mocks http://mockllm:9000 -chat-provider openai_like
# -mocks http://mockllm:9000
# GATEWAY_IMAGE=litellm-rust-gateway:<tag> TOOLS_IMAGE=litellm-loadtest:<tag> docker compose up -d gateway mockllm
# docker compose run --rm loadgen loadgen -workload /workloads/smoke.json \
# -target http://gateway:4000 -key sk-loadtest-master -out out/smoke.jsonl

View file

@ -103,9 +103,9 @@ variable "deployments_per_group" {
}
variable "chat_provider" {
description = "Provider prefix for chat deployments: openai_like until native openai lands in the Rust gateway, then openai."
description = "Provider prefix for chat deployments: openai is served natively, streaming included. openai_like is non-streaming only."
type = string
default = "openai_like"
default = "openai"
}
variable "key_count" {