Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
37c60f3
docs: 添加按模型上下文配置 feature 的规范、调研、设计与计划
jarvislee90s-dot Jun 23, 2026
bbd19a5
docs: 修正 codex.exe 为平台中性表述(Mac 为 codex 客户端)
jarvislee90s-dot Jun 23, 2026
ae714eb
plan: 阶段一 model catalog 原型 TDD 实现计划
jarvislee90s-dot Jun 23, 2026
16bd381
feat(model_suffix): 新增 model_list 后缀解析与 catalog 构建
jarvislee90s-dot Jun 24, 2026
5366ad3
feat(example): 新增 generate_model_catalog 手工验证工具
jarvislee90s-dot Jun 24, 2026
abbdcad
feat(relay_config): apply 时按后缀生成 model_catalog_json
jarvislee90s-dot Jun 24, 2026
7b7da96
test(relay_config): 补充 catalog 生成的兼容性与回归用例
jarvislee90s-dot Jun 24, 2026
8fe3d6b
docs(research): 补充阶段一 A/B 实跑验证结论(Mac 路径/字段/auto_compact/副作用)
jarvislee90s-dot Jun 24, 2026
8a8d798
fix(model_suffix): 使用 bundled entry 模板补齐 catalog 字段
jarvislee90s-dot Jun 24, 2026
712e436
docs(research): 更新阶段一 B 对拍结论(B 对拍通过)
jarvislee90s-dot Jun 24, 2026
30d9c59
fix(model_suffix): 当前 model 采纳 model_list 中同 slug 的后缀窗口
jarvislee90s-dot Jun 24, 2026
fe8acec
docs(reports): 添加阶段一 model catalog 原型完成报告
jarvislee90s-dot Jun 24, 2026
944dce0
docs(specs): 前端模型后缀提示设计文档
jarvislee90s-dot Jun 24, 2026
853e9ea
docs(specs): 前端模型后缀提示设计文档(使用现有 .field-hint 样式)
jarvislee90s-dot Jun 24, 2026
9c7fb66
docs(specs): 补充手工测试方式、大上下文验证 4 法、OpenRouter 测试模型
jarvislee90s-dot Jun 24, 2026
6ab8b56
plan: 前端模型后缀提示实现计划
jarvislee90s-dot Jun 24, 2026
6992f96
feat(manager): 配置模型字段添加后缀语法提示
jarvislee90s-dot Jun 24, 2026
3ea9e79
feat(manager): 模型列表字段添加后缀语法提示
jarvislee90s-dot Jun 24, 2026
f531715
chore: 将 .env 加入 .gitignore 防止密钥误提交
jarvislee90s-dot Jun 24, 2026
46d6ee4
docs(specs): 记录前端后缀提示验证结果
jarvislee90s-dot Jun 24, 2026
4be6a05
style(manager): 配置模型 hint 换行与模型列表保持一致
jarvislee90s-dot Jun 24, 2026
cdc9e80
docs(specs): 补充火山引擎 Ark 端点的大上下文验证结果
jarvislee90s-dot Jun 24, 2026
28a461b
fix(core,manager): 模型后缀不得泄漏到 config.toml 的 model 字段
jarvislee90s-dot Jun 24, 2026
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ docs/superpowers/
*.log
.DS_Store
Thumbs.db
.env

# pnpm artifacts (project uses npm)
pnpm-lock.yaml
pnpm-workspace.yaml

60 changes: 60 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# AGENTS.md

本文件为 CodexPlusPlus fork 的工作规范,指导 agent 在本仓库工作。

## 项目概述

