fix(chatgpt): preserve account receipts on the official own-grant route

Reconcile the official open-source integration with incoming account-label changes. Engine guidance and reasoning scope share the selected owned credential snapshot; terminal status/list/get use the same canonical endpoint rule and protected registration check. Imported Codex credentials and ambient tokens remain inactive for the public plan API. Preserve route-bound custom API keys and xAI diagnostics, account labels, and activation replacement receipts. Clarify returning login versus explicit new-account/workspace registration in English and Chinese. Keep raw URL credentials out of terminal diagnostics.

Validation on shared wave over e94014909:
- Focused Engine/TUI nextest: 362 passed, 0 failed, 14326 skipped.
- Normal CLI dependency compile initially failed on two test-only legacy helpers; restricted them to cfg(test), then library compilation passed.
- Focused CLI nextest: 17 run, 15 passed, 2 fixture failures (masked-key prefix and create-versus-replace setup). Corrected only those fixture assertions/setup; failed-case rerun: 2 passed, 483 skipped. All 17 selected cases validated; production source unchanged after the 15 passing cases.
- npm test && npm run check:web: exit 0. Web 65 files/653 tests passed; extension host 91 passed/1 skipped/0 failed; package and runtime SDK tests passed. Web lint/types/facts/docs/tokens and 840-page build passed. Package/SDK count was not retained for this run.
- Existing fresh audit: 0 vulnerabilities across727 dependencies; existing bincode informational maintenance warning. Dependency manifest/lock unchanged by rebase. AWS-LC JWT backend; Windows x64 upstream assembly fallback.
- Scoped rustfmt and git diff --check passed. macOS TUI test linker warns about oversized unwind section; tests succeeded.

Earlier config qualification: 139 passed/1 legacy assertion failed, then corrected case passed on focused rerun. No real ChatGPT OAuth login, live catalog request, inference/allowance spend, deploy, native app QA, commercial approval, or release. Platform/exhaustive CI remains separate.

Refs: SHA-6830
This commit is contained in:
CodeWhale Bot committed 2026-10-02 00:33:37 -07:00
1 parent 6a73315951
commit c58a853f77
5 files changed
+519 -230

No files matched your search

