diff --git a/crates/adapters/src/anthropic.rs b/crates/adapters/src/anthropic.rs new file mode 100644 index 0000000..d7aaca7 --- /dev/null +++ b/crates/adapters/src/anthropic.rs @@ -0,0 +1,473 @@ +use std::collections::BTreeMap; + +use codeischeap_capture_ipc::{CapturedBodyState, CapturedField}; +use codeischeap_prompt_ir::{ + BodyState, Completeness, Evidence, EvidenceSource, GenerationOptions, Instruction, + InstructionRole, Message, MessageRole, PromptIr, PromptPart, ToolDefinition, +}; +use serde_json::{Map, Value, json}; + +use crate::model::{ + AdapterError, AdapterInput, AdapterOutput, ParseIssue, ParseIssueCode, PromptAdapter, +}; + +pub const ANTHROPIC_ADAPTER_ID: &str = "anthropic/v0.1"; + +#[derive(Debug, Clone, Copy, Default)] +pub struct AnthropicAdapter; + +impl PromptAdapter for AnthropicAdapter { + fn id(&self) -> &'static str { + ANTHROPIC_ADAPTER_ID + } + + fn detect(&self, input: AdapterInput<'_>) -> Option { + let supported_path = matches!( + input.envelope.request.path.as_str(), + "/v1/messages" | "/v1/complete" + ); + (input.target_id == "anthropic" && supported_path).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 mut prompt = PromptIr::new(&input.envelope.capture_id, "anthropic"); + prompt.provider.host = Some(input.envelope.request.host.clone()); + prompt.provider.confidence = Some(1.0); + prompt.model = body.get("model").and_then(Value::as_str).map(str::to_owned); + prompt.generation = parse_generation(body); + prompt.completeness = Completeness { + request_body: BodyState::Complete, + response_body: BodyState::Missing, + }; + copy_vendor_fields(body, &input.envelope.request.headers, &mut prompt.vendor); + + let mut issues = Vec::new(); + if prompt.model.is_none() { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::MissingField, + "/model", + )); + } + if body.get("max_tokens").and_then(Value::as_u64).is_none() { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::MissingField, + "/max_tokens", + )); + } + + match input.envelope.request.path.as_str() { + "/v1/messages" => parse_messages_request(body, &mut prompt, &mut issues), + "/v1/complete" => parse_complete_request(body, &mut prompt, &mut issues), + _ => { + return Err(AdapterError::at( + ParseIssueCode::UnsupportedOperation, + "/request/path", + )); + } + } + prompt.tools = parse_tools(body.get("tools"), &mut issues); + Ok(AdapterOutput { + prompt_ir: prompt, + issues, + }) + } +} + +fn parse_messages_request( + body: &Map, + prompt: &mut PromptIr, + issues: &mut Vec, +) { + prompt.operation = Some("messages.create".to_owned()); + parse_system(body.get("system"), prompt, issues); + let Some(messages) = body.get("messages").and_then(Value::as_array) else { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::MissingField, + "/messages", + )); + return; + }; + for (index, value) in messages.iter().enumerate() { + parse_message(value, index, prompt, issues); + } +} + +fn parse_complete_request( + body: &Map, + prompt: &mut PromptIr, + issues: &mut Vec, +) { + prompt.operation = Some("completions.create".to_owned()); + let Some(value) = body.get("prompt") else { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::MissingField, + "/prompt", + )); + return; + }; + prompt.messages.push(Message { + id: "message_0".to_owned(), + role: MessageRole::User, + parts: parse_content(value, "/prompt", "message_0", issues), + evidence: observed("/prompt"), + }); +} + +fn parse_system(value: Option<&Value>, prompt: &mut PromptIr, issues: &mut Vec) { + let Some(value) = value else { + return; + }; + match value { + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + let pointer = format!("/system/{index}"); + let id = format!("instruction_{index}"); + prompt.instructions.push(Instruction { + id: id.clone(), + role: InstructionRole::System, + parts: parse_content(value, &pointer, &id, issues), + evidence: observed(pointer), + }); + } + } + Value::String(_) | Value::Object(_) => prompt.instructions.push(Instruction { + id: "instruction_0".to_owned(), + role: InstructionRole::System, + parts: parse_content(value, "/system", "instruction_0", issues), + evidence: observed("/system"), + }), + _ => issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::InvalidField, + "/system", + )), + } +} + +fn parse_message(value: &Value, index: usize, prompt: &mut PromptIr, issues: &mut Vec) { + let pointer = format!("/messages/{index}"); + let Some(object) = value.as_object() else { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::InvalidField, + pointer, + )); + return; + }; + let content = object.get("content").unwrap_or(&Value::Null); + let role = match object.get("role").and_then(Value::as_str) { + Some("user") if all_tool_results(content) => MessageRole::Tool, + Some("user") => MessageRole::User, + Some("assistant") => MessageRole::Assistant, + _ => { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::InvalidField, + format!("{pointer}/role"), + )); + MessageRole::Unknown + } + }; + let id = format!("message_{index}"); + prompt.messages.push(Message { + id: id.clone(), + role, + parts: parse_content(content, &format!("{pointer}/content"), &id, issues), + evidence: observed(pointer), + }); +} + +fn all_tool_results(content: &Value) -> bool { + let Some(blocks) = content.as_array() else { + return false; + }; + !blocks.is_empty() + && blocks + .iter() + .all(|block| block.get("type").and_then(Value::as_str) == Some("tool_result")) +} + +fn parse_content( + value: &Value, + pointer: &str, + id_prefix: &str, + issues: &mut Vec, +) -> Vec { + match value { + Value::Null => Vec::new(), + Value::String(text) => vec![PromptPart::Text { + id: format!("{id_prefix}_part_0"), + text: text.clone(), + evidence: observed(pointer), + }], + Value::Array(values) => values + .iter() + .enumerate() + .map(|(index, value)| { + parse_content_block( + value, + &format!("{pointer}/{index}"), + &format!("{id_prefix}_part_{index}"), + issues, + ) + }) + .collect(), + _ => vec![parse_content_block( + value, + pointer, + &format!("{id_prefix}_part_0"), + issues, + )], + } +} + +fn parse_content_block( + value: &Value, + pointer: &str, + fallback_id: &str, + issues: &mut Vec, +) -> PromptPart { + let Some(object) = value.as_object() else { + return PromptPart::Json { + id: fallback_id.to_owned(), + value: value.clone(), + evidence: observed(pointer), + }; + }; + match object.get("type").and_then(Value::as_str) { + Some("text") | None if object.get("text").is_some() => PromptPart::Text { + id: fallback_id.to_owned(), + text: object + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + evidence: observed(pointer), + }, + Some("image") => media_part(object, pointer, fallback_id, true), + Some("document") => media_part(object, pointer, fallback_id, false), + Some("tool_use" | "server_tool_use") => { + let id = object + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .unwrap_or(fallback_id) + .to_owned(); + let name = object + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown_tool") + .to_owned(); + if object.get("name").and_then(Value::as_str).is_none() { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::MissingField, + format!("{pointer}/name"), + )); + } + PromptPart::ToolUse { + id, + name, + input: object.get("input").cloned().unwrap_or_else(|| json!({})), + evidence: observed(pointer), + } + } + Some("tool_result" | "web_search_tool_result") => { + let tool_use_id = object + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or("unknown_tool_use") + .to_owned(); + if object.get("tool_use_id").and_then(Value::as_str).is_none() { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::MissingField, + format!("{pointer}/tool_use_id"), + )); + } + let mut result = Map::new(); + result.insert( + "content".to_owned(), + object.get("content").cloned().unwrap_or(Value::Null), + ); + if let Some(is_error) = object.get("is_error") { + result.insert("is_error".to_owned(), is_error.clone()); + } + PromptPart::ToolResult { + id: fallback_id.to_owned(), + tool_use_id, + value: Value::Object(result), + evidence: observed(pointer), + } + } + _ => { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::UnsupportedContent, + pointer, + )); + PromptPart::Unknown { + id: fallback_id.to_owned(), + value: value.clone(), + evidence: observed(pointer), + } + } + } +} + +fn media_part(object: &Map, pointer: &str, id: &str, image: bool) -> PromptPart { + let source = object.get("source").and_then(Value::as_object); + let location = source + .and_then(|source| source.get("url").and_then(Value::as_str)) + .map(str::to_owned) + .or_else(|| { + source + .and_then(|source| source.get("file_id").and_then(Value::as_str)) + .map(|file_id| format!("file:{file_id}")) + }) + .or_else(|| { + source + .and_then(|source| source.get("data")) + .map(|_| "inline:base64".to_owned()) + }) + .unwrap_or_else(|| "unknown media location".to_owned()); + let media_type = source + .and_then(|source| source.get("media_type").and_then(Value::as_str)) + .map(str::to_owned); + let evidence = observed(pointer); + if image { + PromptPart::ImageRef { + id: id.to_owned(), + location, + media_type, + evidence, + } + } else { + PromptPart::FileRef { + id: id.to_owned(), + location, + 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(); + }; + tools + .iter() + .enumerate() + .filter_map(|(index, value)| { + let pointer = format!("/tools/{index}"); + let object = value.as_object()?; + let Some(name) = object.get("name").and_then(Value::as_str) else { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::MissingField, + format!("{pointer}/name"), + )); + return None; + }; + if object.get("input_schema").is_none() { + issues.push(ParseIssue::at( + ANTHROPIC_ADAPTER_ID, + ParseIssueCode::MissingField, + format!("{pointer}/input_schema"), + )); + } + Some(ToolDefinition { + id: format!("tool_definition_{index}"), + name: name.to_owned(), + description: object + .get("description") + .and_then(Value::as_str) + .map(str::to_owned), + input_schema: object + .get("input_schema") + .cloned() + .unwrap_or_else(|| json!({})), + evidence: observed(pointer), + }) + }) + .collect() +} + +fn parse_generation(body: &Map) -> GenerationOptions { + let stop = body + .get("stop_sequences") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + let mut extra = BTreeMap::new(); + if let Some(top_k) = body.get("top_k") { + extra.insert("top_k".to_owned(), top_k.clone()); + } + GenerationOptions { + temperature: body.get("temperature").and_then(Value::as_f64), + top_p: body.get("top_p").and_then(Value::as_f64), + max_output_tokens: body.get("max_tokens").and_then(Value::as_u64), + stop, + extra, + } +} + +fn copy_vendor_fields( + body: &Map, + headers: &[CapturedField], + vendor: &mut BTreeMap, +) { + for key in [ + "stream", + "tool_choice", + "metadata", + "thinking", + "service_tier", + ] { + if let Some(value) = body.get(key) { + vendor.insert(key.to_owned(), value.clone()); + } + } + for (header, key) in [ + ("anthropic-version", "anthropic_version"), + ("anthropic-beta", "anthropic_beta"), + ] { + if let Some(value) = headers + .iter() + .find(|field| field.name.eq_ignore_ascii_case(header)) + .map(|field| field.value.clone()) + { + vendor.insert(key.to_owned(), Value::String(value)); + } + } +} + +fn observed(pointer: impl Into) -> Evidence { + Evidence::observed(EvidenceSource::JsonPointer { + pointer: pointer.into(), + }) +} diff --git a/crates/adapters/src/lib.rs b/crates/adapters/src/lib.rs index e6778a8..3efe1b5 100644 --- a/crates/adapters/src/lib.rs +++ b/crates/adapters/src/lib.rs @@ -1,9 +1,11 @@ //! Isolated provider adapters that turn sanitized captures into Prompt IR. +mod anthropic; mod model; mod openai; mod registry; +pub use anthropic::{ANTHROPIC_ADAPTER_ID, AnthropicAdapter}; 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 4fed0cf..ecf6535 100644 --- a/crates/adapters/src/registry.rs +++ b/crates/adapters/src/registry.rs @@ -4,6 +4,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use codeischeap_capture_policy::SanitizedCapture; use codeischeap_prompt_ir::Validate; +use crate::anthropic::AnthropicAdapter; use crate::model::{AdapterInput, ParseIssue, ParseIssueCode, ParseResult, PromptAdapter}; use crate::openai::OpenAiAdapter; @@ -15,6 +16,7 @@ impl Default for AdapterRegistry { fn default() -> Self { let mut registry = Self::new(); registry.register(OpenAiAdapter); + registry.register(AnthropicAdapter); registry } } diff --git a/crates/adapters/tests/anthropic.rs b/crates/adapters/tests/anthropic.rs new file mode 100644 index 0000000..8b28509 --- /dev/null +++ b/crates/adapters/tests/anthropic.rs @@ -0,0 +1,180 @@ +use std::fs; +use std::path::PathBuf; + +use codeischeap_adapters::{ANTHROPIC_ADAPTER_ID, AdapterRegistry, ParseIssueCode}; +use codeischeap_capture_ipc::{ + CAPTURE_ENVELOPE_VERSION, CaptureEnvelope, CaptureSource, CapturedBody, CapturedBodyState, + CapturedRequest, +}; +use codeischeap_capture_policy::{CapturePolicy, SanitizedCapture}; +use codeischeap_prompt_ir::{MessageRole, PromptPart}; +use serde_json::{Value, json}; + +#[test] +fn messages_fixture_matches_the_golden_prompt_ir() { + let capture = sanitized_fixture("anthropic-messages-capture.json"); + let result = AdapterRegistry::default().parse(&capture); + let actual = serde_json::to_value(result.prompt_ir.expect("fixture must produce Prompt IR")) + .expect("Prompt IR must serialize"); + let expected: Value = serde_json::from_str( + &fs::read_to_string(fixtures().join("anthropic-messages-prompt-ir.json")) + .expect("golden must be readable"), + ) + .expect("golden must parse"); + + assert_eq!(result.adapter_id.as_deref(), Some(ANTHROPIC_ADAPTER_ID)); + assert_eq!(result.confidence, Some(1.0)); + assert!(!result.raw_fallback); + assert!(result.issues.is_empty()); + assert_eq!(actual, expected); +} + +#[test] +fn messages_preserve_media_tools_and_tool_results() { + let result = + AdapterRegistry::default().parse(&sanitized_fixture("anthropic-messages-capture.json")); + let prompt = result.prompt_ir.expect("fixture must parse"); + + assert_eq!(prompt.operation.as_deref(), Some("messages.create")); + assert_eq!(prompt.model.as_deref(), Some("claude-sonnet-4-5")); + assert_eq!(prompt.messages[2].role, MessageRole::Tool); + assert!(matches!( + &prompt.messages[0].parts[1], + PromptPart::ImageRef { location, media_type, .. } + if location == "inline:base64" && media_type.as_deref() == Some("image/png") + )); + assert!(matches!( + &prompt.messages[1].parts[0], + PromptPart::ToolUse { id, name, .. } + if id == "toolu_read_1" && name == "read_file" + )); + assert!(matches!( + &prompt.messages[2].parts[0], + PromptPart::ToolResult { tool_use_id, .. } if tool_use_id == "toolu_read_1" + )); + assert_eq!(prompt.generation.max_output_tokens, Some(2048)); + assert_eq!(prompt.generation.stop, [""]); + assert_eq!(prompt.vendor["anthropic_version"], "2023-06-01"); +} + +#[test] +fn legacy_complete_requests_remain_supported() { + let capture = sanitized_anthropic( + "anthropic_complete", + "/v1/complete", + CapturedBody { + state: CapturedBodyState::Json, + content: Some(json!({ + "model": "claude-2.1", + "prompt": "\n\nHuman: Explain Cargo features.\n\nAssistant:", + "max_tokens": 256, + "stop_sequences": ["\n\nHuman:"] + })), + }, + ); + + let result = AdapterRegistry::default().parse(&capture); + let prompt = result.prompt_ir.expect("legacy completion must parse"); + + assert_eq!(prompt.operation.as_deref(), Some("completions.create")); + assert_eq!(prompt.messages.len(), 1); + assert!(matches!( + &prompt.messages[0].parts[0], + PromptPart::Text { text, .. } if text.contains("Explain Cargo features") + )); + assert!(result.issues.is_empty()); +} + +#[test] +fn unsupported_blocks_are_preserved_as_partial_issues() { + let capture = sanitized_anthropic( + "anthropic_thinking", + "/v1/messages", + CapturedBody { + state: CapturedBodyState::Json, + content: Some(json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 128, + "messages": [{ + "role": "assistant", + "content": [{"type": "thinking", "thinking": "hidden reasoning"}] + }] + })), + }, + ); + + let result = AdapterRegistry::default().parse(&capture); + let prompt = result.prompt_ir.expect("partial request must parse"); + + assert!(!result.raw_fallback); + assert!(result.issues.iter().any(|issue| { + issue.code == ParseIssueCode::UnsupportedContent + && issue.path.as_deref() == Some("/messages/0/content/0") + })); + assert!(matches!( + prompt.messages[0].parts[0], + PromptPart::Unknown { .. } + )); +} + +#[test] +fn truncated_anthropic_requests_degrade_to_raw() { + let capture = sanitized_anthropic( + "anthropic_truncated", + "/v1/messages", + CapturedBody { + state: CapturedBodyState::Truncated, + content: None, + }, + ); + + let result = AdapterRegistry::default().parse(&capture); + + assert!(result.raw_fallback); + assert!(result.prompt_ir.is_none()); + assert!( + result + .issues + .iter() + .any(|issue| issue.code == ParseIssueCode::InvalidBody) + ); +} + +fn sanitized_fixture(name: &str) -> SanitizedCapture { + let envelope: CaptureEnvelope = serde_json::from_str( + &fs::read_to_string(fixtures().join(name)).expect("fixture must be readable"), + ) + .expect("fixture must parse"); + CapturePolicy::load_default() + .expect("policy must load") + .sanitize_envelope(envelope) + .expect("fixture must be in scope") +} + +fn sanitized_anthropic(capture_id: &str, path: &str, body: CapturedBody) -> SanitizedCapture { + let envelope = CaptureEnvelope { + version: CAPTURE_ENVELOPE_VERSION.to_owned(), + capture_id: capture_id.to_owned(), + observed_at_unix_ms: 1_784_072_000_000, + source: CaptureSource::Gateway, + request: CapturedRequest { + method: "POST".to_owned(), + scheme: "https".to_owned(), + host: "api.anthropic.com".to_owned(), + port: 443, + path: path.to_owned(), + query: Vec::new(), + headers: Vec::new(), + body, + }, + redactions: Vec::new(), + }; + CapturePolicy::load_default() + .expect("policy must load") + .sanitize_envelope(envelope) + .expect("capture must be in scope") +} + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} diff --git a/crates/adapters/tests/fixtures/anthropic-messages-capture.json b/crates/adapters/tests/fixtures/anthropic-messages-capture.json new file mode 100644 index 0000000..c15992b --- /dev/null +++ b/crates/adapters/tests/fixtures/anthropic-messages-capture.json @@ -0,0 +1,88 @@ +{ + "version": "0.1", + "capture_id": "anthropic_messages_fixture", + "observed_at_unix_ms": 1784072000000, + "source": "gateway", + "request": { + "method": "POST", + "scheme": "https", + "host": "api.anthropic.com", + "port": 443, + "path": "/v1/messages", + "query": [], + "headers": [ + { "name": "content-type", "value": "application/json" }, + { "name": "anthropic-version", "value": "2023-06-01" }, + { "name": "x-api-key", "value": "anthropic-fixture-canary" } + ], + "body": { + "state": "json", + "content": { + "model": "claude-sonnet-4-5", + "max_tokens": 2048, + "system": [ + { + "type": "text", + "text": "You are a careful coding assistant.", + "cache_control": { "type": "ephemeral" } + } + ], + "messages": [ + { + "role": "user", + "content": [ + { "type": "text", "text": "Read Cargo.toml and summarize the workspace." }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "fixture-image-data" + } + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_read_1", + "name": "read_file", + "input": { "path": "Cargo.toml" } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_read_1", + "content": "[workspace]\nmembers = [\"crates/adapters\"]" + } + ] + } + ], + "tools": [ + { + "name": "read_file", + "description": "Read a UTF-8 file from the workspace.", + "input_schema": { + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + } + } + ], + "temperature": 0.0, + "top_p": 0.9, + "top_k": 40, + "stop_sequences": [""], + "stream": true, + "tool_choice": { "type": "auto" } + } + } + }, + "redactions": [] +} diff --git a/crates/adapters/tests/fixtures/anthropic-messages-prompt-ir.json b/crates/adapters/tests/fixtures/anthropic-messages-prompt-ir.json new file mode 100644 index 0000000..ce6355e --- /dev/null +++ b/crates/adapters/tests/fixtures/anthropic-messages-prompt-ir.json @@ -0,0 +1,178 @@ +{ + "ir_version": "0.1.0", + "request_id": "anthropic_messages_fixture", + "provider": { + "id": "anthropic", + "host": "api.anthropic.com", + "confidence": 1.0 + }, + "operation": "messages.create", + "model": "claude-sonnet-4-5", + "instructions": [ + { + "id": "instruction_0", + "role": "system", + "parts": [ + { + "kind": "text", + "id": "instruction_0_part_0", + "text": "You are a careful coding assistant.", + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/system/0" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/system/0" + }, + "confidence": 1.0 + } + } + ], + "messages": [ + { + "id": "message_0", + "role": "user", + "parts": [ + { + "kind": "text", + "id": "message_0_part_0", + "text": "Read Cargo.toml and summarize the workspace.", + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/messages/0/content/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": "/messages/0/content/1" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/messages/0" + }, + "confidence": 1.0 + } + }, + { + "id": "message_1", + "role": "assistant", + "parts": [ + { + "kind": "tool_use", + "id": "toolu_read_1", + "name": "read_file", + "input": { "path": "Cargo.toml" }, + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/messages/1/content/0" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/messages/1" + }, + "confidence": 1.0 + } + }, + { + "id": "message_2", + "role": "tool", + "parts": [ + { + "kind": "tool_result", + "id": "message_2_part_0", + "tool_use_id": "toolu_read_1", + "value": { + "content": "[workspace]\nmembers = [\"crates/adapters\"]" + }, + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/messages/2/content/0" + }, + "confidence": 1.0 + } + } + ], + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/messages/2" + }, + "confidence": 1.0 + } + } + ], + "context": [], + "tools": [ + { + "id": "tool_definition_0", + "name": "read_file", + "description": "Read a UTF-8 file from the workspace.", + "input_schema": { + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + }, + "evidence": { + "level": "observed", + "source": { + "kind": "json_pointer", + "pointer": "/tools/0" + }, + "confidence": 1.0 + } + } + ], + "generation": { + "temperature": 0.0, + "top_p": 0.9, + "max_output_tokens": 2048, + "stop": [""], + "extra": { "top_k": 40 } + }, + "vendor": { + "anthropic_version": "2023-06-01", + "stream": true, + "tool_choice": { "type": "auto" } + }, + "completeness": { + "request_body": "complete", + "response_body": "missing" + } +} diff --git a/crates/adapters/tests/openai.rs b/crates/adapters/tests/openai.rs index 7865f1e..1795b05 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 anthropic = sanitized( - "anthropic_fixture", - "api.anthropic.com", - "/v1/messages", + let gemini = sanitized( + "gemini_fixture", + "generativelanguage.googleapis.com", + "/v1beta/models/gemini-2.5-pro:generateContent", CapturedBody { state: CapturedBodyState::Json, - content: Some(json!({"model": "claude", "messages": []})), + content: Some(json!({"contents": []})), }, ); - let unknown = AdapterRegistry::default().parse(&anthropic); + let unknown = AdapterRegistry::default().parse(&gemini); assert!(unknown.raw_fallback); assert!(unknown.prompt_ir.is_none()); assert_eq!(unknown.issues[0].code, ParseIssueCode::NoAdapter); diff --git a/docs/progress.html b/docs/progress.html index fadb87b..6033162 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -16,13 +16,13 @@

开发进度跟踪

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

-
工程进度28%
+
工程进度30%
当前阶段Gateway 闭环
当前迭代S2 · 进行中
最后更新2026-07-15
-
当前结论CAP-001/002 已完成:Gateway 以有界非阻塞 tee 捕获完整网络流,请求经 Policy、OpenAI adapter 与 SQLCipher 实时进入桌面工作台;暂停只停止记录,不中断转发。下一项是 APP-003 千条列表性能验收与 PAR-004 Anthropic adapter。
+
当前结论OpenAI Gateway 实时闭环已完成;PAR-004 请求侧达到 70%,Anthropic Messages/Complete、system、多模态、tools 与 tool result 已进入 Prompt IR。下一项是响应存储契约、Anthropic SSE 重组和 APP-003 千条列表性能验收。

1. 更新规则

    @@ -47,7 +47,7 @@

    3. 里程碑

    - + @@ -60,7 +60,7 @@

    4. 工作流进度

    - +
    ID里程碑权重进度状态目标Owner
    M0Discovery 与技术验证5%
    95%
    In progressW2TBD
    M1基础设施与加密数据链路15%
    75%
    In progressW4Codex / TBD
    M2Gateway 与双厂商闭环20%
    60%
    In progressW8Codex / TBD
    M2Gateway 与双厂商闭环20%
    70%
    In progressW8Codex / TBD
    M3显式代理、安全与恢复20%
    0%
    Not startedW12TBD
    M4四厂商、Compare 与搜索15%
    0%
    Not startedW14TBD
    M5Public Beta 稳定性15%
    0%
    Not startedW18TBD
    架构与工程基础ARC-001~00495%In progressCodex / TBD补齐响应事件持久化契约
    Desktop / FrontendAPP-001~00655%In progressCodex / TBD完成千条列表性能验收
    Capture / NetworkCAP-001~00729%In progressCodex / TBD推进显式代理与证书生命周期
    Prompt / AdaptersPAR-001~00743%In progressCodex / TBD实现 Anthropic adapter
    Prompt / AdaptersPAR-001~00753%In progressCodex / TBD补齐 Anthropic SSE 与响应证据
    Data / SecurityDAT-001~002、SEC-001~00433%In progressCodex / TBD继续日志与临时文件 canary
    Test / ReleaseTST-001~004、REL-001~00310%In progressCodex / TBD建立性能与真实应用矩阵
    @@ -82,6 +82,7 @@

    5. 当前迭代:S2

    PAR-001Prompt IR v0.1 与首批 fixturesDone100%Codex / TBD2026-07-14OpenAI/Anthropic fixtures、校验与 schema 完成 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 适配器In progress70%Codex / TBD2026-07-28Messages/Complete、system、多模态引用、tools、tool result、生成参数与版本头 golden tests 通过;响应 SSE 待响应契约 SPIKE-001Gateway 流式透明转发验证Done100%Codex / TBD2026-07-14双向流式、取消传递、头清理与稳定 502 集成测试通过 SPIKE-002mitmproxy sidecar IPC/打包验证Done100%Codex / TBD2026-07-14凭据清理、IPC、打包与真实转发通过 SPIKE-003Windows/macOS 代理恢复验证Done100%Codex / TBD2026-07-14Windows / macOS 真实强杀恢复通过 @@ -102,7 +103,7 @@

    6. 后续迭代承诺

    - + @@ -162,6 +163,7 @@

    11. 决策与变更记录

    +
    迭代计划任务状态进入条件
    S1APP-001/002、DAT-001、SEC-001、Core 事件骨架In progress加密桌面链路已通过;SEC-001、Core event 与独立安全评审待完成
    S2CAP-001/002、PAR-002/003、APP-003In progressCAP-001/002、PAR-002/003 完成;APP-003 实时接入完成,千条列表性能待验收
    S3PAR-004、APP-004、TST-001、DAT-002Not startedGateway 闭环稳定
    S3PAR-004、APP-004、TST-001、DAT-002In progressPAR-004 请求侧完成;响应契约、Inspector 与数据生命周期待开始
    S4CAP-003~005、TST-002Not startedsidecar 打包方案接受
    S5CAP-006/007、SEC-002/003、APP-006、TST-003Not started显式代理可用
    S6PAR-005~007、APP-005Not started双厂商 Inspector 稳定
    2026-07-15Prompt / AdaptersPAR-002/003 解析链完成OpenAI 三类端点、工具调用与多模态请求进入已校验 Prompt IR;失败适配器隔离并降级 RawCodex
    2026-07-15Gateway / SecurityCAP-001/002 经 PR #15 完成非阻塞 tee 在队列压力下保持完整转发,请求经双层 scrub、OpenAI adapter 和 SQLCipher 进入 Desktop APICodex
    2026-07-15Desktop / Gateway桌面实时 Gateway 生命周期接入固定本地端点、暂停记录、运行错误事件和 React 增量刷新形成首个可操作捕获闭环Codex
    2026-07-15Prompt / AdaptersPAR-004 Anthropic 请求适配器完成Messages 与 legacy Complete 的 system、消息、多模态、工具关联和参数进入可验证 Prompt IR;SSE 依赖响应存储契约Codex
    后续范围、架构、日期或资源变化均在此追加,并链接对应 ADR/会议结论。