Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## v2.2.x — 进行中

**第三方会话切真 GPT 报 `Instructions are required` 修复 (MOC-153)**:第三方模型(MiniMax / MiMo 等)起步的对话**切换到真 GPT**(`gpt-5.5` 等)后发消息,Codex composer 内联报 `Instructions are required`、对话卡死。根因:Codex 把会话所用模型 model catalog 的 `base_instructions` 字段**在会话创建时冻结**(定死、跨续话不重算;2026-06-07 isolated `codex exec` 真机实测坐实:catalog 写什么字符串,`session_meta.base_instructions` 就原样冻结),transfer 历史上给 catalog 每个 entry 写空串 → 第三方起步的会话顶层 `instructions` 一直空;之后停 transfer 切真 GPT 直连 chatgpt.com(不经 proxy),OpenAI Responses 后端硬校验顶层 instructions 非空 → 400(真 GPT 不经 proxy,只能让会话出生即带非空)。修复:`registry` 新增共享常量 `CAS_BASE_INSTRUCTIONS`,`model_catalog.rs` 给每个 catalog entry 写该非空值(替代旧 `""`),会话出生即带非空 base_instructions、切 GPT 不再 400;该 sentinel 对第三方请求由 `responses/request.rs` 的 adapter 精确剥离(只为切真 GPT 续话过校验而存在),第三方对话字节级不变。Refs MOC-153。

**经严格中转网关(AIOHub 等)接入时工具 schema 400 修复 (MOC-188)**:Codex 部分内置工具(`list_mcp_resources` / `load_workspace_dependencies` / `read_thread_terminal` 等,参数全 optional 或无参)的 parameters schema 省略了 `required` 数组;OpenAI / DeepSeek 官方等宽容上游默认把缺失当空集放行,但严格 OpenAI 兼容中转网关(如 AIOHub)的 JSON Schema validator 要求 object schema 显式带 `required`,读到缺失字段得 `null` → 报 `null is not of type "array"` 把整轮请求 400 拒掉,导致经此类网关的所有工具调用请求失败。修复:`core/schema.rs` 新增协议无关 helper `ensure_object_schema_required`,在 `responses → chat` 转换路径的 function tool 分支(`strict:false`)统一递归补 `required:[]`(语义中性,对宽容上游是 no-op);`strict:true` 工具原样透传(补空反而违反 OpenAI strict 规范)。Refs MOC-188。

