Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ axum-prometheus = "0.10.0"
metrics = "0.24"
governor = "0.10.1"
rand = "0.9"
uuid = { version = "1", features = ["v4"] }

[dev-dependencies]
axum-test = "18.0.0"
Expand Down
1,158 changes: 1,049 additions & 109 deletions src/strict/handlers.rs

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions src/strict/schemas/chat_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,120 @@
//! See: https://platform.openai.com/docs/api-reference/chat

use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

use super::utils::ensure_field;

pub(crate) fn generated_chat_completion_id() -> String {
format!("chatcmpl-{}", Uuid::new_v4())
}

fn normalize_chat_message_value(value: &mut Value) {
let Some(object) = value.as_object_mut() else {
return;
};

ensure_field(object, "role", || Value::String("assistant".to_string()));
ensure_field(object, "content", || Value::Null);
}

fn normalize_chat_choice_value(value: &mut Value, fallback_index: usize) {
let Some(object) = value.as_object_mut() else {
return;
};

ensure_field(object, "index", || Value::from(fallback_index));
ensure_field(object, "finish_reason", || Value::Null);
ensure_field(object, "logprobs", || Value::Null);

if let Some(message) = object.get_mut("message") {
normalize_chat_message_value(message);
}
}

fn normalize_chat_chunk_choice_value(value: &mut Value, fallback_index: usize) {
let Some(object) = value.as_object_mut() else {
return;
};

ensure_field(object, "index", || Value::from(fallback_index));
ensure_field(object, "finish_reason", || Value::Null);
ensure_field(object, "logprobs", || Value::Null);

if !object.contains_key("delta") {
object.insert("delta".to_string(), serde_json::json!({}));
}
}

/// Backfill omitted non-critical chat completion response fields during strict
/// sanitization. This is intentionally kept out of serde defaults so we only
/// relax third-party provider payloads, not every deserialize path.
pub(crate) fn normalize_chat_completion_response_value(value: &mut Value, fallback_model: &str) {
let Some(object) = value.as_object_mut() else {
return;
};

// Only coerce payloads that already look like chat completion responses.
if !object.contains_key("choices") {
return;
}

ensure_field(object, "id", || {
Value::String(generated_chat_completion_id())
});
ensure_field(object, "object", || {
Value::String("chat.completion".to_string())
});
ensure_field(object, "created", || Value::from(0));
ensure_field(object, "model", || {
Value::String(fallback_model.to_string())
});
ensure_field(object, "usage", || Value::Null);
ensure_field(object, "system_fingerprint", || Value::Null);
ensure_field(object, "service_tier", || Value::Null);

if let Some(choices) = object.get_mut("choices").and_then(Value::as_array_mut) {
for (index, choice) in choices.iter_mut().enumerate() {
normalize_chat_choice_value(choice, index);
}
}
}

/// Backfill omitted non-critical chat completion chunk fields during strict
/// sanitization. This keeps partial-but-usable streamed chunks from failing.
pub(crate) fn normalize_chat_completion_chunk_value(
value: &mut Value,
fallback_model: &str,
fallback_id: &str,
) {
let Some(object) = value.as_object_mut() else {
return;
};

// Only coerce payloads that already look like streamed chat chunks.
if !object.contains_key("choices") {
return;
}

ensure_field(object, "id", || Value::String(fallback_id.to_string()));
ensure_field(object, "object", || {
Value::String("chat.completion.chunk".to_string())
});
ensure_field(object, "created", || Value::from(0));
ensure_field(object, "model", || {
Value::String(fallback_model.to_string())
});
ensure_field(object, "usage", || Value::Null);
ensure_field(object, "system_fingerprint", || Value::Null);
ensure_field(object, "service_tier", || Value::Null);

if let Some(choices) = object.get_mut("choices").and_then(Value::as_array_mut) {
for (index, choice) in choices.iter_mut().enumerate() {
normalize_chat_chunk_choice_value(choice, index);
}
}
}

