mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
feat(rust): add the openai_like chat config foundation (#43379)
* feat(rust): add the openai_like chat config foundation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(rust): let max_completion_tokens outrank max_tokens and decline refusal responses Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
b396b0b724
commit
501ef23f4a
7 changed files with 677 additions and 0 deletions
|
|
@ -3,6 +3,7 @@ use litellm_llms::{
|
|||
anthropic::chat::transformation::ANTHROPIC_CHAT_COMPLETIONS_CONFIG,
|
||||
base_llm::chat::transformation::BaseConfig,
|
||||
bedrock::chat::converse_transformation::BEDROCK_CHAT_COMPLETIONS_CONFIG,
|
||||
openai_like::chat::transformation::OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ pub(super) fn chat_completions_provider_config(provider: &str) -> Option<&'stati
|
|||
match provider {
|
||||
"anthropic" => Some(&ANTHROPIC_CHAT_COMPLETIONS_CONFIG),
|
||||
"bedrock" => Some(&BEDROCK_CHAT_COMPLETIONS_CONFIG),
|
||||
"openai_like" => Some(&OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub mod cohere;
|
|||
mod error;
|
||||
pub mod mistral;
|
||||
pub mod openai;
|
||||
pub mod openai_like;
|
||||
pub mod reducto;
|
||||
pub mod vertex_ai;
|
||||
|
||||
|
|
|
|||
1
litellm-rust/crates/llms/src/openai_like/chat/mod.rs
Normal file
1
litellm-rust/crates/llms/src/openai_like/chat/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod transformation;
|
||||
270
litellm-rust/crates/llms/src/openai_like/chat/transformation.rs
Normal file
270
litellm-rust/crates/llms/src/openai_like/chat/transformation.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//! `litellm/llms/openai_like/chat/transformation.py`: the chat config every
|
||||
//! OpenAI-compatible endpoint shares. The body is already OpenAI-shaped, so
|
||||
//! parameters pass through verbatim; the port keeps Python's two deviations,
|
||||
//! the `max_completion_tokens` -> `max_tokens` rename and the usage
|
||||
//! `*_tokens` null-to-zero sanitize.
|
||||
|
||||
use litellm_auth::{CredentialPlacement, SecretValue};
|
||||
use litellm_core_utils::core_helpers::unix_now;
|
||||
use litellm_types::{
|
||||
llms::openai::ChatMessage,
|
||||
utils::{ChatCompletionsChoice, ChatCompletionsChoiceMessage, ChatCompletionsResponse},
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
base_llm::{
|
||||
auth::AuthScheme,
|
||||
chat::transformation::{
|
||||
BaseConfig, Headers, ProviderChatRequestData, ProviderChatResponseData,
|
||||
ValidatedEnvironment,
|
||||
},
|
||||
},
|
||||
openai_like::common_utils::{complete_openai_like_url, openai_compatible_provider_info},
|
||||
};
|
||||
|
||||
/// OpenAI parameter names the Rust path can place verbatim in the request body.
|
||||
/// Tool parameters are absent on purpose: the message gate already declines
|
||||
/// tool-call content, and a `tools` request that did get through would produce
|
||||
/// a tool-call response this port cannot normalize yet, so it declines before
|
||||
/// the call instead of after it.
|
||||
const SUPPORTED_PARAMS: &[(&str, &str)] = &[
|
||||
("frequency_penalty", "frequency_penalty"),
|
||||
("logit_bias", "logit_bias"),
|
||||
("logprobs", "logprobs"),
|
||||
("top_logprobs", "top_logprobs"),
|
||||
("max_tokens", "max_tokens"),
|
||||
("max_completion_tokens", "max_completion_tokens"),
|
||||
("modalities", "modalities"),
|
||||
("prediction", "prediction"),
|
||||
("n", "n"),
|
||||
("presence_penalty", "presence_penalty"),
|
||||
("seed", "seed"),
|
||||
("stop", "stop"),
|
||||
("stream_options", "stream_options"),
|
||||
("temperature", "temperature"),
|
||||
("top_p", "top_p"),
|
||||
("audio", "audio"),
|
||||
("web_search_options", "web_search_options"),
|
||||
("service_tier", "service_tier"),
|
||||
("safety_identifier", "safety_identifier"),
|
||||
("prompt_cache_key", "prompt_cache_key"),
|
||||
("prompt_cache_retention", "prompt_cache_retention"),
|
||||
("store", "store"),
|
||||
("response_format", "response_format"),
|
||||
];
|
||||
|
||||
/// Call configuration the caller may pass that never enters the request body.
|
||||
const CONFIG_PARAMS: &[&str] = &["custom_endpoint", "extra_headers", "max_retries"];
|
||||
|
||||
pub struct OpenAILikeChatConfig;
|
||||
|
||||
pub const OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG: OpenAILikeChatConfig = OpenAILikeChatConfig;
|
||||
|
||||
impl BaseConfig for OpenAILikeChatConfig {
|
||||
fn supported_openai_param_mappings(&self) -> &'static [(&'static str, &'static str)] {
|
||||
SUPPORTED_PARAMS
|
||||
}
|
||||
|
||||
fn get_complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
_model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
let custom_endpoint = optional_params
|
||||
.get("custom_endpoint")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
complete_openai_like_url(api_base, custom_endpoint, env_lookup)
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
&self,
|
||||
model: &str,
|
||||
messages: Vec<ChatMessage>,
|
||||
optional_params: Map<String, Value>,
|
||||
) -> Result<ProviderChatRequestData, Error> {
|
||||
let mut params = Map::from_iter(
|
||||
optional_params
|
||||
.into_iter()
|
||||
.filter(|(key, _)| !CONFIG_PARAMS.contains(&key.as_str())),
|
||||
);
|
||||
// Most OpenAI-compatible endpoints take `max_tokens`, not
|
||||
// `max_completion_tokens`, so Python's `map_openai_params` renames it
|
||||
// and lets it overwrite a `max_tokens` the caller also sent.
|
||||
if let Some(limit) = params.remove("max_completion_tokens") {
|
||||
params.insert("max_tokens".to_string(), limit);
|
||||
}
|
||||
let body = Map::from_iter(
|
||||
[
|
||||
("model".to_string(), json!(model)),
|
||||
("messages".to_string(), json!(messages)),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(params),
|
||||
);
|
||||
Ok(ProviderChatRequestData {
|
||||
body: Value::Object(body),
|
||||
stream_shape: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn transform_response(
|
||||
&self,
|
||||
model: &str,
|
||||
response: ProviderChatResponseData,
|
||||
) -> Result<ChatCompletionsResponse, Error> {
|
||||
let mut body = response.body;
|
||||
sanitize_usage(&mut body);
|
||||
let body = body
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::InvalidResponse("chat response is not an object".into()))?;
|
||||
|
||||
let choices = body
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or(Error::MissingField("choices"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(position, choice)| normalize_choice(position, choice))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let field = |name: &str| {
|
||||
usage
|
||||
.and_then(|usage| usage.get(name))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let details = usage.and_then(|usage| usage.get("prompt_tokens_details"));
|
||||
|
||||
Ok(ChatCompletionsResponse {
|
||||
created: body
|
||||
.get("created")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(unix_now),
|
||||
model: body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(model)
|
||||
.to_string(),
|
||||
choices,
|
||||
usage: litellm_types::utils::ChatCompletionsUsage {
|
||||
prompt_tokens: field("prompt_tokens"),
|
||||
completion_tokens: field("completion_tokens"),
|
||||
total_tokens: field("total_tokens"),
|
||||
prompt_tokens_details: litellm_types::utils::PromptTokensDetails {
|
||||
cached_tokens: details
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
cache_creation_tokens: details
|
||||
.and_then(|d| d.get("cache_creation_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
text_tokens: details
|
||||
.and_then(|d| d.get("text_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// `OpenAILikeBase._validate_environment`: a forwarded `authorization` is
|
||||
/// the whole credential, and any other call authenticates with the
|
||||
/// resolved key as a bearer. The key resolves to `""` when neither the
|
||||
/// deployment nor `OPENAI_LIKE_API_KEY` sets one, because vllm-compatible
|
||||
/// endpoints take no key; Python still sends `Bearer ` in that case.
|
||||
fn validate_environment(
|
||||
&self,
|
||||
headers: Headers,
|
||||
api_key: Option<&str>,
|
||||
_model: &str,
|
||||
_optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<ValidatedEnvironment, Error> {
|
||||
if headers
|
||||
.iter()
|
||||
.any(|(name, _)| name.eq_ignore_ascii_case("authorization"))
|
||||
{
|
||||
return Ok(ValidatedEnvironment {
|
||||
headers,
|
||||
auth: AuthScheme::Forwarded,
|
||||
});
|
||||
}
|
||||
let (_, key) = openai_compatible_provider_info(None, api_key, env_lookup);
|
||||
Ok(ValidatedEnvironment {
|
||||
headers,
|
||||
auth: AuthScheme::Credential {
|
||||
placement: CredentialPlacement::Bearer,
|
||||
secret: SecretValue::new(key.unwrap_or_default()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn config_params(&self) -> &'static [&'static str] {
|
||||
CONFIG_PARAMS
|
||||
}
|
||||
}
|
||||
|
||||
/// `OpenAILikeChatConfig._sanitize_usage_obj`: a provider that reports a null
|
||||
/// `*_tokens` entry breaks OpenAI clients, so nulls become 0. Python scrubs
|
||||
/// every top-level usage key ending in `_tokens`.
|
||||
fn sanitize_usage(body: &mut Value) {
|
||||
if let Some(usage) = body.get_mut("usage").and_then(Value::as_object_mut) {
|
||||
for (key, value) in usage.iter_mut() {
|
||||
if key.ends_with("_tokens") && value.is_null() {
|
||||
*value = json!(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_choice(position: usize, choice: &Value) -> Result<ChatCompletionsChoice, Error> {
|
||||
let message = choice
|
||||
.get("message")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or(Error::MissingField("message"))?;
|
||||
if message
|
||||
.get("tool_calls")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|calls| !calls.is_empty())
|
||||
{
|
||||
// Python rewrites the lone tool call into content only under
|
||||
// `json_mode`, a request flag `transform_response` cannot see, and the
|
||||
// normalized type cannot carry tool calls at all. Declining is
|
||||
// terminal at this point, but passing back an empty assistant turn
|
||||
// would fabricate the reply.
|
||||
return Err(Error::Unsupported("tool call response"));
|
||||
}
|
||||
if message.get("refusal").is_some_and(|value| !value.is_null()) {
|
||||
return Err(Error::Unsupported("refusal response"));
|
||||
}
|
||||
let content = message.get("content");
|
||||
if content.is_some_and(|value| !value.is_null() && !value.is_string()) {
|
||||
return Err(Error::Unsupported("non-text response content"));
|
||||
}
|
||||
Ok(ChatCompletionsChoice {
|
||||
index: choice
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(position as u64),
|
||||
message: ChatCompletionsChoiceMessage {
|
||||
role: message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("assistant")
|
||||
.to_string(),
|
||||
content: content.and_then(Value::as_str).map(str::to_string),
|
||||
},
|
||||
finish_reason: choice
|
||||
.get("finish_reason")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
58
litellm-rust/crates/llms/src/openai_like/common_utils.rs
Normal file
58
litellm-rust/crates/llms/src/openai_like/common_utils.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Shared OpenAI-like credential and endpoint resolution, mirroring
|
||||
//! `litellm/llms/openai_like/common_utils.py`.
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// `OpenAILikeChatConfig._get_openai_compatible_provider_info`: the deployment's
|
||||
/// `api_base` wins over `OPENAI_LIKE_API_BASE`, and the deployment key over
|
||||
/// `OPENAI_LIKE_API_KEY`, with an empty key allowed because vllm-compatible
|
||||
/// endpoints do not require one.
|
||||
pub fn openai_compatible_provider_info(
|
||||
api_base: Option<&str>,
|
||||
api_key: Option<&str>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
let api_base = api_base
|
||||
.map(str::to_string)
|
||||
.or_else(|| env_lookup("OPENAI_LIKE_API_BASE"));
|
||||
let api_key = api_key
|
||||
.map(str::to_string)
|
||||
.or_else(|| env_lookup("OPENAI_LIKE_API_KEY"))
|
||||
.or(Some(String::new()));
|
||||
(api_base, api_key)
|
||||
}
|
||||
|
||||
/// `OpenAILikeBase._validate_environment` requires an api base and, when the
|
||||
/// caller gave no `custom_endpoint`, appends the route suffix. A caller-supplied
|
||||
/// `custom_endpoint` base is used as is.
|
||||
pub fn complete_openai_like_url(
|
||||
api_base: Option<&str>,
|
||||
custom_endpoint: bool,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
let (api_base, _) = openai_compatible_provider_info(api_base, None, env_lookup);
|
||||
let api_base = api_base.ok_or_else(|| {
|
||||
Error::InvalidRequest(
|
||||
"Missing API Base - A call is being made to LLM Provider but no api base is set either in the environment variables ({LLM_PROVIDER}_API_KEY) or via params"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
if custom_endpoint {
|
||||
return Ok(api_base);
|
||||
}
|
||||
Ok(format!(
|
||||
"{}/chat/completions",
|
||||
api_base.trim_end_matches('/')
|
||||
))
|
||||
}
|
||||
|
||||
/// The api key the call resolves to. `None` means neither the deployment nor the
|
||||
/// environment supplied one, which is valid for endpoints that take no key.
|
||||
pub fn resolve_openai_like_api_key(
|
||||
api_key: Option<&str>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
openai_compatible_provider_info(None, api_key, env_lookup)
|
||||
.1
|
||||
.filter(|key| !key.is_empty())
|
||||
}
|
||||
2
litellm-rust/crates/llms/src/openai_like/mod.rs
Normal file
2
litellm-rust/crates/llms/src/openai_like/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod chat;
|
||||
pub mod common_utils;
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
use litellm_llms::{
|
||||
Error,
|
||||
base_llm::{
|
||||
auth::AuthScheme,
|
||||
chat::transformation::{BaseConfig, ProviderChatResponseData, Unsupported},
|
||||
},
|
||||
openai_like::chat::transformation::OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG,
|
||||
};
|
||||
use litellm_types::{llms::openai::ChatMessage, utils::ChatCompletionsResponse};
|
||||
use rstest::rstest;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn messages(value: Value) -> Vec<ChatMessage> {
|
||||
serde_json::from_value(value).expect("valid messages")
|
||||
}
|
||||
|
||||
fn params(value: Value) -> Map<String, Value> {
|
||||
match value {
|
||||
Value::Object(map) => map,
|
||||
other => panic!("params must be an object, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn no_env(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn env_with<'a>(name: &'a str, value: &'a str) -> impl Fn(&str) -> Option<String> + 'a {
|
||||
move |key| (key == name).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn transform(model: &str, msgs: Value, opts: Value) -> Value {
|
||||
OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.transform_request(model, messages(msgs), params(opts))
|
||||
.expect("request transforms")
|
||||
.body
|
||||
}
|
||||
|
||||
fn transform_response(body: Value) -> Result<ChatCompletionsResponse, Error> {
|
||||
OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.transform_response("some-model", ProviderChatResponseData { body })
|
||||
}
|
||||
|
||||
fn reason(msgs: Value, opts: Value) -> Option<Unsupported> {
|
||||
OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG.unsupported_reason(&messages(msgs), ¶ms(opts))
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn builds_the_openai_shaped_body() {
|
||||
let body = transform(
|
||||
"my-model",
|
||||
json!([
|
||||
{"role": "system", "content": "be terse"},
|
||||
{"role": "user", "content": "hi"},
|
||||
]),
|
||||
json!({"temperature": 0.5, "max_tokens": 8}),
|
||||
);
|
||||
assert_eq!(body["model"], json!("my-model"));
|
||||
assert_eq!(
|
||||
body["messages"],
|
||||
json!([
|
||||
{"role": "system", "content": "be terse"},
|
||||
{"role": "user", "content": "hi"},
|
||||
])
|
||||
);
|
||||
assert_eq!(body["temperature"], json!(0.5));
|
||||
assert_eq!(body["max_tokens"], json!(8));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn renames_max_completion_tokens_to_max_tokens() {
|
||||
// `OpenAILikeChatConfig.map_openai_params`: most OpenAI-compatible providers
|
||||
// support `max_tokens`, not `max_completion_tokens`.
|
||||
let body = transform(
|
||||
"my-model",
|
||||
json!([{"role": "user", "content": "hi"}]),
|
||||
json!({"max_completion_tokens": 12}),
|
||||
);
|
||||
assert_eq!(body["max_tokens"], json!(12));
|
||||
assert!(body.get("max_completion_tokens").is_none());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn max_completion_tokens_wins_when_both_limits_are_sent() {
|
||||
// Python assigns `max_tokens = max_completion_tokens` after copying the
|
||||
// params, so the renamed value outranks a caller-supplied `max_tokens`.
|
||||
let body = transform(
|
||||
"my-model",
|
||||
json!([{"role": "user", "content": "hi"}]),
|
||||
json!({"max_tokens": 8, "max_completion_tokens": 12}),
|
||||
);
|
||||
assert_eq!(body["max_tokens"], json!(12));
|
||||
assert!(body.get("max_completion_tokens").is_none());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn call_configuration_never_enters_the_body() {
|
||||
let body = transform(
|
||||
"my-model",
|
||||
json!([{"role": "user", "content": "hi"}]),
|
||||
json!({"custom_endpoint": true, "extra_headers": {"x": "y"}, "max_retries": 2}),
|
||||
);
|
||||
assert_eq!(
|
||||
body.as_object().unwrap().keys().collect::<Vec<_>>(),
|
||||
vec!["model", "messages"]
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::appends_the_chat_completions_suffix("https://vllm.example.com/v1", json!({}), "https://vllm.example.com/v1/chat/completions")]
|
||||
#[case::trims_a_trailing_slash("https://vllm.example.com/v1/", json!({}), "https://vllm.example.com/v1/chat/completions")]
|
||||
#[case::a_custom_endpoint_is_used_as_is("https://vllm.example.com/v1/chat/completions", json!({"custom_endpoint": true}), "https://vllm.example.com/v1/chat/completions")]
|
||||
fn complete_url(#[case] api_base: &str, #[case] opts: Value, #[case] expected: &str) {
|
||||
assert_eq!(
|
||||
OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.get_complete_url(Some(api_base), "my-model", ¶ms(opts), &no_env)
|
||||
.expect("url resolves"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn api_base_falls_back_to_the_environment() {
|
||||
assert_eq!(
|
||||
OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.get_complete_url(
|
||||
None,
|
||||
"my-model",
|
||||
¶ms(json!({})),
|
||||
&env_with("OPENAI_LIKE_API_BASE", "https://env.example.com/v1"),
|
||||
)
|
||||
.expect("url resolves"),
|
||||
"https://env.example.com/v1/chat/completions"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_missing_api_base_is_an_error() {
|
||||
assert!(matches!(
|
||||
OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.get_complete_url(None, "my-model", ¶ms(json!({})), &no_env),
|
||||
Err(Error::InvalidRequest(message)) if message.starts_with("Missing API Base")
|
||||
));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn the_resolved_key_authenticates_as_a_bearer() {
|
||||
let validated = OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.validate_environment(
|
||||
vec![],
|
||||
Some("sk-test"),
|
||||
"my-model",
|
||||
¶ms(json!({})),
|
||||
&no_env,
|
||||
)
|
||||
.expect("validates");
|
||||
assert!(matches!(
|
||||
validated.auth,
|
||||
AuthScheme::Credential {
|
||||
placement: litellm_auth::CredentialPlacement::Bearer,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn the_key_falls_back_to_the_environment() {
|
||||
let validated = OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.validate_environment(
|
||||
vec![],
|
||||
None,
|
||||
"my-model",
|
||||
¶ms(json!({})),
|
||||
&env_with("OPENAI_LIKE_API_KEY", "sk-env"),
|
||||
)
|
||||
.expect("validates");
|
||||
let AuthScheme::Credential { secret, .. } = validated.auth else {
|
||||
panic!("expected a bearer credential");
|
||||
};
|
||||
assert_eq!(secret.expose(), "sk-env");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_forwarded_authorization_is_the_whole_credential() {
|
||||
// Python adds `Bearer <key>` only when the caller did not already send
|
||||
// `Authorization`, so the forwarded header wins over the deployment key.
|
||||
let headers = vec![("Authorization".to_string(), "Bearer caller".to_string())];
|
||||
let validated = OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.validate_environment(
|
||||
headers,
|
||||
Some("sk-test"),
|
||||
"my-model",
|
||||
¶ms(json!({})),
|
||||
&no_env,
|
||||
)
|
||||
.expect("validates");
|
||||
assert!(matches!(validated.auth, AuthScheme::Forwarded));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn keyless_calls_still_validate_for_endpoints_that_take_no_key() {
|
||||
// vllm-compatible endpoints require no api key; Python resolves `""` and
|
||||
// sends `Bearer `, so validation must not fail on the missing key.
|
||||
let validated = OPENAI_LIKE_CHAT_COMPLETIONS_CONFIG
|
||||
.validate_environment(vec![], None, "my-model", ¶ms(json!({})), &no_env)
|
||||
.expect("validates");
|
||||
let AuthScheme::Credential { secret, .. } = validated.auth else {
|
||||
panic!("expected a bearer credential");
|
||||
};
|
||||
assert_eq!(secret.expose(), "");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn normalizes_an_openai_response() {
|
||||
let response = transform_response(json!({
|
||||
"created": 1_700_000_000,
|
||||
"model": "served-model-name",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hello"},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8},
|
||||
}))
|
||||
.expect("response normalizes");
|
||||
assert_eq!(response.created, 1_700_000_000);
|
||||
assert_eq!(response.model, "served-model-name");
|
||||
assert_eq!(
|
||||
response.choices[0].message.content.as_deref(),
|
||||
Some("hello")
|
||||
);
|
||||
assert_eq!(response.choices[0].finish_reason, "stop");
|
||||
assert_eq!(response.usage.prompt_tokens, 3);
|
||||
assert_eq!(response.usage.completion_tokens, 5);
|
||||
assert_eq!(response.usage.total_tokens, 8);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn null_token_fields_in_usage_become_zero() {
|
||||
// `_sanitize_usage_obj`: providers that return null token values break
|
||||
// OpenAI clients, so the response is scrubbed at the source.
|
||||
let response = transform_response(json!({
|
||||
"model": "m",
|
||||
"choices": [{"message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": null, "total_tokens": null},
|
||||
}))
|
||||
.expect("response normalizes");
|
||||
assert_eq!(response.usage.completion_tokens, 0);
|
||||
assert_eq!(response.usage.total_tokens, 0);
|
||||
assert_eq!(response.usage.prompt_tokens, 3);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_tool_call_response_declines_instead_of_dropping_the_calls() {
|
||||
// The `json_mode` rewrite needs a request flag the route does not carry, so
|
||||
// a tool-call answer falls back to Python rather than losing the calls.
|
||||
assert_eq!(
|
||||
transform_response(json!({
|
||||
"model": "m",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "f", "arguments": "{}"},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
})),
|
||||
Err(Error::Unsupported("tool call response"))
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_refusal_declines_instead_of_returning_an_empty_reply() {
|
||||
assert_eq!(
|
||||
transform_response(json!({
|
||||
"model": "m",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": null, "refusal": "cannot help"},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
})),
|
||||
Err(Error::Unsupported("refusal response"))
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_non_text_response_content_declines() {
|
||||
assert_eq!(
|
||||
transform_response(json!({
|
||||
"model": "m",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": [{"type": "text", "text": "hi"}]},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
})),
|
||||
Err(Error::Unsupported("non-text response content"))
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::streaming(json!({"stream": true}), "streaming")]
|
||||
#[case::unrecognized_param(json!({"some_provider_knob": 1}), "unrecognized request parameter")]
|
||||
fn declines(#[case] opts: Value, #[case] expected: &'static str) {
|
||||
assert_eq!(
|
||||
reason(json!([{"role": "user", "content": "hi"}]), opts),
|
||||
Some(Unsupported(expected))
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn accepts_standard_openai_params() {
|
||||
assert_eq!(
|
||||
reason(
|
||||
json!([{"role": "user", "content": "hi"}]),
|
||||
json!({
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.9,
|
||||
"max_tokens": 16,
|
||||
"response_format": {"type": "json_object"},
|
||||
"custom_endpoint": true,
|
||||
}),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn tool_parameters_decline_before_the_call() {
|
||||
// A `tools` request would come back with tool calls this port cannot
|
||||
// normalize, so it declines at the gate instead of after the call.
|
||||
assert_eq!(
|
||||
reason(
|
||||
json!([{"role": "user", "content": "hi"}]),
|
||||
json!({"tools": [{"type": "function", "function": {"name": "f"}}]}),
|
||||
),
|
||||
Some(Unsupported("unrecognized request parameter"))
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue