diff --git a/.code-review-graphignore b/.code-review-graphignore new file mode 100644 index 000000000..4ca384b54 --- /dev/null +++ b/.code-review-graphignore @@ -0,0 +1,45 @@ +# Build artifacts & target directories +target/ +**/target/ +.cargo-target-antigravity/ +**/.cargo-target-antigravity/ +dist/ +**/dist/ +build/ +**/build/ +out/ +**/out/ +gen/ +**/gen/ + +# Node & Web dependencies +node_modules/ +**/node_modules/ +vendor/ +**/vendor/ + +# Lock files & Minified assets +*.lock +**/*.lock +package-lock.json +**/package-lock.json +pnpm-lock.yaml +**/pnpm-lock.yaml +*.min.js +**/*.min.js +*.min.css +**/*.min.css +*.map +**/*.map +*.wasm +**/*.wasm + +# Cache & VCS & Data dirs +.git/ +**/.git/ +.code-review-graph/ +**/.code-review-graph/ +.tmp/ +**/.tmp/ +tmp/ +**/tmp/ diff --git a/README.en.md b/README.en.md index e5541ef0d..178d6c97d 100644 --- a/README.en.md +++ b/README.en.md @@ -74,6 +74,8 @@ A sixth theme (Carton) carries a floating mascot in the bottom-right that reacts Injects a standalone "Usage" section at the bottom of Codex Desktop's "Toggle pinned summary" popup: 5-hour / weekly / monthly plan quota bars (whitelisted providers: antigravity gemini series + GLM Coding Plan + Xiaomi MiMo Token Plan + **OpenCode Go** + **Kimi Code**), balance / usage numeric entries (DeepSeek / Kimi Moonshot / anyrouter), context usage, real-time token rate + cumulative total, and cache hit rate. The **Context** row expands into a Claude-style by-source breakdown dropdown (classified by what is sent upstream: tool calls & output / reasoning / developer instructions / conversation messages / tool definitions / system prompt), persisted per conversation for instant load. See "What it does" below. +> Codex 26.715 compatibility: the context-ring accessibility label follows the UI language, so the current implementation recognizes English and Simplified Chinese and falls back to the percentage SVG-ring structure. The pinned-summary transition can also keep an off-screen hidden copy alongside the visible copy; the mount logic now selects the on-screen visible container so the complete Usage node cannot land in the hidden copy while the UI shows only Outputs / Sources. + ![Usage panel inside Codex](img/codex-usage-breakdown.jpg) @@ -91,6 +93,7 @@ Stash/restore buttons next to the composer + a "Stash" list below the Usage pane ## What it does +- **Official-first with third-party coexistence**: the Providers page keeps an Official Codex entry at the top, and new configurations default to direct official access. If no official account is present, it launches `codex login` and enables official mode after sign-in. Selecting any third-party provider switches model traffic to the proxy while preserving the ChatGPT credentials; you can switch back to official without signing in again. Existing upgrades with an active provider keep their current third-party route to avoid a silent behavior change. Quota detection and automatic failover are intentionally not performed—the user decides when to switch. - Manage multiple providers; map OpenAI model names (`gpt-5.5` / `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.3-codex` / `gpt-5.2`) to the provider's real model IDs - Translate Codex App's Responses API streaming / non-streaming requests into upstream protocols: Chat Completions, Gemini Native (`:streamGenerateContent`), Gemini CLI OAuth (Cloud Code Assist), Anthropic Messages (`/v1/messages`), Grok Web (`/rest/app-chat/conversations/new`), Responses passthrough, etc. - Multi-turn tool conversation context + `previous_response_id` history replay + autocompact expansion + thinking / reasoning_content injection — all aligned with the OpenAI Responses API protocol; remote compact supported on both protocol generations: the legacy `/responses/compact` endpoint plus remote compaction v2 (a regular streaming `/responses` request carrying a `compaction_trigger` marker, answered with an SSE stream containing a single compaction item) — newer Codex builds previously failed autocompact with `expected exactly one compaction output item`, now fixed (MOC-198) @@ -156,7 +159,7 @@ macOS builds are **not yet signed with an Apple Developer ID** and **not yet Not 1. Launch Codex App Transfer; the desktop window opens 2. On the dashboard, click the top-right "+" → pick a preset or add a custom provider; fill in API Base URL, API Key, then "Fetch models" and add model mappings 3. Click the **Apply** button at the bottom — config is written and a toast confirms sync (if a provider is already configured, just click **Apply** on its card on the home page) -4. To make Codex Desktop pick up the new config, click the ↻ **Restart Codex** button at the top-right (decoupled from Apply since #281 to avoid losing in-flight context on misclicks) +4. To make Codex Desktop pick up the new config, click the ↻ **Restart Codex** button at the top-right (decoupled from Apply since #281 to avoid losing in-flight context on misclicks). On Windows 26.715+, where the GUI executable is now `ChatGPT.exe`, restart still verifies ownership through the `OpenAI.Codex` package path and will not terminate the consumer ChatGPT client or the bundled `codex.exe` CLI ## Provider compatibility matrix diff --git a/README.md b/README.md index 4ac831425..596e20cb9 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Codex App Transfer 是一个面向 **OpenAI Codex APP** 的轻量桌面配置 + 在 Codex Desktop 顶栏「Toggle pinned summary」弹窗底部注入独立「Usage」分区:5 小时 / 每周 / 月度套餐额度(白名单 provider:antigravity gemini 系 + GLM Coding Plan + 小米 MiMo Token Plan + **OpenCode Go** + **Kimi Code**)、余额 / 已用额度数值(DeepSeek / Kimi 月之暗面 / anyrouter)、上下文用量、实时 Tokens 速率与累计、缓存命中率。其中**上下文**行可展开 Claude 风格的 by-source 明细下拉(按发往上游的内容分类:工具调用与输出 / 推理 / 开发者指令 / 对话消息 / 工具定义 / 系统提示),数据按对话持久化、启动即用。详见下方「能做什么」。 +> Codex 26.715 兼容:上下文环的辅助标签会随界面语言本地化,当前版本同时识别英文、简体中文,并以百分比 SVG 圆环结构兜底;此外,摘要弹窗动画期间会并存一个离屏隐藏副本和一个可见副本,挂载器会筛选屏幕内可见容器,避免完整的 Usage 节点被注入隐藏副本而界面只剩「输出 / 来源」。 + ![Codex 内用量显示](img/codex-usage-breakdown.jpg) @@ -91,6 +93,7 @@ Codex App Transfer 是一个面向 **OpenAI Codex APP** 的轻量桌面配置 + ## 能做什么 +- **官方优先、第三方共存**:提供商页顶部固定显示「Codex 官方」入口,新配置默认官方直连;官方账号未登录时可直接调起 `codex login`,登录成功自动启用。点击任一第三方 Provider 即切到 proxy,官方 ChatGPT 凭据会被保留;额度不足时可手动切第三方,之后一键切回官方且无需重复登录。旧版本已有活动 Provider 的配置升级后继续沿用当前第三方,避免静默改路由。本功能不做自动额度探测/自动故障转移,切换时机由用户决定。 - 管理多套供应商,按 OpenAI 模型名(`gpt-5.5` / `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.3-codex` / `gpt-5.2`)映射到供应商真实模型 ID - 把 Codex APP 的 Responses API 流式 / 非流式请求转换为上游协议:Chat Completions、Gemini Native(`:streamGenerateContent`)、Gemini CLI OAuth(Cloud Code Assist)、Anthropic Messages(`/v1/messages`)、Grok Web(`/rest/app-chat/conversations/new`)、Responses 透传等 - 多轮工具对话上下文 + `previous_response_id` 历史回放 + autocompact 展开 + thinking / reasoning_content 注入全部对齐 OpenAI Responses API 协议;remote compact 新旧协议双轨支持:旧版 `/responses/compact` 端点 + 新版 remote compaction v2(普通流式 `/responses` 带 `compaction_trigger` 标记,响应回单个 compaction item 的 SSE 流)——新版 Codex 触发自动压缩时此前会报 `expected exactly one compaction output item` 已修(MOC-198) @@ -155,7 +158,7 @@ macOS 暂未做 **Apple Developer ID 代码签名** 与 **Apple 公证(Notarizat 1. 启动 Codex App Transfer,弹出桌面窗口 2. 在仪表盘点右上角加号 → 选择 preset 或自定义供应商,填入 API Base URL、API Key、获取模型、添加模型映射 3. 点击页面底部的 应用 按钮即可写入配置(toast 提示已同步;如果已配置好提供商,直接点击主页面提供商卡片上的 应用 按钮即可) -4. 让 Codex Desktop 生效:点击右上角 ↻ **重启 Codex** 按钮(#281 起从强制 modal 解耦,避免误触杀进程丢上下文) +4. 让 Codex Desktop 生效:点击右上角 ↻ **重启 Codex** 按钮(#281 起从强制 modal 解耦,避免误触杀进程丢上下文)。Windows 26.715+ 即使 GUI 已改名为 `ChatGPT.exe`,仍按 `OpenAI.Codex` 包路径确认归属后安全重启,不会误杀消费级 ChatGPT 或包内 `codex.exe` CLI ## 供应商兼容矩阵 diff --git a/crates/adapters/src/gemini_native/response.rs b/crates/adapters/src/gemini_native/response.rs index 4ab1fc719..fdd0da70e 100644 --- a/crates/adapters/src/gemini_native/response.rs +++ b/crates/adapters/src/gemini_native/response.rs @@ -137,6 +137,16 @@ struct OpenReasoning { text_acc: String, } +/// Gemini thought parts 的 Responses 输出形态。 +/// +/// 默认维持既有 reasoning summary wire;Antigravity 单独使用 commentary message, +/// 让 Codex 原生 Working 区域持久显示思考过程,而不影响 gemini_native / gemini_cli。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReasoningOutputMode { + ReasoningSummary, + CommentaryMessage, +} + struct ClosedFunctionCall { item_id: String, output_index: u32, @@ -196,6 +206,10 @@ pub struct GeminiToResponsesConverter { next_output_index: u32, open_message: Option, open_reasoning: Option, + reasoning_output_mode: ReasoningOutputMode, + /// Antigravity commentary 模式下,一旦已输出普通回答,后到的 thought part 丢弃, + /// 避免 Working 内容穿插到 final answer 之后。 + has_seen_normal_text: bool, /// 已 close 的 function_call(完整 envelope output[] 用) closed_function_calls: Vec, /// 已 close 的 message items(H3 修复:Gemini 多轮 text→fc→text 序列会让 @@ -270,6 +284,18 @@ impl GeminiToResponsesConverter { pub fn new( original_request: Option, response_session: Option, + ) -> Self { + Self::new_with_reasoning_output_mode( + original_request, + response_session, + ReasoningOutputMode::ReasoningSummary, + ) + } + + pub(crate) fn new_with_reasoning_output_mode( + original_request: Option, + response_session: Option, + reasoning_output_mode: ReasoningOutputMode, ) -> Self { let response_id = response_session .as_ref() @@ -299,6 +325,8 @@ impl GeminiToResponsesConverter { next_output_index: 0, open_message: None, open_reasoning: None, + reasoning_output_mode, + has_seen_normal_text: false, closed_function_calls: Vec::new(), closed_messages: Vec::new(), closed_reasonings: Vec::new(), @@ -509,6 +537,15 @@ impl GeminiToResponsesConverter { for item in output_items { match item.get("type").and_then(|v| v.as_str()) { Some("message") => { + if item + .get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id.starts_with("msg_thought_")) + { + // Antigravity 的可见思考只属于当轮 Working transcript;不把它 + // 写进上游会话历史,避免下一轮把思考当 assistant 正文重放。 + continue; + } if let Some(content) = item.get("content").and_then(|v| v.as_array()) { for part in content { if part.get("type").and_then(|v| v.as_str()) == Some("output_text") { @@ -798,6 +835,13 @@ impl GeminiToResponsesConverter { // 空 item 既渲染空白行,又打断 Codex 按「连续 tool item」 // 分组的工具折叠(对齐 chat 路径 emit_text_delta 空守卫)。 } else if part.thought == Some(true) { + if self.reasoning_output_mode == ReasoningOutputMode::CommentaryMessage + && self.has_seen_normal_text + { + // Antigravity 偶尔在正常回答后补发 thought;Working transcript + // 不应穿插到 final answer 后面,按官方参考实现丢弃。 + continue; + } // reasoning text:必要时关 message + 开 reasoning if self.open_message.is_some() { self.close_message(out); @@ -807,6 +851,7 @@ impl GeminiToResponsesConverter { } self.emit_reasoning_delta(out, text); } else { + self.has_seen_normal_text = true; // 文本 text:必要时关 reasoning + 开 message if self.open_reasoning.is_some() { self.close_reasoning(out); @@ -1352,36 +1397,74 @@ impl GeminiToResponsesConverter { // ───── reasoning item ───── fn open_reasoning(&mut self, out: &mut Vec) { - let item_id = format!("rs_{}", synthesize_id()); + let item_id = match self.reasoning_output_mode { + ReasoningOutputMode::ReasoningSummary => format!("rs_{}", synthesize_id()), + ReasoningOutputMode::CommentaryMessage => format!("msg_thought_{}", synthesize_id()), + }; let output_index = self.next_output_index; self.next_output_index += 1; - emit_event( - out, - &mut self.sequence_number, - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": { - "type": "reasoning", - "status": "in_progress", - "id": item_id, - "summary": [], - }, - }), - ); - emit_event( - out, - &mut self.sequence_number, - "response.reasoning_summary_part.added", - json!({ - "type": "response.reasoning_summary_part.added", - "item_id": item_id, - "output_index": output_index, - "summary_index": 0, - "part": { "type": "summary_text", "text": "" }, - }), - ); + match self.reasoning_output_mode { + ReasoningOutputMode::ReasoningSummary => { + emit_event( + out, + &mut self.sequence_number, + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": { + "type": "reasoning", + "status": "in_progress", + "id": item_id, + "summary": [], + }, + }), + ); + emit_event( + out, + &mut self.sequence_number, + "response.reasoning_summary_part.added", + json!({ + "type": "response.reasoning_summary_part.added", + "item_id": item_id, + "output_index": output_index, + "summary_index": 0, + "part": { "type": "summary_text", "text": "" }, + }), + ); + } + ReasoningOutputMode::CommentaryMessage => { + emit_event( + out, + &mut self.sequence_number, + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": { + "type": "message", + "phase": "commentary", + "status": "in_progress", + "id": item_id, + "role": "assistant", + "content": [], + }, + }), + ); + emit_event( + out, + &mut self.sequence_number, + "response.content_part.added", + json!({ + "type": "response.content_part.added", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "part": { "type": "output_text", "text": "", "annotations": [] }, + }), + ); + } + } self.open_reasoning = Some(OpenReasoning { item_id, output_index, @@ -1397,6 +1480,21 @@ impl GeminiToResponsesConverter { } None => return, }; + if self.reasoning_output_mode == ReasoningOutputMode::CommentaryMessage { + emit_event( + out, + &mut self.sequence_number, + "response.output_text.delta", + json!({ + "type": "response.output_text.delta", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "delta": delta, + }), + ); + return; + } // [单发 summary 通道] 实测官方 gpt-5.5(v26.623)reasoning 只发 // `reasoning_summary_text.delta`(不发 content 通道 `reasoning_text.delta`), // 思考照常显示——当前 Codex 靠 summary 通道渲染。原双发(额外 content 通道) @@ -1420,6 +1518,57 @@ impl GeminiToResponsesConverter { let Some(rs) = self.open_reasoning.take() else { return; }; + if self.reasoning_output_mode == ReasoningOutputMode::CommentaryMessage { + emit_event( + out, + &mut self.sequence_number, + "response.output_text.done", + json!({ + "type": "response.output_text.done", + "item_id": rs.item_id, + "output_index": rs.output_index, + "content_index": 0, + "text": rs.text_acc, + }), + ); + let part = json!({ + "type": "output_text", + "text": rs.text_acc, + "annotations": [], + }); + emit_event( + out, + &mut self.sequence_number, + "response.content_part.done", + json!({ + "type": "response.content_part.done", + "item_id": rs.item_id, + "output_index": rs.output_index, + "content_index": 0, + "part": part, + }), + ); + let item = json!({ + "type": "message", + "phase": "commentary", + "status": "completed", + "id": rs.item_id, + "role": "assistant", + "content": [part], + }); + emit_event( + out, + &mut self.sequence_number, + "response.output_item.done", + json!({ + "type": "response.output_item.done", + "output_index": rs.output_index, + "item": item.clone(), + }), + ); + self.closed_reasonings.push((rs.output_index, item)); + return; + } emit_event( out, &mut self.sequence_number, @@ -2329,6 +2478,20 @@ pub fn convert_gemini_to_responses_stream( input: ByteStream, original_request: Option, response_session: Option, +) -> ByteStream { + convert_gemini_to_responses_stream_with_mode( + input, + original_request, + response_session, + ReasoningOutputMode::ReasoningSummary, + ) +} + +pub(crate) fn convert_gemini_to_responses_stream_with_mode( + input: ByteStream, + original_request: Option, + response_session: Option, + reasoning_output_mode: ReasoningOutputMode, ) -> ByteStream { struct State { input: ByteStream, @@ -2337,7 +2500,11 @@ pub fn convert_gemini_to_responses_stream( } let init = State { input, - conv: GeminiToResponsesConverter::new(original_request, response_session), + conv: GeminiToResponsesConverter::new_with_reasoning_output_mode( + original_request, + response_session, + reasoning_output_mode, + ), finished: false, }; let s: Pin> + Send>> = @@ -2924,6 +3091,129 @@ mod tests { assert!(item_added.1["item"].get("content").is_none()); } + #[test] + fn antigravity_thought_emits_commentary_message_and_keeps_signature_on_tool_call() { + let chunk = br#"data: {"candidates":[{"content":{"parts":[{"text":"inspect the repository","thought":true},{"functionCall":{"name":"search","args":{"q":"wire format"}},"thoughtSignature":"AG_TEST_SIGNATURE"}]},"finishReason":"STOP"}]} + +"#; + let mut conv = GeminiToResponsesConverter::new_with_reasoning_output_mode( + None, + None, + ReasoningOutputMode::CommentaryMessage, + ); + let events = drive_to_events(&mut conv, &[chunk]); + let parsed: Vec<(String, Value)> = events.iter().map(|e| parse_event(e)).collect(); + let names: Vec<&str> = parsed.iter().map(|(name, _)| name.as_str()).collect(); + + assert!(names.contains(&"response.content_part.added")); + assert!(names.contains(&"response.output_text.delta")); + assert!(names.contains(&"response.output_text.done")); + assert!(names.contains(&"response.content_part.done")); + assert!(!names.contains(&"response.reasoning_summary_part.added")); + assert!(!names.contains(&"response.reasoning_summary_text.delta")); + + let commentary_added = parsed + .iter() + .find(|(name, data)| { + name == "response.output_item.added" + && data["item"]["id"] + .as_str() + .is_some_and(|id| id.starts_with("msg_thought_")) + }) + .expect("Antigravity thought should open a commentary message"); + assert_eq!(commentary_added.1["item"]["type"], "message"); + assert_eq!(commentary_added.1["item"]["phase"], "commentary"); + assert_eq!(commentary_added.1["item"]["role"], "assistant"); + + let completed = parsed + .iter() + .find(|(name, _)| name == "response.completed") + .unwrap(); + let output = completed.1["response"]["output"].as_array().unwrap(); + let commentary = output + .iter() + .find(|item| item["phase"] == "commentary") + .expect("completed.output should retain the commentary item"); + assert_eq!(commentary["content"][0]["text"], "inspect the repository"); + assert!(!commentary["content"][0]["text"] + .as_str() + .unwrap() + .contains("**Thinking**")); + + let function_call = output + .iter() + .find(|item| item["type"] == "function_call") + .expect("function call should follow commentary"); + let call_id = function_call["call_id"].as_str().unwrap(); + assert!(!call_id.contains("AG_TEST_SIGNATURE")); + let cached = crate::responses::global_tool_call_cache() + .get(call_id) + .expect("thoughtSignature should be cached for tool roundtrip"); + assert_eq!( + cached.thought_signature.as_deref(), + Some("AG_TEST_SIGNATURE") + ); + } + + #[test] + fn antigravity_drops_late_thought_after_normal_text() { + let chunk = br#"data: {"candidates":[{"content":{"parts":[{"text":"final answer"},{"text":"late internal note","thought":true}]},"finishReason":"STOP"}]} + +"#; + let mut conv = GeminiToResponsesConverter::new_with_reasoning_output_mode( + None, + None, + ReasoningOutputMode::CommentaryMessage, + ); + let events = drive_to_events(&mut conv, &[chunk]); + let completed = events + .iter() + .map(|event| parse_event(event)) + .find(|(name, _)| name == "response.completed") + .unwrap(); + let output = completed.1["response"]["output"].as_array().unwrap(); + assert_eq!(output.len(), 1); + assert_eq!(output[0]["content"][0]["text"], "final answer"); + assert!(output.iter().all(|item| item["phase"] != "commentary")); + } + + #[test] + fn antigravity_commentary_is_not_saved_as_session_assistant_content() { + let output = vec![ + json!({ + "type": "message", + "id": "msg_thought_test", + "phase": "commentary", + "role": "assistant", + "content": [{"type":"output_text","text":"private working"}] + }), + json!({ + "type": "message", + "phase": "final_answer", + "role": "assistant", + "content": [{"type":"output_text","text":"public answer"}] + }), + ]; + let message = GeminiToResponsesConverter::build_assistant_message_for_session(&output) + .expect("final answer should produce a session message"); + assert_eq!(message["content"], "public answer"); + assert!(!message.to_string().contains("private working")); + } + + #[test] + fn ordinary_commentary_message_remains_in_session_history() { + let output = vec![json!({ + "type": "message", + "id": "msg_tool_preamble", + "phase": "commentary", + "role": "assistant", + "content": [{"type":"output_text","text":"I will inspect the file."}] + })]; + let message = GeminiToResponsesConverter::build_assistant_message_for_session(&output) + .expect("non-thought commentary is a real assistant preamble"); + assert_eq!(message["content"], "I will inspect the file."); + } + /// MOC-203 折叠修复锁定:gemini functionCall 后常发 `{"text":""}` 空 part, /// 不得为它开空 message item(空白行 + 打断 Codex 同轮工具折叠分组); /// 空 thought part 同理不开 reasoning item。 diff --git a/crates/adapters/src/mapper/cloud_code.rs b/crates/adapters/src/mapper/cloud_code.rs index 5fae60202..c97c2c5d9 100644 --- a/crates/adapters/src/mapper/cloud_code.rs +++ b/crates/adapters/src/mapper/cloud_code.rs @@ -1,3 +1,4 @@ +use crate::gemini_native::types::{Part, RequestBody, SystemInstruction}; use crate::mapper::{RequestMapper, ResponseMapper}; use crate::responses::compact::{ build_compact_chat_request, build_compact_response_plan, build_compact_v2_response_plan, @@ -21,6 +22,9 @@ pub(crate) enum CloudCodeApiFlavor { Antigravity, } +const ANTIGRAVITY_COMMENTARY_METADATA_KEY: &str = "antigravity_commentary_thoughts"; +const ANTIGRAVITY_PROMPT_INJECTION_KEY: &str = "promptInjection"; + impl CloudCodeApiFlavor { pub(crate) fn from_api_format(api_format: &str) -> Self { if matches!( @@ -45,6 +49,35 @@ pub fn is_antigravity_api_format(api_format: &str) -> bool { CloudCodeApiFlavor::from_api_format(api_format).is_antigravity() } +fn apply_antigravity_prompt_injection( + request: &mut RequestBody, + provider: &Provider, + flavor: CloudCodeApiFlavor, +) { + if !flavor.is_antigravity() { + return; + } + let Some(prompt) = provider + .request_options + .get(ANTIGRAVITY_PROMPT_INJECTION_KEY) + .and_then(Value::as_str) + .filter(|text| !text.trim().is_empty()) + else { + return; + }; + + let system_instruction = request + .system_instruction + .get_or_insert_with(|| SystemInstruction { + role: None, + parts: Vec::new(), + }); + system_instruction.parts.push(Part { + text: Some(prompt.to_owned()), + ..Default::default() + }); +} + /// 按 provider `api_format` 选择 token 文件名。 pub(crate) fn token_filename_for_api_format(api_format: &str) -> &'static str { if CloudCodeApiFlavor::from_api_format(api_format).is_antigravity() { @@ -424,7 +457,7 @@ pub(crate) fn prepare_cloud_code_request( }); } - let parsed: Value = serde_json::from_slice(&body)?; + let mut parsed: Value = serde_json::from_slice(&body)?; let stream = parsed .get("stream") .and_then(|v| v.as_bool()) @@ -438,6 +471,10 @@ pub(crate) fn prepare_cloud_code_request( let flavor = CloudCodeApiFlavor::from_api_format(&provider.api_format); let project_id = resolve_cloud_code_project_id(provider)?; + if flavor.is_antigravity() { + filter_antigravity_transcript_only_commentary(&mut parsed); + } + // **task 25 / silent-failure-hunter PR #145 HIGH-2 修(2026-05-13)**:旧 // 实现调 `responses_body_to_gemini_request`(无 _with_session 版本),完全 // 不传 session_cache → cloud_code prod 路径(gemini_cli OAuth)multi-turn @@ -454,11 +491,13 @@ pub(crate) fn prepare_cloud_code_request( // (`core/input::response_id_for_session()`),OAuth 跟 API key 走**同一个生成器**, // collision domain 跟既有 gemini_native 主路径完全一致,本 PR 不引入新碰撞风险; // 切换用户重新 OAuth 后旧 response_id 不可能被新 session 反查到,行为天然隔离。 - let conversion = crate::gemini_native::request::responses_body_to_gemini_request_with_session( - &parsed, - provider, - Some(crate::responses::session::global_response_session_cache()), - )?; + let mut conversion = + crate::gemini_native::request::responses_body_to_gemini_request_with_session( + &parsed, + provider, + Some(crate::responses::session::global_response_session_cache()), + )?; + apply_antigravity_prompt_injection(&mut conversion.request, provider, flavor); let mut inner_value = serde_json::to_value(&conversion.request).map_err(AdapterError::BodyDecode)?; apply_cloud_code_request_compat(&mut inner_value, flavor); @@ -482,14 +521,58 @@ pub(crate) fn prepare_cloud_code_request( response_session: Some(conversion.response_session), // [MOC-232] context_breakdown 不再经 adapter_metadata 透传 —— 改由 responses::request // 内 spawn_blocking 后台算并按对话 uuid 落盘(搬离转发关键路径,见 context_breakdown.rs)。 - // 本路径无其它 metadata。 - adapter_metadata: None, + // 仅 Antigravity 响应把 thought 映射为 Codex 原生 commentary message; + // Gemini CLI 仍保持既有 reasoning_summary wire。 + adapter_metadata: flavor + .is_antigravity() + .then(|| json!({ "antigravity_commentary_thoughts": true })), is_compact: false, compact_v2: false, original_responses_request: Some(parsed), }) } +/// 只在 Antigravity 归一化前移除 Codex 本地 transcript 里的 Working 消息。 +/// 这些内容已在上一轮展示,不应作为 assistant 正文再次发给 Gemini。 +fn filter_antigravity_transcript_only_commentary(body: &mut Value) { + let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) else { + return; + }; + items.retain(|item| !is_codex_transcript_only_commentary(item)); +} + +fn is_codex_transcript_only_commentary(item: &Value) -> bool { + if item.get("type").and_then(Value::as_str) != Some("message") + || item.get("role").and_then(Value::as_str) != Some("assistant") + { + return false; + } + if item.get("phase").and_then(Value::as_str) == Some("commentary") { + return true; + } + if item + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("msg_thought_")) + { + return true; + } + message_texts(item).any(|text| text.trim_start().starts_with("**Thinking**")) +} + +fn message_texts(item: &Value) -> impl Iterator { + item.get("content") + .into_iter() + .flat_map(|content| match content { + Value::String(text) => vec![text.as_str()], + Value::Array(parts) => parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect(), + _ => Vec::new(), + }) +} + // ─────────────────── [MOC-210] antigravity 出图(image_gen 履约)─────────────────── /// antigravity 默认图像后端模型。model id 含 "image" → `apply_antigravity_transform` @@ -676,10 +759,12 @@ pub(crate) fn transform_cloud_code_response_stream( }); } let unwrapped = crate::gemini_cli::response::unwrap_cloud_code_sse_envelope(upstream_stream); - let stream = crate::gemini_native::response::convert_gemini_to_responses_stream( + let reasoning_output_mode = cloud_code_reasoning_output_mode(request_plan); + let stream = crate::gemini_native::response::convert_gemini_to_responses_stream_with_mode( unwrapped, request_plan.original_responses_request.clone(), request_plan.response_session.clone(), + reasoning_output_mode, ); Ok(ResponsePlan { status: upstream_status, @@ -688,6 +773,22 @@ pub(crate) fn transform_cloud_code_response_stream( }) } +fn cloud_code_reasoning_output_mode( + request_plan: &RequestPlan, +) -> crate::gemini_native::response::ReasoningOutputMode { + if request_plan + .adapter_metadata + .as_ref() + .and_then(|metadata| metadata.get(ANTIGRAVITY_COMMENTARY_METADATA_KEY)) + .and_then(Value::as_bool) + .unwrap_or(false) + { + crate::gemini_native::response::ReasoningOutputMode::CommentaryMessage + } else { + crate::gemini_native::response::ReasoningOutputMode::ReasoningSummary + } +} + /// cloudcode-pa 不识别 `toolConfig.includeServerSideToolInvocations`(camel/snake)。 /// 无条件 strip 两种形态,`toolConfig` 因此变空时同时移除外层 key。 pub(crate) fn strip_include_server_side_tool_invocations(obj: &mut serde_json::Map) { @@ -789,8 +890,22 @@ impl ResponseMapper for CloudCodeMapper { #[cfg(test)] mod tests { use super::*; + use futures_util::{stream, StreamExt}; use serde_json::json; + fn test_request_plan(adapter_metadata: Option) -> RequestPlan { + RequestPlan { + upstream_path: String::new(), + body: bytes::Bytes::new(), + upstream_headers: HeaderMap::new(), + response_session: None, + adapter_metadata, + is_compact: false, + compact_v2: false, + original_responses_request: None, + } + } + #[test] fn uuid_v4_format_matches_rfc_4122() { let id = uuid_v4().unwrap(); @@ -889,6 +1004,239 @@ mod tests { } } + fn prompt_injection_provider(prompt: Value) -> Provider { + serde_json::from_value(json!({ + "id": "test-provider", + "name": "Test Provider", + "baseUrl": "https://example.com", + "apiFormat": "antigravity_oauth", + "cloud_code_project_id": "proj-test", + "requestOptions": { + "promptInjection": prompt + } + })) + .unwrap() + } + + #[test] + fn antigravity_prompt_injection_appends_to_existing_system_instruction() { + let provider = prompt_injection_provider(json!(" injected\nprompt ")); + let mut request = RequestBody { + system_instruction: Some(SystemInstruction { + role: None, + parts: vec![Part { + text: Some("existing".into()), + ..Default::default() + }], + }), + ..Default::default() + }; + + apply_antigravity_prompt_injection( + &mut request, + &provider, + CloudCodeApiFlavor::Antigravity, + ); + + let parts = &request.system_instruction.as_ref().unwrap().parts; + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].text.as_deref(), Some("existing")); + assert_eq!(parts[1].text.as_deref(), Some(" injected\nprompt ")); + } + + #[test] + fn antigravity_prompt_injection_creates_system_instruction_when_missing() { + let provider = prompt_injection_provider(json!("injected")); + let mut request = RequestBody::default(); + + apply_antigravity_prompt_injection( + &mut request, + &provider, + CloudCodeApiFlavor::Antigravity, + ); + + let system_instruction = request.system_instruction.unwrap(); + assert_eq!(system_instruction.role, None); + assert_eq!(system_instruction.parts.len(), 1); + assert_eq!( + system_instruction.parts[0].text.as_deref(), + Some("injected") + ); + } + + #[test] + fn antigravity_prompt_injection_ignores_blank_prompt() { + let provider = prompt_injection_provider(json!(" \n\t ")); + let mut request = RequestBody::default(); + + apply_antigravity_prompt_injection( + &mut request, + &provider, + CloudCodeApiFlavor::Antigravity, + ); + + assert!(request.system_instruction.is_none()); + } + + #[test] + fn gemini_cli_does_not_apply_antigravity_prompt_injection() { + let provider = prompt_injection_provider(json!("injected")); + let mut request = RequestBody::default(); + + apply_antigravity_prompt_injection(&mut request, &provider, CloudCodeApiFlavor::GeminiCli); + + assert!(request.system_instruction.is_none()); + } + + fn prompt_injection_responses_body() -> bytes::Bytes { + bytes::Bytes::from( + serde_json::to_vec(&json!({ + "model": "gemini-3-pro-low", + "instructions": "original instructions", + "input": [ + {"type": "message", "role": "user", "content": "hello"} + ], + "stream": true + })) + .unwrap(), + ) + } + + fn system_instruction_texts(envelope: &Value) -> Vec<&str> { + envelope + .pointer("/request/systemInstruction/parts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect() + } + + #[test] + fn antigravity_entry_appends_prompt_injection_after_original_instructions() { + let provider = prompt_injection_provider(json!("injected from provider")); + + let plan = + prepare_cloud_code_request("/responses", prompt_injection_responses_body(), &provider) + .unwrap(); + let envelope: Value = serde_json::from_slice(&plan.body).unwrap(); + let texts = system_instruction_texts(&envelope); + + assert_eq!(texts.first().copied(), Some("original instructions")); + assert_eq!(texts.last().copied(), Some("injected from provider")); + } + + #[test] + fn gemini_cli_entry_does_not_apply_antigravity_prompt_injection() { + let mut provider = prompt_injection_provider(json!("injected from provider")); + provider.api_format = "gemini_cli_oauth".into(); + + let plan = + prepare_cloud_code_request("/responses", prompt_injection_responses_body(), &provider) + .unwrap(); + let envelope: Value = serde_json::from_slice(&plan.body).unwrap(); + let texts = system_instruction_texts(&envelope); + + assert_eq!(texts, vec!["original instructions"]); + assert!(!envelope["request"] + .to_string() + .contains("injected from provider")); + } + + #[test] + fn compact_entry_does_not_apply_antigravity_prompt_injection() { + let provider = prompt_injection_provider(json!("injected from provider")); + + let plan = prepare_cloud_code_request( + "/responses/compact", + prompt_injection_responses_body(), + &provider, + ) + .unwrap(); + let envelope: Value = serde_json::from_slice(&plan.body).unwrap(); + + assert!(plan.is_compact); + assert!(!envelope["request"] + .to_string() + .contains("injected from provider")); + } + + #[test] + fn antigravity_filters_only_transcript_thought_messages() { + let mut body = json!({ + "input": [ + {"type":"message","id":"msg_thought_1","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"new working"}]}, + {"type":"message","id":"msg_old","role":"assistant","content":[{"type":"output_text","text":"**Thinking**\n\nlegacy working"}]}, + {"type":"message","id":"msg_preamble","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"I will inspect it."}]}, + {"type":"message","id":"msg_final","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"answer"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]} + ] + }); + filter_antigravity_transcript_only_commentary(&mut body); + let items = body["input"].as_array().unwrap(); + assert_eq!(items.len(), 2); + assert!(items.iter().any(|item| item["id"] == "msg_final")); + assert!(items.iter().any(|item| item["role"] == "user")); + assert!(!items.iter().any(|item| item["id"] == "msg_thought_1")); + assert!(!items.iter().any(|item| item["id"] == "msg_old")); + assert!(!items.iter().any(|item| item["id"] == "msg_preamble")); + } + + #[test] + fn cloud_code_reasoning_mode_is_enabled_only_by_antigravity_metadata() { + let base = test_request_plan(None); + assert_eq!( + cloud_code_reasoning_output_mode(&base), + crate::gemini_native::response::ReasoningOutputMode::ReasoningSummary, + "Gemini CLI/no metadata must keep reasoning_summary" + ); + let mut antigravity = base.clone(); + antigravity.adapter_metadata = Some(json!({ + "antigravity_commentary_thoughts": true + })); + assert_eq!( + cloud_code_reasoning_output_mode(&antigravity), + crate::gemini_native::response::ReasoningOutputMode::CommentaryMessage + ); + } + + #[tokio::test] + async fn cloud_code_stream_uses_commentary_only_for_antigravity_plan() { + const RAW: &[u8] = br#"data: {"response":{"candidates":[{"content":{"parts":[{"text":"working","thought":true}]},"finishReason":"STOP"}]}} + +"#; + async fn convert(plan: RequestPlan) -> String { + let upstream: ByteStream = + Box::pin(stream::iter(vec![Ok(bytes::Bytes::from_static(RAW))])); + let response = transform_cloud_code_response_stream( + StatusCode::OK, + HeaderMap::new(), + upstream, + &plan, + ) + .unwrap(); + let chunks: Vec<_> = response.stream.collect().await; + let bytes = chunks + .into_iter() + .flat_map(|chunk| chunk.unwrap().to_vec()) + .collect::>(); + String::from_utf8(bytes).unwrap() + } + + let gemini_cli = convert(test_request_plan(None)).await; + assert!(gemini_cli.contains("response.reasoning_summary_text.delta")); + assert!(!gemini_cli.contains("msg_thought_")); + + let antigravity = convert(test_request_plan(Some(json!({ + "antigravity_commentary_thoughts": true + })))) + .await; + assert!(antigravity.contains("response.output_text.delta")); + assert!(antigravity.contains("\"phase\":\"commentary\"")); + assert!(antigravity.contains("msg_thought_")); + assert!(!antigravity.contains("response.reasoning_summary_text.delta")); + } + #[test] fn token_filename_selection_is_stable() { assert_eq!( @@ -1144,7 +1492,11 @@ mod tests { #[test] fn prepare_image_request_builds_image_gen_envelope() { - let provider = antigravity_image_provider(json!({ "default": "gemini-3-flash-agent" })); + let mut provider = antigravity_image_provider(json!({ "default": "gemini-3-flash-agent" })); + provider.request_options.insert( + ANTIGRAVITY_PROMPT_INJECTION_KEY.into(), + json!("injected from provider"), + ); let body = serde_json::to_vec(&json!({ "model": "gpt-image-1", "prompt": "a cute orange tabby cat", @@ -1190,6 +1542,8 @@ mod tests { .and_then(|v| v.as_str()), Some("IMAGE") ); + assert!(req.get("systemInstruction").is_none()); + assert!(!req.to_string().contains("injected from provider")); } #[test] diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index f4e199533..515213d1c 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -3,6 +3,13 @@ import type { Provider, Preset, ProviderPayload } from './types' type IconSpec = { logo?: string; icon?: string } +export type CodexRoutingMode = 'official' | 'provider' + +export interface ProvidersResult { + providers: Provider[] + routingMode: CodexRoutingMode +} + // ── 逐字移植 api.js ICON_MAP ── // 子串匹配 + insertion 顺序敏感: 特定 OAuth/品牌规则必须放在通用规则前(见各行注释)。 const ICON_MAP: Record = { @@ -123,15 +130,22 @@ export function providerBody(payload: ProviderPayload, includeModels = true): Re } // ── 端点(路径/请求 shape 保持与后端契约一致) ── -export async function getProviders(): Promise { +export async function getProviders(): Promise { // 后端 list_providers 返回的 key 是 `activeId`(非 activeProviderId);读错即导致 // 没有 provider 被标记 default → 「已启用」徽章/置顶不生效。 - const data = await api<{ providers?: Record[]; activeId?: string | null }>( + const data = await api<{ + providers?: Record[] + activeId?: string | null + routingMode?: CodexRoutingMode + }>( 'GET', '/api/providers', ) const activeId = data.activeId ?? null - return (data.providers || []).map((p) => mapProvider(p, activeId)) + return { + providers: (data.providers || []).map((p) => mapProvider(p, activeId)), + routingMode: data.routingMode === 'provider' ? 'provider' : 'official', + } } export async function getPresets(): Promise { @@ -148,6 +162,11 @@ export const reorderProviders = (providerIds: string[]) => api('PUT', '/api/providers/reorder', { providerIds }) export const setDefaultProvider = (id: string) => api('PUT', `/api/providers/${id}/default`) export const activateProvider = (id: string) => api('POST', `/api/providers/${id}/activate`) +export const activateOfficialCodex = () => + api<{ success: boolean; routingMode?: CodexRoutingMode }>( + 'POST', + '/api/desktop/real-account/activate-official', + ) export const getProviderSecret = (id: string) => api<{ apiKey?: string }>('GET', `/api/providers/${id}/secret`) diff --git a/frontend/src/components/provider/OfficialCodexCard.vue b/frontend/src/components/provider/OfficialCodexCard.vue new file mode 100644 index 000000000..02a649766 --- /dev/null +++ b/frontend/src/components/provider/OfficialCodexCard.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/provider/ProviderFormModal.vue b/frontend/src/components/provider/ProviderFormModal.vue index 2f5391035..1995acb22 100644 --- a/frontend/src/components/provider/ProviderFormModal.vue +++ b/frontend/src/components/provider/ProviderFormModal.vue @@ -19,6 +19,8 @@ import AccountPoolSection from '@/components/provider/AccountPoolSection.vue' import { oauthClaimPending, type OAuthKind } from '@/api/oauth' import { useToast } from '@/composables/useToast' +const PROMPT_INJECTION_KEY = 'promptInjection' + // editId 为空 = 添加;非空 = 编辑(从 store 取数据 + 拉 secret 回填) const props = defineProps<{ editId?: string | null }>() const emit = defineEmits<{ close: []; saved: [] }>() @@ -31,6 +33,11 @@ function stringifyIfAny(o: Record | undefined): string { return o && Object.keys(o).length ? JSON.stringify(o, null, 2) : '' } +function promptInjectionFromRequestOptions(o: Record | undefined): string { + const value = o?.[PROMPT_INJECTION_KEY] + return typeof value === 'string' ? value : '' +} + // 模型下拉选项:value=原始 model id(发往上游), label=显示名(如 Gemini displayName)。 type ModelOpt = { value: string; label: string } // ── 获取到的模型清单本地持久化(按 baseUrl), 下次打开同上游 provider 无需重新「获取模型」── @@ -113,6 +120,7 @@ const form = reactive({ extraHeaders: '', modelCapabilities: '', requestOptions: '', + promptInjection: '', grokSso: '', }) const saving = ref(false) @@ -156,6 +164,11 @@ const isCustomProvider = computed(() => !isBuiltin.value && !matchedPreset.value // 编辑内置/预设 provider 时 baseUrl 不可改:后端 update_provider 对 builtin 跳过 baseUrl, // 改了会被静默丢弃(含小米多集群切换)→ 设为只读, 如实反映后端行为(添加时仍可选集群)。 const baseUrlReadonly = computed(() => isEdit.value && !isCustomProvider.value) +const isAntigravity = computed(() => + ['antigravity_oauth', 'antigravity', 'google_oauth_antigravity'].includes( + form.apiFormat.trim().toLowerCase().replace(/-/g, '_'), + ), +) // OAuth 账号登录类 provider(按 authScheme 判定, 添加经 preset 预填 / 编辑从后端回填均适用): // 这些用浏览器授权登录, 用登录区取代 API Key 输入。 @@ -308,6 +321,7 @@ function resetToCustom() { form.extraHeaders = '' form.modelCapabilities = '' form.requestOptions = '' + form.promptInjection = '' availableModels.value = [] } @@ -342,6 +356,9 @@ function applyPreset(p: Preset) { form.extraHeaders = stringifyIfAny(p.extraHeaders) form.modelCapabilities = stringifyIfAny(p.modelCapabilities) form.requestOptions = stringifyIfAny(p.requestOptions as Record | undefined) + form.promptInjection = promptInjectionFromRequestOptions( + p.requestOptions as Record | undefined, + ) // 该上游若之前抓过模型, 带出缓存清单, 否则清空待用户「获取模型」 availableModels.value = [] loadCachedModels(form.baseUrl) @@ -466,6 +483,7 @@ onMounted(async () => { form.extraHeaders = stringifyIfAny(p.extraHeaders) form.modelCapabilities = stringifyIfAny(p.modelCapabilities) form.requestOptions = stringifyIfAny(p.requestOptions) + form.promptInjection = promptInjectionFromRequestOptions(p.requestOptions) // 该上游之前抓过模型 → 带出本地缓存清单, 无需重新「获取模型」即可切换映射 loadCachedModels(form.baseUrl) loadMsgFiltered(form.baseUrl) @@ -508,6 +526,12 @@ async function save() { error.value = (e as Error).message return } + if (isAntigravity.value && form.promptInjection.trim()) { + requestOptions ??= {} + requestOptions[PROMPT_INJECTION_KEY] = form.promptInjection + } else if (requestOptions) { + delete requestOptions[PROMPT_INJECTION_KEY] + } // 全部 6 槽都发(空发 ""),对齐旧 normalized 行为,支持清空某槽回落默认 const models: Record = {} for (const s of MODEL_SLOTS) models[s.key] = form.models[s.key].trim() @@ -645,6 +669,17 @@ async function save() { +
+ + + {{ t('providerForm.promptInjectionHint') }} +
+