/// Request body for POST /v1/chat/completions
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
90 changes: 90 additions & 0 deletions src/strict/schemas/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,98 @@
//! sanitized (unknown fields stripped, model field rewritten).

use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

use super::chat_completions::{StopSequence, Usage};
use super::utils::ensure_field;

pub(crate) fn generated_completion_id() -> String {
format!("cmpl-{}", Uuid::new_v4())
}

fn normalize_completion_response_choice_value(value: &mut Value, fallback_index: usize) {
let Some(object) = value.as_object_mut() else {
return;
};

ensure_field(object, "index", || Value::from(fallback_index));
ensure_field(object, "logprobs", || Value::Null);
ensure_field(object, "finish_reason", || Value::Null);
}

fn normalize_completion_chunk_choice_value(value: &mut Value, fallback_index: usize) {
let Some(object) = value.as_object_mut() else {
return;
};

ensure_field(object, "text", || Value::String(String::new()));
ensure_field(object, "index", || Value::from(fallback_index));
ensure_field(object, "logprobs", || Value::Null);
ensure_field(object, "finish_reason", || Value::Null);
Comment thread
pjb157 marked this conversation as resolved.
Comment thread
pjb157 marked this conversation as resolved.
}

/// Backfill omitted non-critical legacy completion response fields during
/// strict sanitization. Kept out of serde defaults so only provider payloads
/// are relaxed.
pub(crate) fn normalize_completion_response_value(value: &mut Value, fallback_model: &str) {
let Some(object) = value.as_object_mut() else {
return;
};

if !object.contains_key("choices") {
return;
}

ensure_field(object, "id", || Value::String(generated_completion_id()));
ensure_field(object, "object", || {
Value::String("text_completion".to_string())
});
ensure_field(object, "created", || Value::from(0));
ensure_field(object, "model", || {
Value::String(fallback_model.to_string())
});
ensure_field(object, "usage", || Value::Null);
ensure_field(object, "system_fingerprint", || Value::Null);

if let Some(choices) = object.get_mut("choices").and_then(Value::as_array_mut) {
for (index, choice) in choices.iter_mut().enumerate() {
normalize_completion_response_choice_value(choice, index);
}
}
}

/// Backfill omitted non-critical legacy completion chunk fields during strict
/// streaming sanitization.
pub(crate) fn normalize_completion_chunk_value(
value: &mut Value,
fallback_model: &str,
fallback_id: &str,
) {
let Some(object) = value.as_object_mut() else {
return;
};

if !object.contains_key("choices") {
return;
}

ensure_field(object, "id", || Value::String(fallback_id.to_string()));
ensure_field(object, "object", || {
Value::String("text_completion".to_string())
});
ensure_field(object, "created", || Value::from(0));
ensure_field(object, "model", || {
Value::String(fallback_model.to_string())
});
ensure_field(object, "usage", || Value::Null);
Comment thread
pjb157 marked this conversation as resolved.

if let Some(choices) = object.get_mut("choices").and_then(Value::as_array_mut) {
for (index, choice) in choices.iter_mut().enumerate() {
normalize_completion_chunk_choice_value(choice, index);
}
}
}

/// Prompt input — matches the OpenAI spec `oneOf`: string | string[] | integer[] | integer[][]
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
1 change: 1 addition & 0 deletions src/strict/schemas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ pub mod chat_completions;
pub mod completions;
pub mod embeddings;
pub mod responses;
pub mod utils;
137 changes: 137 additions & 0 deletions src/strict/schemas/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,143 @@
//! See: https://www.openresponses.org/specification

use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

use super::utils::ensure_field;

pub(crate) fn generated_response_id() -> String {
format!("resp_{}", Uuid::new_v4())
}

fn response_status_for_event_type(event_type: &str) -> &'static str {
match event_type {
"response.created" | "response.in_progress" => "in_progress",
"response.completed" => "completed",
"response.failed" => "failed",
"response.incomplete" => "incomplete",
"response.cancelled" | "response.canceled" => "cancelled",
_ => "completed",
}
}

