V2配置模型账户协议
本文承接模型账户认证共享类型与AppServer V2方法注册机制。配置、模型和账户看起来都像“读取一些状态”,但在 App Server 中它们由不同 owner 管理:配置由 ConfigManager 解析层级并负责写入,模型目录由 ThreadManager 触发缓存刷新后投影,账户由 AuthManager 与当前模型 provider 共同决定。学习这篇文章时,要把协议字段、状态来源、缓存边界、写入副作用和异步通知顺序分开;只有把请求处理器、协议类型和测试放在同一条调用链上,才能解释一个字段为什么为空、一个写入为什么暂不影响活动线程,以及一个登录为什么要等待后续通知。
1. 三种状态来源
V2 的三个读取面有不同的语义:config/read 返回按工作目录求值后的配置,model/list 返回当前 catalog 的可选模型,account/read 返回 provider 判断出的账户类型。它们都可能带缓存,但缓存的 owner 不同,不能把一个 response 当作另一个 response 的事实来源。
源码位置:codex-rs/app-server-protocol/src/protocol/v2/config.rs :: ConfigReadParams、ConfigReadResponse
pub struct ConfigReadParams {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub include_layers: bool,
pub cwd: Option<String>,
}
pub struct ConfigReadResponse {
pub config: Config,
pub origins: HashMap<String, ConfigLayerMetadata>,
pub layers: Option<Vec<ConfigLayer>>,
}cwd 不是展示字段,而是 project layer 求值的输入;include_layers 决定是否附带每层原始配置。即使不返回 layers,origins 仍能说明每个有效键来自哪一层。
源码位置:codex-rs/app-server-protocol/src/protocol/v2/config.rs :: ConfigLayerSource::precedence
pub fn precedence(&self) -> i16 {
match self {
ConfigLayerSource::PackagedDefaults { .. } => -10,
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::EnterpriseManaged { .. } => 15,
ConfigLayerSource::User { profile, .. } => {
if profile.is_some() { 21 } else { 20 }
}
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
}
}这里的数值只表达覆盖顺序,不代表“配置可信度”或“是否启用”。例如 project layer 可以覆盖 user layer,但 managed layer 仍可能通过 requirements 约束可写范围。
2. config/read求值
源码位置:codex-rs/app-server/src/request_processors/config_processor.rs :: ConfigRequestProcessor::read
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
let mut response = self.config_manager.read(params).await.map_err(map_error)?;
let config = self.load_latest_config(fallback_cwd).await?;
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
let Some(feature) = feature_for_key(feature_key) else {
continue;
};
let features = response
.config
.additional
.entry("features".to_string())
.or_insert_with(|| json!({}));
if !features.is_object() {
*features = json!({});
}
if let Some(features) = features.as_object_mut() {
features.insert(
(*feature_key).to_string(),
json!(config.features.enabled(feature)),
);
}
}
Ok(response)
}这段代码有两个容易被忽略的边界:第一,ConfigManager::read 先生成协议 response,再用最新求值配置补实验 feature;第二,features 不是对象时会被替换为空对象,避免向客户端暴露无法合并的 JSON 形状。因此客户端看到的是“当前有效配置加公开 feature 投影”,不是某个 TOML 文件的直接反序列化。
当 cwd 指向项目目录时,ConfigManager 会寻找从该目录到项目根之间的 .codex 层。测试 config_read_includes_project_layers_for_cwd 通过比较 config、origins 与 layers,验证 project layer 确实参与求值,而不是只出现在诊断字段中。
3. config/write版本
源码位置:codex-rs/app-server-protocol/src/protocol/v2/config.rs :: ConfigValueWriteParams、ConfigBatchWriteParams、ConfigWriteResponse、ConfigWriteErrorCode
pub struct ConfigValueWriteParams {
pub key_path: String,
pub value: JsonValue,
pub merge_strategy: MergeStrategy,
pub file_path: Option<String>,
pub expected_version: Option<String>,
}
pub struct ConfigBatchWriteParams {
pub edits: Vec<ConfigEdit>,
pub file_path: Option<String>,
pub expected_version: Option<String>,
pub reload_user_config: bool,
}
pub struct ConfigWriteResponse {
pub status: WriteStatus,
pub version: String,
pub file_path: AbsolutePathBuf,
pub overridden_metadata: Option<OverriddenMetadata>,
}
pub enum ConfigWriteErrorCode {
ConfigLayerReadonly,
ConfigRequirementReadonly,
ConfigVersionConflict,
ConfigValidationError,
ConfigPathNotFound,
ConfigSchemaUnknownKey,
UserLayerNotFound,
}expected_version 是并发写保护:客户端先读出某一层版本,再携带该版本写入;版本不一致时服务端返回 configVersionConflict,而不是静默覆盖别人的修改。status=OkOverridden 则表示写入成功,但更高优先级层仍决定 effective value,此时 overridden_metadata 解释覆盖者和最终值。
源码位置:codex-rs/app-server/src/request_processors/config_processor.rs :: value_write、batch_write
pub(crate) async fn value_write(
&self,
params: ConfigValueWriteParams,
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
self.handle_config_mutation_result(self.write_value(params).await)
.await
.map(ClientResponsePayload::ConfigValueWrite)
}
pub(crate) async fn batch_write(
&self,
params: ConfigBatchWriteParams,
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
let session_defaults_only = !params.edits.is_empty()
&& params.edits.iter().all(|edit| {
matches!(
edit.key_path.as_str(),
"model"
| "model_reasoning_effort"
| "plan_mode_reasoning_effort"
| "service_tier"
| "personality"
)
});
let reload_user_config = params.reload_user_config;
let response = self.batch_write_inner(params).await?;
if !session_defaults_only {
self.handle_config_mutation().await;
if reload_user_config {
self.reload_user_config().await;
}
}
Ok(ClientResponsePayload::ConfigBatchWrite(response))
}写入成功后的副作用取决于键集合:插件和 Skills cache 会被清理;批量写入要求 reload_user_config=true 时,已加载线程会重新读取运行配置;模型、推理 effort、Plan-mode effort、service tier 和 personality 这类 session 默认值不会被热重载。这个分支解释了为什么“文件已经写入”不等于“所有活动 Turn 立刻换配置”。
源码位置:codex-rs/app-server/src/request_processors/config_processor.rs :: map_error
pub(super) fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
if let Some(code) = err.write_error_code() {
return config_write_error(code, err.to_string());
}
internal_error(err.to_string())
}
fn config_write_error(
code: ConfigWriteErrorCode,
message: impl Into<String>,
) -> JSONRPCErrorError {
let mut error = invalid_request(message);
error.data = Some(json!({
"config_write_error_code": code,
}));
error
}只有能映射为配置写入错误的失败才带 config_write_error_code;解析、I/O 等其他失败仍是内部错误。客户端应先看 JSON-RPC 错误码,再看 data 中的配置专用代码。
4. requirements
configRequirements/read 读取的是 managed requirements,而不是普通 effective config。它描述允许的认证存储、审批策略、sandbox、权限 profile、网络、模型默认值和功能开关,部分字段还会被转换为公开协议中的枚举。
源码位置:codex-rs/app-server/src/request_processors/config_processor.rs :: config_requirements_read
pub(crate) async fn config_requirements_read(
&self,
) -> Result<ConfigRequirementsReadResponse, JSONRPCErrorError> {
let requirements = self
.config_manager
.read_requirements()
.await
.map_err(map_error)?
.map(map_requirements_toml_to_api);
Ok(ConfigRequirementsReadResponse { requirements })
}返回 requirements: None 表示没有 managed requirements;返回对象中的字段是约束或默认值,不应被解释为当前 Turn 的实际配置。比如 allowed_permission_profiles 是允许集合,default_permissions 是默认选择,二者都不等同于某个线程已经采用的 profile。
5. 模型目录来源
源码位置:codex-rs/app-server/src/models.rs :: supported_models、model_from_preset
pub async fn supported_models(
thread_manager: Arc<ThreadManager>,
include_hidden: bool,
http_client_factory: HttpClientFactory,
) -> Vec<Model> {
thread_manager
.list_models(RefreshStrategy::OnlineIfUncached, http_client_factory)
.await
.into_iter()
.filter(|preset| include_hidden || preset.show_in_picker)
.map(model_from_preset)
.collect()
}
fn model_from_preset(preset: ModelPreset) -> Model {
Model {
id: preset.id.to_string(),
model: preset.model.to_string(),
upgrade: preset.upgrade.as_ref().map(|upgrade| upgrade.id.clone()),
upgrade_info: preset.upgrade.as_ref().map(|upgrade| ModelUpgradeInfo {
model: upgrade.id.clone(),
upgrade_copy: upgrade.upgrade_copy.clone(),
model_link: upgrade.model_link.clone(),
migration_markdown: upgrade.migration_markdown.clone(),
retirement_at: upgrade
.retirement_at
.as_ref()
.map(chrono::DateTime::timestamp),
}),
availability_nux: preset.availability_nux.map(Into::into),
display_name: preset.display_name.to_string(),
description: preset.description.to_string(),
model_specialty: preset.model_specialty,
hidden: !preset.show_in_picker,
supported_reasoning_efforts: reasoning_efforts_from_preset(
preset.supported_reasoning_efforts,
),
default_reasoning_effort: preset.default_reasoning_effort,
input_modalities: preset.input_modalities,
supports_personality: preset.supports_personality,
multi_agent_version: preset.multi_agent_version.map(Into::into),
additional_speed_tiers: preset.additional_speed_tiers,
service_tiers: preset
.service_tiers
.into_iter()
.map(|service_tier| ModelServiceTier {
id: service_tier.id,
name: service_tier.name,
description: service_tier.description,
})
.collect(),
default_service_tier: preset.default_service_tier,
is_default: preset.is_default,
}
}模型目录先由 ThreadManager::list_models 按 OnlineIfUncached 刷新策略获取,再根据 include_hidden 过滤。模型投影不仅有 id 和名称,还携带 reasoning effort、输入模态、personality、multi-agent 版本、service tiers 和升级信息;因此客户端不能只保存 model 字符串来重建 picker 行为。
6. model/list分页
源码位置:codex-rs/app-server/src/request_processors/catalog_processor.rs :: list_models
async fn list_models(
thread_manager: Arc<ThreadManager>,
http_client_factory: codex_http_client::HttpClientFactory,
params: ModelListParams,
) -> Result<ModelListResponse, JSONRPCErrorError> {
let ModelListParams {
limit,
cursor,
include_hidden,
} = params;
let models = supported_models(
thread_manager,
include_hidden.unwrap_or(false),
http_client_factory,
)
.await;
let total = models.len();
if total == 0 {
return Ok(ModelListResponse {
data: Vec::new(),
next_cursor: None,
});
}
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let effective_limit = effective_limit.min(total);
let start = match cursor {
Some(cursor) => cursor
.parse::<usize>()
.map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?,
None => 0,
};
if start > total {
return Err(invalid_request(format!(
"cursor {start} exceeds total models {total}"
)));
}
let end = start.saturating_add(effective_limit).min(total);
Ok(ModelListResponse {
data: models[start..end].to_vec(),
next_cursor: (end < total).then(|| end.to_string()),
})
}这里的 cursor 是服务端生成的数组位置字符串。客户端只能原样传回,不能把它当作模型 id 或稳定排序键;include_hidden 改变的是源集合,切换它时应重新从首屏开始。list_models_pagination_works 以 limit=1 连续消费 cursor,直到 next_cursor=None,验证的是分页收敛和顺序保持。
远程 ChatGPT catalog 还可能覆盖本地缓存。测试 list_models_uses_chatgpt_remote_catalog_as_source_of_truth 用 mock backend 返回远程专属模型,并断言升级退休时间、reasoning effort 和请求次数,说明 App Server 不会只依赖打包的静态列表。
7. 账户类型与登录
源码位置:codex-rs/app-server-protocol/src/protocol/v2/account.rs :: Account、LoginAccountParams、LoginAccountResponse
pub enum Account {
ApiKey {},
Chatgpt {
email: Option<String>,
plan_type: PlanType,
},
AmazonBedrock {
uses_codex_managed_credentials: bool,
},
}
pub enum LoginAccountParams {
ApiKey { api_key: String },
Chatgpt {
codex_streamlined_login: bool,
use_hosted_login_success_page: bool,
app_brand: Option<LoginAppBrand>,
},
ChatgptDeviceCode,
ChatgptAuthTokens {
access_token: String,
chatgpt_account_id: String,
chatgpt_plan_type: Option<String>,
},
AmazonBedrock { api_key: String, region: String },
AmazonBedrockAccessKeys {
access_key_id: String,
secret_access_key: String,
session_token: Option<String>,
region: String,
},
}
pub enum LoginAccountResponse {
ApiKey {},
Chatgpt { login_id: String, auth_url: String },
ChatgptDeviceCode {
login_id: String,
verification_url: String,
user_code: String,
},
ChatgptAuthTokens {},
AmazonBedrock {},
}Account 是当前 provider 的公开分类,不暴露 access token。登录参数则是带凭据的输入联合体,包含 API key、浏览器 OAuth、device code、外部 ChatGPT token 和两种 Bedrock 凭据。ChatGPT 浏览器登录先返回 login_id 与 URL,最终成功或失败通过异步 notification 汇报。
源码位置:codex-rs/app-server/src/request_processors/account_processor.rs :: login_account、login_v2
pub(crate) async fn login_account(
&self,
request_id: ConnectionRequestId,
params: LoginAccountParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.login_v2(request_id, params).await.map(|()| None)
}
async fn login_v2(
&self,
request_id: ConnectionRequestId,
params: LoginAccountParams,
) -> Result<(), JSONRPCErrorError> {
if self.auth_manager.is_workload_identity_selected() {
return Err(self.configured_auth_owned_by_host_error());
}
match params {
LoginAccountParams::ApiKey { api_key } => {
self.login_api_key_v2(request_id, LoginApiKeyParams { api_key })
.await;
}
LoginAccountParams::Chatgpt {
app_brand,
codex_streamlined_login,
use_hosted_login_success_page,
} => {
let login_success_page = if use_hosted_login_success_page {
let app_brand = match app_brand.unwrap_or_default() {
LoginAppBrand::Codex => LoginSuccessPageBrand::Codex,
LoginAppBrand::Chatgpt => LoginSuccessPageBrand::Chatgpt,
};
LoginSuccessPage::Hosted {
url: CODEX_OPEN_APP_URL.parse().map_err(|err| {
internal_error(format!("invalid Codex open app URL: {err}"))
})?,
app_brand,
}
} else {
LoginSuccessPage::default()
};
self.login_chatgpt_v2(
request_id,
codex_streamlined_login,
login_success_page,
)
.await;
}
LoginAccountParams::ChatgptDeviceCode => {
self.login_chatgpt_device_code_v2(request_id).await;
}
LoginAccountParams::ChatgptAuthTokens {
access_token,
chatgpt_account_id,
chatgpt_plan_type,
} => {
self.login_chatgpt_auth_tokens(
request_id,
access_token,
chatgpt_account_id,
chatgpt_plan_type,
)
.await;
}
LoginAccountParams::AmazonBedrock { api_key, region } => {
self.login_amazon_bedrock_v2(
request_id,
BedrockLoginCredentials::ApiKey(api_key),
region,
)
.await;
}
LoginAccountParams::AmazonBedrockAccessKeys {
access_key_id,
secret_access_key,
session_token,
region,
} => {
self.login_amazon_bedrock_v2(
request_id,
BedrockLoginCredentials::AccessKeys {
access_key_id,
secret_access_key,
session_token,
},
region,
)
.await;
}
}
Ok(())
}分派前的 workload identity 检查很重要:当凭据由宿主环境管理时,App Server 不允许客户端替换它。不同登录分支还会对 provider、实验能力和配置要求做进一步检查;因此“请求被接受”只说明登录流程启动,不能说明账户状态已经改变。
8. account/read快照
源码位置:codex-rs/app-server-protocol/src/protocol/v2/account.rs :: GetAccountParams、GetAccountResponse、AccountUpdatedNotification
pub struct GetAccountParams {
pub refresh_token: bool,
}
pub struct GetAccountResponse {
pub account: Option<Account>,
pub requires_openai_auth: bool,
}
pub struct AccountUpdatedNotification {
pub auth_mode: Option<AuthMode>,
pub plan_type: Option<PlanType>,
}account/read 的 refresh_token 只请求一次主动刷新;external auth 模式会忽略这个标志,客户端应通过 chatgptAuthTokens 自己更新 token。requires_openai_auth 表示 provider 是否要求 OpenAI 认证,不代表本次读取已经成功认证。
源码位置:codex-rs/app-server/src/request_processors/account_processor.rs :: get_account_response
async fn get_account_response(
&self,
params: GetAccountParams,
) -> Result<GetAccountResponse, JSONRPCErrorError> {
let do_refresh = params.refresh_token;
self.refresh_token_if_requested(do_refresh).await;
let config = self.load_latest_config().await;
let provider =
create_model_provider(config.model_provider, Some(self.auth_manager.clone()));
let account_state = match provider.account_state() {
Ok(account_state) => account_state,
Err(err) => return Err(invalid_request(err.to_string())),
};
let account = account_state.account.map(Account::from);
Ok(GetAccountResponse {
account,
requires_openai_auth: account_state.requires_openai_auth,
})
}账户分类不是直接读取 AuthManager 的枚举,而是先加载最新配置、创建 model provider,再读取 provider 的 account_state。这使得 API key、ChatGPT、Bedrock 和不要求 OpenAI auth 的 provider 可以共享同一 response 形状。
9. 登录通知与缓存
源码位置:codex-rs/app-server/src/request_processors/account_processor.rs :: send_login_success_notifications
async fn send_login_success_notifications(&self, login_id: Option<Uuid>) {
Self::maybe_refresh_plugin_caches_for_current_config(
&self.config_manager,
&self.thread_manager,
self.auth_manager.auth_cached(),
)
.await;
let payload_login_completed = AccountLoginCompletedNotification {
login_id: login_id.map(|id| id.to_string()),
success: true,
error: None,
onboarding_entrypoint: None,
};
self.outgoing
.send_server_notification(ServerNotification::AccountLoginCompleted(
payload_login_completed,
))
.await;
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(
self.current_account_updated_notification(),
))
.await;
}成功顺序是先 account/login/completed,再 account/updated。ChatGPT 异步登录成功时还会 reload auth、替换 cloud config bundle loader、同步 residency requirement 并刷新 plugin/Skills cache。客户端若只监听第二个事件,可能错过登录流程的成功/失败结果;若只监听第一个事件,又无法得到新的认证模式和套餐。
10. 限额与用量
源码位置:codex-rs/app-server-protocol/src/protocol/v2/account.rs :: GetAccountRateLimitsResponse、RateLimitSnapshot、GetAccountTokenUsageResponse
pub struct GetAccountRateLimitsResponse {
pub rate_limits: RateLimitSnapshot,
pub rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>,
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
}
pub struct RateLimitSnapshot {
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
pub spend_control_reached: Option<bool>,
pub plan_type: Option<PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
pub struct GetAccountTokenUsageResponse {
pub summary: AccountTokenUsageSummary,
pub daily_usage_buckets: Option<Vec<AccountTokenUsageDailyBucket>>,
pub thread_usage: Option<ThreadUsage>,
}限额 response 同时保留历史单 bucket 字段和按 limit_id 索引的多 bucket 视图;reset credits 的 credits=None 与空数组含义不同,前者表示后端只提供数量,后者表示已获取详情但没有可用项。Token usage 则分为账户汇总和可选 thread usage,不能把 daily bucket 当作某个线程的精确账单。
源码位置:codex-rs/app-server/src/request_processors/account_processor.rs :: get_account_rate_limits_response
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read rate limits",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read rate limits",
));
}
let client = BackendClient::from_auth(
self.config.chatgpt_base_url.clone(),
&auth,
self.config.http_client_factory(),
);
let (response, detailed_rate_limit_reset_credits) = tokio::join!(
client.get_rate_limits_with_reset_credits(),
Self::detailed_rate_limit_reset_credits(&client),
);读取限额要求已有 auth,并且要求该 auth 使用 Codex backend;否则在发起网络请求前就返回 invalid request。服务端并行获取主限额和 reset credit 详情,再选择 limit_id=codex 的快照作为向后兼容的 rate_limits 字段。
11. 测试与边界
源码位置:codex-rs/app-server/tests/suite/v2/config_rpc.rs :: config_read_returns_effective_and_layers、config_read_includes_project_layers_for_cwd、config_value_write_replaces_value、config_value_write_rejects_version_conflict
源码位置:codex-rs/app-server/tests/suite/v2/model_list.rs :: list_models_returns_all_models_with_large_limit、list_models_includes_hidden_models、list_models_pagination_works、list_models_rejects_invalid_cursor
源码位置:codex-rs/app-server/tests/suite/v2/account.rs :: login_account_api_key_succeeds_and_notifies、get_account_no_auth、get_account_with_api_key、account_reads_use_startup_config_when_config_reload_fails
cd codex-rs
cargo test -p codex-app-server config_read_returns_effective_and_layers -- --nocapture --test-threads=1
cargo test -p codex-app-server config_read_includes_project_layers_for_cwd -- --nocapture --test-threads=1
cargo test -p codex-app-server config_value_write_replaces_value -- --nocapture --test-threads=1
cargo test -p codex-app-server config_value_write_rejects_version_conflict -- --nocapture --test-threads=1
cargo test -p codex-app-server list_models_pagination_works -- --nocapture --test-threads=1
cargo test -p codex-app-server list_models_rejects_invalid_cursor -- --nocapture --test-threads=1
cargo test -p codex-app-server login_account_api_key_succeeds_and_notifies -- --nocapture --test-threads=1
cargo test -p codex-app-server get_account_no_auth -- --nocapture --test-threads=1这些测试分别断言:配置 response 是否包含有效值和层来源,写入是否真正改变后续读取以及是否拒绝过期版本,模型分页是否终止且拒绝非法 cursor,账户登录是否发出预期通知,以及无认证读取是否返回明确状态。它们不能证明真实 OAuth 页面、所有 provider 的远程失败、跨平台 keyring 或每一种 managed requirements 组合都可用。
12. 源码定位练习
遇到配置“写入成功但读取仍是旧值”,先检查 status 与 overridden_metadata,再确认写入层的优先级和 reload_user_config 是否适用。遇到模型 picker 缺少条目,先确认 include_hidden、远程 catalog 和 cursor 是否属于同一组请求。遇到账户 UI 没有刷新,沿 login/completed → auth reload → account/updated 的顺序检查,而不是只读取一次 account/read。
这组 API 的共同学习方法是:先定位协议类型,再定位 processor 的 owner,最后沿测试找到刷新、降级和错误边界。这样才能理解 V2 response 为什么同时包含稳定字段、兼容字段和明确的缺失状态。
