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
27 changes: 16 additions & 11 deletions crates/adaptive/src/acg/request_surfaces/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ pub(crate) trait RequestSurfaceApplier: Send + Sync {
}

impl RequestSurface {
fn from_provider_surface(surface: ProviderSurface) -> Self {
fn from_provider_surface(surface: ProviderSurface) -> Option<Self> {
match surface {
ProviderSurface::OpenAIChat => Self::OpenAIChat,
ProviderSurface::OpenAIResponses => Self::OpenAIResponses,
ProviderSurface::AnthropicMessages => Self::AnthropicMessages,
ProviderSurface::OpenAIChat => Some(Self::OpenAIChat),
ProviderSurface::OpenAIResponses => Some(Self::OpenAIResponses),
ProviderSurface::AnthropicMessages => Some(Self::AnthropicMessages),
// Gemini generateContent ACG request editing is intentionally unsupported.
ProviderSurface::GeminiGenerateContent => None,
}
}

Expand Down Expand Up @@ -90,13 +92,16 @@ impl RequestSurface {
pub(crate) fn resolve_request_surface_from_request(
request: &LlmRequest,
) -> crate::acg::Result<RequestSurface> {
detect_request_surface(&request.content)
.map(RequestSurface::from_provider_surface)
.ok_or_else(|| {
crate::acg::AcgError::Internal(
"unable to resolve request surface from request shape".to_string(),
)
})
let Some(surface) = detect_request_surface(&request.content) else {
return Err(crate::acg::AcgError::Internal(
"unable to resolve request surface from request shape".to_string(),
));
};
RequestSurface::from_provider_surface(surface).ok_or_else(|| {
crate::acg::AcgError::Internal(format!(
"resolved request surface {surface:?} does not have an ACG applier"
))
})
}

#[cfg_attr(not(test), allow(dead_code))]
Expand Down
145 changes: 145 additions & 0 deletions crates/adaptive/src/response_cache/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,28 @@ fn lossy_request_shape(surface: ProviderSurface, content: &Json) -> bool {
.is_some_and(|blocks| blocks.iter().any(lossy_system_block))
}
ProviderSurface::OpenAIResponses => false,
ProviderSurface::GeminiGenerateContent => {
object
.get("generationConfig")
.is_some_and(|generation_config| {
let Some(generation_config) = generation_config.as_object() else {
return true;
};
generation_config.keys().any(|key| {
!matches!(
key.as_str(),
"temperature" | "topP" | "maxOutputTokens" | "stopSequences"
)
})
})
|| object
.get("systemInstruction")
.is_some_and(lossy_gemini_system_instruction)
|| object
.get("contents")
.and_then(Json::as_array)
.is_some_and(|items| items.iter().any(lossy_gemini_content_item))
}
}
}

Expand All @@ -362,6 +384,129 @@ fn lossy_system_block(block: &Json) -> bool {
.any(|key| !matches!(key.as_str(), "type" | "text" | "cache_control"))
}

/// Whether a Gemini `systemInstruction` would lose detail in the normalized
/// request key. The Gemini codec flattens it to a single system text string, so
/// only one non-empty plain text part without sibling fields is lossless.
fn lossy_gemini_system_instruction(value: &Json) -> bool {
let Some(object) = value.as_object() else {
return true;
};
if object.keys().any(|key| key != "parts") {
return true;
}
let Some(parts) = object.get("parts").and_then(Json::as_array) else {
return true;
};
let [part] = parts.as_slice() else {
return true;
};
let Some(part_object) = part.as_object() else {
return true;
};
if part_object.len() != 1 {
return true;
}
part_object
.get("text")
.and_then(Json::as_str)
.is_none_or(str::is_empty)
}

fn lossy_gemini_content_item(item: &Json) -> bool {
let Some(object) = item.as_object() else {
return true;
};
if object
.keys()
.any(|key| !matches!(key.as_str(), "role" | "parts"))
{
return true;
}
let Some(parts) = object.get("parts").and_then(Json::as_array) else {
return true;
};

let mut plain_text_parts = 0usize;
for part in parts {
if lossy_gemini_part(part, &mut plain_text_parts) {
return true;
}
}
plain_text_parts > 1
}

fn lossy_gemini_part(part: &Json, plain_text_parts: &mut usize) -> bool {
let Some(object) = part.as_object() else {
return true;
};
if object.get("thought").and_then(Json::as_bool) == Some(true) {
return true;
}
let data_keys = object
.keys()
.filter(|key| is_gemini_part_data_key(key))
.collect::<Vec<_>>();
if data_keys.len() > 1 {
return true;
}
let Some(data_key) = data_keys.first().map(|key| key.as_str()) else {
return true;
};
match data_key {
"text" => {
if !object.get("text").is_some_and(Json::is_string) {
return true;
}
if object.len() == 1 {
*plain_text_parts += 1;
}
false
}
"functionCall" => {
object.keys().any(|key| key != "functionCall")
|| match object.get("functionCall").and_then(Json::as_object) {
Some(fc) => {
fc.keys()
.any(|key| !matches!(key.as_str(), "name" | "id" | "args"))
|| fc.get("args").is_some_and(|args| !args.is_object())
}
None => true,
}
}
"functionResponse" => {
object.keys().any(|key| key != "functionResponse")
|| match object.get("functionResponse").and_then(Json::as_object) {
Some(fr) => {
fr.keys().any(|key| {
!matches!(key.as_str(), "id" | "name" | "response" | "parts")
}) || match (
fr.get("id").and_then(Json::as_str),
fr.get("name").and_then(Json::as_str),
) {
(Some(id), Some(name)) => id != name,
_ => false,
}
}
None => true,
}
}
_ => false,
}
}

fn is_gemini_part_data_key(key: &str) -> bool {
matches!(
key,
"text"
| "inlineData"
| "fileData"
| "functionCall"
| "functionResponse"
| "executableCode"
| "codeExecutionResult"
)
}

/// Whether the raw request body carries a non-empty `messages` (chat) or `input`
/// (Responses) array — the content a decode is expected to preserve.
fn raw_has_messages(request: &LlmRequest) -> bool {
Expand Down
10 changes: 8 additions & 2 deletions crates/adaptive/src/response_cache/replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ use serde_json::{Map, Value as Json, json};
///
/// A strict streaming client parses only its provider's wire chunks (Anthropic
/// `message_start → content_block_delta → … → message_stop`, OpenAI Chat
/// `chat.completion.chunk` deltas, OpenAI Responses lifecycle events) — replaying
/// the aggregate as one frame breaks such clients even though the body is correct.
/// `chat.completion.chunk` deltas, OpenAI Responses lifecycle events, or Gemini
/// `GenerateContentResponse` chunks) — replaying the aggregate as one frame
/// breaks such clients even though the body is correct.
/// The surface is detected from the stored aggregate's own shape (the codec
/// finalizer's output, or a buffered body of the same shape), so no codec handle
/// is needed at the call sites. An unrecognized shape falls back to a
Expand Down Expand Up @@ -79,6 +80,11 @@ fn synthesize_replay_chunks(aggregate: &Json) -> Option<Vec<Json>> {
ProviderSurface::AnthropicMessages => synthesize_anthropic_chunks(aggregate),
ProviderSurface::OpenAIChat => synthesize_chat_chunks(aggregate),
ProviderSurface::OpenAIResponses => synthesize_responses_chunks(aggregate),
// Gemini streaming events are GenerateContentResponse objects; a stored
// aggregate is a valid single native chunk. `replay_is_lossy` still
// re-aggregates it and rejects shapes the streaming collector cannot
// preserve exactly.
ProviderSurface::GeminiGenerateContent => vec![aggregate.clone()],
})
}