fn backfill_responses_response_fields(
object: &mut serde_json::Map<String, Value>,
fallback_model: &str,
fallback_response_id: &str,
) {
ensure_field(object, "id", || {
Value::String(fallback_response_id.to_string())
});
ensure_field(object, "object", || Value::String("response".to_string()));
ensure_field(object, "created_at", || Value::from(0));
ensure_field(object, "completed_at", || Value::Null);
ensure_field(object, "status", || Value::String("completed".to_string()));
ensure_field(object, "incomplete_details", || Value::Null);
ensure_field(object, "model", || {
Value::String(fallback_model.to_string())
});
ensure_field(object, "previous_response_id", || Value::Null);
ensure_field(object, "instructions", || Value::Null);
ensure_field(object, "output", || Value::Array(Vec::new()));
ensure_field(object, "error", || Value::Null);
ensure_field(object, "tools", || Value::Array(Vec::new()));
ensure_field(object, "tool_choice", || Value::String("auto".to_string()));
ensure_field(object, "truncation", || {
Value::String("disabled".to_string())
});
ensure_field(object, "parallel_tool_calls", || Value::Bool(true));
ensure_field(object, "text", || {
serde_json::json!({
"format": {
"type": "text"
}
})
});
ensure_field(object, "top_p", || Value::from(1.0));
ensure_field(object, "presence_penalty", || Value::from(0.0));
ensure_field(object, "frequency_penalty", || Value::from(0.0));
ensure_field(object, "top_logprobs", || Value::from(0));
ensure_field(object, "temperature", || Value::from(1.0));
ensure_field(object, "reasoning", || Value::Null);
ensure_field(object, "usage", || Value::Null);
ensure_field(object, "max_output_tokens", || Value::Null);
ensure_field(object, "max_tool_calls", || Value::Null);
ensure_field(object, "store", || Value::Bool(false));
ensure_field(object, "background", || Value::Bool(false));
ensure_field(object, "service_tier", || {
Value::String("default".to_string())
});
ensure_field(object, "metadata", || Value::Null);
ensure_field(object, "safety_identifier", || Value::Null);
ensure_field(object, "prompt_cache_key", || Value::Null);
}

/// Fill in omitted non-critical fields so provider Responses payloads can still
/// round-trip through the strict schema without discarding successful generations.
///
/// This is intentionally separate from serde defaults on `ResponsesResponse`.
/// The struct is used as a strict schema in multiple contexts, and broad serde
/// defaults would silently relax every deserialize path, including internal
/// loads from the response store. We only want that leniency when sanitizing
/// third-party provider payloads for client-facing strict mode.
pub(crate) fn normalize_responses_response_value(value: &mut Value, fallback_model: &str) {
let Some(object) = value.as_object_mut() else {
return;
};

// Only coerce payloads that already look like Responses API success bodies.
if !object.contains_key("output") {
return;
}

let fallback_response_id = generated_response_id();
backfill_responses_response_fields(object, fallback_model, &fallback_response_id);
}

/// Normalize a Responses streaming event, backfilling a missing nested response
/// snapshot with schema-valid defaults when the provider omits bookkeeping fields.
///
/// Streaming needs a dedicated normalizer because some defaults depend on the
/// SSE event type itself, notably `status`.
pub(crate) fn normalize_responses_streaming_event_value(
value: &mut Value,
fallback_model: &str,
fallback_response_id: &str,
) {
let Some(object) = value.as_object_mut() else {
return;
};

let event_type = object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();

if let Some(response) = object.get_mut("response") {
let missing_status = response
.as_object()
.map(|response_object| !response_object.contains_key("status"))
.unwrap_or(false);

if let Some(response_object) = response.as_object_mut() {
if missing_status {
response_object.insert(
"status".to_string(),
Value::String(response_status_for_event_type(&event_type).to_string()),
);
}

// Streaming snapshots like response.created may legitimately omit output.
backfill_responses_response_fields(
response_object,
fallback_model,
fallback_response_id,
);
}
}
}

/// Request body for POST /v1/responses
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
Loading