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
44 changes: 43 additions & 1 deletion crates/protocols/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,16 @@ pub struct JsonSchemaFormat {
// Streaming
// ============================================================================

#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, schemars::JsonSchema)]
pub struct StreamOptions {
/// Chat Completions / Completions: include usage block at end of stream.
#[serde(skip_serializing_if = "Option::is_none")]
pub include_usage: Option<bool>,

/// Responses API: add random chars on `obfuscation` field of delta events
/// to normalize payload sizes. Defaults to `true` upstream when absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub include_obfuscation: Option<bool>,
}

#[serde_with::skip_serializing_none]
Expand Down Expand Up @@ -717,6 +723,42 @@ pub enum Detail {
Auto,
}

// ============================================================================
// Responses API: prompt-cache retention & context management
// ============================================================================

/// Retention policy for prompt-cache entries on the Responses API.
///
/// Spec: `prompt_cache_retention: "in-memory" | "24h"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub enum PromptCacheRetention {
#[serde(rename = "in-memory")]
InMemory,
#[serde(rename = "24h")]
Duration24h,
}

/// A single entry in the Responses API `context_management` array.
///
/// Spec: each entry has `type` (currently only `"compaction"`) and an optional
/// `compact_threshold` token count.
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ContextManagementEntry {
#[serde(rename = "type")]
pub r#type: ContextManagementType,
pub compact_threshold: Option<u32>,
}

/// Type tag for [`ContextManagementEntry`]. Currently only `compaction` is
/// defined by the spec; the enum is kept small so unknown values serde-fail
/// (consistent with P5's fail-fast direction).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ContextManagementType {
Compaction,
}

