refactor error responses into shared utils::response helpers (#33894)
This commit is contained in:
@@ -159,12 +159,6 @@ fn hidden_states_rows(vals: &[f32], lens: &[u32]) -> serde_json::Value {
|
|||||||
serde_json::Value::Array(rows)
|
serde_json::Value::Array(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `{ "error": { message, code } }` object every error path emits (an SSE
|
|
||||||
/// event's data, a unary body, or one entry of a batch array).
|
|
||||||
pub(super) fn error_value(code: u16, message: &str) -> serde_json::Value {
|
|
||||||
serde_json::json!({ "error": { "message": message, "code": code } })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a decoded [`ChunkEvent`] as one SGLang `/generate` frame's JSON. `rid`
|
/// Format a decoded [`ChunkEvent`] as one SGLang `/generate` frame's JSON. `rid`
|
||||||
/// (response `meta_info.id`) is passed as a string; the event's numeric `rid` is
|
/// (response `meta_info.id`) is passed as a string; the event's numeric `rid` is
|
||||||
/// just the shard routing key.
|
/// just the shard routing key.
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ use tokio::sync::mpsc;
|
|||||||
|
|
||||||
use super::AppState;
|
use super::AppState;
|
||||||
use super::frame::{
|
use super::frame::{
|
||||||
OutputAccumulator, cumulative_frame_string, error_value, frame_value, stream_frame_string,
|
OutputAccumulator, cumulative_frame_string, frame_value, stream_frame_string, tag_value,
|
||||||
tag_value,
|
|
||||||
};
|
};
|
||||||
use super::guard::AbortGuard;
|
use super::guard::AbortGuard;
|
||||||
use super::submit::{pre_submit_error, submit};
|
use super::submit::submit;
|
||||||
use crate::environ::env_bool;
|
use crate::environ::env_bool;
|
||||||
use crate::ids::Rid;
|
use crate::ids::Rid;
|
||||||
use crate::message::{EgressItem, GenerateBody, GenerateRequest, RequestKind, SamplingParams};
|
use crate::message::{EgressItem, GenerateBody, GenerateRequest, RequestKind, SamplingParams};
|
||||||
|
use crate::utils::response::{error_response, error_value};
|
||||||
|
|
||||||
/// The routes this module owns, mounted by `api_server::serve`.
|
/// The routes this module owns, mounted by `api_server::serve`.
|
||||||
pub(super) fn routes() -> Router<AppState> {
|
pub(super) fn routes() -> Router<AppState> {
|
||||||
@@ -40,6 +40,12 @@ pub(super) fn routes() -> Router<AppState> {
|
|||||||
.merge(health_routes())
|
.merge(health_routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// native api error response: unary → `code` plus the JSON `body`,
|
||||||
|
/// streaming → 200 with one SSE error frame + `[DONE]`.
|
||||||
|
pub(super) fn native_error(code: StatusCode, message: &str, stream: bool) -> Response {
|
||||||
|
error_response(code, error_value(code.as_u16(), message), stream)
|
||||||
|
}
|
||||||
|
|
||||||
/// `/health` + `/health_generate`. Both env knobs are resolved ONCE here, at
|
/// `/health` + `/health_generate`. Both env knobs are resolved ONCE here, at
|
||||||
/// router build (server startup) — changing them on a live process needs a
|
/// router build (server startup) — changing them on a live process needs a
|
||||||
/// restart. The deep-probe handler is built once with
|
/// restart. The deep-probe handler is built once with
|
||||||
@@ -148,7 +154,7 @@ async fn generate(
|
|||||||
// can only answer unary — as Python's does (FastAPI rejects before its
|
// can only answer unary — as Python's does (FastAPI rejects before its
|
||||||
// handler runs).
|
// handler runs).
|
||||||
Err(rejection) => {
|
Err(rejection) => {
|
||||||
return pre_submit_error(StatusCode::BAD_REQUEST, &rejection.body_text(), false);
|
return native_error(StatusCode::BAD_REQUEST, &rejection.body_text(), false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let stream = body.stream;
|
let stream = body.stream;
|
||||||
@@ -159,13 +165,13 @@ async fn generate(
|
|||||||
// The error carries its own status (a bad batch is `Validation` → 400).
|
// The error carries its own status (a bad batch is `Validation` → 400).
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let code = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::BAD_REQUEST);
|
let code = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::BAD_REQUEST);
|
||||||
return pre_submit_error(code, &e.to_string(), stream);
|
return native_error(code, &e.to_string(), stream);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Media I/O (URL downloads, file reads) happens here, on the API runtime
|
// Media I/O (URL downloads, file reads) happens here, on the API runtime
|
||||||
// — never on the MM worker pool (see `prefetch`).
|
// — never on the MM worker pool (see `prefetch`).
|
||||||
if let Err(e) = super::prefetch::prefetch_all(&mut payloads).await {
|
if let Err(e) = super::prefetch::prefetch_all(&mut payloads).await {
|
||||||
return pre_submit_error(StatusCode::BAD_REQUEST, &e, stream);
|
return native_error(StatusCode::BAD_REQUEST, &e, stream);
|
||||||
}
|
}
|
||||||
if !is_batch {
|
if !is_batch {
|
||||||
// `into_requests` guarantees exactly one payload for a non-batch body.
|
// `into_requests` guarantees exactly one payload for a non-batch body.
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
//! request and response primitives. Native [`ChunkEvent`] values remain the one
|
//! request and response primitives. Native [`ChunkEvent`] values remain the one
|
||||||
//! backend output type for both unary and streaming responses.
|
//! backend output type for both unary and streaming responses.
|
||||||
|
|
||||||
use axum::{
|
use axum::{Router, http::StatusCode, response::Response};
|
||||||
Json, Router,
|
|
||||||
http::StatusCode,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
@@ -28,6 +24,7 @@ use super::submit::submit;
|
|||||||
use crate::ids::Rid;
|
use crate::ids::Rid;
|
||||||
use crate::message::{ChunkEvent, EgressItem, GenerateRequest, RequestKind};
|
use crate::message::{ChunkEvent, EgressItem, GenerateRequest, RequestKind};
|
||||||
use crate::runtime::ServerArgs;
|
use crate::runtime::ServerArgs;
|
||||||
|
use crate::utils::response::error_response;
|
||||||
|
|
||||||
const MAX_OPENAI_CHOICES: usize = 4096;
|
const MAX_OPENAI_CHOICES: usize = 4096;
|
||||||
|
|
||||||
@@ -86,10 +83,9 @@ fn unix_seconds_u32() -> u32 {
|
|||||||
u32::try_from(unix_seconds()).unwrap_or(u32::MAX)
|
u32::try_from(unix_seconds()).unwrap_or(u32::MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The OpenAI `{"error": {...}}` payload: `type` is the SDK-facing error kind
|
/// The OpenAI error payload.
|
||||||
/// (`AuthenticationError` / `InternalServerError` / `BadRequestError`), and
|
pub(super) fn error_payload(code: StatusCode, message: impl Into<String>) -> serde_json::Value {
|
||||||
/// `code` carries the HTTP status — the shape Python's OpenAI frontend emits.
|
let message = message.into();
|
||||||
fn error_payload(code: StatusCode, message: String) -> serde_json::Value {
|
|
||||||
let error_type = if code == StatusCode::UNAUTHORIZED {
|
let error_type = if code == StatusCode::UNAUTHORIZED {
|
||||||
"AuthenticationError"
|
"AuthenticationError"
|
||||||
} else if code.is_server_error() {
|
} else if code.is_server_error() {
|
||||||
@@ -108,36 +104,15 @@ fn error_payload(code: StatusCode, message: String) -> serde_json::Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shape a `StatusCode` + message into an OpenAI error response, mirroring
|
/// Form an OpenAI error response: unary → `code` plus the JSON `body`,
|
||||||
/// `pre_submit_error`'s rule: unary requests get the JSON error with its
|
/// streaming → 200 with one SSE error frame + `[DONE]`.
|
||||||
/// status; a request whose stream is already committed gets 200 + one SSE
|
pub(super) fn openai_error(code: StatusCode, message: impl Into<String>, stream: bool) -> Response {
|
||||||
/// error frame + `[DONE]`.
|
error_response(code, error_payload(code, message), stream)
|
||||||
pub(super) fn openai_error_response(
|
|
||||||
code: StatusCode,
|
|
||||||
message: impl Into<String>,
|
|
||||||
stream: bool,
|
|
||||||
) -> Response {
|
|
||||||
let body = error_payload(code, message.into());
|
|
||||||
if !stream {
|
|
||||||
return (code, Json(body)).into_response();
|
|
||||||
}
|
|
||||||
super::submit::sse_error_response(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unary OpenAI error — the common pre-submit case (Python validates before
|
|
||||||
/// the stream starts and answers 4xx in JSON even for `stream=true`).
|
|
||||||
fn openai_error(code: StatusCode, message: impl Into<String>) -> Response {
|
|
||||||
openai_error_response(code, message, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The OpenAI error frame payload for errors raised *inside* a committed
|
|
||||||
/// stream, where only a `data:` frame can be emitted (the status is folded
|
|
||||||
/// into the body, since the response status is already 200).
|
|
||||||
pub(super) fn streaming_error(code: u16, message: impl Into<String>) -> String {
|
|
||||||
let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
error_payload(status, message.into()).to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drain one submitted request to its terminal output: fold frames, disarm
|
||||||
|
/// `guard` on a natural terminal, and map errors / validation aborts /
|
||||||
|
/// truncation to `(status, message)` for the OpenAI error shape.
|
||||||
async fn collect_output(
|
async fn collect_output(
|
||||||
mut rx: mpsc::Receiver<EgressItem>,
|
mut rx: mpsc::Receiver<EgressItem>,
|
||||||
guard: &mut AbortGuard,
|
guard: &mut AbortGuard,
|
||||||
@@ -191,10 +166,10 @@ async fn submit_generation(
|
|||||||
guard.arm(rid);
|
guard.arm(rid);
|
||||||
Ok(rx)
|
Ok(rx)
|
||||||
}
|
}
|
||||||
// Same rule as `pre_submit_error`: a committed stream gets 200 plus an
|
// Same `error_response` rule: a committed stream gets 200 plus an
|
||||||
// SSE error frame + `[DONE]`, not a unary 503 — but with the OpenAI
|
// SSE error frame + `[DONE]`, not a unary 503 — but with the OpenAI
|
||||||
// error shape, since this is the OpenAI frontend.
|
// error shape, since this is the OpenAI frontend.
|
||||||
Err(_) => Err(openai_error_response(
|
Err(_) => Err(openai_error(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"service unavailable",
|
"service unavailable",
|
||||||
stream,
|
stream,
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ use super::tools::{
|
|||||||
parse_chat_tool_calls,
|
parse_chat_tool_calls,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
AppState, ChatFormatter, collect_output, contains_media, indexed_egress_stream, openai_error,
|
AppState, ChatFormatter, collect_output, contains_media, error_payload, indexed_egress_stream,
|
||||||
streaming_error, submit_generation, unix_seconds_u32,
|
openai_error, submit_generation, unix_seconds_u32,
|
||||||
};
|
};
|
||||||
use crate::ids::Rid;
|
use crate::ids::Rid;
|
||||||
use crate::message::{ChunkExtras, EgressItem, GenerateRequest, OneOrMany, SamplingParams};
|
use crate::message::{ChunkExtras, EgressItem, GenerateRequest, OneOrMany, SamplingParams};
|
||||||
@@ -49,25 +49,29 @@ async fn chat_completions(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
let request = match body {
|
let request = match body {
|
||||||
Ok(Json(request)) => request,
|
Ok(Json(request)) => request,
|
||||||
Err(rejection) => return openai_error(StatusCode::BAD_REQUEST, rejection.body_text()),
|
Err(rejection) => {
|
||||||
|
return openai_error(StatusCode::BAD_REQUEST, rejection.body_text(), false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if request.model != state.server_args.served_model_name {
|
if request.model != state.server_args.served_model_name {
|
||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("The model `{}` does not exist", request.model),
|
format!("The model `{}` does not exist", request.model),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if request.messages.is_empty() {
|
if request.messages.is_empty() {
|
||||||
return openai_error(StatusCode::BAD_REQUEST, "messages cannot be empty");
|
return openai_error(StatusCode::BAD_REQUEST, "messages cannot be empty", false);
|
||||||
}
|
}
|
||||||
if serde_json::to_value(&request.messages).is_ok_and(|messages| contains_media(&messages)) {
|
if serde_json::to_value(&request.messages).is_ok_and(|messages| contains_media(&messages)) {
|
||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"image, audio, video, and file message content is not supported",
|
"image, audio, video, and file message content is not supported",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if request.n == Some(0) {
|
if request.n == Some(0) {
|
||||||
return openai_error(StatusCode::BAD_REQUEST, "n must be at least 1");
|
return openai_error(StatusCode::BAD_REQUEST, "n must be at least 1", false);
|
||||||
}
|
}
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
let max_tokens = request.max_completion_tokens.or(request.max_tokens);
|
let max_tokens = request.max_completion_tokens.or(request.max_tokens);
|
||||||
@@ -75,6 +79,7 @@ async fn chat_completions(
|
|||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"max_completion_tokens must be positive",
|
"max_completion_tokens must be positive",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if request.modalities.as_ref().is_some_and(|modalities| {
|
if request.modalities.as_ref().is_some_and(|modalities| {
|
||||||
@@ -87,6 +92,7 @@ async fn chat_completions(
|
|||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"audio, prediction, web search, and multimodal inputs are not supported",
|
"audio, prediction, web search, and multimodal inputs are not supported",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
@@ -94,6 +100,7 @@ async fn chat_completions(
|
|||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"deprecated function_call/functions are not supported; use tools and tool_choice",
|
"deprecated function_call/functions are not supported; use tools and tool_choice",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +117,7 @@ async fn chat_completions(
|
|||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"tool calls require --tool-call-parser",
|
"tool calls require --tool-call-parser",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Python gates the split on `request.separate_reasoning` (default true);
|
// Python gates the split on `request.separate_reasoning` (default true);
|
||||||
@@ -143,7 +151,9 @@ async fn chat_completions(
|
|||||||
&state.server_args,
|
&state.server_args,
|
||||||
) {
|
) {
|
||||||
Ok(sampling) => sampling,
|
Ok(sampling) => sampling,
|
||||||
Err(message) => return openai_error(StatusCode::BAD_REQUEST, message),
|
Err(message) => {
|
||||||
|
return openai_error(StatusCode::BAD_REQUEST, message, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = request.stream.unwrap_or(false);
|
let stream = request.stream.unwrap_or(false);
|
||||||
@@ -244,6 +254,7 @@ pub(super) async fn prepare_chat_request(
|
|||||||
return Err(openai_error(
|
return Err(openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"this model has no usable chat template",
|
"this model has no usable chat template",
|
||||||
|
false,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
// Template stops first, then the request's own — Python
|
// Template stops first, then the request's own — Python
|
||||||
@@ -255,6 +266,7 @@ pub(super) async fn prepare_chat_request(
|
|||||||
openai_error(
|
openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("chat template render failed: {error}"),
|
format!("chat template render failed: {error}"),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
Ok((request, prompt))
|
Ok((request, prompt))
|
||||||
@@ -428,7 +440,9 @@ pub(super) async fn unary_chat(
|
|||||||
for (index, rid, rx) in submitted {
|
for (index, rid, rx) in submitted {
|
||||||
let output = match collect_output(rx, &mut guard, &rid).await {
|
let output = match collect_output(rx, &mut guard, &rid).await {
|
||||||
Ok(output) => output,
|
Ok(output) => output,
|
||||||
Err((status, message)) => return openai_error(status, message),
|
Err((status, message)) => {
|
||||||
|
return openai_error(status, message, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if prompt_tokens == 0 {
|
if prompt_tokens == 0 {
|
||||||
@@ -559,7 +573,7 @@ pub(super) fn chat_event_stream(
|
|||||||
id: None,
|
id: None,
|
||||||
event: None,
|
event: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
error: Some(streaming_error(500, "response truncated before completion")),
|
error: Some(error_payload(StatusCode::INTERNAL_SERVER_ERROR, "response truncated before completion").to_string()),
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -576,7 +590,7 @@ pub(super) fn chat_event_stream(
|
|||||||
id: None,
|
id: None,
|
||||||
event: None,
|
event: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
error: Some(streaming_error(error.http_status(), error.to_string())),
|
error: Some(error_payload(StatusCode::from_u16(error.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), error.to_string()).to_string()),
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -592,7 +606,7 @@ pub(super) fn chat_event_stream(
|
|||||||
id: None,
|
id: None,
|
||||||
event: None,
|
event: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
error: Some(streaming_error(code, message)),
|
error: Some(error_payload(StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), message).to_string()),
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ use tokio::sync::mpsc;
|
|||||||
use super::super::guard::AbortGuard;
|
use super::super::guard::AbortGuard;
|
||||||
use super::super::submit::submit;
|
use super::super::submit::submit;
|
||||||
use super::{
|
use super::{
|
||||||
AppState, MAX_OPENAI_CHOICES, collect_output, indexed_egress_stream, openai_error,
|
AppState, MAX_OPENAI_CHOICES, collect_output, error_payload, indexed_egress_stream,
|
||||||
streaming_error, submit_generation, unix_seconds_u32,
|
openai_error, submit_generation, unix_seconds_u32,
|
||||||
};
|
};
|
||||||
use crate::ids::Rid;
|
use crate::ids::Rid;
|
||||||
use crate::message::{
|
use crate::message::{
|
||||||
@@ -64,7 +64,7 @@ async fn completions(
|
|||||||
let request = match body {
|
let request = match body {
|
||||||
Ok(Json(request)) => request,
|
Ok(Json(request)) => request,
|
||||||
Err(rejection) => {
|
Err(rejection) => {
|
||||||
return openai_error(StatusCode::BAD_REQUEST, rejection.body_text());
|
return openai_error(StatusCode::BAD_REQUEST, rejection.body_text(), false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let stream = request.stream.unwrap_or(false);
|
let stream = request.stream.unwrap_or(false);
|
||||||
@@ -74,6 +74,7 @@ async fn completions(
|
|||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("The model `{model}` does not exist"),
|
format!("The model `{model}` does not exist"),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,33 +82,44 @@ async fn completions(
|
|||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"prompt_embeds is not supported by the Rust frontend",
|
"prompt_embeds is not supported by the Rust frontend",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if request.suffix.is_some() {
|
if request.suffix.is_some() {
|
||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"suffix is not supported by this model",
|
"suffix is not supported by this model",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if request.best_of.is_some_and(|best_of| best_of != 1) {
|
if request.best_of.is_some_and(|best_of| best_of != 1) {
|
||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"best_of values greater than 1 are not supported",
|
"best_of values greater than 1 are not supported",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if request.max_tokens == Some(0) {
|
if request.max_tokens == Some(0) {
|
||||||
return openai_error(StatusCode::BAD_REQUEST, "max_tokens must be positive");
|
return openai_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"max_tokens must be positive",
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if request.n == Some(0) {
|
if request.n == Some(0) {
|
||||||
return openai_error(StatusCode::BAD_REQUEST, "n must be at least 1");
|
return openai_error(StatusCode::BAD_REQUEST, "n must be at least 1", false);
|
||||||
}
|
}
|
||||||
let prompts = match completion_prompt_specs(&request.prompt) {
|
let prompts = match completion_prompt_specs(&request.prompt) {
|
||||||
Ok(prompts) => prompts,
|
Ok(prompts) => prompts,
|
||||||
Err(message) => return openai_error(StatusCode::BAD_REQUEST, message),
|
Err(message) => {
|
||||||
|
return openai_error(StatusCode::BAD_REQUEST, &message, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let mut sampling = match completion_sampling_params(&request) {
|
let mut sampling = match completion_sampling_params(&request) {
|
||||||
Ok(sampling) => sampling,
|
Ok(sampling) => sampling,
|
||||||
Err(message) => return openai_error(StatusCode::BAD_REQUEST, message),
|
Err(message) => {
|
||||||
|
return openai_error(StatusCode::BAD_REQUEST, &message, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = sampling.normalize(
|
if let Err(error) = sampling.normalize(
|
||||||
state.server_args.skip_tokenizer_init,
|
state.server_args.skip_tokenizer_init,
|
||||||
@@ -117,7 +129,7 @@ async fn completions(
|
|||||||
.vocab_size
|
.vocab_size
|
||||||
.unwrap_or(u64::MAX),
|
.unwrap_or(u64::MAX),
|
||||||
) {
|
) {
|
||||||
return openai_error(StatusCode::BAD_REQUEST, error.to_string());
|
return openai_error(StatusCode::BAD_REQUEST, error.to_string(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let n = request.n.unwrap_or(1) as usize;
|
let n = request.n.unwrap_or(1) as usize;
|
||||||
@@ -127,6 +139,7 @@ async fn completions(
|
|||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("prompt count times n exceeds the maximum of {MAX_OPENAI_CHOICES}"),
|
format!("prompt count times n exceeds the maximum of {MAX_OPENAI_CHOICES}"),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -235,6 +248,7 @@ async fn decode_prompt_echo(state: &AppState, token_ids: TokenIds) -> Result<Str
|
|||||||
return Err(openai_error(
|
return Err(openai_error(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"service unavailable",
|
"service unavailable",
|
||||||
|
false,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
@@ -242,10 +256,11 @@ async fn decode_prompt_echo(state: &AppState, token_ids: TokenIds) -> Result<Str
|
|||||||
openai_error(
|
openai_error(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"detokenized prompt is not valid UTF-8",
|
"detokenized prompt is not valid UTF-8",
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
Some(EgressItem::Error(crate::error::Error::Validation(message))) => {
|
Some(EgressItem::Error(crate::error::Error::Validation(message))) => {
|
||||||
Err(openai_error(StatusCode::BAD_REQUEST, message))
|
Err(openai_error(StatusCode::BAD_REQUEST, &message, false))
|
||||||
}
|
}
|
||||||
Some(EgressItem::Error(error)) => {
|
Some(EgressItem::Error(error)) => {
|
||||||
let status = StatusCode::from_u16(error.http_status())
|
let status = StatusCode::from_u16(error.http_status())
|
||||||
@@ -253,11 +268,13 @@ async fn decode_prompt_echo(state: &AppState, token_ids: TokenIds) -> Result<Str
|
|||||||
Err(openai_error(
|
Err(openai_error(
|
||||||
status,
|
status,
|
||||||
format!("failed to decode prompt for echo: {error}"),
|
format!("failed to decode prompt for echo: {error}"),
|
||||||
|
false,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Some(_) | None => Err(openai_error(
|
Some(_) | None => Err(openai_error(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"failed to decode prompt for echo: reply channel closed",
|
"failed to decode prompt for echo: reply channel closed",
|
||||||
|
false,
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,7 +375,9 @@ pub(super) async fn unary_completion(
|
|||||||
for choice in submitted {
|
for choice in submitted {
|
||||||
let output = match collect_output(choice.rx, &mut guard, &choice.rid).await {
|
let output = match collect_output(choice.rx, &mut guard, &choice.rid).await {
|
||||||
Ok(output) => output,
|
Ok(output) => output,
|
||||||
Err((status, message)) => return openai_error(status, message),
|
Err((status, message)) => {
|
||||||
|
return openai_error(status, &message, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
prompt_tokens
|
prompt_tokens
|
||||||
@@ -525,7 +544,7 @@ pub(super) fn completion_event_stream(
|
|||||||
|
|
||||||
while let Some((index, item)) = events.next().await {
|
while let Some((index, item)) = events.next().await {
|
||||||
let Some(item) = item else {
|
let Some(item) = item else {
|
||||||
yield streaming_error(500, "response truncated before completion");
|
yield error_payload(StatusCode::INTERNAL_SERVER_ERROR, "response truncated before completion").to_string();
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let output = match item {
|
let output = match item {
|
||||||
@@ -536,7 +555,7 @@ pub(super) fn completion_event_stream(
|
|||||||
}
|
}
|
||||||
EgressItem::Error(error) => {
|
EgressItem::Error(error) => {
|
||||||
guard.disarm(&rids[index]);
|
guard.disarm(&rids[index]);
|
||||||
yield streaming_error(error.http_status(), error.to_string());
|
yield error_payload(StatusCode::from_u16(error.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), error.to_string()).to_string();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
EgressItem::Control(_) | EgressItem::Data(_) => continue,
|
EgressItem::Control(_) | EgressItem::Data(_) => continue,
|
||||||
@@ -547,7 +566,7 @@ pub(super) fn completion_event_stream(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|reason| reason.abort_status())
|
.and_then(|reason| reason.abort_status())
|
||||||
{
|
{
|
||||||
yield streaming_error(code, message);
|
yield error_payload(StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), message).to_string();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn retrieve_model(State(state): State<AppState>, Path(model): Path<String>
|
|||||||
return openai_error(
|
return openai_error(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
format!("The model `{model}` does not exist"),
|
format!("The model `{model}` does not exist"),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Json(model_card(&state)).into_response()
|
Json(model_card(&state)).into_response()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use axum::response::Response;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
use super::routes;
|
use super::{openai_error, routes};
|
||||||
use crate::ids::Rid;
|
use crate::ids::Rid;
|
||||||
use crate::message::{ChunkEvent, EgressItem};
|
use crate::message::{ChunkEvent, EgressItem};
|
||||||
use crate::runtime::ServerArgs;
|
use crate::runtime::ServerArgs;
|
||||||
@@ -157,13 +157,13 @@ pub(super) async fn body_json(response: Response) -> serde_json::Value {
|
|||||||
serde_json::from_slice(&bytes).unwrap()
|
serde_json::from_slice(&bytes).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The common StatusCode→error helper follows `pre_submit_error`'s shape:
|
/// The common StatusCode→error helper follows `error_response`'s shape:
|
||||||
/// unary requests get the JSON error with its status; a committed stream gets
|
/// unary requests get the JSON error with its status; a committed stream gets
|
||||||
/// 200 + one SSE error frame + `[DONE]`, and the frame carries the OpenAI
|
/// 200 + one SSE error frame + `[DONE]`, and the frame carries the OpenAI
|
||||||
/// error fields (`type`, `param`, `code`) that the SDKs dispatch on.
|
/// error fields (`type`, `param`, `code`) that the SDKs dispatch on.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn openai_error_response_covers_unary_and_sse() {
|
async fn openai_error_response_covers_unary_and_sse() {
|
||||||
let unary = super::openai_error_response(StatusCode::BAD_REQUEST, "bad input", false);
|
let unary = openai_error(StatusCode::BAD_REQUEST, "bad input", false);
|
||||||
assert_eq!(unary.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(unary.status(), StatusCode::BAD_REQUEST);
|
||||||
let value = body_json(unary).await;
|
let value = body_json(unary).await;
|
||||||
assert_eq!(value["error"]["message"], "bad input");
|
assert_eq!(value["error"]["message"], "bad input");
|
||||||
@@ -171,7 +171,7 @@ async fn openai_error_response_covers_unary_and_sse() {
|
|||||||
assert_eq!(value["error"]["code"], 400);
|
assert_eq!(value["error"]["code"], 400);
|
||||||
assert!(value["error"]["param"].is_null());
|
assert!(value["error"]["param"].is_null());
|
||||||
|
|
||||||
let streamed = super::openai_error_response(StatusCode::BAD_REQUEST, "bad input", true);
|
let streamed = openai_error(StatusCode::BAD_REQUEST, "bad input", true);
|
||||||
assert_eq!(streamed.status(), StatusCode::OK);
|
assert_eq!(streamed.status(), StatusCode::OK);
|
||||||
let bytes = axum::body::to_bytes(streamed.into_body(), 64 * 1024)
|
let bytes = axum::body::to_bytes(streamed.into_body(), 64 * 1024)
|
||||||
.await
|
.await
|
||||||
@@ -290,7 +290,7 @@ async fn basic_openai_router_excludes_responses_api() {
|
|||||||
|
|
||||||
/// A closed tm inbox with a *streaming* request must answer inside the
|
/// A closed tm inbox with a *streaming* request must answer inside the
|
||||||
/// committed stream: 200 + one OpenAI-shaped SSE error frame + `[DONE]` (the
|
/// committed stream: 200 + one OpenAI-shaped SSE error frame + `[DONE]` (the
|
||||||
/// same rule `pre_submit_error` applies to the native API), not a unary 503.
|
/// same `error_response` rule the native API applies), not a unary 503.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn streaming_submit_failure_answers_inside_the_stream() {
|
async fn streaming_submit_failure_answers_inside_the_stream() {
|
||||||
let app = routes().with_state(app_state(senders_closed()));
|
let app = routes().with_state(app_state(senders_closed()));
|
||||||
|
|||||||
@@ -2,20 +2,10 @@
|
|||||||
//! module: mint the client-visible rid (uuid hex, Python-parity), build the
|
//! module: mint the client-visible rid (uuid hex, Python-parity), build the
|
||||||
//! `Request`, and hand it to the TM with an egress receiver for the response.
|
//! `Request`, and hand it to the TM with an egress receiver for the response.
|
||||||
|
|
||||||
use std::convert::Infallible;
|
use axum::{http::StatusCode, response::Response};
|
||||||
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
http::StatusCode,
|
|
||||||
response::{
|
|
||||||
IntoResponse, Response,
|
|
||||||
sse::{Event, Sse},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use super::AppState;
|
use super::{AppState, native_api::native_error};
|
||||||
use super::frame::error_value;
|
|
||||||
use crate::fsm::RequestState;
|
use crate::fsm::RequestState;
|
||||||
use crate::ids::Rid;
|
use crate::ids::Rid;
|
||||||
use crate::message::{EgressItem, EgressSink, Request, RequestKind};
|
use crate::message::{EgressItem, EgressSink, Request, RequestKind};
|
||||||
@@ -29,8 +19,7 @@ pub(super) async fn submit(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
kind: RequestKind,
|
kind: RequestKind,
|
||||||
// `stream`: the client is reading an SSE stream, so it expects 200 plus an
|
// `stream`: the client is reading an SSE stream, so it expects 200 plus an
|
||||||
// error frame rather than a 4xx — same rule `pre_submit_error` applies
|
// error frame rather than a 4xx — `utils::response::error_response`'s rule.
|
||||||
// everywhere else.
|
|
||||||
stream: bool,
|
stream: bool,
|
||||||
) -> Result<(Rid, mpsc::Receiver<EgressItem>), Response> {
|
) -> Result<(Rid, mpsc::Receiver<EgressItem>), Response> {
|
||||||
let rid = match &kind {
|
let rid = match &kind {
|
||||||
@@ -61,7 +50,7 @@ pub(super) async fn submit(
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::error!(%rid, "tm inbox closed; request rejected");
|
tracing::error!(%rid, "tm inbox closed; request rejected");
|
||||||
// Return 503 so the client can retry.
|
// Return 503 so the client can retry.
|
||||||
Err(pre_submit_error(
|
Err(native_error(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"service unavailable",
|
"service unavailable",
|
||||||
stream,
|
stream,
|
||||||
@@ -69,68 +58,3 @@ pub(super) async fn submit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shape an error that occurs *before* (or instead of) a successful submit into a
|
|
||||||
/// client response. Two parity points with Python's `generate_request`. The body
|
|
||||||
/// is the same `{"error": {...}}` object every other path emits — not bare text,
|
|
||||||
/// which a client parsing JSON chokes on. And a streaming request gets 200 plus
|
|
||||||
/// one SSE error frame and `[DONE]`, not a 4xx: the client has already committed
|
|
||||||
/// to reading a stream, and Python answers it inside `stream_results()`.
|
|
||||||
pub(super) fn pre_submit_error(code: StatusCode, message: &str, stream: bool) -> Response {
|
|
||||||
let body = error_value(code.as_u16(), message);
|
|
||||||
if !stream {
|
|
||||||
return (code, Json(body)).into_response();
|
|
||||||
}
|
|
||||||
sse_error_response(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A 200 SSE response carrying one error frame + `[DONE]` — how a stream the
|
|
||||||
/// client is already committed to reading reports a failure. Shared by every
|
|
||||||
/// endpoint family: the native API via [`pre_submit_error`] and the OpenAI
|
|
||||||
/// frontend's `openai_error_response`.
|
|
||||||
pub(super) fn sse_error_response(body: serde_json::Value) -> Response {
|
|
||||||
let frames = [body.to_string(), "[DONE]".to_string()];
|
|
||||||
Sse::new(futures::stream::iter(
|
|
||||||
frames.map(|data| Ok::<_, Infallible>(Event::default().data(data))),
|
|
||||||
))
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Unary pre-submit errors are a 4xx/5xx with a JSON `{"error":...}` body;
|
|
||||||
/// streaming ones are 200 + an SSE error frame + `[DONE]`, because Python
|
|
||||||
/// answers from inside `stream_results()` once the stream is committed.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn pre_submit_errors_match_python_shape() {
|
|
||||||
let unary = pre_submit_error(StatusCode::BAD_REQUEST, "bad input", false);
|
|
||||||
assert_eq!(unary.status(), StatusCode::BAD_REQUEST);
|
|
||||||
let body = axum::body::to_bytes(unary.into_body(), 64 * 1024)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).expect("JSON body");
|
|
||||||
assert_eq!(v["error"]["message"], "bad input");
|
|
||||||
assert_eq!(v["error"]["code"], 400);
|
|
||||||
|
|
||||||
let streamed = pre_submit_error(StatusCode::BAD_REQUEST, "bad input", true);
|
|
||||||
assert_eq!(
|
|
||||||
streamed.status(),
|
|
||||||
StatusCode::OK,
|
|
||||||
"the stream itself is 200"
|
|
||||||
);
|
|
||||||
let body = axum::body::to_bytes(streamed.into_body(), 64 * 1024)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
|
||||||
assert!(
|
|
||||||
text.contains(r#""code":400"#),
|
|
||||||
"carries the status in-band: {text}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
text.trim_end().ends_with("data: [DONE]"),
|
|
||||||
"terminated: {text}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Shared helpers with no home in a pipeline stage.
|
//! Shared helpers with no home in a pipeline stage.
|
||||||
|
|
||||||
pub mod regex;
|
pub mod regex;
|
||||||
|
pub mod response;
|
||||||
pub mod sock;
|
pub mod sock;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Shared HTTP error-response shaping for the api-server endpoint modules.
|
||||||
|
//!
|
||||||
|
//! Two mechanics live here; the WIRE SHAPES stay owned by their endpoints:
|
||||||
|
//! the native `{"error": {"message", "code"}}` body (Python
|
||||||
|
//! `http_server.generate_request` parity) built by [`error_value`], and the
|
||||||
|
//! SSE variant [`sse_error_response`] used by any
|
||||||
|
//! endpoint family (native and OpenAI alike — the caller supplies its own
|
||||||
|
//! body shape). The OpenAI error payload and the PD bootstrap registry's
|
||||||
|
//! plain-text bodies are protocol-owned and deliberately not unified here.
|
||||||
|
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{
|
||||||
|
IntoResponse, Response,
|
||||||
|
sse::{Event, Sse},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The native error body — the same `{"error": {...}}` object every native
|
||||||
|
/// path emits, not bare text, which a client parsing JSON chokes on.
|
||||||
|
pub fn error_value(code: u16, message: &str) -> serde_json::Value {
|
||||||
|
serde_json::json!({ "error": { "message": message, "code": code } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Form an error in the shape the client committed to: unary → `code` plus
|
||||||
|
/// the JSON `body`; streaming → 200 with one SSE error frame + `[DONE]` (the
|
||||||
|
/// client is already reading a stream — Python answers in-stream too, from
|
||||||
|
/// `stream_results()`). The `body` is caller-shaped: native [`error_value`]
|
||||||
|
/// or the OpenAI error payload.
|
||||||
|
pub fn error_response(code: StatusCode, body: serde_json::Value, stream: bool) -> Response {
|
||||||
|
if !stream {
|
||||||
|
return (code, Json(body)).into_response();
|
||||||
|
}
|
||||||
|
sse_error_response(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 200 SSE response carrying one error frame + `[DONE]` — how a stream the
|
||||||
|
/// client is already committed to reading reports a failure. Shared by every
|
||||||
|
/// endpoint family: the native API and the OpenAI
|
||||||
|
/// frontend's `openai_error_response`.
|
||||||
|
pub fn sse_error_response(body: serde_json::Value) -> Response {
|
||||||
|
let frames = [body.to_string(), "[DONE]".to_string()];
|
||||||
|
Sse::new(futures::stream::iter(
|
||||||
|
frames.map(|data| Ok::<_, Infallible>(Event::default().data(data))),
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Python-parity pins for the native error shapes (`generate_request`):
|
||||||
|
/// unary errors are a 4xx/5xx with a JSON `{"error": ...}` body; streaming
|
||||||
|
/// ones are 200 + one SSE error frame + `[DONE]`, because Python answers
|
||||||
|
/// from inside `stream_results()` once the stream is committed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn error_responses_match_python_shape() {
|
||||||
|
let unary = error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
error_value(400, "bad input"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(unary.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = axum::body::to_bytes(unary.into_body(), 64 * 1024)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).expect("JSON body");
|
||||||
|
assert_eq!(v["error"]["message"], "bad input");
|
||||||
|
assert_eq!(v["error"]["code"], 400);
|
||||||
|
|
||||||
|
let streamed = error_response(StatusCode::BAD_REQUEST, error_value(400, "bad input"), true);
|
||||||
|
assert_eq!(
|
||||||
|
streamed.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"the stream itself is 200"
|
||||||
|
);
|
||||||
|
let body = axum::body::to_bytes(streamed.into_body(), 64 * 1024)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(
|
||||||
|
text.contains(r#""code":400"#),
|
||||||
|
"carries the status in-band: {text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.trim_end().ends_with("data: [DONE]"),
|
||||||
|
"terminated: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user