+458 -192
View File
@@ -1658,7 +1658,7 @@ enum AuthCommand {
/// account is signed in.
#[command(name = "xai-device")]
XaiDevice,
/// Sign in with ChatGPT for Codex subscription access; run again to switch accounts.
/// Sign in with ChatGPT; use CODEWHALE_CHATGPT_NEW_ACCOUNT=1 to register another account.
///
/// Opens the ChatGPT sign-in page (PKCE loopback) and asks you to sign
/// in, so you can choose a different account than the one the browser
@@ -3241,12 +3241,12 @@ impl XaiAuthDiagnostics {
/// redacted tail prevents the presentation layer from accidentally retaining a
/// plaintext credential after it has derived the effective route.
#[derive(Debug, Clone, Default)]
struct XaiRuntimeApiKey {
struct RuntimeAuthApiKey {
source: Option<RuntimeApiKeySource>,
last4: Option<String>,
}
impl XaiRuntimeApiKey {
impl RuntimeAuthApiKey {
fn source_name(&self) -> Option<&'static str> {
match self.source {
Some(RuntimeApiKeySource::Cli) => Some("cli"),
@@ -3371,13 +3371,13 @@ fn xai_runtime_api_key(
store: &ConfigStore,
secrets: &Secrets,
runtime_overrides: &CliRuntimeOverrides,
) -> XaiRuntimeApiKey {
) -> RuntimeAuthApiKey {
let resolved = store.config.resolve_runtime_options_with_secrets(
&runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
secrets,
);
debug_assert_eq!(resolved.provider, ProviderKind::Xai);
XaiRuntimeApiKey {
RuntimeAuthApiKey {
source: resolved.api_key_source,
last4: resolved.api_key.as_deref().map(last4_label),
}
@@ -3401,7 +3401,7 @@ fn api_key_source_name(
fn xai_status_summary_source(
diagnostics: &XaiAuthDiagnostics,
api_key: Option<&XaiRuntimeApiKey>,
api_key: Option<&RuntimeAuthApiKey>,
) -> String {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => {
@@ -3409,7 +3409,7 @@ fn xai_status_summary_source(
}
XaiAuthDiagnosticRoute::NeedsRepair => {
let api_key = api_key
.and_then(XaiRuntimeApiKey::source_name)
.and_then(RuntimeAuthApiKey::source_name)
.unwrap_or("no runtime-effective API key");
format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
}
@@ -3417,7 +3417,7 @@ fn xai_status_summary_source(
"external consent configured/unprobed".to_string()
}
XaiAuthDiagnosticRoute::ApiKey => api_key
.and_then(XaiRuntimeApiKey::source_name)
.and_then(RuntimeAuthApiKey::source_name)
.unwrap_or("unset")
.to_string(),
}
@@ -3425,7 +3425,7 @@ fn xai_status_summary_source(
fn xai_credential_route_label(
diagnostics: &XaiAuthDiagnostics,
api_key: Option<&XaiRuntimeApiKey>,
api_key: Option<&RuntimeAuthApiKey>,
) -> String {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => {
@@ -3434,7 +3434,7 @@ fn xai_credential_route_label(
}
XaiAuthDiagnosticRoute::NeedsRepair => {
let api_key = api_key
.and_then(XaiRuntimeApiKey::source_with_last4)
.and_then(RuntimeAuthApiKey::source_with_last4)
.unwrap_or_else(|| "no runtime-effective API key".to_string());
format!(
"xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
@@ -3444,13 +3444,13 @@ fn xai_credential_route_label(
"external read-only consent configured/unprobed".to_string()
}
XaiAuthDiagnosticRoute::ApiKey => api_key
.and_then(XaiRuntimeApiKey::source_with_last4)
.and_then(RuntimeAuthApiKey::source_with_last4)
.unwrap_or_else(|| "missing".to_string()),
}
}
fn xai_table_storage_status(
api_key: Option<&XaiRuntimeApiKey>,
api_key: Option<&RuntimeAuthApiKey>,
source: RuntimeApiKeySource,
) -> &'static str {
match api_key {
@@ -3463,7 +3463,7 @@ fn xai_table_storage_status(
}
fn xai_list_storage_status(
api_key: Option<&XaiRuntimeApiKey>,
api_key: Option<&RuntimeAuthApiKey>,
source: RuntimeApiKeySource,
) -> &'static str {
match api_key {
@@ -3475,7 +3475,7 @@ fn xai_list_storage_status(
fn xai_list_route(
diagnostics: &XaiAuthDiagnostics,
api_key: Option<&XaiRuntimeApiKey>,
api_key: Option<&RuntimeAuthApiKey>,
) -> &'static str {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
@@ -3493,7 +3493,7 @@ fn xai_list_route(
fn xai_storage_detail(
diagnostics: &XaiAuthDiagnostics,
api_key: Option<&XaiRuntimeApiKey>,
api_key: Option<&RuntimeAuthApiKey>,
source: RuntimeApiKeySource,
) -> String {
match api_key {
@@ -3536,13 +3536,13 @@ fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
}
}
fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&RuntimeAuthApiKey>) -> String {
match diagnostics.route {
XaiAuthDiagnosticRoute::OwnedOAuth => {
"xai: configured (source: Codewhale-owned OAuth generation; valid pointer; token availability unprobed)".to_string()
}
XaiAuthDiagnosticRoute::NeedsRepair => {
let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
let api_key = match api_key.and_then(RuntimeAuthApiKey::source_name) {
Some("config") => "config-file".to_string(),
Some("secret store") => "secret-store".to_string(),
Some("env") => "env".to_string(),
@@ -3557,7 +3557,7 @@ fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApi
XaiAuthDiagnosticRoute::ExternalConsent => {
"xai: configured (source: external read-only consent; availability unprobed)".to_string()
}
XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(RuntimeAuthApiKey::source_name) {
Some("config") => "xai: set (source: config-file)".to_string(),
Some("secret store") => "xai: set (source: secret-store)".to_string(),
Some("env") => "xai: set (source: env)".to_string(),
@@ -3568,6 +3568,148 @@ fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApi
}
}
/// Describe the selected ChatGPT route without refreshing credentials or
/// consulting ambient tokens, API-key storage, or external CLI files for the
/// official plan endpoint. Custom routes use the dispatcher's bound-key resolver.
struct ChatgptAuthDiagnostics {
official_endpoint: bool,
source: String,
api_key: Option<RuntimeAuthApiKey>,
}
fn chatgpt_auth_diagnostics(
store: &ConfigStore,
secrets: &Secrets,
runtime_overrides: &CliRuntimeOverrides,
) -> ChatgptAuthDiagnostics {
let overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::OpenaiCodex);
let mut route_overrides = overrides.clone();
route_overrides.api_key = None;
route_overrides.auth_mode = Some("none".to_string());
let resolved = store.config.resolve_runtime_options(&route_overrides);
let official_endpoint = codewhale_tui::is_official_chatgpt_api_base(&resolved.base_url);
let api_key = (!official_endpoint).then(|| {
let resolved = store
.config
.resolve_runtime_options_with_secrets(&overrides, secrets);
RuntimeAuthApiKey {
source: resolved.api_key_source,
last4: resolved.api_key.as_deref().map(last4_label),
}
});
let source = if official_endpoint {
if store.config.providers.openai_codex.auth_mode.as_deref() != Some("oauth") {
"missing (run `codewhale auth chatgpt` to register an official grant)".to_string()
} else {
match owned_oauth_account(store, ProviderKind::OpenaiCodex) {
Ok(Some(account)) => format!(
"Codewhale-owned ChatGPT sign-in as {account} (verified grant; no refresh or network)"
),
Ok(None) => {
"Codewhale-owned ChatGPT sign-in (verified grant; no refresh or network)"
.to_string()
}
Err(reason) => format!(
"missing (Codewhale-owned ChatGPT sign-in is unusable: {reason}; run `codewhale auth chatgpt`)"
),
}
}
} else {
api_key
.as_ref()
.and_then(RuntimeAuthApiKey::source_with_last4)
.unwrap_or_else(|| {
"missing (custom endpoint requires an explicit or route-bound API key)".to_string()
})
};
ChatgptAuthDiagnostics {
official_endpoint,
source,
api_key,
}
}
fn chatgpt_auth_status_lines(
store: &ConfigStore,
secrets: &Secrets,
runtime_overrides: &CliRuntimeOverrides,
) -> Vec<String> {
let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
let marker = if store.config.provider == ProviderKind::OpenaiCodex {
" (active provider)"
} else {
""
};
let storage_detail = |source| {
if diagnostics.official_endpoint {
"inactive for official ChatGPT sign-in".to_string()
} else if let Some(key) = diagnostics.api_key.as_ref().filter(|key| key.uses(source)) {
key.last4
.as_deref()
.map(|tail| format!("runtime-effective, last4: {tail}"))
.unwrap_or_else(|| "runtime-effective".to_string())
} else {
"not eligible or not selected for this custom endpoint".to_string()
}
};
let mut lines = vec![
format!("provider: openai-codex{marker}"),
format!(
"route: {}",
if diagnostics.official_endpoint {
"official ChatGPT plan API"
} else {
"custom API-key endpoint"
}
),
format!(
"model: {}",
store
.config
.providers
.openai_codex
.model
.as_deref()
.unwrap_or("(default)")
),
format!(
"auth mode: {}",
if diagnostics.official_endpoint {
"oauth"
} else {
"api_key"
}
),
format!("active source: {}", diagnostics.source),
if diagnostics.official_endpoint {
"lookup order: verified Codewhale-owned ChatGPT sign-in only; ambient tokens and external CLI credentials are inactive".to_string()
} else {
"lookup order: endpoint-bound API key only (explicit CLI key or route-bound config key)"
.to_string()
},
format!(
"config file: {} ({})",
codewhale_config::quote_os_path(store.path()),
storage_detail(RuntimeApiKeySource::ConfigFile)
),
format!(
"secret store: {} ({})",
secrets.backend_name(),
storage_detail(RuntimeApiKeySource::Keyring)
),
format!(
"env var: {} ({})",
provider_env_vars(ProviderKind::OpenaiCodex).join("/"),
storage_detail(RuntimeApiKeySource::Env)
),
"external credentials: inactive for this route (no file was probed)".to_string(),
];
if diagnostics.official_endpoint {
lines.push("switch account: `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt` (choose another account, then restart open Codewhale sessions); `/auth chatgpt` reauthorizes the selected account".to_string());
}
lines
}
fn auth_get_line_with_runtime(
store: &ConfigStore,
secrets: &Secrets,
@@ -3583,6 +3725,19 @@ fn auth_get_line_with_runtime(
return xai_get_line(&diagnostics, api_key.as_ref());
}
if provider == ProviderKind::OpenaiCodex {
let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
return format!(
"{slot}: {} (source: {})",
if diagnostics.source.starts_with("missing") {
"not set"
} else {
"configured"
},
diagnostics.source
);
}
let config_key = provider_config_api_key(store, provider);
let keyring_key = config_key
.is_none()
@@ -3647,6 +3802,35 @@ fn auth_status_all_providers_with_runtime(
continue;
}
if provider == ProviderKind::OpenaiCodex {
let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
let status = |source| {
if diagnostics
.api_key
.as_ref()
.is_some_and(|key| key.uses(source))
{
"set"
} else {
"-"
}
};
lines.push(format!(
"{:<14} {:<8} {:<10} {:<8} {}{}",
provider.as_str(),
status(RuntimeApiKeySource::ConfigFile),
status(RuntimeApiKeySource::Keyring),
status(RuntimeApiKeySource::Env),
diagnostics.source,
if provider == active_provider {
" *"
} else {
""
}
));
continue;
}
let config_key = provider_config_api_key(store, provider);
let keyring_key = provider_keyring_api_key(secrets, provider);
let env_key = provider_env_value(provider);
@@ -3656,18 +3840,7 @@ fn auth_status_all_providers_with_runtime(
let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
let source = if provider == ProviderKind::OpenaiCodex {
// Keep the summary consistent with `auth status`: Codex auth is
// OAuth-file (or env token) based — config/keyring keys are not
// consulted for it.
if env_key.is_some() {
"env".to_string()
} else if external_selected {
"external consent (not probed)".to_string()
} else {
"unset".to_string()
}
} else if external_selected {
let source = if external_selected {
"external consent (not probed)".to_string()
} else if config_key.is_some() {
"config".to_string()
@@ -3960,37 +4133,31 @@ fn auth_list_lines_with_runtime(
continue;
}
if provider == ProviderKind::OpenaiCodex {
let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
let status = |source| {
yes_no(
diagnostics
.api_key
.as_ref()
.is_some_and(|key| key.uses(source)),
)
};
lines.push(format!(
"{label:<12} {} {} {} {}",
status(RuntimeApiKeySource::ConfigFile),
status(RuntimeApiKeySource::Keyring),
status(RuntimeApiKeySource::Env),
diagnostics.source
));
continue;
}
let file = provider_config_set(store, provider);
let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
let env = provider_env_set(provider);
let external_selected = external_oauth_selected(store, provider);
let active = if provider == ProviderKind::OpenaiCodex {
// Same lookup order as `auth status`: env, then the
// Codewhale-owned sign-in, then consented Codex CLI import.
if env {
"env".to_string()
} else if store
.config
.providers
.openai_codex
.oauth_credential_generation
.as_deref()
.is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
{
// An unusable owned sign-in falls through to consent at
// runtime (`codex_credentials`), so name what is really used.
match owned_oauth_account(store, provider) {
Ok(Some(account)) => format!("owned-oauth ({account})"),
Ok(None) => "owned-oauth".to_string(),
Err(_) if external_selected => "external-consent".to_string(),
Err(_) => "owned-oauth-unusable".to_string(),
}
} else if external_selected {
"external-consent".to_string()
} else {
"missing".to_string()
}
} else if external_selected {
let active = if external_selected {
"external-consent".to_string()
} else if file {
"config".to_string()
@@ -4035,46 +4202,18 @@ fn auth_status_lines_for_provider_with_runtime(
return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
}
if provider == ProviderKind::OpenaiCodex {
return chatgpt_auth_status_lines(store, secrets, runtime_overrides);
}
let config_key = provider_config_api_key(store, provider);
let keyring_key = provider_keyring_api_key(secrets, provider);
let env_key = provider_env_value(provider);
let external = external_consent(store, provider);
let external_selected = external_oauth_selected(store, provider);
let owned_chatgpt_source = (provider == ProviderKind::OpenaiCodex
&& store
.config
.providers
.openai_codex
.oauth_credential_generation
.as_deref()
.is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation))
.then(|| match owned_oauth_account(store, provider) {
Ok(Some(account)) => {
format!("codewhale-owned ChatGPT sign-in as {account} (availability not probed)")
}
Ok(None) => "codewhale-owned ChatGPT sign-in (availability not probed)".to_string(),
// The runtime skips an unusable owned sign-in and falls through to
// consent (`codex_credentials`), so say so rather than name it.
Err(reason) if external_selected => format!(
"external read-only consent (availability not probed; the Codewhale-owned ChatGPT sign-in is unusable: {reason})"
),
Err(reason) => format!(
"missing (the Codewhale-owned ChatGPT sign-in is unusable: {reason}; run `codewhale auth chatgpt`)"
),
});
let active_label = {
let active_source = if provider == ProviderKind::OpenaiCodex {
if env_key.is_some() {
"env"
} else if let Some(source) = owned_chatgpt_source.as_deref() {
source
} else if external_selected {
"external read-only consent (availability not probed)"
} else {
"missing"
}
} else if external_selected {
let active_source = if external_selected {
"external read-only consent (availability not probed)"
} else if config_key.is_some() {
"config"
@@ -4085,14 +4224,10 @@ fn auth_status_lines_for_provider_with_runtime(
} else {
"missing"
};
let active_last4 = if provider == ProviderKind::OpenaiCodex {
env_key.as_ref().map(|(_, value)| last4_label(value))
} else {
config_key
.map(last4_label)
.or_else(|| keyring_key.as_deref().map(last4_label))
.or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
};
let active_last4 = config_key
.map(last4_label)
.or_else(|| keyring_key.as_deref().map(last4_label))
.or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));
active_last4
.map(|last4| format!("{active_source} (last4: {last4})"))
.unwrap_or_else(|| active_source.to_string())
@@ -4114,22 +4249,13 @@ fn auth_status_lines_for_provider_with_runtime(
let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
let model = provider_cfg.model.as_deref().unwrap_or("(default)");
let lookup_order = if provider == ProviderKind::OpenaiCodex {
"lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
.to_string()
} else {
"lookup order: config -> secret store -> env".to_string()
};
let auth_mode = if provider == ProviderKind::OpenaiCodex {
"codex_oauth".to_string()
} else {
provider_cfg
.auth_mode
.as_deref()
.or(store.config.auth_mode.as_deref())
.unwrap_or("api_key")
.to_string()
};
let lookup_order = "lookup order: config -> secret store -> env".to_string();
let auth_mode = provider_cfg
.auth_mode
.as_deref()
.or(store.config.auth_mode.as_deref())
.unwrap_or("api_key")
.to_string();
let mut lines = vec![
format!("provider: {}{}", provider.as_str(), active_marker),
@@ -4150,18 +4276,6 @@ fn auth_status_lines_for_provider_with_runtime(
),
format!("env var: {env_var_label} ({env_status})"),
];
if provider == ProviderKind::OpenaiCodex {
// An env token outranks every sign-in, so a new login alone would
// change nothing (the client leaves its switch hint out likewise).
lines.push(match env_key.as_ref() {
Some((name, _)) => format!(
"switch account: unset {name} first (it outranks every sign-in), then run `codewhale auth chatgpt` and choose the other ChatGPT account"
),
None => "switch account: `codewhale auth chatgpt` (choose the other ChatGPT account; replaces the Codewhale-owned sign-in)"
.to_string(),
});
}
if let Ok((source, expected_path)) = external_credential_target(provider, None) {
let status = codewhale_config::external_credential_consent_status(
external,
@@ -9818,8 +9932,9 @@ verbosity = "concise"
assert!(
ProviderKind::ALL
.iter()
.filter(|p| **p != ProviderKind::OpenaiCodex)
.all(|p| probed.contains(&provider_slot(*p).to_string())),
"every known provider should be probed by auth list: {:?}",
"API-key providers should be probed by auth list: {:?}",
*probed
);
@@ -10005,79 +10120,167 @@ verbosity = "concise"
}
#[test]
fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
use codewhale_secrets::InMemoryKeyringStore;
use std::sync::Arc;
fn chatgpt_auth_diagnostics_ignore_ambient_tokens_and_external_consent() {
let _lock = env_lock();
let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
let dir = tempfile::TempDir::new().expect("tempdir");
let config_path = dir.path().join("config.toml");
let home = dir
.path()
.canonicalize()
.expect("canonical root")
.join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "ambient-secret-9911");
let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "alias-secret-9922");
let auth_path = dir.path().join("auth.json");
std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
.expect("write auth file");
let auth_path_str = auth_path.to_string_lossy().into_owned();
let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
let external_raw = r#"{"tokens":{"access_token":"external-secret-9933"}}"#;
std::fs::write(&auth_path, external_raw).expect("external trap");
let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path.to_string_lossy());
let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
store.config.provider = ProviderKind::OpenaiCodex;
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
let output =
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
assert!(output.contains("provider: openai-codex"));
assert!(output.contains("auth mode: codex_oauth"));
assert!(output.contains("active source: missing"));
assert!(output.contains(
"lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
));
assert!(output.contains("external credentials: disabled"));
assert!(output.contains("scope_valid=false"));
assert!(output.contains("disabled; no external-credential probing, reading"));
assert!(output.contains("file not probed"));
assert!(!output.contains("secret-token"));
store.config.providers.openai_codex.external_credentials =
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
ProviderKind::OpenaiCodex,
codewhale_config::ExternalCredentialSource::CodexCli,
auth_path.clone(),
));
let output =
let keyring = std::sync::Arc::new(RecordingKeyringStore::default());
let secrets = Secrets::new(keyring.clone());
let runtime = CliRuntimeOverrides::default();
let status =
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
let list = auth_list_lines(&store, &secrets).join("\n");
let summary = auth_status_all_providers(&store, &secrets).join("\n");
assert!(status.contains("auth mode: oauth"), "{status}");
assert!(status.contains("active source: missing"), "{status}");
assert!(
output.contains("active source: external read-only consent (availability not probed)")
status.contains("verified Codewhale-owned ChatGPT sign-in only"),
"{status}"
);
assert!(output.contains("external credentials: read_only"));
assert!(output.contains("provider=openai-codex"));
assert!(output.contains("source=codex_cli"));
assert!(output.contains(&format!(
"path={}",
codewhale_config::quote_os_path(&auth_path)
)));
assert!(output.contains(&format!(
"consent_version={}",
codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
)));
assert!(output.contains("file not probed"));
assert!(!output.contains("secret-token"));
assert!(
status.contains("CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt"),
"{status}"
);
assert!(get.starts_with("openai-codex: not set"), "{get}");
for output in [&status, &get, &list, &summary] {
assert!(!output.contains("secret-99"), "{output}");
assert!(!output.contains("active source: env"), "{output}");
assert!(!output.contains("external-consent"), "{output}");
}
assert!(
!keyring.queried().iter().any(|slot| slot == "openai-codex"),
"official ChatGPT must not inspect API-key storage"
);
assert_eq!(
std::fs::read_to_string(auth_path).expect("external trap unchanged"),
external_raw
);
}
let ambient_path = dir.path().join("new-ambient-auth.json");
let ambient_path_str = ambient_path.to_string_lossy().into_owned();
let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
let changed =
#[test]
fn chatgpt_custom_endpoint_diagnostics_use_only_route_bound_api_keys() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir
.path()
.canonicalize()
.expect("canonical root")
.join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
let _token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "ambient-secret-9944");
let _alias = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "alias-secret-9955");
let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
store.config.provider = ProviderKind::OpenaiCodex;
let provider = &mut store.config.providers.openai_codex;
provider.base_url = Some("https://custom.example/v1".to_string());
provider.api_key = Some("route-bound-secret-9966".to_string());
provider.auth_mode = Some("oauth".to_string());
provider.oauth_credential_generation = Some("../must-not-read.json".to_string());
let secrets = no_keyring_secrets();
let runtime = CliRuntimeOverrides::default();
let status =
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
assert!(changed.contains("state=active"), "{changed}");
assert!(changed.contains("ambient_path_changed=true"), "{changed}");
assert!(changed.contains("consent remains pinned"), "{changed}");
let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
let list = auth_list_lines(&store, &secrets).join("\n");
let summary = auth_status_all_providers(&store, &secrets).join("\n");
assert!(status.contains("auth mode: api_key"), "{status}");
assert!(
changed.contains(&codewhale_config::quote_os_path(&auth_path)),
"{changed}"
status.contains("active source: config (last4: ...9966)"),
"{status}"
);
assert!(!changed.contains(&ambient_path_str), "{changed}");
for output in [&status, &get, &list, &summary] {
assert!(output.contains("config (last4: ...9966)"), "{output}");
assert!(!output.contains("secret-99"), "{output}");
assert!(!output.contains("verified grant"), "{output}");
}
assert!(!status.contains("switch account:"), "{status}");
let another_endpoint = CliRuntimeOverrides {
base_url: Some("https://other.example/v1".to_string()),
..CliRuntimeOverrides::default()
};
let get = auth_get_line_with_runtime(
&store,
&secrets,
ProviderKind::OpenaiCodex,
&another_endpoint,
);
assert!(get.starts_with("openai-codex: not set"), "{get}");
assert!(
!get.contains("9966"),
"key must remain bound to its configured endpoint: {get}"
);
let explicit = CliRuntimeOverrides {
api_key: Some("explicit-secret-9977".to_string()),
..another_endpoint
};
let get =
auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &explicit);
assert!(get.contains("cli (last4: ...9977)"), "{get}");
assert!(!get.contains("explicit-secret"), "{get}");
}
#[test]
fn chatgpt_auth_diagnostics_never_display_custom_url_credentials() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir
.path()
.canonicalize()
.expect("canonical root")
.join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
store.config.provider = ProviderKind::OpenaiCodex;
let raw_url = "https://private-user:[email protected]/v1?token=private-query-token#private-fragment";
store.config.providers.openai_codex.base_url = Some(raw_url.to_string());
store.config.providers.openai_codex.api_key = Some("route-bound-secret-9988".to_string());
let secrets = no_keyring_secrets();
let runtime = CliRuntimeOverrides::default();
let status =
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
assert!(
status.contains("route: custom API-key endpoint"),
"{status}"
);
let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
let list = auth_list_lines(&store, &secrets).join("\n");
let summary = auth_status_all_providers(&store, &secrets).join("\n");
for output in [&status, &get, &list, &summary] {
for secret in [
raw_url,
"private-user",
"private-password",
"private-query-token",
"private-fragment",
"route-bound-secret",
] {
assert!(
!output.contains(secret),
"URL or credential leaked in diagnostic output: {output}"
);
}
}
}
#[test]
@@ -10103,6 +10306,7 @@ verbosity = "concise"
.providers
.openai_codex
.oauth_credential_generation = Some(chatgpt_generation.to_string());
store.config.providers.openai_codex.auth_mode = Some("oauth".to_string());
store.config.providers.xai.auth_mode = Some("oauth".to_string());
store.config.providers.xai.oauth_credential_generation = Some(xai_generation.to_string());
@@ -10136,14 +10340,54 @@ verbosity = "concise"
.expect("seed Codewhale-owned sign-ins");
let secrets = no_keyring_secrets();
let legacy =
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
assert!(legacy.contains("active source: missing"), "{legacy}");
assert!(
!legacy.contains("[email protected]"),
"legacy claims must not name an official account: {legacy}"
);
// Stored-proof fixture; signed JWT verification is exercised in the
// OAuth tests. Only the protected, verified grant may supply a label.
let official_file = serde_json::json!({
"https://auth.openai.com::oaiapp_codewhale_test": {
"access_token": "chatgpt-access-secret-9911",
"refresh_token": "chatgpt-refresh-secret-9912",
"id_token": format!("hdr.{chatgpt_claims}.sig-secret-9913"),
"account_id": "test-sub",
"oidc_issuer": "https://auth.openai.com",
"oidc_client_id": "oaiapp_codewhale_test",
"siwc_registration": {
"issuer": "https://auth.openai.com",
"client_id": "oaiapp_codewhale_test",
"subject": "test-sub",
"email": "[email protected]",
"host_id": "urn:uuid:01234567-89ab-cdef-0123-456789abcdef"
},
"siwc_scope": "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
"siwc_token_sha256": "oPH6E3xSo6jmzfjPkkNVow-MDP5cTEzxdTLxk_woXeY"
}
});
codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
owned.write(
chatgpt_generation,
official_file.to_string().as_bytes(),
true,
)
})
.expect("install protected verified-grant fixture");
let codex =
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
assert!(
codex.contains("active source: codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
codex.contains("active source: Codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
"{codex}"
);
assert!(
codex.contains("switch account: `codewhale auth chatgpt`"),
codex.contains(
"switch account: `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`"
),
"{codex}"
);
let xai = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
@@ -10153,7 +10397,10 @@ verbosity = "concise"
"{xai}"
);
let list = auth_list_lines(&store, &secrets).join("\n");
assert!(list.contains("owned-oauth ([email protected] (pro))"), "{list}");
assert!(
list.contains("Codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
"{list}"
);
assert!(
list.contains("owned-oauth-configured ([email protected])"),
"{list}"
@@ -10164,21 +10411,36 @@ verbosity = "concise"
}
}
// An env token outranks the sign-in: a re-login alone switches
// nothing, so status must not advertise it as the switch.
// Ambient tokens never replace the verified own grant or its label.
{
let _token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "env-token-secret-9931");
let codex = auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex)
.join("\n");
assert!(
codex.contains("switch account: unset OPENAI_CODEX_ACCESS_TOKEN first"),
codex.contains("Codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
"{codex}"
);
assert!(
!codex.contains("replaces the Codewhale-owned sign-in"),
codex.contains("CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt"),
"{codex}"
);
assert!(
!codex.contains("unset OPENAI_CODEX_ACCESS_TOKEN first"),
"{codex}"
);
assert!(!codex.contains("env-token-secret-9931"), "{codex}");
let get = auth_get_line_with_runtime(
&store,
&secrets,
ProviderKind::OpenaiCodex,
&CliRuntimeOverrides::default(),
);
assert!(get.contains("[email protected] (pro)"), "{get}");
let summary = auth_status_all_providers(&store, &secrets).join("\n");
assert!(
summary.contains("Codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
"{summary}"
);
}
// A missing generation file is reported, not silently dropped.
@@ -10707,7 +10969,7 @@ verbosity = "concise"
}
#[test]
fn auth_list_uses_persisted_consent_without_probing_codex_file() {
fn auth_list_keeps_legacy_codex_consent_inactive_without_probing_file() {
use codewhale_secrets::InMemoryKeyringStore;
use std::sync::Arc;
@@ -10738,7 +11000,11 @@ verbosity = "concise"
.lines()
.find(|line| line.starts_with("openai-codex"))
.unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
assert!(row.ends_with("external-consent"), "{row}");
assert!(
row.contains("missing (run `codewhale auth chatgpt`"),
"{row}"
);
assert!(!row.contains("external-consent"), "{row}");
assert!(!output.contains("secret-token"));
}
+7
View File
@@ -2282,6 +2282,13 @@ pub fn owned_oauth_account_label(
oauth::owned_account_label_for_generation(provider, generation)
}
/// Whether a resolved route is the canonical public ChatGPT API endpoint.
/// CLI diagnostics use the same destination check as inference dispatch.
#[must_use]
pub fn is_official_chatgpt_api_base(base_url: &str) -> bool {
pricing::is_official_chatgpt_api(base_url)
}
/// Compatibility command to explicitly enable usage under the current policy.
/// This optional choice never arms the current process.
pub fn accept_telemetry_notice(config_path: Option<PathBuf>, version: u32) -> Result<String> {
+2
View File
@@ -140,6 +140,7 @@ fn load_credentials(grant: &ExternalCredentialReadGrant) -> Result<Option<CodexC
/// this to find out whether the consented file *still* holds a usable token,
/// because a record that outlives its token would otherwise read as stored.
#[must_use]
#[cfg(test)]
pub fn stored_credentials_present(grant: &ExternalCredentialReadGrant) -> bool {
load_credentials(grant)
.ok()
@@ -1332,6 +1333,7 @@ pub fn parse_callback_query(params: &OAuthProviderParams, query: &str) -> Result
})
}
#[cfg(test)]
pub fn accept_callback(expected_state: &str, outcome: CallbackOutcome) -> Result<String> {
accept_callback_with_client(expected_state, outcome).map(|(code, _)| code)
}
+46 -36
View File
@@ -106,7 +106,9 @@ and never changes the saved provider or model. A command-line API key is
confined to the active provider; other routes are reported as skipped for
that invocation.
Plain `models` lists the active provider's saved catalog without provider requests. Successful refreshes are saved under Codewhale's
Plain `models` lists the active provider's saved catalog without provider requests.
For ChatGPT, it validates the selected registration locally before reading
that account's saved roster. Successful refreshes are saved under Codewhale's
catalog directory and used by the model/provider pickers. Cache files are
scoped to provider identity and endpoint; a failed refresh preserves prior
rows. Text output reports source, last successful fetch time (Unix seconds),
@@ -660,43 +662,51 @@ OAuth remains blocked on a vendor-registered Codewhale identity.
Codewhale keeps one sign-in of its own per subscription provider. It is
separate from the browser, the ChatGPT or Grok apps, and the Codex or Grok
CLIs, so those can be signed in to a different account.
CLIs, so those can be signed in to a different account. A successful login
replaces the previous owned grant; there is no picker for multiple saved
ChatGPT accounts.
- **See which account is signed in.** `codewhale auth status --provider
openai-codex` (or `--provider xai`) and `codewhale auth list` show the
account email and, for ChatGPT, the plan; a team, business, enterprise or
other workspace plan also shows the first characters of the workspace's
account id, so two workspaces on one email are told apart. The label comes
from the ID token the issuer returned at sign-in, decoded locally; no token
is printed. A sign-in that can no longer produce a token (expired, no
refresh token, file missing) shows no account and says why. The provider
picker shows the label of the sign-in the route would use (for xAI that
includes a consented Grok CLI import) next to the credential, and a
successful login ends with `Signed in to ChatGPT as <email> (<plan>).` (in
the TUI, in its interface language).
- **Switch accounts.** Inside Codewhale, run `/auth chatgpt` (or
`/auth xai-device`); this switches the running session. From a shell, run
`codewhale auth chatgpt` (or `codewhale auth xai-device`) and restart any
open Codewhale session, which keeps the credential it started with. The
ChatGPT sign-in page is opened with `prompt=login`, asking the issuer to
show the sign-in step instead of reusing the browser's current account; set
`CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1` to leave that parameter out if the
issuer ever refuses it. The xAI device page approves with whichever account
the browser holds. For either, if the browser is signed in to the wrong
account, sign out there or open the printed URL in a private window. The
new account replaces the Codewhale-owned sign-in outright, and the login
names the account it replaced, or says it is the same account as before.
- **Usage limits.** When the ChatGPT subscription reports its usage limit
(`usage_limit_reached`), says the plan does not include Codex
(`usage_not_included`), or a provider reports plan quota exhausted on a
subscription sign-in, the error names the account whose credential made the
request and how to switch. Codewhale does not retry that error, because
the limit resets on the plan's schedule. An xAI route set to OAuth that fell
back to an API key gets no sign-in guidance, since no sign-in was used.
- **Precedence.** `OPENAI_CODEX_ACCESS_TOKEN` / `CODEX_ACCESS_TOKEN` outrank
the Codewhale-owned ChatGPT sign-in, and the owned sign-in outranks a
consented Codex CLI import. Unset the token variable if a re-login seems to
have no effect; `auth status` and the login itself say so when one is set.
openai-codex` (or `--provider xai`) and `codewhale auth list` show an account
label when the ID token contains an email. ChatGPT plan and workspace
details appear only when those claims are present; a workspace label uses
the first characters of its account ID. The label is display metadata, not
proof of plan entitlement. A valid sign-in may have no email label. Status
reads local credential state without contacting the issuer: an expired
access token with a stored refresh token remains structurally usable, while
missing or invalid storage, or an expired token without a refresh token,
is reported unusable. The provider picker shows the label of the sign-in
the route would use (for xAI this includes a consented Grok CLI import).
Successful login reports the account when known and whether it replaced
an earlier sign-in, in the TUI's selected language. No token is printed.
- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the
running session, or `codewhale auth chatgpt` from a shell and restart open
Codewhale sessions. Ordinary login reuses the selected account/workspace's
issued client ID and checks that the returned account is the same. The
browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`
omits that parameter if the issuer refuses it.
- **Replace the ChatGPT account or workspace.** From a shell, run
`CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its
model catalog and restart open Codewhale sessions. If the browser holds the
wrong account, sign out there or open the printed URL in a private window.
Only a fully validated new grant replaces the active owned sign-in.
- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or
`codewhale auth xai-device` from a shell and restart open sessions. The
device page approves with whichever account the browser holds; sign out
there or use a private window to approve another account. The login names
the account it replaced, or says it is the same account as before.
- **Usage limits.** ChatGPT plan requests can fail with
`subscription_sharing_usage_limit_exceeded` or
`subscription_sharing_usage_unavailable`. These errors, and subscription
quota errors from other providers, name the account that made the request
when its label is known and offer recovery guidance. Codewhale does not
retry these terminal errors or silently choose another billing route. An
xAI route set to OAuth that fell back to an API key gets no sign-in
guidance, since no sign-in was used.
- **ChatGPT credential authority.** The official ChatGPT plan route uses
only its selected, verified Codewhale-owned grant.
`OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI
tokens cannot override or authorize this route.
### External CLI credential consent
+6 -2
View File
@@ -2,7 +2,7 @@
> 英文原文:[PROVIDERS.md](../PROVIDERS.md)。
> 最后与英文同步日期(last synced with English revision):2026-09-29。
> ChatGPT 登录相关内容于 2026-10-01 按当前实现更新。
> ChatGPT 登录相关内容于 2026-10-02 按当前实现更新。
本注册表描述已接入当前 Codewhale 代码库的提供商行为。它刻意保持保守:随附条目仅限于代码已知的提供商 ID、配置键、认证路径、base URL、模型解析和能力元数据。
@@ -54,7 +54,7 @@ codewhale models --provider openai --json
`models --update`(也可写作 `--refresh`)会更新共享的 Models.dev 元数据,并用每个已配置提供商各自的凭据调用其现有的 `/models` 端点。`--provider ID` 把刷新限定在该提供商,包括具名的自定义端点。它不发起任何推理请求,也不会更改已保存的提供商或模型。命令行传入的 API 密钥只限于当前提供商;本次调用中其他路由会被报告为已跳过。
不带 `--update` 的 `models` 会列出当前提供商已保存的目录,不发起提供商请求。刷新成功后,结果保存在 Codewhale 的目录文件夹下,供模型/提供商选择器使用。缓存文件按提供商身份和端点划分;刷新失败时会保留原有行。文本输出报告来源、最近一次成功获取的时间(Unix 秒)和新鲜度。`--update --json` 会额外给出每个来源的回执和汇总计数;部分失败时,会在写完这些回执后返回非零退出码。普通的 `models --json` 仍保持模型数组格式。随附的或已配置的回退项并不能证明账户可以使用列出的每个模型。
不带 `--update` 的 `models` 会列出当前提供商已保存的目录,不发起提供商请求。ChatGPT 路由会先在本地验证所选注册,再读取该账户的缓存目录。刷新成功后,结果保存在 Codewhale 的目录文件夹下,供模型/提供商选择器使用。缓存文件按提供商身份和端点划分;刷新失败时会保留原有行。文本输出报告来源、最近一次成功获取的时间(Unix 秒)和新鲜度。`--update --json` 会额外给出每个来源的回执和汇总计数;部分失败时,会在写完这些回执后返回非零退出码。普通的 `models --json` 仍保持模型数组格式。随附的或已配置的回退项并不能证明账户可以使用列出的每个模型。
### 使用 ChatGPT 登录
@@ -68,6 +68,10 @@ codewhale --provider openai-codex
登录通过系统浏览器、回环回调和 PKCE 完成。Codewhale 验证返回的身份与套餐使用权限,把注册信息和可续期令牌保存在自己的受保护凭据存储中。身份登录不等于套餐授权;未授予 `chatgpt.tokens.use.direct` 权限时,推理会停止。使用 `codewhale auth chatgpt-revoke` 退出 Codewhale 的登录会话。当前实验版只保留一个活动账户注册,尚未提供已保存账户的切换界面。
在 Codewhale 内运行 `/auth chatgpt` 会重新授权当前账户和工作区,并更新正在运行的会话;普通重新登录会沿用已签发的客户端 ID,验证返回的仍是同一账户。要更换账户或工作区,请在终端运行 `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`,刷新新账户的模型目录,再重启已打开的 Codewhale 会话。只有通过完整验证的新授权才会替换现有登录。浏览器若登录了错误的账户,可先退出该账户,或用隐私窗口打开打印出的登录链接。
`codewhale auth status --provider openai-codex` 和 `codewhale auth list` 会在 ID 令牌含有邮箱时显示账户标签;套餐和工作区信息只在相应字段存在时显示。标签用于识别账户,不能证明套餐权限;没有邮箱标签也不代表已经退出登录。状态检查只读取本地凭据,不向签发方发起请求:访问令牌已过期但仍存有刷新令牌时,会被视为结构上可用,这不保证续期一定成功。登录成功后,会显示已知的账户标签,并说明是否替换了先前的登录。
模型目录使用已获授权的令牌请求 `GET https://api.openai.com/v1/models`;推理使用公共端点 `POST https://api.openai.com/v1/responses`。选择器保留官方显示名称和顺序,仅列出 `visibility: list` 的模型。目录不含凭据,并按已验证的签发方、签发的客户端 ID 和账户主体隔离;模型列表不能证明上下文窗口或思考档位。目录缺失、无效或超过 24 小时时,选择器不会列出任何账户模型。Codex CLI 导入令牌和旧版进程令牌变量不能授权这条官方路由。
符合条件的请求消耗 ChatGPT 套餐额度或点数。Plus 的五小时额度在各应用之间共享,不会为每个应用单独分配;这项五小时限制不适用于 Pro。应用还可能有各自的使用上限。可在 [ChatGPT 设置 → 使用情况](https://chatgpt.com/settings/usage) 查看额度和授权。额度耗尽时,Codewhale 不会自动换用 API 密钥或其他提供商。