#[cfg(test)]
mod tests {
use serde::Deserialize;
Expand Down
180 changes: 178 additions & 2 deletions crates/protocols/src/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ use validator::{Validate, ValidationError};

use super::{
common::{
default_true, validate_stop, ChatLogProbs, Function, GenerationRequest,
PromptTokenUsageInfo, StringOrArray, ToolChoice, ToolChoiceValue, ToolReference, UsageInfo,
default_true, validate_stop, ChatLogProbs, ContextManagementEntry, Function,
GenerationRequest, PromptCacheRetention, PromptTokenUsageInfo, ResponsePrompt,
StreamOptions, StringOrArray, ToolChoice, ToolChoiceValue, ToolReference, UsageInfo,
},
sampling_params::{validate_top_k_value, validate_top_p_value},
};
Expand Down Expand Up @@ -931,6 +932,38 @@ pub struct ResponsesRequest {
#[validate(custom(function = "validate_stop"))]
pub stop: Option<StringOrArray>,

/// Reference to a prompt template and its variables.
/// Spec: body param `prompt` (ResponsePrompt).
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<ResponsePrompt>,

/// Stable cache key used by upstream to share prompt-prefix caches across
/// requests. Spec: body param `prompt_cache_key` (replaces `user`).
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,

/// Retention policy for prompt-cache entries.
/// Spec: body param `prompt_cache_retention` (`"in-memory"` | `"24h"`).
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_retention: Option<PromptCacheRetention>,

/// Stable user identifier for policy/abuse detection (max 64 chars on the
/// spec, but we do not enforce length here — routers may pass through).
/// Spec: body param `safety_identifier` (replaces `user` on request side).
#[serde(skip_serializing_if = "Option::is_none")]
pub safety_identifier: Option<String>,

/// Streaming-only options. Spec: body param `stream_options`.
/// On the Responses API the only documented field is `include_obfuscation`.
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
Comment on lines +956 to +959

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce stream requirement for Responses stream_options

The new stream_options field is documented as streaming-only, but no cross-parameter validation was added for Responses requests. As a result, requests with stream: false and stream_options set are accepted and then silently dropped during chat conversion, which differs from the explicit validation behavior already enforced in Chat/Completions. Adding a stream_options-requires-stream check in validate_responses_cross_parameters would prevent this silent no-op.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching. This is legitimate validation hygiene, but cross-parameter validation is deliberately out of P2's schema-only scope — the P-group tasks close wire-format gaps, while semantic validation (including cross-param rules like "stream_options requires stream=true") belongs to a separate validation pass that hasn't been carved as an audit task yet. Filing as a follow-up rather than expanding P2 mid-flight.


/// Per-request context-management configuration.
/// Spec: body param `context_management` — array of entries describing how
/// the upstream should compact context for this request.
#[serde(skip_serializing_if = "Option::is_none")]
pub context_management: Option<Vec<ContextManagementEntry>>,

/// Top-k sampling parameter (SGLang extension)
#[serde(default = "default_top_k")]
#[validate(custom(function = "validate_top_k_value"))]
Expand Down Expand Up @@ -985,6 +1018,12 @@ impl Default for ResponsesRequest {
frequency_penalty: None,
presence_penalty: None,
stop: None,
prompt: None,
prompt_cache_key: None,
prompt_cache_retention: None,
safety_identifier: None,
stream_options: None,
context_management: None,
top_k: default_top_k(),
min_p: 0.0,
repetition_penalty: default_repetition_penalty(),
Expand Down Expand Up @@ -1966,4 +2005,141 @@ mod tests {
let serialized = serde_json::to_value(&tool).expect("file_search tool should serialize");
assert_eq!(serialized, payload);
}

// ------------------------------------------------------------------
// P2: new top-level ResponsesRequest fields
// ------------------------------------------------------------------

/// Acceptance: the six new top-level fields deserialize and re-serialize
/// without loss (`prompt`, `prompt_cache_key`, `prompt_cache_retention`,
/// `safety_identifier`, `stream_options`, `context_management`).
#[test]
fn test_responses_request_new_top_level_fields_round_trip() {
let payload = json!({
"model": "gpt-5.4",
"input": "hello",
"prompt": {
"id": "pmpt_abc",
"variables": {
"name": "ada",
"picture": {
"type": "input_image",
"image_url": "https://example.com/pic.png",
"detail": "high"
}
},
"version": "1"
},
"prompt_cache_key": "pck-123",
"prompt_cache_retention": "24h",
"safety_identifier": "sid-123",
"stream_options": { "include_obfuscation": false },
"context_management": [{
"type": "compaction",
"compact_threshold": 4096
}]
});

let request: ResponsesRequest =
serde_json::from_value(payload.clone()).expect("request should deserialize");

assert!(request.prompt.is_some());
assert_eq!(
request.prompt.as_ref().map(|p| p.id.as_str()),
Some("pmpt_abc")
);
assert_eq!(
request.prompt.as_ref().and_then(|p| p.version.as_deref()),
Some("1")
);
assert_eq!(request.prompt_cache_key.as_deref(), Some("pck-123"));
assert_eq!(
request.prompt_cache_retention,
Some(PromptCacheRetention::Duration24h)
);
assert_eq!(request.safety_identifier.as_deref(), Some("sid-123"));
assert_eq!(
request
.stream_options
.as_ref()
.and_then(|s| s.include_obfuscation),
Some(false)
);
let ctx = request
.context_management
.as_ref()
.expect("context_management must round-trip");
assert_eq!(ctx.len(), 1);
assert_eq!(
ctx[0].r#type,
crate::common::ContextManagementType::Compaction
);
assert_eq!(ctx[0].compact_threshold, Some(4096));

// Re-serialize and confirm the wire form matches the inputs.
let reserialized = serde_json::to_value(&request).expect("should serialize");
assert_eq!(reserialized["prompt"]["id"], "pmpt_abc");
assert_eq!(reserialized["prompt_cache_key"], "pck-123");
assert_eq!(reserialized["prompt_cache_retention"], "24h");
assert_eq!(reserialized["safety_identifier"], "sid-123");
assert_eq!(reserialized["stream_options"]["include_obfuscation"], false);
assert_eq!(reserialized["context_management"][0]["type"], "compaction");
assert_eq!(
reserialized["context_management"][0]["compact_threshold"],
4096
);
}

/// `prompt_cache_retention` accepts the other spec value and serializes
/// back with the hyphenated rename.
#[test]
fn test_prompt_cache_retention_in_memory_round_trip() {
let request: ResponsesRequest = serde_json::from_value(json!({
"model": "gpt-5.4",
"input": "hello",
"prompt_cache_retention": "in-memory"
}))
.expect("should deserialize");

assert_eq!(
request.prompt_cache_retention,
Some(PromptCacheRetention::InMemory)
);

let reserialized = serde_json::to_value(&request).expect("should serialize");
assert_eq!(reserialized["prompt_cache_retention"], "in-memory");
}

/// Absent fields must stay absent on the wire (no `"prompt": null` etc.),
/// matching every other `Option<_>` field on `ResponsesRequest`.
#[test]
fn test_responses_request_new_fields_omitted_when_absent() {
let request: ResponsesRequest = serde_json::from_value(json!({
"model": "gpt-5.4",
"input": "hello"
}))
.expect("should deserialize");

assert!(request.prompt.is_none());
assert!(request.prompt_cache_key.is_none());
assert!(request.prompt_cache_retention.is_none());
assert!(request.safety_identifier.is_none());
assert!(request.stream_options.is_none());
assert!(request.context_management.is_none());

let serialized = serde_json::to_value(&request).expect("should serialize");
for key in [
"prompt",
"prompt_cache_key",
"prompt_cache_retention",
"safety_identifier",
"stream_options",
"context_management",
] {
assert!(
serialized.get(key).is_none(),
"field {key} should be skipped when absent"
);
}
}
Comment thread
slin1237 marked this conversation as resolved.
}
10 changes: 10 additions & 0 deletions model_gateway/src/routers/grpc/regular/responses/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,16 @@ pub(super) fn build_next_request(
frequency_penalty: current_request.frequency_penalty,
presence_penalty: current_request.presence_penalty,
stop: current_request.stop,
// Responses API top-level fields (P2): propagate per-request knobs so
// multi-turn tool-loop continuations keep the same prompt template,
// cache key, safety identifier, streaming options, and context-
// management config as the original request.
prompt: current_request.prompt,
prompt_cache_key: current_request.prompt_cache_key,
prompt_cache_retention: current_request.prompt_cache_retention,
safety_identifier: current_request.safety_identifier,
stream_options: current_request.stream_options,
context_management: current_request.context_management,
top_k: current_request.top_k,
min_p: current_request.min_p,
repetition_penalty: current_request.repetition_penalty,
Expand Down
79 changes: 73 additions & 6 deletions model_gateway/src/routers/grpc/regular/responses/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@

use openai_protocol::{
chat::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage, MessageContent},
common::{
FunctionCallResponse, JsonSchemaFormat, ResponseFormat, StreamOptions, ToolCall, UsageInfo,
},
common::{FunctionCallResponse, JsonSchemaFormat, ResponseFormat, ToolCall, UsageInfo},
responses::{
ResponseContentPart, ResponseInput, ResponseInputOutputItem, ResponseOutputItem,
ResponseReasoningContent::ReasoningText, ResponseStatus, ResponsesRequest,
Expand Down Expand Up @@ -184,10 +182,15 @@ pub(crate) fn responses_to_chat(req: &ResponsesRequest) -> Result<ChatCompletion
temperature: req.temperature,
max_completion_tokens: req.max_output_tokens,
stream: is_streaming,
// Preserve caller-provided stream_options (e.g. `include_obfuscation: false`
// on the Responses API) and only default `include_usage` when the caller
// did not set it. Non-streaming requests intentionally drop stream_options.
stream_options: if is_streaming {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward safety and cache IDs in Responses conversion

When building ChatCompletionRequest in responses_to_chat, the new ResponsesRequest fields prompt_cache_key and safety_identifier are never copied, so they always fall back to None via ..Default::default(). In the regular gRPC Responses flow, requests that set either field will deserialize successfully but the backend never receives them, so prompt-cache routing and safety attribution are silently dropped.

Useful? React with 👍 / 👎.

Some(StreamOptions {
include_usage: Some(true),
})
let mut opts = req.stream_options.clone().unwrap_or_default();
if opts.include_usage.is_none() {
opts.include_usage = Some(true);
}
Some(opts)
} else {
None
},
Expand Down Expand Up @@ -372,6 +375,8 @@ pub(crate) fn chat_to_responses(

#[cfg(test)]
mod tests {
use openai_protocol::common::StreamOptions;

use super::*;

#[test]
Expand Down Expand Up @@ -433,4 +438,66 @@ mod tests {
let result = responses_to_chat(&req);
assert!(result.is_ok());
}

#[test]
fn test_stream_options_include_obfuscation_roundtrip() {
// Regression: ensure caller-provided stream_options (e.g. `include_obfuscation`)
// are preserved through the Responses → Chat conversion when streaming.
let req = ResponsesRequest {
input: ResponseInput::Text("hi".to_string()),
stream: Some(true),
stream_options: Some(StreamOptions {
include_usage: None,
include_obfuscation: Some(false),
}),
..Default::default()
};

let chat_req = responses_to_chat(&req).unwrap();
assert!(chat_req.stream);
let opts = chat_req
.stream_options
.expect("stream_options populated when streaming");
// Caller-provided value is preserved verbatim.
assert_eq!(opts.include_obfuscation, Some(false));
// include_usage defaults to true when absent so downstream consumers
// still emit the usage block at end-of-stream.
assert_eq!(opts.include_usage, Some(true));
}

#[test]
fn test_stream_options_caller_include_usage_preserved() {
// Caller-set `include_usage` must not be clobbered by the conversion layer.
let req = ResponsesRequest {
input: ResponseInput::Text("hi".to_string()),
stream: Some(true),
stream_options: Some(StreamOptions {
include_usage: Some(false),
include_obfuscation: Some(true),
}),
..Default::default()
};

let opts = responses_to_chat(&req).unwrap().stream_options.unwrap();
assert_eq!(opts.include_usage, Some(false));
assert_eq!(opts.include_obfuscation, Some(true));
}

#[test]
fn test_stream_options_non_streaming_dropped() {
// stream=false must produce None stream_options even if caller set it.
let req = ResponsesRequest {
input: ResponseInput::Text("hi".to_string()),
stream: Some(false),
stream_options: Some(StreamOptions {
include_usage: Some(true),
include_obfuscation: Some(false),
}),
..Default::default()
};

let chat_req = responses_to_chat(&req).unwrap();
assert!(!chat_req.stream);
assert!(chat_req.stream_options.is_none());
}
}
Loading
Loading