diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 22c663d..fb0e4e6 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -146,7 +146,7 @@ kbd { min-width: 18px; padding: 1px 5px; border: 1px solid var(--border); border .inspector-provider > span:last-child { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; } .inspector-actions { display: flex; align-items: center; gap: 12px; } .provider-mark { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 4px; background: var(--text); color: var(--surface); font-weight: 800; } -.provider-openai { background: #16705a; }.provider-anthropic { background: #9b542e; }.provider-google { background: #2d65a6; }.provider-mistral { background: #b56513; } +.provider-openai { background: #16705a; }.provider-anthropic { background: #9b542e; }.provider-gemini, .provider-google { background: #2d65a6; }.provider-mistral { background: #b56513; } .inspector-metrics { display: flex; align-items: center; gap: 18px; color: var(--text-tertiary); font-size: 10px; white-space: nowrap; } .inspector-metrics span { display: grid; gap: 2px; } .inspector-metrics b { color: var(--text-secondary); font-size: 11px; } diff --git a/crates/adapters/README.md b/crates/adapters/README.md index 35dadd7..14b8d6e 100644 --- a/crates/adapters/README.md +++ b/crates/adapters/README.md @@ -15,6 +15,8 @@ The OpenAI-compatible adapter currently covers: The Anthropic adapter covers Messages and legacy Complete requests, non-streaming JSON responses, and SSE response traces. SSE text, tool input fragments, cumulative usage, stop reasons, errors, and unknown future events retain source evidence. +The Gemini adapter covers v1/v1beta `generateContent` and `streamGenerateContent`, including system instructions, multimodal parts, function declarations/calls/results, generation options, JSON responses, SSE reconstruction, finish reasons, and usage metadata. + OpenAI-compatible support currently covers request reconstruction only. Response reconstruction is not declared in the capability matrix. Regenerate every golden declared by the capability matrix after an intentional mapping change: diff --git a/crates/adapters/src/gemini.rs b/crates/adapters/src/gemini.rs new file mode 100644 index 0000000..a48360e --- /dev/null +++ b/crates/adapters/src/gemini.rs @@ -0,0 +1,507 @@ +use std::collections::BTreeMap; + +use codeischeap_capture_ipc::CapturedBodyState; +use codeischeap_prompt_ir::{ + BodyState, Completeness, Evidence, EvidenceSource, GenerationOptions, Instruction, + InstructionRole, Message, MessageRole, PromptIr, PromptPart, ToolDefinition, +}; +use serde_json::{Map, Value, json}; + +use crate::gemini_response::parse_gemini_response; +use crate::model::{ + AdapterError, AdapterInput, AdapterOutput, ParseIssue, ParseIssueCode, PromptAdapter, +}; + +pub const GEMINI_ADAPTER_ID: &str = "gemini/v0.1"; + +#[derive(Debug, Clone, Copy, Default)] +pub struct GeminiAdapter; + +impl PromptAdapter for GeminiAdapter { + fn id(&self) -> &'static str { + GEMINI_ADAPTER_ID + } + + fn detect(&self, input: AdapterInput<'_>) -> Option { + (input.target_id == "gemini" && parse_path(&input.envelope.request.path).is_some()) + .then_some(1.0) + } + + fn parse(&self, input: AdapterInput<'_>) -> Result { + if input.envelope.request.body.state != CapturedBodyState::Json { + return Err(AdapterError::at( + ParseIssueCode::InvalidBody, + "/request/body", + )); + } + let body = input + .envelope + .request + .body + .content + .as_ref() + .and_then(Value::as_object) + .ok_or_else(|| { + AdapterError::at(ParseIssueCode::InvalidBody, "/request/body/content") + })?; + let path = parse_path(&input.envelope.request.path).ok_or_else(|| { + AdapterError::at(ParseIssueCode::UnsupportedOperation, "/request/path") + })?; + + let mut prompt = PromptIr::new(&input.envelope.capture_id, "gemini"); + prompt.provider.host = Some(input.envelope.request.host.clone()); + prompt.provider.confidence = Some(1.0); + prompt.operation = Some(path.operation.to_owned()); + prompt.model = Some(path.model.to_owned()); + prompt.generation = parse_generation(body.get("generationConfig")); + prompt.completeness = Completeness { + request_body: BodyState::Complete, + response_body: BodyState::Missing, + }; + prompt.vendor.insert( + "api_version".to_owned(), + Value::String(path.api_version.to_owned()), + ); + if path.streaming { + prompt.vendor.insert("stream".to_owned(), Value::Bool(true)); + } + copy_vendor_fields(body, &mut prompt.vendor); + + let mut issues = Vec::new(); + parse_system_instruction(body.get("systemInstruction"), &mut prompt, &mut issues); + parse_contents(body.get("contents"), &mut prompt, &mut issues); + prompt.tools = parse_tools(body.get("tools"), &mut issues); + parse_gemini_response( + input.envelope.outcome.as_ref(), + path.streaming, + &mut prompt, + &mut issues, + ); + + Ok(AdapterOutput { + prompt_ir: prompt, + issues, + }) + } +} + +struct GeminiPath<'a> { + api_version: &'a str, + model: &'a str, + operation: &'static str, + streaming: bool, +} + +fn parse_path(path: &str) -> Option> { + let path = path.strip_prefix('/')?; + let (api_version, rest) = path.split_once("/models/")?; + if !matches!(api_version, "v1" | "v1beta") { + return None; + } + let (model, method) = rest.rsplit_once(':')?; + if model.is_empty() || model.contains('/') { + return None; + } + let (operation, streaming) = match method { + "generateContent" => ("models.generate_content", false), + "streamGenerateContent" => ("models.stream_generate_content", true), + _ => return None, + }; + Some(GeminiPath { + api_version, + model, + operation, + streaming, + }) +} + +fn parse_system_instruction( + value: Option<&Value>, + prompt: &mut PromptIr, + issues: &mut Vec, +) { + let Some(value) = value else { + return; + }; + let Some(parts_value) = value.get("parts") else { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::MissingField, + "/systemInstruction/parts", + )); + return; + }; + let parts = parse_parts( + parts_value, + "/systemInstruction/parts", + "instruction_0", + issues, + ); + prompt.instructions.push(Instruction { + id: "instruction_0".to_owned(), + role: InstructionRole::System, + parts, + evidence: observed("/systemInstruction"), + }); +} + +fn parse_contents(value: Option<&Value>, prompt: &mut PromptIr, issues: &mut Vec) { + let Some(contents) = value.and_then(Value::as_array) else { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::MissingField, + "/contents", + )); + return; + }; + for (index, value) in contents.iter().enumerate() { + let pointer = format!("/contents/{index}"); + let Some(object) = value.as_object() else { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::InvalidField, + pointer, + )); + continue; + }; + let parts_value = object.get("parts").unwrap_or(&Value::Null); + let role = match object.get("role").and_then(Value::as_str) { + Some("model") => MessageRole::Assistant, + Some("function") => MessageRole::Tool, + Some("user") | None if all_function_responses(parts_value) => MessageRole::Tool, + Some("user") | None => MessageRole::User, + Some("system") => MessageRole::System, + _ => { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::InvalidField, + format!("{pointer}/role"), + )); + MessageRole::Unknown + } + }; + let id = format!("message_{index}"); + prompt.messages.push(Message { + id: id.clone(), + role, + parts: parse_parts(parts_value, &format!("{pointer}/parts"), &id, issues), + evidence: observed(pointer), + }); + } +} + +fn all_function_responses(parts: &Value) -> bool { + let Some(parts) = parts.as_array() else { + return false; + }; + !parts.is_empty() + && parts.iter().all(|part| { + part.get("functionResponse") + .or_else(|| part.get("function_response")) + .is_some() + }) +} + +fn parse_parts( + value: &Value, + pointer: &str, + id_prefix: &str, + issues: &mut Vec, +) -> Vec { + match value { + Value::Array(parts) => parts + .iter() + .enumerate() + .map(|(index, part)| { + parse_part( + part, + &format!("{pointer}/{index}"), + &format!("{id_prefix}_part_{index}"), + issues, + ) + }) + .collect(), + Value::Null => Vec::new(), + _ => vec![parse_part( + value, + pointer, + &format!("{id_prefix}_part_0"), + issues, + )], + } +} + +fn parse_part( + value: &Value, + pointer: &str, + fallback_id: &str, + issues: &mut Vec, +) -> PromptPart { + parse_part_with_evidence(value, fallback_id, observed(pointer), pointer, issues) +} + +pub(crate) fn parse_part_with_evidence( + value: &Value, + fallback_id: &str, + evidence: Evidence, + issue_path: &str, + issues: &mut Vec, +) -> PromptPart { + let Some(object) = value.as_object() else { + return PromptPart::Json { + id: fallback_id.to_owned(), + value: value.clone(), + evidence, + }; + }; + if let Some(text) = object.get("text").and_then(Value::as_str) { + return PromptPart::Text { + id: fallback_id.to_owned(), + text: text.to_owned(), + evidence, + }; + } + if let Some(data) = object + .get("inlineData") + .or_else(|| object.get("inline_data")) + .and_then(Value::as_object) + { + return media_part(data, "inline:base64", fallback_id, evidence); + } + if let Some(data) = object + .get("fileData") + .or_else(|| object.get("file_data")) + .and_then(Value::as_object) + { + let location = + string_field(data, &["fileUri", "file_uri"]).unwrap_or("unknown media location"); + return media_part(data, location, fallback_id, evidence); + } + if let Some(call) = object + .get("functionCall") + .or_else(|| object.get("function_call")) + .and_then(Value::as_object) + { + let name = call + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown_tool") + .to_owned(); + if call.get("name").and_then(Value::as_str).is_none() { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::MissingField, + format!("{issue_path}/functionCall/name"), + )); + } + return PromptPart::ToolUse { + id: call + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .unwrap_or(fallback_id) + .to_owned(), + name, + input: call.get("args").cloned().unwrap_or_else(|| json!({})), + evidence, + }; + } + if let Some(response) = object + .get("functionResponse") + .or_else(|| object.get("function_response")) + .and_then(Value::as_object) + { + let name = response + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown_tool"); + return PromptPart::ToolResult { + id: fallback_id.to_owned(), + tool_use_id: response + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map_or_else(|| format!("function:{name}"), str::to_owned), + value: response + .get("response") + .cloned() + .unwrap_or_else(|| json!({})), + evidence, + }; + } + if object.contains_key("executableCode") + || object.contains_key("codeExecutionResult") + || object.contains_key("thoughtSignature") + { + return PromptPart::Json { + id: fallback_id.to_owned(), + value: value.clone(), + evidence, + }; + } + + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::UnsupportedContent, + issue_path, + )); + PromptPart::Unknown { + id: fallback_id.to_owned(), + value: value.clone(), + evidence, + } +} + +fn media_part( + data: &Map, + location: &str, + id: &str, + evidence: Evidence, +) -> PromptPart { + let media_type = string_field(data, &["mimeType", "mime_type"]).map(str::to_owned); + match media_type.as_deref() { + Some(value) if value.starts_with("image/") => PromptPart::ImageRef { + id: id.to_owned(), + location: location.to_owned(), + media_type, + evidence, + }, + Some(value) if value.starts_with("audio/") => PromptPart::AudioRef { + id: id.to_owned(), + location: location.to_owned(), + media_type, + evidence, + }, + _ => PromptPart::FileRef { + id: id.to_owned(), + location: location.to_owned(), + media_type, + evidence, + }, + } +} + +fn parse_tools(value: Option<&Value>, issues: &mut Vec) -> Vec { + let Some(tools) = value.and_then(Value::as_array) else { + return Vec::new(); + }; + let mut definitions = Vec::new(); + for (tool_index, tool) in tools.iter().enumerate() { + let Some(declarations) = tool.get("functionDeclarations").and_then(Value::as_array) else { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::UnsupportedContent, + format!("/tools/{tool_index}"), + )); + continue; + }; + for (function_index, declaration) in declarations.iter().enumerate() { + let pointer = format!("/tools/{tool_index}/functionDeclarations/{function_index}"); + let Some(object) = declaration.as_object() else { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::InvalidField, + pointer, + )); + continue; + }; + let Some(name) = object.get("name").and_then(Value::as_str) else { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::MissingField, + format!("{pointer}/name"), + )); + continue; + }; + definitions.push(ToolDefinition { + id: format!("tool_{tool_index}_function_{function_index}"), + name: name.to_owned(), + description: object + .get("description") + .and_then(Value::as_str) + .map(str::to_owned), + input_schema: object + .get("parametersJsonSchema") + .or_else(|| object.get("parameters")) + .cloned() + .unwrap_or_else(|| json!({})), + evidence: observed(pointer), + }); + } + } + definitions +} + +fn parse_generation(value: Option<&Value>) -> GenerationOptions { + let Some(config) = value.and_then(Value::as_object) else { + return GenerationOptions::default(); + }; + let stop = config + .get("stopSequences") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + let mut extra = BTreeMap::new(); + for key in [ + "topK", + "candidateCount", + "seed", + "responseMimeType", + "responseSchema", + "responseJsonSchema", + "presencePenalty", + "frequencyPenalty", + "thinkingConfig", + "mediaResolution", + "speechConfig", + ] { + if let Some(value) = config.get(key) { + extra.insert(key.to_owned(), value.clone()); + } + } + GenerationOptions { + temperature: config.get("temperature").and_then(Value::as_f64), + top_p: config.get("topP").and_then(Value::as_f64), + max_output_tokens: config.get("maxOutputTokens").and_then(Value::as_u64), + stop, + extra, + } +} + +fn copy_vendor_fields(body: &Map, vendor: &mut BTreeMap) { + for key in ["cachedContent", "safetySettings", "toolConfig", "labels"] { + if let Some(value) = body.get(key) { + vendor.insert(key.to_owned(), value.clone()); + } + } +} + +fn string_field<'a>(object: &'a Map, keys: &[&str]) -> Option<&'a str> { + keys.iter() + .find_map(|key| object.get(*key).and_then(Value::as_str)) +} + +fn observed(pointer: impl Into) -> Evidence { + Evidence::observed(EvidenceSource::JsonPointer { + pointer: pointer.into(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gemini_paths_extract_version_model_and_operation() { + let path = parse_path("/v1beta/models/gemini-2.5-pro:streamGenerateContent") + .expect("path must match"); + assert_eq!(path.api_version, "v1beta"); + assert_eq!(path.model, "gemini-2.5-pro"); + assert_eq!(path.operation, "models.stream_generate_content"); + assert!(path.streaming); + assert!(parse_path("/v1beta/models/gemini-2.5-pro:embedContent").is_none()); + } +} diff --git a/crates/adapters/src/gemini_response.rs b/crates/adapters/src/gemini_response.rs new file mode 100644 index 0000000..ce43230 --- /dev/null +++ b/crates/adapters/src/gemini_response.rs @@ -0,0 +1,437 @@ +use std::collections::BTreeMap; + +use codeischeap_capture_ipc::{ + CaptureOutcome, CapturedBodyState, CapturedResponse, ResponseCompleteness, +}; +use codeischeap_prompt_ir::{ + BodyState, Evidence, EvidenceSource, MessageRole, PromptIr, PromptPart, ResponseEvent, + ResponseTrace, +}; +use serde_json::{Map, Value}; + +use crate::gemini::{GEMINI_ADAPTER_ID, parse_part_with_evidence}; +use crate::model::{ParseIssue, ParseIssueCode}; + +pub(crate) fn parse_gemini_response( + outcome: Option<&CaptureOutcome>, + streaming: bool, + prompt: &mut PromptIr, + issues: &mut Vec, +) { + let Some(CaptureOutcome::Response(response)) = outcome else { + return; + }; + prompt.completeness.response_body = response_body_state(response); + match response.body.state { + CapturedBodyState::Json => { + let Some(value) = response.body.content.as_ref() else { + return; + }; + if let Some(chunks) = value.as_array() { + let parsed = build_chunk_trace(chunks.iter(), false, issues); + prompt.response = parsed.trace; + mark_incomplete_stream(parsed.terminal, prompt, issues); + } else if let Some(body) = value.as_object() { + prompt.response = Some(parse_json_response(body, issues)); + } else { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::InvalidBody, + "/outcome/result/body/content", + )); + } + } + CapturedBodyState::Text if streaming => { + let text = response + .body + .content + .as_ref() + .and_then(Value::as_str) + .unwrap_or_default(); + let parsed = parse_sse_response(text, issues); + prompt.response = parsed.trace; + mark_incomplete_stream(parsed.terminal, prompt, issues); + } + CapturedBodyState::Empty => {} + CapturedBodyState::Text + | CapturedBodyState::InvalidJson + | CapturedBodyState::InvalidUtf8 + | CapturedBodyState::Truncated + | CapturedBodyState::OmittedUnsupportedContentType => {} + } +} + +fn mark_incomplete_stream(terminal: bool, prompt: &mut PromptIr, issues: &mut Vec) { + if prompt.completeness.response_body == BodyState::Complete && !terminal { + prompt.completeness.response_body = BodyState::Partial; + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::MissingField, + "/response/events/finishReason", + )); + } +} + +fn response_body_state(response: &CapturedResponse) -> BodyState { + if response.completeness != ResponseCompleteness::Complete { + return BodyState::Partial; + } + match response.body.state { + CapturedBodyState::Empty | CapturedBodyState::Json | CapturedBodyState::Text => { + BodyState::Complete + } + CapturedBodyState::Truncated => BodyState::Partial, + CapturedBodyState::InvalidJson + | CapturedBodyState::InvalidUtf8 + | CapturedBodyState::OmittedUnsupportedContentType => BodyState::Unsupported, + } +} + +fn parse_json_response(body: &Map, issues: &mut Vec) -> ResponseTrace { + let root = "/outcome/result/body/content"; + if body.contains_key("error") { + return ResponseTrace { + id: None, + model: body + .get("modelVersion") + .and_then(Value::as_str) + .map(str::to_owned), + role: MessageRole::Unknown, + parts: Vec::new(), + stop_reason: None, + stop_sequence: None, + usage: value_map(body.get("usageMetadata")), + error: body.get("error").cloned(), + events: Vec::new(), + evidence: observed(root), + }; + } + let candidate = body + .get("candidates") + .and_then(Value::as_array) + .and_then(|candidates| candidates.first()); + let parts = candidate + .and_then(|candidate| candidate.get("content")) + .and_then(|content| content.get("parts")) + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .enumerate() + .map(|(index, part)| { + let pointer = format!("{root}/candidates/0/content/parts/{index}"); + parse_part_with_evidence( + part, + &format!("response_part_{index}"), + observed(&pointer), + &pointer, + issues, + ) + }) + .collect() + }) + .unwrap_or_else(|| { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::MissingField, + format!("{root}/candidates/0/content/parts"), + )); + Vec::new() + }); + ResponseTrace { + id: body + .get("responseId") + .and_then(Value::as_str) + .map(str::to_owned), + model: body + .get("modelVersion") + .and_then(Value::as_str) + .map(str::to_owned), + role: candidate + .and_then(|value| value.get("content")) + .and_then(|content| content.get("role")) + .and_then(Value::as_str) + .map(message_role) + .unwrap_or(MessageRole::Assistant), + parts, + stop_reason: candidate + .and_then(|value| value.get("finishReason")) + .and_then(Value::as_str) + .map(str::to_owned), + stop_sequence: None, + usage: value_map(body.get("usageMetadata")), + error: None, + events: Vec::new(), + evidence: observed(root), + } +} + +struct ParsedStream { + trace: Option, + terminal: bool, +} + +fn parse_sse_response(text: &str, issues: &mut Vec) -> ParsedStream { + let values = parse_sse_values(text, issues); + build_chunk_trace(values.iter(), true, issues) +} + +fn parse_sse_values(text: &str, issues: &mut Vec) -> Vec { + let mut values = Vec::new(); + let mut data = Vec::new(); + for raw_line in text.split('\n') { + let line = raw_line.trim_end_matches('\r'); + if line.is_empty() { + flush_sse_data(&mut values, &mut data, issues); + continue; + } + if line.starts_with(':') { + continue; + } + if let Some(value) = line.strip_prefix("data:") { + data.push(value.strip_prefix(' ').unwrap_or(value).to_owned()); + } + } + flush_sse_data(&mut values, &mut data, issues); + values +} + +fn flush_sse_data(values: &mut Vec, data: &mut Vec, issues: &mut Vec) { + if data.is_empty() { + return; + } + let index = values.len(); + let encoded = std::mem::take(data).join("\n"); + if encoded == "[DONE]" { + return; + } + match serde_json::from_str(&encoded) { + Ok(value) => values.push(value), + Err(_) => issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::InvalidStreamEvent, + format!("/response/events/{index}"), + )), + } +} + +#[derive(Default)] +struct StreamBuilder { + id: Option, + model: Option, + role: Option, + parts: BTreeMap, + stop_reason: Option, + usage: BTreeMap, + error: Option, + events: Vec, + first_evidence: Option, +} + +enum StreamPart { + Text { evidence: Evidence, text: String }, + Value { evidence: Evidence, value: Value }, +} + +fn build_chunk_trace<'a>( + chunks: impl Iterator, + stream_evidence_enabled: bool, + issues: &mut Vec, +) -> ParsedStream { + let mut builder = StreamBuilder::default(); + for (index, chunk) in chunks.enumerate() { + let event_index = u64::try_from(index).unwrap_or(u64::MAX); + let Some(body) = chunk.as_object() else { + issues.push(ParseIssue::at( + GEMINI_ADAPTER_ID, + ParseIssueCode::InvalidStreamEvent, + format!("/response/events/{index}"), + )); + continue; + }; + let event_evidence = if stream_evidence_enabled { + stream_evidence(event_index) + } else { + observed(format!("/outcome/result/body/content/{index}")) + }; + builder + .first_evidence + .get_or_insert_with(|| event_evidence.clone()); + builder.events.push(ResponseEvent { + index: event_index, + kind: if body.contains_key("error") { + "error" + } else { + "generate_content_chunk" + } + .to_owned(), + content_index: None, + delta_kind: None, + evidence: event_evidence, + }); + apply_chunk(&mut builder, body, event_index, stream_evidence_enabled); + } + if builder.events.is_empty() { + return ParsedStream { + trace: None, + terminal: false, + }; + } + let trace_evidence = builder + .first_evidence + .clone() + .unwrap_or_else(|| stream_evidence(0)); + let parts = std::mem::take(&mut builder.parts) + .into_iter() + .map(|(index, part)| match part { + StreamPart::Text { evidence, text } => PromptPart::Text { + id: format!("response_part_{index}"), + text, + evidence, + }, + StreamPart::Value { evidence, value } => parse_part_with_evidence( + &value, + &format!("response_part_{index}"), + evidence, + &format!("/response/events/{index}/parts/{index}"), + issues, + ), + }) + .collect(); + let terminal = builder.stop_reason.is_some() || builder.error.is_some(); + ParsedStream { + terminal, + trace: Some(ResponseTrace { + id: builder.id, + model: builder.model, + role: builder.role.unwrap_or(MessageRole::Assistant), + parts, + stop_reason: builder.stop_reason, + stop_sequence: None, + usage: builder.usage, + error: builder.error, + events: builder.events, + evidence: trace_evidence, + }), + } +} + +fn apply_chunk( + builder: &mut StreamBuilder, + body: &Map, + event_index: u64, + stream_evidence_enabled: bool, +) { + builder.id = body + .get("responseId") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| builder.id.take()); + builder.model = body + .get("modelVersion") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| builder.model.take()); + builder.usage.extend(value_map(body.get("usageMetadata"))); + if let Some(error) = body.get("error") { + builder.error = Some(error.clone()); + } + let Some(candidate) = body + .get("candidates") + .and_then(Value::as_array) + .and_then(|candidates| candidates.first()) + else { + return; + }; + if let Some(reason) = candidate.get("finishReason").and_then(Value::as_str) { + builder.stop_reason = Some(reason.to_owned()); + } + let Some(content) = candidate.get("content") else { + return; + }; + if let Some(role) = content.get("role").and_then(Value::as_str) { + builder.role = Some(message_role(role)); + } + let Some(parts) = content.get("parts").and_then(Value::as_array) else { + return; + }; + for (index, part) in parts.iter().enumerate() { + let evidence = if stream_evidence_enabled { + stream_evidence(event_index) + } else { + observed(format!( + "/outcome/result/body/content/{event_index}/candidates/0/content/parts/{index}" + )) + }; + if let Some(text) = part.get("text").and_then(Value::as_str) { + match builder + .parts + .entry(index) + .or_insert_with(|| StreamPart::Text { + evidence, + text: String::new(), + }) { + StreamPart::Text { text: output, .. } => output.push_str(text), + StreamPart::Value { .. } => {} + } + } else { + builder.parts.insert( + index, + StreamPart::Value { + evidence, + value: part.clone(), + }, + ); + } + } +} + +fn message_role(role: &str) -> MessageRole { + match role { + "model" => MessageRole::Assistant, + "user" => MessageRole::User, + "function" => MessageRole::Tool, + "system" => MessageRole::System, + _ => MessageRole::Unknown, + } +} + +fn value_map(value: Option<&Value>) -> BTreeMap { + value + .and_then(Value::as_object) + .map(|object| { + object + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + }) + .unwrap_or_default() +} + +fn observed(pointer: impl Into) -> Evidence { + Evidence::observed(EvidenceSource::JsonPointer { + pointer: pointer.into(), + }) +} + +fn stream_evidence(index: u64) -> Evidence { + Evidence::observed(EvidenceSource::StreamEvent { index }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn multiline_sse_data_is_parsed() { + let mut issues = Vec::new(); + let values = parse_sse_values( + ": keepalive\r\ndata: {\"candidates\":\r\ndata: []}\r\n\r\n", + &mut issues, + ); + assert_eq!(values, vec![json!({"candidates": []})]); + assert!(issues.is_empty()); + } +} diff --git a/crates/adapters/src/lib.rs b/crates/adapters/src/lib.rs index f0843b9..e27f848 100644 --- a/crates/adapters/src/lib.rs +++ b/crates/adapters/src/lib.rs @@ -2,11 +2,14 @@ mod anthropic; mod anthropic_response; +mod gemini; +mod gemini_response; mod model; mod openai; mod registry; pub use anthropic::{ANTHROPIC_ADAPTER_ID, AnthropicAdapter}; +pub use gemini::{GEMINI_ADAPTER_ID, GeminiAdapter}; pub use model::{ AdapterError, AdapterInput, AdapterOutput, ParseIssue, ParseIssueCode, ParseResult, PromptAdapter, diff --git a/crates/adapters/src/registry.rs b/crates/adapters/src/registry.rs index ecf6535..31927bc 100644 --- a/crates/adapters/src/registry.rs +++ b/crates/adapters/src/registry.rs @@ -5,6 +5,7 @@ use codeischeap_capture_policy::SanitizedCapture; use codeischeap_prompt_ir::Validate; use crate::anthropic::AnthropicAdapter; +use crate::gemini::GeminiAdapter; use crate::model::{AdapterInput, ParseIssue, ParseIssueCode, ParseResult, PromptAdapter}; use crate::openai::OpenAiAdapter; @@ -17,6 +18,7 @@ impl Default for AdapterRegistry { let mut registry = Self::new(); registry.register(OpenAiAdapter); registry.register(AnthropicAdapter); + registry.register(GeminiAdapter); registry } } diff --git a/crates/adapters/tests/fixtures/capability-matrix.json b/crates/adapters/tests/fixtures/capability-matrix.json index 8a5cef1..f88e38e 100644 --- a/crates/adapters/tests/fixtures/capability-matrix.json +++ b/crates/adapters/tests/fixtures/capability-matrix.json @@ -84,6 +84,36 @@ "capabilities": ["request.messages", "request.generation"] } ] + }, + { + "id": "gemini/v0.1", + "operations": ["models.generate_content", "models.stream_generate_content"], + "cases": [ + { + "id": "gemini-generate-content", + "capture": "gemini-generate-content-capture.json", + "golden": "gemini-generate-content-prompt-ir.json", + "operation": "models.generate_content", + "outcome": "parsed", + "capabilities": ["request.instructions", "request.messages", "request.multimodal.image", "request.tool_use", "request.tool_result", "request.tool_definitions", "request.generation", "response.json", "response.text", "response.tool_use", "response.usage", "response.stop_reason"] + }, + { + "id": "gemini-stream-generate-content", + "capture": "gemini-stream-content-capture.json", + "golden": "gemini-stream-content-prompt-ir.json", + "operation": "models.stream_generate_content", + "outcome": "parsed", + "capabilities": ["request.messages", "request.streaming_flag", "response.sse", "response.text", "response.usage", "response.stop_reason"] + }, + { + "id": "gemini-invalid-body", + "capture": "gemini-invalid-capture.json", + "operation": null, + "outcome": "raw_fallback", + "capabilities": ["fallback.invalid_body"], + "issues": ["invalid_body", "all_adapters_failed"] + } + ] } ] } diff --git a/crates/adapters/tests/fixtures/gemini-generate-content-capture.json b/crates/adapters/tests/fixtures/gemini-generate-content-capture.json new file mode 100644 index 0000000..e1fd1c3 --- /dev/null +++ b/crates/adapters/tests/fixtures/gemini-generate-content-capture.json @@ -0,0 +1,110 @@ +{ + "version": "0.1", + "capture_id": "gemini_generate_content_fixture", + "observed_at_unix_ms": 1784157000000, + "source": "gateway", + "request": { + "method": "POST", + "scheme": "https", + "host": "generativelanguage.googleapis.com", + "port": 443, + "path": "/v1beta/models/gemini-2.5-pro:generateContent", + "query": [], + "headers": [ + { "name": "content-type", "value": "application/json" }, + { "name": "x-goog-api-key", "value": "gemini-fixture-canary" } + ], + "body": { + "state": "json", + "content": { + "systemInstruction": { + "role": "system", + "parts": [{ "text": "Act as a precise repository maintainer." }] + }, + "contents": [ + { + "role": "user", + "parts": [ + { "text": "Inspect the screenshot and audio note." }, + { "inlineData": { "mimeType": "image/png", "data": "c3ludGhldGlj" } }, + { "fileData": { "mimeType": "audio/wav", "fileUri": "gs://fixtures/note.wav" } } + ] + }, + { + "role": "model", + "parts": [ + { "functionCall": { "id": "call_lookup", "name": "lookup_symbol", "args": { "name": "parse_request" } } } + ] + }, + { + "role": "user", + "parts": [ + { "functionResponse": { "id": "call_lookup", "name": "lookup_symbol", "response": { "file": "src/parser.rs", "line": 42 } } } + ] + } + ], + "tools": [ + { + "functionDeclarations": [ + { + "name": "lookup_symbol", + "description": "Locate a symbol in the repository.", + "parametersJsonSchema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + ] + } + ], + "generationConfig": { + "temperature": 0.2, + "topP": 0.9, + "topK": 20, + "maxOutputTokens": 2048, + "stopSequences": [""], + "responseMimeType": "application/json" + }, + "cachedContent": "cachedContents/repository-context", + "safetySettings": [{ "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_ONLY_HIGH" }], + "toolConfig": { "functionCallingConfig": { "mode": "AUTO" } } + } + } + }, + "outcome": { + "kind": "response", + "result": { + "status": 200, + "headers": [{ "name": "content-type", "value": "application/json" }], + "body": { + "state": "json", + "content": { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + { "text": "The parser needs a bounded fallback." }, + { "functionCall": { "id": "call_read", "name": "read_file", "args": { "path": "src/parser.rs" } } } + ] + }, + "finishReason": "STOP" + } + ], + "usageMetadata": { + "promptTokenCount": 148, + "candidatesTokenCount": 24, + "totalTokenCount": 172, + "cachedContentTokenCount": 64 + }, + "modelVersion": "gemini-2.5-pro", + "responseId": "gemini-response-fixture" + } + }, + "duration_ms": 86, + "completeness": "complete" + } + }, + "redactions": [] +} diff --git a/crates/adapters/tests/fixtures/gemini-generate-content-prompt-ir.json b/crates/adapters/tests/fixtures/gemini-generate-content-prompt-ir.json new file mode 100644 index 0000000..bb894c9 --- /dev/null +++ b/crates/adapters/tests/fixtures/gemini-generate-content-prompt-ir.json @@ -0,0 +1,268 @@ +{ + "ir_version": "0.1.0", + "request_id": "gemini_generate_content_fixture", + "provider": { + "id": "gemini", + "host": "generativelanguage.googleapis.com", + "confidence": 1.0 + }, + "operation": "models.generate_content", + "model": "gemini-2.5-pro", + "instructions": [ + { + "id": "instruction_0", + "role": "system", + "parts": [ + { + "kind": "text", + "id": "instruction_0_part_0", + "text": "Act as a precise repository maintainer.", + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/systemInstruction/parts/0" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/systemInstruction" + }, + "confidence": 1.0 + } + } + ], + "messages": [ + { + "id": "message_0", + "role": "user", + "parts": [ + { + "kind": "text", + "id": "message_0_part_0", + "text": "Inspect the screenshot and audio note.", + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/0/parts/0" + }, + "confidence": 1.0 + } + }, + { + "kind": "image_ref", + "id": "message_0_part_1", + "location": "inline:base64", + "media_type": "image/png", + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/0/parts/1" + }, + "confidence": 1.0 + } + }, + { + "kind": "audio_ref", + "id": "message_0_part_2", + "location": "gs://fixtures/note.wav", + "media_type": "audio/wav", + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/0/parts/2" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/0" + }, + "confidence": 1.0 + } + }, + { + "id": "message_1", + "role": "assistant", + "parts": [ + { + "kind": "tool_use", + "id": "call_lookup", + "name": "lookup_symbol", + "input": { + "name": "parse_request" + }, + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/1/parts/0" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/1" + }, + "confidence": 1.0 + } + }, + { + "id": "message_2", + "role": "tool", + "parts": [ + { + "kind": "tool_result", + "id": "message_2_part_0", + "tool_use_id": "call_lookup", + "value": { + "file": "src/parser.rs", + "line": 42 + }, + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/2/parts/0" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/2" + }, + "confidence": 1.0 + } + } + ], + "context": [], + "tools": [ + { + "id": "tool_0_function_0", + "name": "lookup_symbol", + "description": "Locate a symbol in the repository.", + "input_schema": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/tools/0/functionDeclarations/0" + }, + "confidence": 1.0 + } + } + ], + "generation": { + "temperature": 0.2, + "top_p": 0.9, + "max_output_tokens": 2048, + "stop": [ + "" + ], + "extra": { + "responseMimeType": "application/json", + "topK": 20 + } + }, + "vendor": { + "api_version": "v1beta", + "cachedContent": "cachedContents/repository-context", + "safetySettings": [ + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "toolConfig": { + "functionCallingConfig": { + "mode": "AUTO" + } + } + }, + "response": { + "id": "gemini-response-fixture", + "model": "gemini-2.5-pro", + "role": "assistant", + "parts": [ + { + "kind": "text", + "id": "response_part_0", + "text": "The parser needs a bounded fallback.", + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/outcome/result/body/content/candidates/0/content/parts/0" + }, + "confidence": 1.0 + } + }, + { + "kind": "tool_use", + "id": "call_read", + "name": "read_file", + "input": { + "path": "src/parser.rs" + }, + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/outcome/result/body/content/candidates/0/content/parts/1" + }, + "confidence": 1.0 + } + } + ], + "stop_reason": "STOP", + "usage": { + "cachedContentTokenCount": 64, + "candidatesTokenCount": 24, + "promptTokenCount": 148, + "totalTokenCount": 172 + }, + "events": [], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/outcome/result/body/content" + }, + "confidence": 1.0 + } + }, + "completeness": { + "request_body": "complete", + "response_body": "complete" + } +} diff --git a/crates/adapters/tests/fixtures/gemini-invalid-capture.json b/crates/adapters/tests/fixtures/gemini-invalid-capture.json new file mode 100644 index 0000000..ecf8337 --- /dev/null +++ b/crates/adapters/tests/fixtures/gemini-invalid-capture.json @@ -0,0 +1,17 @@ +{ + "version": "0.1", + "capture_id": "gemini_invalid_fixture", + "observed_at_unix_ms": 1784157002000, + "source": "gateway", + "request": { + "method": "POST", + "scheme": "https", + "host": "generativelanguage.googleapis.com", + "port": 443, + "path": "/v1beta/models/gemini-2.5-pro:generateContent", + "query": [], + "headers": [{ "name": "content-type", "value": "application/json" }], + "body": { "state": "invalid_json", "content": null } + }, + "redactions": [] +} diff --git a/crates/adapters/tests/fixtures/gemini-stream-content-capture.json b/crates/adapters/tests/fixtures/gemini-stream-content-capture.json new file mode 100644 index 0000000..87b98e3 --- /dev/null +++ b/crates/adapters/tests/fixtures/gemini-stream-content-capture.json @@ -0,0 +1,35 @@ +{ + "version": "0.1", + "capture_id": "gemini_stream_content_fixture", + "observed_at_unix_ms": 1784157001000, + "source": "mitmproxy", + "request": { + "method": "POST", + "scheme": "https", + "host": "generativelanguage.googleapis.com", + "port": 443, + "path": "/v1/models/gemini-2.5-flash:streamGenerateContent", + "query": [{ "name": "alt", "value": "sse" }], + "headers": [{ "name": "content-type", "value": "application/json" }], + "body": { + "state": "json", + "content": { + "contents": [{ "role": "user", "parts": [{ "text": "Name this product." }] }] + } + } + }, + "outcome": { + "kind": "response", + "result": { + "status": 200, + "headers": [{ "name": "content-type", "value": "text/event-stream" }], + "body": { + "state": "text", + "content": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"CodeIs\"}]}}],\"modelVersion\":\"gemini-2.5-flash\",\"responseId\":\"gemini-stream-response\"}\n\ndata: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Cheap\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":6,\"candidatesTokenCount\":3,\"totalTokenCount\":9},\"modelVersion\":\"gemini-2.5-flash\",\"responseId\":\"gemini-stream-response\"}\n\n" + }, + "duration_ms": 43, + "completeness": "complete" + } + }, + "redactions": [] +} diff --git a/crates/adapters/tests/fixtures/gemini-stream-content-prompt-ir.json b/crates/adapters/tests/fixtures/gemini-stream-content-prompt-ir.json new file mode 100644 index 0000000..ac9fdf6 --- /dev/null +++ b/crates/adapters/tests/fixtures/gemini-stream-content-prompt-ir.json @@ -0,0 +1,112 @@ +{ + "ir_version": "0.1.0", + "request_id": "gemini_stream_content_fixture", + "provider": { + "id": "gemini", + "host": "generativelanguage.googleapis.com", + "confidence": 1.0 + }, + "operation": "models.stream_generate_content", + "model": "gemini-2.5-flash", + "instructions": [], + "messages": [ + { + "id": "message_0", + "role": "user", + "parts": [ + { + "kind": "text", + "id": "message_0_part_0", + "text": "Name this product.", + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/0/parts/0" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/contents/0" + }, + "confidence": 1.0 + } + } + ], + "context": [], + "tools": [], + "generation": {}, + "vendor": { + "api_version": "v1", + "stream": true + }, + "response": { + "id": "gemini-stream-response", + "model": "gemini-2.5-flash", + "role": "assistant", + "parts": [ + { + "kind": "text", + "id": "response_part_0", + "text": "CodeIsCheap", + "evidence": { + "level": "observed", + "source": { + "kind": "stream_event", + "index": 0 + }, + "confidence": 1.0 + } + } + ], + "stop_reason": "STOP", + "usage": { + "candidatesTokenCount": 3, + "promptTokenCount": 6, + "totalTokenCount": 9 + }, + "events": [ + { + "index": 0, + "kind": "generate_content_chunk", + "evidence": { + "level": "observed", + "source": { + "kind": "stream_event", + "index": 0 + }, + "confidence": 1.0 + } + }, + { + "index": 1, + "kind": "generate_content_chunk", + "evidence": { + "level": "observed", + "source": { + "kind": "stream_event", + "index": 1 + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "stream_event", + "index": 0 + }, + "confidence": 1.0 + } + }, + "completeness": { + "request_body": "complete", + "response_body": "complete" + } +} diff --git a/crates/adapters/tests/gemini.rs b/crates/adapters/tests/gemini.rs new file mode 100644 index 0000000..c5d4df3 --- /dev/null +++ b/crates/adapters/tests/gemini.rs @@ -0,0 +1,123 @@ +use std::fs; +use std::path::PathBuf; + +use codeischeap_adapters::{AdapterRegistry, GEMINI_ADAPTER_ID, ParseIssueCode}; +use codeischeap_capture_ipc::{CaptureEnvelope, CaptureOutcome}; +use codeischeap_capture_policy::CapturePolicy; +use codeischeap_prompt_ir::{BodyState, EvidenceSource, MessageRole, PromptPart}; +use serde_json::Value; + +#[test] +fn generate_content_fixture_matches_golden_and_maps_gemini_semantics() { + let result = AdapterRegistry::default().parse(&fixture("gemini-generate-content-capture.json")); + let prompt = result.prompt_ir.expect("fixture must produce Prompt IR"); + let actual = serde_json::to_value(&prompt).expect("Prompt IR must serialize"); + let expected: Value = read_json("gemini-generate-content-prompt-ir.json"); + + assert_eq!(result.adapter_id.as_deref(), Some(GEMINI_ADAPTER_ID)); + assert_eq!(result.confidence, Some(1.0)); + assert!(result.issues.is_empty()); + assert_eq!(actual, expected); + assert_eq!(prompt.model.as_deref(), Some("gemini-2.5-pro")); + assert_eq!(prompt.messages[2].role, MessageRole::Tool); + assert!(matches!( + prompt.messages[0].parts[1], + PromptPart::ImageRef { .. } + )); + assert!(matches!( + prompt.messages[0].parts[2], + PromptPart::AudioRef { .. } + )); + assert!(matches!( + prompt.messages[1].parts[0], + PromptPart::ToolUse { .. } + )); + assert!(matches!( + prompt.messages[2].parts[0], + PromptPart::ToolResult { .. } + )); + let response = prompt + .response + .expect("JSON response must be reconstructed"); + assert_eq!(response.stop_reason.as_deref(), Some("STOP")); + assert_eq!(response.usage["totalTokenCount"], 172); + assert!(matches!(response.parts[1], PromptPart::ToolUse { .. })); +} + +#[test] +fn streaming_fixture_reassembles_text_and_usage() { + let result = AdapterRegistry::default().parse(&fixture("gemini-stream-content-capture.json")); + let prompt = result.prompt_ir.expect("fixture must produce Prompt IR"); + let actual = serde_json::to_value(&prompt).expect("Prompt IR must serialize"); + let expected: Value = read_json("gemini-stream-content-prompt-ir.json"); + + assert!(result.issues.is_empty()); + assert_eq!(actual, expected); + assert_eq!(prompt.completeness.response_body, BodyState::Complete); + let response = prompt.response.expect("SSE response must be reconstructed"); + assert!(matches!( + &response.parts[0], + PromptPart::Text { text, evidence, .. } + if text == "CodeIsCheap" + && evidence.source == Some(EvidenceSource::StreamEvent { index: 0 }) + )); + assert_eq!(response.usage["totalTokenCount"], 9); + assert_eq!(response.events.len(), 2); +} + +#[test] +fn stream_without_finish_reason_is_retained_as_partial() { + let mut envelope: CaptureEnvelope = read_json("gemini-stream-content-capture.json"); + let Some(CaptureOutcome::Response(response)) = envelope.outcome.as_mut() else { + panic!("fixture must have a response"); + }; + response.body.content = Some(Value::String( + "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"partial\"}]}}]}\n\n" + .to_owned(), + )); + let capture = CapturePolicy::load_default() + .expect("policy must load") + .sanitize_envelope(envelope) + .expect("fixture must be in scope"); + let result = AdapterRegistry::default().parse(&capture); + let prompt = result + .prompt_ir + .expect("partial stream must retain Prompt IR"); + + assert_eq!(prompt.completeness.response_body, BodyState::Partial); + assert!(result.issues.iter().any(|issue| { + issue.code == ParseIssueCode::MissingField + && issue.path.as_deref() == Some("/response/events/finishReason") + })); +} + +#[test] +fn invalid_body_degrades_to_raw() { + let result = AdapterRegistry::default().parse(&fixture("gemini-invalid-capture.json")); + assert!(result.raw_fallback); + assert!(result.prompt_ir.is_none()); + assert!( + result + .issues + .iter() + .any(|issue| issue.code == ParseIssueCode::InvalidBody) + ); +} + +fn fixture(name: &str) -> codeischeap_capture_policy::SanitizedCapture { + CapturePolicy::load_default() + .expect("policy must load") + .sanitize_envelope(read_json(name)) + .expect("fixture must be in scope") +} + +fn read_json(name: &str) -> T { + serde_json::from_str( + &fs::read_to_string(fixtures().join(name)).expect("fixture must be readable"), + ) + .expect("fixture must be valid JSON") +} + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} diff --git a/crates/adapters/tests/openai.rs b/crates/adapters/tests/openai.rs index 4c228b3..5a45791 100644 --- a/crates/adapters/tests/openai.rs +++ b/crates/adapters/tests/openai.rs @@ -134,16 +134,16 @@ fn without_panic_output(operation: impl FnOnce() -> T) -> T { #[test] fn unknown_provider_and_invalid_json_degrade_to_raw() { - let gemini = sanitized( - "gemini_fixture", - "generativelanguage.googleapis.com", - "/v1beta/models/gemini-2.5-pro:generateContent", + let mistral = sanitized( + "mistral_fixture", + "api.mistral.ai", + "/v1/fim/completions", CapturedBody { state: CapturedBodyState::Json, - content: Some(json!({"contents": []})), + content: Some(json!({"prompt": "complete this"})), }, ); - let unknown = AdapterRegistry::default().parse(&gemini); + let unknown = AdapterRegistry::default().parse(&mistral); assert!(unknown.raw_fallback); assert!(unknown.prompt_ir.is_none()); assert_eq!(unknown.issues[0].code, ParseIssueCode::NoAdapter); diff --git a/crates/desktop-api/src/lib.rs b/crates/desktop-api/src/lib.rs index 541ce75..2659f9f 100644 --- a/crates/desktop-api/src/lib.rs +++ b/crates/desktop-api/src/lib.rs @@ -888,6 +888,7 @@ fn provider_label(provider: &str) -> String { match provider.to_ascii_lowercase().as_str() { "openai" => "OpenAI".to_owned(), "anthropic" => "Anthropic".to_owned(), + "gemini" => "Gemini".to_owned(), "google" => "Google".to_owned(), "mistral" => "Mistral".to_owned(), _ => provider.to_owned(), @@ -1094,6 +1095,11 @@ mod tests { assert_eq!(read_bindings(&checked_in), read_bindings(generated.path())); } + #[test] + fn gemini_provider_uses_a_product_label() { + assert_eq!(provider_label("gemini"), "Gemini"); + } + fn read_bindings(directory: &Path) -> Vec<(PathBuf, String)> { let mut pending = vec![directory.to_path_buf()]; let mut bindings = Vec::new(); diff --git a/docs/progress.html b/docs/progress.html index 5772591..473ed8a 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -16,13 +16,13 @@

开发进度跟踪

本页是项目状态的单一入口。开发计划定义“应该做什么”,本页只记录“当前做到哪里、是否健康、下一步是什么”。

-
工程进度58%
-
当前阶段恢复与安全导出
-
当前迭代S4 / S5 · 进行中
-
最后更新2026-07-16
+
工程进度62%
+
当前阶段恢复、安全与协议扩展
+
当前迭代S5 / S6 · 进行中
+
最后更新2026-07-17
-
当前结论Windows Proxy 已具备事务恢复与 sidecar 崩溃回退,macOS 特权 helper 待签名制品。SEC-002 已实现单请求 Minimal / Reproducible / Forensic 安全预览与写出,批量导出和支持包待完成。
+
当前结论Windows Proxy 已具备事务恢复与 sidecar 崩溃回退,macOS 特权 helper 待签名制品。单请求安全导出已闭环;Gemini 请求及 JSON/SSE 响应已进入 Prompt IR,下一步推进 Ollama、token 来源与 Compare。

1. 更新规则

    @@ -49,7 +49,7 @@

    3. 里程碑

    M1基础设施与加密数据链路15%
    95%In progressW4Codex / TBD M2Gateway 与双厂商闭环20%
    100%DoneW8Codex / TBD M3显式代理、安全与恢复20%
    65%In progressW12Codex / TBD - M4四厂商、Compare 与搜索15%
    0%Not startedW14TBD + M4四厂商、Compare 与搜索15%
    25%In progressW14Codex / TBD M5Public Beta 稳定性15%
    0%Not startedW18TBD M6GA 发布10%
    0%Not startedW20TBD @@ -60,13 +60,13 @@

    4. 工作流进度

    架构与工程基础ARC-001~004100%DoneCodex / TBD保持 schema、事件与 command 契约同步 Desktop / FrontendAPP-001~00680%In progressCodex / TBD准备 Compare 与设置诊断交互 Capture / NetworkCAP-001~00772%In progressCodex / TBD实现 macOS 特权代理 helper;生产签名随发布凭据补齐 - Prompt / AdaptersPAR-001~00757%In progressCodex / TBD准备 Gemini 适配器与 token 来源模型 + Prompt / AdaptersPAR-001~00771%In progressCodex / TBD准备 Ollama 适配器与 token 来源模型 Data / SecurityDAT-001~002、SEC-001~00455%In progressCodex / TBD继续日志/临时文件 canary 与批量导出 Test / ReleaseTST-001~004、REL-001~00335%In progressCodex / TBD继续 TST-002 Proxy 取消/背压与证书生命周期测试 -

    5. 当前迭代:S4 / S5

    -

    目标:完成生产 sidecar、证书生命周期与系统代理恢复闭环,并建立真实协议和故障注入测试。

    +

    5. 当前迭代:S5 / S6

    +

    目标:完成恢复与安全闭环,并扩展 Gemini/Ollama 协议、token 来源和 Compare。

    @@ -90,7 +90,8 @@

    5. 当前迭代:S4 / S5

    - + + @@ -115,7 +116,7 @@

    6. 后续迭代承诺

    - + @@ -197,6 +198,7 @@

    11. 决策与变更记录

    +
    ID任务状态进度Owner目标日期备注
    ARC-001建立仓库与模块边界In progress80%Codex / TBD2026-07-28Prompt IR、Gateway、Core、Desktop API、Capture IPC、sidecar 与恢复边界已建立
    ARC-002建立 Windows/macOS CIDone100%Codex / TBD2026-07-14Windows、macOS、Linux PR 检查通过
    PAR-002解析器注册表与检测链Done100%Codex / TBD2026-07-15置信度排序、错误/panic 隔离、IR 校验、partial issues 与 Raw fallback 完成
    PAR-003OpenAI-compatible 适配器Done100%Codex / TBD2026-07-15Responses、Chat、Completions、多模态与 tools golden fixtures 通过
    PAR-004Anthropic 适配器Done100%Codex / TBD2026-07-15Messages/Complete 请求与 JSON/SSE 响应完整重组;文本、thinking、tool use、usage、错误及未知事件均保留证据
    TST-001协议 fixture 与 golden testsDone100%Codex / TBD2026-07-15版本化能力矩阵覆盖 OpenAI 与 Anthropic 的请求、响应、流式、工具、多模态、错误和 Raw fallback;声明均由 fixture 与 golden 验证
    PAR-005Gemini 适配器Done100%Codex / TBD2026-07-17v1/v1beta generateContent 与 streamGenerateContent、systemInstruction、contents、多模态、function calling、生成参数及 JSON/SSE usage 重组完成
    TST-001协议 fixture 与 golden testsDone100%Codex / TBD2026-07-17版本化能力矩阵覆盖 OpenAI、Anthropic 与 Gemini 的请求、响应、流式、工具、多模态、错误和 Raw fallback;声明均由 fixture 与 golden 验证
    TST-002Gateway/Proxy 集成测试In progress65%Codex / TBD2026-08-25认证 IPC、暂停丢弃、真实模式切换与进程树清理、压缩、流式脱敏、非目标 TLS、HTTP/2 基线一致性及 Windows CA 精确增删通过;真实 ROOT 往返需交互式 Windows 会话,Proxy 取消、背压与完整协议一致性待扩展
    SPIKE-001Gateway 流式透明转发验证Done100%Codex / TBD2026-07-14双向流式、取消传递、头清理与稳定 502 集成测试通过
    SPIKE-002mitmproxy sidecar IPC/打包验证Done100%Codex / TBD2026-07-14凭据清理、IPC、打包与真实转发通过
    S3PAR-004、APP-004、TST-001、DAT-002Done双厂商 Inspector、能力矩阵与数据生命周期全部通过验收
    S4CAP-003~005、TST-002In progresssidecar bundle、桌面运行时、协议矩阵、跨平台 CA 状态及两平台用户级信任生命周期已实现;签名、Proxy 取消/背压与交互式验收待推进
    S5CAP-006/007、SEC-002/003、APP-006、TST-003In progressWindows 恢复、sidecar 崩溃回退与单请求安全导出已接入;继续 macOS helper、诊断、批量导出与其余故障注入
    S6PAR-005~007、APP-005Not started双厂商 Inspector 稳定
    S6PAR-005~007、APP-005In progressGemini 适配器完成;继续 Ollama、token/成本/指纹与 Compare
    S7TST-004、性能、可访问性、诊断与保留Not started功能冻结
    S8SEC-004、REL-001/002、安全评审Not startedBeta 质量门槛通过
    S9REL-003、完整回归与发布演练Not started阻断项清零
    2026-07-16Desktop / RecoveryCAP-006 Windows 桌面系统代理事务接入受信任 CA 下 Proxy 模式自动建立 WinINet 快照与 armed journal,桌面自身以 --journal 运行 watchdog;切回 Gateway、正常退出、强杀后 watchdog 或下次启动均恢复原设置,活 owner 不会被第二实例误恢复,Unix journal 权限固定为目录 0700/文件 0600Codex
    2026-07-16Desktop / RecoveryCAP-006 sidecar 独立退出即时回退桌面按 250 ms 周期非阻塞检查 sidecar;异常退出或监控失败会撤销对应代际的 Proxy runtime、恢复系统代理、重新启用 Gateway 捕获并通知前端刷新,旧监控任务不能关闭后续新会话Codex
    2026-07-16Security / ExportSEC-002 单请求安全导出闭环Minimal、Reproducible、Forensic 均由后端生成;已知 API key、Bearer、JWT 与私钥模式替换为稳定占位符并记录 JSON Pointer,用户预览后经系统对话框保存,写入前重算 SHA-256 防止流式内容变化,目标文件只允许新建Codex
    2026-07-17Prompt / AdaptersPAR-005 Gemini 适配器完成v1/v1beta 两类生成端点的请求、JSON 与 SSE 响应进入 Prompt IR;systemInstruction、contents、多模态、函数声明/调用/结果、生成参数、finish reason 与 usage 均保留可回溯证据Codex
    后续范围、架构、日期或资源变化均在此追加,并链接对应 ADR/会议结论。