feat: support per-request model override in /v1/chat/completions (#103)

* feat: support per-request model override for /v1/chat/completions

- add optional model override to completion request types\n- forward request model through gateway, worker, and orchestrator proxy paths\n- use request model in NEAR AI providers with fallback to active model\n- replace model-mismatch integration test with override propagation checks\n- update FEATURE_PARITY.md note for OpenAI-compatible API behavior\n\nRefs #49

* Wire gateway OpenAI-compatible routes to active LLM provider

* Validate OpenAI model name length before streaming

* Address PR103 review feedback on model override and validation

* Report effective model in OpenAI-compatible responses

* Use async mutexes in OpenAI compatibility integration tests

* fix tests for per-request model field in response cache

* fix formatting and clippy lint after main merge

* Fix model override reporting and cache correctness

---------

Co-authored-by: Illia Polosukhin <[email protected]>
This commit is contained in:
Raahim Salman
2026-02-19 21:45:37 +00:00
committed by GitHub
co-authored by Illia Polosukhin
parent 89fdd81420
commit ccf60055f4
13 changed files with 519 additions and 62 deletions
+51 -24
View File
@@ -24,6 +24,8 @@ use crate::llm::{
use super::server::GatewayState;
const MAX_MODEL_NAME_BYTES: usize = 256;
// ---------------------------------------------------------------------------
// OpenAI request types
// ---------------------------------------------------------------------------
@@ -380,6 +382,27 @@ fn unix_timestamp() -> u64 {
.as_secs()
}
fn validate_model_name(model: &str) -> Result<(), String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return Err("model must not be empty".to_string());
}
if trimmed != model {
return Err("model must not have leading or trailing whitespace".to_string());
}
if model.len() > MAX_MODEL_NAME_BYTES {
return Err(format!(
"model must be at most {} bytes",
MAX_MODEL_NAME_BYTES
));
}
if model.chars().any(char::is_control) {
return Err("model contains control characters".to_string());
}
Ok(())
}
/// Extract stop sequences from the flexible `stop` field.
fn parse_stop(val: &serde_json::Value) -> Option<Vec<String>> {
match val {
@@ -426,29 +449,17 @@ pub async fn chat_completions_handler(
"invalid_request_error",
));
}
// Validate the requested model matches the active model.
// Per-request model switching is not yet supported (see GH issue).
let active_model = llm.active_model_name();
if req.model != active_model {
return Err((
StatusCode::NOT_FOUND,
Json(OpenAiErrorResponse {
error: OpenAiErrorDetail {
message: format!(
"Model '{}' not found. The active model is '{}'.",
req.model, active_model
),
error_type: "invalid_request_error".to_string(),
param: Some("model".to_string()),
code: Some("model_not_found".to_string()),
},
}),
if let Err(e) = validate_model_name(&req.model) {
return Err(openai_error(
StatusCode::BAD_REQUEST,
e,
"invalid_request_error",
));
}
let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
let stream = req.stream.unwrap_or(false);
let requested_model = req.model.clone();
if stream {
return handle_streaming(llm.clone(), req, has_tools)
@@ -460,13 +471,12 @@ pub async fn chat_completions_handler(
let messages = convert_messages(&req.messages)
.map_err(|e| openai_error(StatusCode::BAD_REQUEST, e, "invalid_request_error"))?;
let model_name = llm.active_model_name();
let id = chat_completion_id();
let created = unix_timestamp();
if has_tools {
let tools = convert_tools(req.tools.as_deref().unwrap_or(&[]));
let mut tool_req = ToolCompletionRequest::new(messages, tools);
let mut tool_req = ToolCompletionRequest::new(messages, tools).with_model(req.model);
if let Some(t) = req.temperature {
tool_req = tool_req.with_temperature(t);
}
@@ -483,6 +493,7 @@ pub async fn chat_completions_handler(
.complete_with_tools(tool_req)
.await
.map_err(map_llm_error)?;
let model_name = llm.effective_model_name(Some(requested_model.as_str()));
let tool_calls_openai = if resp.tool_calls.is_empty() {
None
@@ -515,7 +526,7 @@ pub async fn chat_completions_handler(
Ok(Json(response).into_response())
} else {
let mut comp_req = CompletionRequest::new(messages);
let mut comp_req = CompletionRequest::new(messages).with_model(req.model);
if let Some(t) = req.temperature {
comp_req = comp_req.with_temperature(t);
}
@@ -527,6 +538,7 @@ pub async fn chat_completions_handler(
}
let resp = llm.complete(comp_req).await.map_err(map_llm_error)?;
let model_name = llm.effective_model_name(Some(requested_model.as_str()));
let response = OpenAiChatResponse {
id,
@@ -570,7 +582,7 @@ async fn handle_streaming(
let messages = convert_messages(&req.messages)
.map_err(|e| openai_error(StatusCode::BAD_REQUEST, e, "invalid_request_error"))?;
let model_name = llm.active_model_name();
let requested_model = req.model.clone();
let id = chat_completion_id();
let created = unix_timestamp();
@@ -584,7 +596,7 @@ async fn handle_streaming(
let llm_result = if has_tools {
let tools = convert_tools(req.tools.as_deref().unwrap_or(&[]));
let mut tool_req = ToolCompletionRequest::new(messages, tools);
let mut tool_req = ToolCompletionRequest::new(messages, tools).with_model(req.model);
if let Some(t) = req.temperature {
tool_req = tool_req.with_temperature(t);
}
@@ -602,7 +614,7 @@ async fn handle_streaming(
.map_err(map_llm_error)?,
)
} else {
let mut comp_req = CompletionRequest::new(messages);
let mut comp_req = CompletionRequest::new(messages).with_model(req.model);
if let Some(t) = req.temperature {
comp_req = comp_req.with_temperature(t);
}
@@ -614,6 +626,7 @@ async fn handle_streaming(
}
LlmResult::Simple(llm.complete(comp_req).await.map_err(map_llm_error)?)
};
let model_name = llm.effective_model_name(Some(requested_model.as_str()));
// LLM succeeded — emit the response as SSE chunks
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, std::convert::Infallible>>(64);
@@ -1091,4 +1104,18 @@ mod tests {
let v = serde_json::Value::Null;
assert_eq!(parse_stop(&v), None);
}
#[test]
fn test_validate_model_name_rejects_leading_or_trailing_whitespace() {
let err = validate_model_name(" gpt-4").unwrap_err();
assert!(err.contains("leading or trailing whitespace"));
let err = validate_model_name("gpt-4 ").unwrap_err();
assert!(err.contains("leading or trailing whitespace"));
}
#[test]
fn test_validate_model_name_accepts_normal_name() {
assert!(validate_model_name("gpt-4").is_ok());
}
}