fix(codex): show and save keyless drafts in the provider editor

Opening the Codex add dialog projects the custom template onto the live
config to show what config.toml will look like. The template has an empty
API key and `requires_openai_auth = true`, and the projection ran the same
check a switch does, so the dialog failed with "this Codex config has no
usable API key" before the user could type one. Third-party presets with
the same defaults failed the same way. The editor base then stayed empty,
so saving fell back to storing the row only and global edits made in the
dialog never reached the live config.

The editor now projects such a draft as if a key were present, using an
in-memory placeholder that is removed before display and before the row
is stored. Only the missing-key error is retried; other errors surface as
before. A keyless row can be saved, as the non-editor add path already
allowed once a current provider exists. Every write to the live config
still projects the real row and refuses it: switching or routing to it,
editing the current provider in direct mode, and adding it as the first
provider, with the row rolled back.
This commit is contained in:
Jason committed 2026-10-01 23:45:51 +08:00
1 parent 619c315b8d
commit 85bd880195
3 files changed
+248 -11

No files matched your search

+8 -1
View File
@@ -349,9 +349,16 @@ fn row_key(doc: &DocumentMut, table: Option<&Table>, input: &RowInput<'_>) -> Op
.or_else(|| non_empty_str(doc.get("experimental_bearer_token")))
}
const KEYLESS_FALLBACK_ERROR: &str = "provider.codex.config.official_auth_fallback";
/// 是不是 [`keyless_fallback_error`]:行没有 Key,却会回退去用 `auth.json` 里的登录。
pub fn is_keyless_fallback(error: &AppError) -> bool {
matches!(error, AppError::Localized { key, .. } if *key == KEYLESS_FALLBACK_ERROR)
}
fn keyless_fallback_error() -> AppError {
AppError::localized(
"provider.codex.config.official_auth_fallback",
KEYLESS_FALLBACK_ERROR,
"该 Codex 配置没有可用的 API 密钥,而 requires_openai_auth = true(或顶层 openai_base_url)会让 Codex 回退使用 auth.json 里的登录凭据访问第三方地址。请为供应商填写 API 密钥,或移除该回退指令",
"This Codex config has no usable API key, and requires_openai_auth = true (or a top-level openai_base_url) would make Codex fall back to whatever login auth.json holds for a third-party route. Add an API key to the provider or remove the fallback directive",
)
+156
View File
@@ -6086,4 +6086,160 @@ model_provider = "c"
let restored: Value = crate::config::read_json_file(&codex_auth_path()).unwrap();
assert_eq!(restored["tokens"]["account_id"], "ws");
}
/// `src/config/codexTemplates.ts` 的 `getCodexCustomTemplate()`:Key 为空、
/// `requires_openai_auth = true`。新增对话框一打开就投影它。
fn codex_keyless_template() -> Value {
json!({
"auth": { "OPENAI_API_KEY": "" },
"config": "model_provider = \"custom\"\nmodel = \"gpt-5.6-sol\"\n\n[model_providers.custom]\nname = \"custom\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n",
})
}
/// 没填 Key 的草稿:显示「切过去之后的样子」和只存行都不拿切换的安全闸拒绝,要进 live
/// 时(路由到它、直连切到它)照样拒绝,否则会把 ChatGPT 登录发给第三方。
#[tokio::test]
#[serial]
async fn codex_keyless_draft_is_shown_and_saved_but_never_written_to_live() {
let _home = Home::new();
set_preservation(true);
seed_codex("model = \"gpt-5.4\"\n", Some(&chatgpt_login("acct")));
let [a, b] = codex_a_b();
let official = codex_official();
let state = state_with(AppType::Codex, &[a, b, official.clone()], &official.id).await;
ProviderService::switch(&state, AppType::Codex, &official.id).expect("official");
let template = codex_keyless_template();
let view = |state: &AppState, label: &str| -> Value {
let view = ProviderService::editor_view(state, AppType::Codex, &template, None)
.unwrap_or_else(|err| panic!("{label}: cannot show the template: {err}"));
let shown = view.settings["config"].as_str().unwrap().to_string();
assert!(!shown.contains("pending-key"), "{label}: {shown}");
let doc: toml::Table = toml::from_str(&shown).unwrap();
assert_eq!(
doc["model_provider"].as_str(),
Some("custom"),
"{label}: {shown}"
);
assert!(doc.get("openai_base_url").is_none(), "{label}: {shown}");
let route = &doc["model_providers"]["custom"];
assert_eq!(
route["requires_openai_auth"].as_bool(),
Some(true),
"{label}: {shown}"
);
assert!(
route.get("experimental_bearer_token").is_none(),
"{label}: {shown}"
);
view.settings
};
// 直连(当前是官方卡)。
view(&state, "direct");
// 代理的官方路由:live 顶层是 openai_base_url。
enter(&state, &AppType::Codex, false).await.expect("enter");
let routed = codex_text();
assert!(routed.contains("openai_base_url"), "{routed}");
let shown = view(&state, "official proxy route");
// 表单确认过「不填 Key 也保存」:只存行,live 不动,占位 Key 不进行。
let draft = Provider::with_id("c".into(), "C".into(), template.clone(), None);
add_from_editor(&state, AppType::Codex, draft, shown.clone(), shown)
.expect("a keyless row can be saved");
assert_eq!(codex_text(), routed);
let c = state.db.get_provider_by_id("c", "codex").unwrap().unwrap();
let c_config = c.settings_config["config"].as_str().unwrap();
assert!(!c_config.contains("pending-key"), "{c_config}");
assert!(
!c_config.contains("experimental_bearer_token"),
"{c_config}"
);
assert!(
c_config.contains("requires_openai_auth = true"),
"{c_config}"
);
// 路由到它、直连切到它都拒绝,live 不动。
let err = ProviderService::switch(&state, AppType::Codex, "c").expect_err("route to c");
assert!(err.to_string().contains("requires_openai_auth"), "{err}");
assert_eq!(codex_text(), routed);
exit(&state, &AppType::Codex).await.expect("exit");
let direct_live = codex_text();
let err = ProviderService::switch(&state, AppType::Codex, "c").expect_err("switch to c");
assert!(err.to_string().contains("requires_openai_auth"), "{err}");
assert_eq!(codex_text(), direct_live);
assert_eq!(
direct(&state, &AppType::Codex).as_deref(),
Some(official.id.as_str())
);
}
/// 直连模式下编辑当前供应商:保存会把关键字段一起换进 live,这时去掉 Key 照样拒绝,行
/// 撤回、live 不动。
#[tokio::test]
#[serial]
async fn codex_editing_the_current_provider_into_a_keyless_row_is_refused() {
let _home = Home::new();
set_preservation(true);
seed_codex(CODEX_USER_LIVE, Some(&chatgpt_login("acct")));
let state = state_with(AppType::Codex, &codex_a_b(), "a").await;
ProviderService::switch(&state, AppType::Codex, "a").expect("a");
let before_live = codex_text();
let mut row = state.db.get_provider_by_id("a", "codex").unwrap().unwrap();
let before_row = row.settings_config.clone();
let base = ProviderService::editor_view(&state, AppType::Codex, &row.settings_config, None)
.expect("view")
.settings;
let mut doc: toml_edit::DocumentMut = base["config"].as_str().unwrap().parse().unwrap();
doc["model_providers"]["custom"]["requires_openai_auth"] = toml_edit::value(true);
let mut edited = base.clone();
edited["config"] = json!(doc.to_string());
edited["auth"]["OPENAI_API_KEY"] = json!("");
row.settings_config = edited;
let err = ProviderService::update_from_editor(
&state,
AppType::Codex,
None,
row,
Some(crate::services::provider::EditorSave {
base,
draft: None,
on_conflict: Default::default(),
}),
)
.expect_err("the current provider cannot lose its key");
assert!(err.to_string().contains("requires_openai_auth"), "{err}");
assert_eq!(codex_text(), before_live);
assert_eq!(
state
.db
.get_provider_by_id("a", "codex")
.unwrap()
.unwrap()
.settings_config,
before_row
);
}
/// 新增第一个供应商会同时写进 live:没填 Key 照样拒绝,行不留。
#[tokio::test]
#[serial]
async fn codex_adding_a_keyless_first_provider_is_refused() {
let _home = Home::new();
set_preservation(true);
seed_codex("model = \"gpt-5.4\"\n", Some(&chatgpt_login("acct")));
let state = state_without_providers().await;
let before = codex_text();
let template = codex_keyless_template();
let base = ProviderService::editor_view(&state, AppType::Codex, &template, None)
.expect("view")
.settings;
let draft = Provider::with_id("c".into(), "C".into(), template, None);
let err = add_from_editor(&state, AppType::Codex, draft, base.clone(), base)
.expect_err("a keyless first provider would go live");
assert!(err.to_string().contains("requires_openai_auth"), "{err}");
assert!(state.db.get_provider_by_id("c", "codex").unwrap().is_none());
assert_eq!(codex_text(), before);
}
}
+84 -10
View File
@@ -26,7 +26,7 @@ use crate::live::engine::{read_current, LiveFile};
use crate::live::floor;
use crate::live::patch::toml::parse;
use crate::live::project::codex::{
CodexProjection, Route, RowInput, OFFICIAL_PROXY_ROUTE_ID, ROUTE_ID,
is_keyless_fallback, CodexProjection, Route, RowInput, OFFICIAL_PROXY_ROUTE_ID, ROUTE_ID,
};
use crate::mode::operation::{AppWrite, FileChange};
use crate::mode::state::{op, PendingTarget};
@@ -114,12 +114,7 @@ pub fn view(
Provider::with_id(String::new(), String::new(), settings_config.clone(), None);
provider.category = category.map(str::to_string);
let live_owner = LiveOwner::read(state)?;
let planned = codex_direct::plan(
&state.db,
&live_owner.owner(),
&Target::Direct(Some(&provider)),
&Prepared::default(),
)?;
let planned = plan_for_view(&state.db, &live_owner.owner(), &provider)?;
planned.config().apply_to(&path, &mut doc)?;
// Key 在 API Key 输入框里(行的 auth),TOML 里不再重复显示。
@@ -136,7 +131,7 @@ pub fn view(
.get("experimental_bearer_token")
.and_then(Item::as_str)
.map(str::to_string);
if injected.is_some() && injected == row_key {
if injected.is_some() && (injected == row_key || injected.as_deref() == Some(PENDING_KEY)) {
route.remove("experimental_bearer_token");
}
}
@@ -156,6 +151,84 @@ pub fn view(
})
}
/// 还没填 Key 的行按「填了 Key」投影时用的占位 Key。只出现在内存里的投影中:显示前、存行
/// 前都去掉,从不写盘。
const PENDING_KEY: &str = "cc-switch-editor-pending-key";
/// `settings` 换上占位 Key(没有 `auth` 就补一个)。
fn with_pending_key(settings: &Value) -> Option<Value> {
let mut settings = settings.clone();
let auth = settings
.as_object_mut()?
.entry("auth")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()?;
auth.insert(
"OPENAI_API_KEY".to_string(),
Value::String(PENDING_KEY.to_string()),
);
Some(settings)
}
/// 编辑器显示用的投影。新增对话框一打开就投影自定义模板,选第三方预设也会投影,这时 Key
/// 还没填:行里的 `requires_openai_auth = true`(或顶层 `openai_base_url`)会被切换的
/// 安全闸拒绝。显示不该拒:Key 本来就不在 TOML 里显示,填没填显示都一样。只对这一个错误
/// 按占位 Key 再投影一次;再投影也不行就报原来的错。安全闸留在写 live 的地方(切换、编辑
/// 当前供应商、重写代理契约),它们用的都是真实的行。
fn plan_for_view(
db: &Database,
owner: &Owner<'_>,
provider: &Provider,
) -> Result<codex_direct::Planned, AppError> {
let plan = |provider: &Provider| {
codex_direct::plan(
db,
owner,
&Target::Direct(Some(provider)),
&Prepared::default(),
)
};
let error = match plan(provider) {
Err(error) if is_keyless_fallback(&error) => error,
other => return other,
};
let Some(settings) = with_pending_key(&provider.settings_config) else {
return Err(error);
};
let mut pending = provider.clone();
pending.settings_config = settings;
plan(&pending).map_err(|_| error)
}
/// 保存时拆行用的投影。没填 Key 的行照样能存(和不经编辑器的新增一样,表单会先确认一次):
/// 存行不写 live。行要进 live 时(编辑直连的当前供应商、新增第一个供应商、它是代理路由那
/// 家)写入按真实的行再投影一次,安全闸在那里拦,行跟着撤回。
fn project_for_save(input: &RowInput<'_>) -> Result<CodexProjection, AppError> {
let error = match CodexProjection::of(input) {
Err(error) if is_keyless_fallback(&error) => error,
other => return other,
};
let Some(settings) = with_pending_key(input.settings) else {
return Err(error);
};
let mut projection = CodexProjection::of(&RowInput {
settings: &settings,
official: input.official,
proxy_injected_oauth: input.proxy_injected_oauth,
})
.map_err(|_| error)?;
if let Route::Custom { table, .. } = &mut projection.route {
if table
.get("experimental_bearer_token")
.and_then(Item::as_str)
== Some(PENDING_KEY)
{
table.remove("experimental_bearer_token");
}
}
Ok(projection)
}
/// 行里保存着、但不随切换生效的全局设置。
fn inactive_fields(row_text: &str, display: &DocumentMut) -> Vec<InactiveField> {
let Ok(row) = row_text.parse::<DocumentMut>() else {
@@ -245,7 +318,8 @@ impl Origin {
}
/// 把编辑器里的完整配置拆开:关键字段、独有字段换进行(行里其余内容原样保留),其余部分
/// 和 `base` 比,得出用户改过的全局设置。行有问题(会把官方登录发给第三方等)在这里报错。
/// 和 `base` 比,得出用户改过的全局设置。行本身解析不了在这里报错;没填 Key 不拦(见
/// [`project_for_save`])。
///
/// 从 live 带进来的独有字段不归这个供应商:用户没动就不收进行、也不写(否则切走时会把
/// 用户自己的设置删掉,打开编辑器之后客户端改的值也会被盖回去),用户删了就从 live 删。
@@ -261,7 +335,7 @@ pub(crate) fn plan_save(
) -> Result<CodexEditorPlan, AppError> {
let edited_doc = parse_text(config_text(edited), "edited")?;
let base_doc = parse_text(config_text(base), "base")?;
let mut projection = CodexProjection::of(&RowInput {
let mut projection = project_for_save(&RowInput {
settings: edited,
official,
proxy_injected_oauth,