Expand Down
12 changes: 12 additions & 0 deletions crates/adaptive/tests/unit/acg/request_surface_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,18 @@ fn test_request_surface_resolution_and_passthrough_support_cover_matrix() {
super::resolve_request_surface_from_request(&anthropic_request).unwrap(),
RequestSurface::AnthropicMessages
);
let gemini_request = LlmRequest {
headers: serde_json::Map::new(),
content: json!({
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
}),
};
assert!(matches!(
super::resolve_request_surface_from_request(&gemini_request),
Err(crate::acg::AcgError::Internal(message))
if message.contains("does not have an ACG applier")
));
assert!(matches!(
super::resolve_request_surface_from_request(&invalid_request),
Err(crate::acg::AcgError::Internal(message))
Expand Down
149 changes: 149 additions & 0 deletions crates/adaptive/tests/unit/response_cache/key_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,155 @@ fn chat_shaped_requests_key_on_the_detected_decode() {
);
}

#[test]
fn gemini_shaped_requests_key_on_the_detected_decode() {
let request = request(json!({
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {"temperature": 0.0}
}));
let (body, effective_codec) = resolved_body("gemini_generate_content", &request);
assert_eq!(effective_codec, Some("gemini_generate_content"));
assert_ne!(
body, request.content,
"Gemini requests must use the normalized decode when it is lossless"
);
}

#[test]
fn gemini_unmodeled_generation_config_fields_do_not_collide() {
let config = cache_all_config();
let mime_request = |response_mime_type: &str| {
request(json!({
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {
"temperature": 0.0,
"responseMimeType": response_mime_type
}
}))
};
let text = mime_request("text/plain");
let json = mime_request("application/json");
for request in [&text, &json] {
let (body, effective_codec) = resolved_body("gemini_generate_content", request);
assert_eq!(
effective_codec, None,
"unmodeled Gemini generationConfig fields must force raw fallback"
);
assert_eq!(
body, request.content,
"raw fallback must preserve answer-affecting Gemini generationConfig fields"
);
}
assert_ne!(
key_of("gemini_generate_content", &text, &config),
key_of("gemini_generate_content", &json, &config),
"distinct unmodeled Gemini generationConfig values must not share a cache key"
);
}

#[test]
fn gemini_malformed_generation_config_raw_keys() {
let request = request(json!({
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": "not an object"
}));
let (body, effective_codec) = resolved_body("gemini_generate_content", &request);
assert_eq!(
effective_codec, None,
"malformed Gemini generationConfig must force raw fallback"
);
assert_eq!(body, request.content);
}

#[test]
fn gemini_unmodeled_system_instruction_fields_do_not_collide() {
let config = cache_all_config();
let with_signature = |signature: &str| {
request(json!({
"model": "gemini-2.5-flash",
"systemInstruction": {
"parts": [{"text": "be concise", "thoughtSignature": signature}]
},
"contents": [{"role": "user", "parts": [{"text": "hi"}]}]
}))
};
let first = with_signature("sig_FIRST==");
let second = with_signature("sig_SECOND==");
for request in [&first, &second] {
let (body, effective_codec) = resolved_body("gemini_generate_content", request);
assert_eq!(
effective_codec, None,
"unmodeled Gemini systemInstruction fields must force raw fallback"
);
assert_eq!(
body, request.content,
"raw fallback must preserve systemInstruction metadata"
);
}
assert_ne!(
key_of("gemini_generate_content", &first, &config),
key_of("gemini_generate_content", &second, &config),
"distinct Gemini systemInstruction metadata must not share a cache key"
);
}

#[test]
fn gemini_function_call_part_metadata_forces_raw_keying() {
let request = request(json!({
"model": "gemini-2.5-flash",
"contents": [{
"role": "model",
"parts": [{
"functionCall": {"id": "call_1", "name": "lookup", "args": {"q": "x"}},
"thoughtSignature": "sig_CALL=="
}]
}]
}));
let (body, effective_codec) = resolved_body("gemini_generate_content", &request);
assert_eq!(
effective_codec, None,
"functionCall part metadata is provider-native and must raw-key"
);
assert_eq!(body, request.content);
}

#[test]
fn gemini_function_response_name_differences_do_not_collide() {
let config = cache_all_config();
let response_with_name = |name: &str| {
request(json!({
"model": "gemini-2.5-flash",
"contents": [{
"role": "user",
"parts": [{
"functionResponse": {
"id": "call_1",
"name": name,
"response": {"ok": true}
}
}]
}]
}))
};
let first = response_with_name("lookup");
let second = response_with_name("search");
for request in [&first, &second] {
let (body, effective_codec) = resolved_body("gemini_generate_content", request);
assert_eq!(
effective_codec, None,
"Gemini functionResponse.name is native context and must raw-key when it differs from id"
);
assert_eq!(body, request.content);
}
assert_ne!(
key_of("gemini_generate_content", &first, &config),
key_of("gemini_generate_content", &second, &config)
);
}

#[test]
fn undetectable_shape_falls_back_to_raw_keying() {
// No `messages`/`input`/`system` top-level key: no surface detects, so
Expand Down
Loading
Loading