**web_fetch 大页摘要迭代 (MOC-157)**:在 MOC-156 map-reduce 基础上新增四项改进。① **超时兜底**:map-reduce 某批摘要超时(>90s)→ 丢弃该批、对剩余批继续 reduce、在最终摘要里如实注明"第 X 段超时跳过,可能不完整";全部批均超时才回退原文;非超时硬错误继续整体回退(绝不丢内容)。② **错误文案修正**:区分 reqwest 连接超时("摘要超时")与连接失败(完整 source chain),不再被 reqwest "error sending request" 外层文案误导。③ **关 thinking 提速**:摘要调用复用 registry `compact_disable_thinking_wire` 关掉 reasoning 模型的 CoT(`<think>` 段),大幅提速;支持厂商:MiniMax M3 / Kimi / GLM / MiMo / DeepSeek / Qwen(MiniMax M2.x 不支持,见下)。④ **批大小白名单扩展到 8 厂商**:按 DeepSeek / Kimi / MiMo / GLM / Qwen / MiniMax / Grok / Gemini 系模型名映射真实单批字符上限(替代原四档近似值)。配套:**MiniMax 摘要模型默认推荐 M3**,Settings 页加 warning:若仍用 M2.x,关 thinking 无效、摘要耗时会显著增加。Refs MOC-157。
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ v2.1.5 及之前的版本未把 `role=system` 转 `role=user`,导致 MiniMax `/v

Codex 部分内置工具(如 `list_mcp_resources` / `load_workspace_dependencies` / `read_thread_terminal`,参数全 optional 或无参)的 parameters schema 省略了 `required` 数组。OpenAI / DeepSeek 官方等宽容上游默认把缺失当空集放行;但严格 OpenAI 兼容中转网关(如 AIOHub)的 JSON Schema validator 要求 object schema 显式带 `required`,读到缺失得 `null` → 报 `null is not of type "array"` 整请求 400。**v2.2.2+ 已修(MOC-188)**:转换路径统一给缺失 `required` 的 object schema 补 `required:[]`(语义中性),经此类网关接入不再因工具 schema 缺字段 400。

### 第三方模型会话切到 GPT 报 `Instructions are required`

在第三方模型(如 MiniMax / MiMo)起步的对话里**切换到真 GPT**(`gpt-5.5` 等)后发消息,Codex composer 内联报 `Instructions are required`、对话卡死。根因:Codex 把会话所用模型的 model catalog `base_instructions` 字段**在会话创建时冻结**(定死、跨续话不重算),transfer 历史上给 catalog 写的是空串 → 第三方起步的会话顶层 `instructions` 一直空;之后切真 GPT 直连 chatgpt.com(不经 proxy),OpenAI Responses 后端硬校验顶层 instructions 非空 → 400。**已修(MOC-153)**:transfer 给 catalog 写非空 `base_instructions`,会话出生即带、切 GPT 不再 400;该值对第三方请求由 proxy adapter 精确剥离,第三方对话行为字节级不变。

### 上游 404 / 连不上(Base URL 填了完整 endpoint)

provider 的 Base URL 只填到根或 `/v1`(例 `https://api.example.com/v1`),**不要**把完整 endpoint 路径整段粘进去。本工具会按协议自动补 `/chat/completions`、`/v1/messages`、`/responses` 等;若 Base URL 已含这些后缀(如把 `https://opencode.ai/zen/go/v1/chat/completions` 整段填入),会拼成 `…/chat/completions/chat/completions` 导致上游 404。删掉多余的 endpoint 后缀、只留到 `/v1` 即可。
Expand Down
38 changes: 33 additions & 5 deletions crates/adapters/src/responses/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,21 @@ fn build_messages_from_input(
session_cache: Option<&ResponseSessionCache>,
) -> Result<MergeResult, AdapterError> {
let mut messages: Vec<Value> = Vec::new();
if let Some(msg) = body
.get("instructions")
.and_then(build_instructions_message)
{
messages.push(msg);
// [MOC-153] 剥掉 transfer 注入的 catalog base_instructions sentinel。
// transfer 给 catalog 条目写非空 `CAS_BASE_INSTRUCTIONS`(修"第三方会话切真 GPT
// 续话报 400"),Codex 会把它作为顶层 instructions 发给**每个** turn —— 包括转发给
// 第三方 chat provider 的请求。该 sentinel 对第三方纯属噪音(历史上第三方顶层
// instructions 本就为空;仅在注册 apply_patch 的 first turn 才有下方注入的 chat-path
// 指引),命中即跳过、保持第三方请求与历史行为一致、零污染。非 sentinel 的真实
// instructions 照常转为 system 头。
let instructions = body.get("instructions");
let skip_cas_sentinel = instructions
.map(is_cas_injected_base_instructions)
.unwrap_or(false);
if !skip_cas_sentinel {
if let Some(msg) = instructions.and_then(build_instructions_message) {
messages.push(msg);
}
}

// 紧跟 Codex CLI 自带 instructions 之后注入 apply_patch chat-path 指引
Expand Down Expand Up @@ -387,6 +397,24 @@ fn build_instructions_message(instructions: &Value) -> Option<Value> {
}
}

/// [MOC-153] 顶层 instructions 是否就是 transfer 注入给 catalog 的 sentinel
/// ([`codex_app_transfer_registry::CAS_BASE_INSTRUCTIONS`])。命中即在转发给第三方
/// chat provider 时剥离,详见 [`build_messages_from_input`] 与 registry 模块文档。
///
/// 兼容 wire 上 instructions 既可能是裸 string、也可能是 `{ "text" | "content": ... }`
/// 对象;精确匹配常量值(不 trim,catalog→session_meta→wire 全程原样传递)。
fn is_cas_injected_base_instructions(instructions: &Value) -> bool {
let text = match instructions {
Value::String(s) => Some(s.as_str()),
Value::Object(obj) => obj
.get("text")
.or_else(|| obj.get("content"))
.and_then(|v| v.as_str()),
_ => None,
};
text == Some(codex_app_transfer_registry::CAS_BASE_INSTRUCTIONS)
}

/// 把 `body.input` 字段(可能是 string 也可能是 array)展开成 messages 列表.
fn input_field_to_messages(input: &Value) -> Vec<Value> {
let items = extract_input_items(input);
Expand Down
54 changes: 54 additions & 0 deletions crates/adapters/src/responses/request/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2093,6 +2093,60 @@ fn empty_instructions_is_skipped() {
assert_eq!(msgs[0]["role"], "user");
}

#[test]
fn cas_sentinel_instructions_stripped_for_third_party() {
// [MOC-153] transfer 注入给 catalog 的 base_instructions sentinel 不能转发给第三方
// chat provider:命中即剥离,第三方请求与历史"顶层 instructions 为空"时字节级一致
// (只剩 user 输入,无 system 头)。sentinel 只为"切真 GPT 续话"过后端校验而存在。
let out = convert(json!({
"model": "x",
"instructions": codex_app_transfer_registry::CAS_BASE_INSTRUCTIONS,
"input": "hi"
}));
let msgs = out["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 1, "sentinel 应被剥离,不产生 system 头");
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[0]["content"], "hi");
}

#[test]
fn cas_sentinel_instructions_as_object_text_stripped() {
// 兼容 instructions 以 { "text": ... } 对象形态出现时也精确剥离。
let out = convert(json!({
"instructions": { "text": codex_app_transfer_registry::CAS_BASE_INSTRUCTIONS },
"input": "hi"
}));
let msgs = out["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0]["role"], "user");
}

#[test]
fn cas_sentinel_instructions_as_object_content_stripped() {
// helper doc 声称同时认 { "text" | "content": ... };锁住 content 键分支(与
// build_instructions_message 的取值口径对称)。
let out = convert(json!({
"instructions": { "content": codex_app_transfer_registry::CAS_BASE_INSTRUCTIONS },
"input": "hi"
}));
let msgs = out["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0]["role"], "user");
}

#[test]
fn non_sentinel_instructions_not_stripped() {
// 只精确剥离 sentinel;其它任何非空 instructions(真实用户/Codex 指令)照常作 system 头。
let out = convert(json!({
"instructions": "You are Codex, a coding agent.",
"input": "hi"
}));
let msgs = out["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["role"], "system");
assert_eq!(msgs[0]["content"], "You are Codex, a coding agent.");
}

#[test]
fn array_input_message_item_passthrough() {
let out = convert(json!({
Expand Down
43 changes: 40 additions & 3 deletions crates/codex_integration/src/model_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

use codex_app_transfer_registry::{
documented_context_window, load_raw_config, model_supports_1m, normalize_model_mappings,
save_raw_config, strip_internal_model_suffix, MODEL_SLOTS,
save_raw_config, strip_internal_model_suffix, CAS_BASE_INSTRUCTIONS, MODEL_SLOTS,
};
use serde_json::{json, Value};

Expand Down Expand Up @@ -467,7 +467,11 @@ fn codex_model_template(
"additional_speed_tiers": additional_speed_tiers,
"availability_nux": availability_nux,
"upgrade": upgrade,
"base_instructions": "",
// [MOC-153] 非空 sentinel(替代旧 ""):Codex 把它冻结进会话级
// session_meta.base_instructions,让"第三方会话停 transfer 后切真 GPT 续话"
// 时顶层 instructions 非空、不再被真 ChatGPT 后端 400。第三方请求经 adapter
// 剥离本 sentinel(request.rs),字节级不变。见 registry::CAS_BASE_INSTRUCTIONS。
"base_instructions": CAS_BASE_INSTRUCTIONS,
"supports_reasoning_summaries": true,
"default_reasoning_summary": default_reasoning_summary,
"support_verbosity": true,
Expand Down Expand Up @@ -509,7 +513,9 @@ fn generic_model_template() -> Value {
"additional_speed_tiers": [],
"availability_nux": null,
"upgrade": null,
"base_instructions": "",
// [MOC-153] 见 codex_model_template 同处注释:非空 sentinel 修真 GPT 切换 400,
// 第三方经 adapter 剥离。见 registry::CAS_BASE_INSTRUCTIONS。
"base_instructions": CAS_BASE_INSTRUCTIONS,
"supports_reasoning_summaries": false,
"default_reasoning_summary": "auto",
"support_verbosity": false,
Expand Down Expand Up @@ -597,6 +603,37 @@ mod tests {
assert_eq!(entry["web_search_tool_type"], "text_and_image");
}

#[test]
fn catalog_entries_carry_nonempty_base_instructions() {
// [MOC-153] 每个 catalog 条目的 base_instructions 必须是非空 sentinel(替代旧 ""):
// Codex 把它冻结进会话级 session_meta.base_instructions,第三方会话停 transfer 后切
// 真 GPT 续话才不会因顶层 instructions 空被后端 400。builtin slug(gpt-5.5)与
// generic 模板(非内置 slug)两条路径都要带。第三方请求侧由 adapter 剥离,无污染。
let builtin = catalog_models_for_provider("DeepSeek", "deepseek-v4-pro", true, None, None);
let gpt55 = builtin.iter().find(|m| m.slug == "gpt-5.5").unwrap();
assert_eq!(
model_to_json(gpt55)["base_instructions"],
CAS_BASE_INSTRUCTIONS
);

let non_builtin = CatalogModel {
slug: "custom-non-builtin".into(),
display_name: "custom-non-builtin".into(),
provider_name: "P".into(),
context_window: 258_400,
effective_context_window_percent: 95,
auto_review_model_override: None,
};
assert_eq!(
model_to_json(&non_builtin)["base_instructions"],
CAS_BASE_INSTRUCTIONS
);

// 防回归:绝不退回空串(空串正是 MOC-153 的根因)。
assert_ne!(model_to_json(gpt55)["base_instructions"], "");
assert_ne!(model_to_json(&non_builtin)["base_instructions"], "");
}

#[test]
fn fallback_entry_declares_apply_patch_freeform_for_non_builtin_slug() {
// 回归保护(#222 修了 generic_model_template 但没加测试):
Expand Down
45 changes: 45 additions & 0 deletions crates/registry/src/base_instructions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//! [MOC-153] transfer 注入给 Codex catalog 的顶层 `base_instructions` sentinel。
//!
//! ## 背景(2026-06-07 真机 isolated `codex exec` 实测坐实)
//!
//! Codex 用 `model_catalog_json` 里某 model 条目的 `base_instructions` 字段填充
//! 会话级 `session_meta.base_instructions`,并**在会话创建时冻结、跨 resume 不重
//! 解析**(实测:catalog 写什么字符串,session_meta 就原样冻结什么)。
//!
//! transfer 历史上给每个 catalog 条目写空串 `""`,于是第三方模型起步的会话顶层
//! instructions 一直为空。用户**停掉 transfer 后把该会话切到真 GPT** 续话时,Codex
//! 仍把这个冻结的空 instructions 发给真 ChatGPT 的 Responses 后端;后端硬校验顶层
//! instructions 非空 → 400 `Instructions are required`(MOC-153)。真 GPT 直连、
//! 不经 transfer,proxy 够不着该请求 → 唯一可行的修法是让第三方会话**出生就带非空
//! base_instructions**。
//!
//! ## 这个常量的两端用途
//!
//! - **写入**:`crates/codex_integration/src/model_catalog.rs` 把每个 catalog 条目
//! 的 `base_instructions` 写成本常量(替代旧的 `""`)。
//! - **剥离**:`crates/adapters/src/responses/request.rs` 在构造转发给第三方 chat
//! provider 的请求时,**剥掉**等于本常量的顶层 instructions。
//!
//! 为什么要剥:本 sentinel **只**为"切真 GPT 续话"过后端校验而存在,内容只需非空且
//! 中性即可 —— 真 GPT 的人格/行为不靠它(据 MOC-153 issue 分析,Codex 切模型时把 GPT
//! 人格塞进 input 的 `<model_switch>` developer 消息;**此点引自 issue 描述、未在本仓
//! 抓包亲验**,但本修复的正确性不依赖它成立)。对第三方而言本 sentinel 纯属噪音(历史
//! 上第三方请求顶层 instructions 本就为空,仅在注册 apply_patch 的 first turn 才另有
//! adapter 注入的 chat-path 指引),剥掉后第三方请求与历史行为在**当前 Codex 的
//! instructions wire 形态(裸 string / `{ text | content }` 对象)下字节级一致、零污染**。
//!
//! 两端共用同一常量,保证 single source of truth —— 写进去的 sentinel 一定被对应
//! 版本的 adapter 精确识别并剥离。

/// transfer 注入给 Codex catalog 条目的非空 `base_instructions` sentinel。
///
/// 详见[模块文档](self)。catalog 写入端与 adapter 剥离端共用本常量。内容是一段
/// 中性的 coding-agent 系统提示:既能让"切真 GPT 续话"时顶层 instructions 非空过
/// 校验,万一被单独使用也是一段合理的兜底提示。
///
/// ⚠️ **改这个字面值是 breaking change**:sentinel 在会话创建时被冻结进
/// `session_meta`(见模块文档)。用旧值创建、用新版本 resume 的存量第三方会话,其冻结
/// 的旧 sentinel 不再 `==` 新常量 → adapter 剥不掉 → 旧 sentinel 作 system 头**静默
/// 泄漏给第三方 provider**(轻度污染、非崩溃)。若确需改值,须把旧值一并保留进
/// `is_cas_injected_base_instructions` 的剥离白名单,不能直接替换。
pub const CAS_BASE_INSTRUCTIONS: &str = "You are a coding agent operating inside the Codex CLI, collaborating with the user in their workspace. Read and edit files, run commands, and complete software-engineering tasks precisely. Make minimal, correct changes, verify your work before reporting, and follow any project- or task-specific instructions provided later in the conversation.";
2 changes: 2 additions & 0 deletions crates/registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//! - **未实现** OS 集成层(Windows 注册表 / macOS plist / Codex TOML 注入),
//! 按 docs/refactor/migration.md §4 拆分,留给 Stage 2.5 的 `crates/codex_integration`。

pub mod base_instructions;
pub mod compact_thinking_policy;
pub mod healing;
pub mod model_alias;
Expand All @@ -23,6 +24,7 @@ pub mod raw_io;
pub mod reasoning_effort_policy;
pub mod schema;

pub use base_instructions::CAS_BASE_INSTRUCTIONS;
pub use compact_thinking_policy::{compact_disable_thinking_wire, DisableThinkingWire};
#[allow(deprecated)]
pub use healing::heal_builtin_extra_headers;
Expand Down
Loading