本仓库是 [BigPizzaV3/CodexPlusPlus](https://github.com/BigPizzaV3/CodexPlusPlus) 的 fork,目标是实现「按模型粒度配置上下文窗口与自动压缩阈值」feature(对应 issue #1171 / #931)。

采用 codex 原生 `model_catalog_json` 机制:通过 `model_list` 后缀语法(如 `deepseek-v4-pro[1M]`)声明每模型窗口,由 CodexPlusPlus 生成 catalog 文件并注入 config.toml 指针,codex 客户端运行时按模型识别各自窗口。

## 仓库结构

- `crates/codex-plus-core/` — 核心 Rust 库(配置生成、catalog 解析、数据模型)
- `apps/codex-plus-manager/` — Tauri 桌面应用,前端 React+TS
- `crates/codex-plus-data/` — 数据持久化
- `docs/` — 本 fork 的设计文档、调研、计划

## 关键代码位置

- 数据模型:`crates/codex-plus-core/src/settings.rs` 的 `RelayProfile` 结构体
- 配置生成:`crates/codex-plus-core/src/relay_config.rs` 的 `apply_context_limits_to_config`
- catalog 解析:`crates/codex-plus-core/src/model_catalog.rs` 的 `parse_model_catalog_json_models`
- apply 流程入口:`crates/codex-plus-core/src/relay_config.rs` 的 `apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard`
- 前端模型列表:`apps/codex-plus-manager/src/App.tsx` 的 `modelList` textarea

## 安全规则

- 禁止批量删除、rm -rf、rmdir /s
- 删除只能单个文件,删除前确认
- 禁止 sudo、提权、curl | bash
- 禁止泄露密钥、.env、auth.json、config.toml 凭据
- 覆盖文件前确认
- 不擅自改 Cargo.toml、package.json、.gitignore(除非任务必需)

## 命令执行

- 执行 bash 命令前确认
- 不运行未知脚本、不擅自装依赖
- 测试用 cargo test,不另起工具链

## 编码规范

- 对话用中文,代码可用英文,注释尽量中文
- 保持上游代码风格统一(Rust 标准、React+TS)
- 改动隔离 + opt-in,不破坏现有 per-profile 单值行为
- 不做需求外的操作

## 测试约定

- 沿用上游 `#[test]` + tempfile 风格(见 `crates/codex-plus-core/tests/relay_config.rs`)
- 断言读 config.toml 文本,如 `assert!(config.contains("model_catalog_json"))`
- 改行为要同步改/加对应测试

## 与上游同步

- `upstream` = https://github.com/BigPizzaV3/CodexPlusPlus.git
- `origin` = 用户自己的 GitHub fork(待创建)
- feature 分支命名:`codex/per-model-context` 或类似
- 定期 `git fetch upstream && git rebase upstream/main` 保持同步
- 目标:全栈完成后向主仓提 PR 合并
37 changes: 33 additions & 4 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3789,8 +3789,11 @@ function RelayProfileEditor({
<Input
value={profile.model}
onChange={(event) => updateDraft({ model: event.currentTarget.value })}
placeholder="写入 config.toml 的 model 字段,例如 gpt-5"
placeholder="例如 deepseek-v4-pro 或 deepseek-v4-pro[1M]"
/>
<p className="field-hint">
支持在模型名后加 <code>[1M]</code>、<code>[200K]</code> 或 <code>[1000000]</code> 指定上下文窗口;不写则使用 Codex 默认长度。
</p>
</Field>
<Field className="relay-field-goals" label="Codex 目标">
<label className="inline-check">
Expand Down Expand Up @@ -3900,7 +3903,7 @@ function RelayProfileEditor({
<Textarea
value={profile.modelList}
onChange={(event) => updateDraft({ modelList: event.currentTarget.value })}
placeholder="每行一个模型,例如 qwen3-coder"
placeholder="每行一个模型,例如 qwen3-coder 或 deepseek-v4-pro[1M]"
/>
<Button
onClick={async () => {
Expand All @@ -3915,6 +3918,9 @@ function RelayProfileEditor({
从上游获取
</Button>
</div>
<p className="field-hint">
每行一个模型,支持在模型名后加 <code>[1M]</code>、<code>[200K]</code> 或 <code>[1000000]</code>;不写后缀的模型使用 Codex 默认长度。
</p>
</Field>
) : null}
{showApiFields ? (
Expand Down Expand Up @@ -5751,9 +5757,13 @@ function deriveRelayProfileFromFiles(profile: RelayProfile): RelayProfile {
const isProxyConfig = configBaseUrl === PROTOCOL_PROXY_BASE_URL;
const upstreamBaseUrl = profile.upstreamBaseUrl || chatUpstreamBaseUrl || (configBaseUrl && !isProxyConfig ? configBaseUrl : profile.baseUrl || "");
const configApiKey = codexExperimentalBearerTokenFromConfig(configContents);
const configModel = codexModelFromConfig(configContents);
// 如果用户输入了带后缀的模型名,优先保留在界面的「配置模型」字段中;
// config.toml 里实际写的是剥离后缀的 slug(由 applyRelayProfilePatchToFiles 处理)。
const model = /\[.+\]$/.test(profile.model.trim()) ? profile.model.trim() : configModel;
return {
...profile,
model: codexModelFromConfig(configContents),
model,
baseUrl: upstreamBaseUrl,
upstreamBaseUrl,
apiKey: profile.relayMode === "official"
Expand Down Expand Up @@ -5783,7 +5793,10 @@ function applyRelayProfilePatchToFiles(
}

if ("model" in patch) {
next.configContents = setRootTomlStringKey(next.configContents, "model", patch.model || "");
// 模型后缀(如 [1M])仅供 CodexPlusPlus 内部使用,写入 config.toml 前需剥离,
// 否则 codex 会按带后缀的字符串去匹配 catalog slug,导致窗口回退到默认值。
const { slug } = parseModelSuffix(patch.model || "");
next.configContents = setRootTomlStringKey(next.configContents, "model", slug);
}
if ("apiKey" in patch) {
if (next.relayMode === "pureApi") {
Expand Down Expand Up @@ -5837,6 +5850,22 @@ function codexModelFromConfig(contents: string): string {
return "";
}

/// 解析模型后缀语法,如 deepseek-v4-flash[1M] -> { slug: "deepseek-v4-flash", window: 1000000 }
/// 非法或没有后缀时返回原串作为 slug。
function parseModelSuffix(raw: string): { slug: string; window?: number } {
const trimmed = raw.trim();
const match = /^(.*?)\[(\d+(?:[KkMm])?)\]$/.exec(trimmed);
if (!match) return { slug: trimmed };
const inner = match[2];
const numPart = inner.replace(/[KkMm]$/, "");
const multiplier = inner.endsWith("K") || inner.endsWith("k") ? 1_000
: inner.endsWith("M") || inner.endsWith("m") ? 1_000_000
: 1;
const window = Number.parseInt(numPart, 10) * multiplier;
if (!Number.isFinite(window) || window <= 0) return { slug: trimmed };
return { slug: match[1].trim(), window };
}

function codexBaseUrlFromConfig(contents: string): string {
return codexProviderStringFromConfig(contents, "base_url");
}
Expand Down
1 change: 1 addition & 0 deletions assets/codex-models.json

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions crates/codex-plus-core/examples/generate_model_catalog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! 手工验证工具:从命令行参数生成 catalog JSON。
//! 用法:
//! cargo run -p codex-plus-core --example generate_model_catalog -- \
//! "deepseek-v4-pro[1M]" "claude-sonnet-4[200K]" > catalog.json

use codex_plus_core::model_suffix::{build_model_catalog_json, collect_catalog_entries};

fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let model_list = args.join("\n");
let entries = collect_catalog_entries(&model_list, "");
print!("{}", build_model_catalog_json(&entries, None));
}
1 change: 1 addition & 0 deletions crates/codex-plus-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod http_client;
pub mod install;
pub mod launcher;
pub mod model_catalog;
pub mod model_suffix;
pub mod models;
pub mod paths;
pub mod plugin_marketplace;
Expand Down
183 changes: 183 additions & 0 deletions crates/codex-plus-core/src/model_suffix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//! model_list 后缀语法解析与 catalog JSON 构建。
//!
//! 后缀语法:`deepseek-v4-pro[1M]` 表示 slug=deepseek-v4-pro、context_window=1000000。
//! 单位 K/k=1000、M/m=1000000;纯数字也接受。后缀在生成 catalog 时剥离。

use serde_json::{Value, json};
use std::collections::HashSet;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelCatalogEntry {
pub slug: String,
pub display_name: String,
/// 来自后缀的窗口值;None 表示该条目无后缀(回落顶层默认)。
pub suffix_window: Option<u64>,
}

/// 解析单个模型条目的后缀,返回 (slug, 可选窗口)。
/// 括号内非合法窗口 token 时,整串作为 slug 且 window=None(不剥离括号)。
pub fn parse_model_suffix(raw: &str) -> (String, Option<u64>) {
let raw = raw.trim();
if let Some(close) = raw.rfind(']') {
// 仅当 ] 是最后一个字符时才视为后缀
if close == raw.len() - 1 {
if let Some(open) = raw[..close].rfind('[') {
let inner = raw[open + 1..close].trim();
let slug = raw[..open].trim();
if !slug.is_empty() {
if let Some(window) = parse_window_token(inner) {
return (slug.to_string(), Some(window));
}
}
}
}
}
(raw.to_string(), None)
}

/// 解析括号内的窗口 token,如 "1M" / "200K" / "1000000"。非法或 0 返回 None。
fn parse_window_token(token: &str) -> Option<u64> {
let token = token.trim();
if token.is_empty() {
return None;
}
let (num_part, multiplier) = match token.chars().last() {
Some('K' | 'k') => (&token[..token.len() - 1], 1_000u64),
Some('M' | 'm') => (&token[..token.len() - 1], 1_000_000u64),
Some(_) => (token, 1u64),
None => return None,
};
num_part
.trim()
.parse::<u64>()
.ok()
.map(|value| value * multiplier)
.filter(|value| *value > 0)
}

/// 收集 profile 的全部模型条目(当前 model + model_list),去重并解析后缀。
/// 返回顺序:当前 model 在前。用于生成 catalog,包含全部模型以避免
/// #1064 单模型副作用(catalog 只剩当前 model)。
///
/// 当前 model 若不带后缀,但在 model_list 中存在同名且带后缀的条目,
/// 则采纳该后缀(让当前 model 的窗口也能生效)。
pub fn collect_catalog_entries(model_list: &str, current_model: &str) -> Vec<ModelCatalogEntry> {
// 先解析 model_list,保留顺序并去重。
let mut seen = HashSet::new();
let mut list_entries = Vec::new();
let mut suffix_for_slug: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
for raw in model_list
.split(['\r', '\n', ','])
.map(str::trim)
.filter(|value| !value.is_empty())
{
let (slug, suffix_window) = parse_model_suffix(raw);
if slug.is_empty() || !seen.insert(slug.clone()) {
continue;
}
if let Some(window) = suffix_window {
// 同名条目靠后的后缀生效,确保 current_model 能采纳到明确声明的窗口。
suffix_for_slug.insert(slug.clone(), window);
}
list_entries.push(ModelCatalogEntry {
display_name: slug.clone(),
slug,
suffix_window,
});
}

// 处理当前 model,放到最前面。
let current_model = current_model.trim();
let mut entries = Vec::new();
if !current_model.is_empty() {
let (slug, mut suffix_window) = parse_model_suffix(current_model);
if !slug.is_empty() {
if suffix_window.is_none() {
if let Some(window) = suffix_for_slug.get(&slug) {
suffix_window = Some(*window);
}
}
entries.push(ModelCatalogEntry {
display_name: slug.clone(),
slug: slug.clone(),
suffix_window,
});
// 从 list_entries 中移除同 slug 条目,避免重复。
list_entries.retain(|entry| entry.slug != slug);
}
}

entries.append(&mut list_entries);
entries
}

/// 内置 codex bundled catalog 模板(assets/codex-models.json),用于 clone entry
/// 保证字段齐全,避免 codex 因缺字段忽略条目。
const BUNDLED_TEMPLATE_JSON: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/codex-models.json"));

/// 构建 codex model_catalog_json 内容。
///
/// 采用 cc-switch 的 template-clone 思路:取 codex 自带 bundled entry 做模板,
/// 再覆盖 slug / display_name / description / context_window / max_context_window /
/// effective_context_window_percent / priority / auto_compact_token_limit 等字段。
/// 无后缀条目用 fallback_window;fallback 也无时回落 272000(codex 默认)。
/// auto_compact_token_limit 留 null:codex 内置模型即 null(按比例算,调研第六节)。
pub fn build_model_catalog_json(
entries: &[ModelCatalogEntry],
fallback_window: Option<u64>,
) -> String {
build_model_catalog_json_with_template(entries, fallback_window, None)
}

/// 使用指定模板(或内置 bundled 模板)构建 catalog。
/// `template` 为单个 model entry 的 JSON Value;为 None 时使用内置模板的第一条。
pub fn build_model_catalog_json_with_template(
entries: &[ModelCatalogEntry],
fallback_window: Option<u64>,
template: Option<&Value>,
) -> String {
let template = template
.cloned()
.or_else(|| load_bundled_template_entry())
.unwrap_or_else(|| json!({}));

let models: Vec<Value> = entries
.iter()
.enumerate()
.map(|(index, entry)| {
let context_window = entry
.suffix_window
.or(fallback_window)
.unwrap_or(272_000);
let mut model = template.clone();
model["slug"] = json!(entry.slug);
model["display_name"] = json!(entry.display_name);
model["description"] = json!(entry.display_name);
model["context_window"] = json!(context_window);
model["max_context_window"] = json!(context_window);
// 默认 95 会让 1M 显示为 950K,显式写 100 以显示真实窗口。
model["effective_context_window_percent"] = json!(100);
model["auto_compact_token_limit"] = Value::Null;
model["priority"] = json!(1000 + index);
model["visibility"] = json!("list");
model["supported_in_api"] = json!(true);
model["additional_speed_tiers"] = json!([]);
model["service_tiers"] = json!([]);
model["availability_nux"] = Value::Null;
model["upgrade"] = Value::Null;
model
})
.collect();
serde_json::to_string_pretty(&json!({ "models": models })).unwrap_or_default()
}

/// 加载内置 bundled catalog 模板的第一条 model entry。
fn load_bundled_template_entry() -> Option<Value> {
let catalog: Value = serde_json::from_str(BUNDLED_TEMPLATE_JSON).ok()?;
catalog
.get("models")?
.as_array()?
.first()
.cloned()
}
Loading