Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
168 KiB
JSON

{"repo": "christiandoxa/prodex", "files": {"crates/prodex-app/src/quota_support/virtual_provider.rs": {"a085ce4c6c6b6ec19806845714c72109252a6f20": {"t": 1785745833.4961298, "n": 10300, "h": "7a4ddad9001407b5", "c": "use super::external_provider::fetch_agy_quota_info;\nuse super::{\n AuthSummary, ExternalQuotaDetail, ExternalQuotaInfo, ProviderQuotaSnapshot,\n QuotaProviderFilter, QuotaReport, build_upstream_blocking_http_client, format_response_body,\n quota_error_message,\n};\nuse crate::{RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES, read_blocking_response_body_with_limit};\nuse anyhow::{Context, Result, bail};\nuse chrono::Local;\nuse prodex_state::ProfileProvider;\nuse std::env;\n\npub(super) fn collect_virtual_quota_reports(\n base_url: Option<&str>,\n provider_filter: QuotaProviderFilter,\n) -> Vec<QuotaReport> {\n let mut reports = Vec::new();\n if provider_filter == QuotaProviderFilter::DeepSeek {\n reports.extend(collect_deepseek_quota_reports(base_url));\n }\n if provider_filter == QuotaProviderFilter::Local {\n reports.push(collect_local_quota_report(base_url));\n }\n if provider_filter == QuotaProviderFilter::Agy {\n reports.extend(collect_agy_quota_reports());\n }\n reports\n}\n\nfn collect_deepseek_quota_reports(base_url: Option<&str>) -> Vec<QuotaReport> {\n let keys = deepseek_api_keys_from_env();\n let Some(keys) = keys else {\n return vec![virtual_quota_report(\n \"deepseek\",\n \"deepseek-key\",\n Err(\"DeepSeek quota requires DEEPSEEK_API_KEY or DEEPSEEK_API_KEYS\".to_string()),\n )];\n };\n keys.into_iter()\n .enumerate()\n .map(|(index, key)| {\n let name = if index == 0 {\n \"deepseek\".to_string()\n } else {\n format!(\"deepseek-{}\", index + 1)\n };\n let result =\n fetch_deepseek_quota_info(&key, base_url).map_err(|err| quota_error_message(&err));\n virtual_quota_report(\n &name,\n \"deepseek-key\",\n result.map(ProviderQuotaSnapshot::External),\n )\n })\n .collect()\n}\n\nfn collect_local_quota_report(base_url: Option<&str>) -> QuotaReport {\n let result = base_url\n .map(fetch_local_openai_compatible_quota_info)\n .unwrap_or_else(|| {\n Err(anyhow::anyhow!(\n \"local quota view requires --base-url pointing at the OpenAI-compatible server\"\n ))\n })\n .map(ProviderQuotaSnapshot::External)\n .map_err(|err| quota_error_message(&err));\n virtual_quota_report(\"local\", \"local\", result)\n}\n\nfn collect_agy_quota_reports() -> Vec<QuotaReport> {\n match fetch_agy_quota_info(None) {\n Ok(info) => vec![virtual_quota_report_with_provider(\n &info\n .account\n .as_deref()\n .map(|a| format!(\"agy:{a}\"))\n .unwrap_or_else(|| \"agy\".to_string()),\n \"agy\",\n Ok(ProviderQuotaSnapshot::External(info)),\n ProfileProvider::Agy { account: None },\n )],\n Err(err) => vec![virtual_quota_report_with_provider(\n \"agy\",\n \"agy\",\n Err(quota_error_message(&err)),\n ProfileProvider::Agy { account: None },\n )],\n }\n}\n\nfn virtual_quota_report(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n) -> QuotaReport {\n virtual_quota_report_with_provider(name, auth_label, result, ProfileProvider::Openai)\n}\n\nfn virtual_quota_report_with_provider(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n provider: ProfileProvider,\n) -> QuotaReport {\n QuotaReport {\n name: name.to_string(),\n active: false,\n auth: AuthSummary {\n label: auth_label.to_string(),\n quota_compatible: false,\n },\n provider,\n workspace_id: None,\n workspace_name: None,\n result,\n fetched_at: Local::now().timestamp(),\n }\n}\n\nfn deepseek_api_keys_from_env() -> Option<Vec<String>> {\n env::var(\"DEEPSEEK_API_KEYS\")\n .ok()\n .and_then(|value| provider_api_keys_from_list(&value))\n .or_else(|| {\n env::var(\"DEEPSEEK_API_KEY\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .map(|value| vec![value])\n })\n}\n\nfn provider_api_keys_from_list(value: &str) -> Option<Vec<String>> {\n let keys = value\n .split([',', ';', '\\n'])\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string)\n .collect::<Vec<_>>();\n (!keys.is_empty()).then_some(keys)\n}\n\nfn fetch_deepseek_quota_info(api_key: &str, base_url: Option<&str>) -> Result<ExternalQuotaInfo> {\n let value = fetch_deepseek_balance_json(api_key, base_url)?;\n let available = value\n .get(\"is_available\")\n .and_then(serde_json::Value::as_bool)\n .unwrap_or(false);\n let balances = value\n .get(\"balance_infos\")\n .and_then(serde_json::Value::as_array)\n .cloned()\n .unwrap_or_default();\n let mut details = Vec::new();\n let mut parts = Vec::new();\n for balance in balances {\n let currency = balance\n .get(\"currency\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"balance\");\n let total = balance\n .get(\"total_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n parts.push(format!(\"{currency} {total}\"));\n let granted = balance\n .get(\"granted_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n let topped_up = balance\n .get(\"topped_up_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n details.push(ExternalQuotaDetail {\n label: format!(\"{currency} balance\"),\n value: format!(\"total {total}; granted {granted}; topped up {topped_up}\"),\n });\n }\n Ok(ExternalQuotaInfo {\n provider: \"DeepSeek\".to_string(),\n account: None,\n plan: Some(\"api-key\".to_string()),\n status: if available { \"Ready\" } else { \"Blocked\" }.to_string(),\n main: if parts.is_empty() {\n \"balance unavailable\".to_string()\n } else {\n parts.join(\" | \")\n },\n reset: None,\n available: Some(available),\n details,\n })\n}\n\nfn fetch_deepseek_balance_json(api_key: &str, base_url: Option<&str>) -> Result<serde_json::Value> {\n let client = build_upstream_blocking_http_client(\"DeepSeek quota HTTP\", false)?;\n let url = format!(\"{}/user/balance\", deepseek_base_url(base_url));\n let response = client\n .get(&url)\n .header(\"accept\", \"application/json\")\n .bearer_auth(api_key)\n .send()\n .context(\"failed to fetch DeepSeek balance\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read DeepSeek balance response\",\n )?;\n if !status.is_success() {\n bail!(\n \"DeepSeek balance request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n serde_json::from_slice(&body).context(\"failed to parse DeepSeek balance response\")\n}\n\nfn deepseek_base_url(explicit: Option<&str>) -> String {\n let base = explicit\n .map(str::to_string)\n .or_else(|| env::var(\"PRODEX_DEEPSEEK_BASE_URL\").ok())\n .or_else(|| env::var(\"DEEPSEEK_BASE_URL\").ok())\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n base.trim()\n .trim_end_matches('/')\n .trim_end_matches(\"/v1\")\n .to_string()\n}\n\nfn fetch_local_openai_compatible_quota_info(base_url: &str) -> Result<ExternalQuotaInfo> {\n let client = build_upstream_blocking_http_client(\"local OpenAI-compatible quota HTTP\", false)?;\n let url = openai_compatible_models_url(base_url)?;\n let mut request = client.get(&url).header(\"accept\", \"application/json\");\n if let Some(api_key) = local_openai_compatible_api_key_from_env() {\n request = request.bearer_auth(api_key);\n }\n let response = request\n .send()\n .context(\"failed to fetch local OpenAI-compatible models\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read local OpenAI-compatible response\",\n )?;\n if !status.is_success() {\n bail!(\n \"local OpenAI-compatible models request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n let value: serde_json::Value =\n serde_json::from_slice(&body).context(\"failed to parse local models response\")?;\n let model_count = value\n .get(\"data\")\n .and_then(serde_json::Value::as_array)\n .map(Vec::len);\n Ok(ExternalQuotaInfo {\n provider: \"Local OpenAI-compatible\".to_string(),\n account: Some(base_url.trim().to_string()),\n plan: Some(\"local\".to_string()),\n status: \"Reachable\".to_string(),\n main: model_count.map_or_else(\n || \"models endpoint reachable\".to_string(),\n |count| format!(\"{count} model(s)\"),\n ),\n reset: None,\n available: Some(true),\n details: vec![ExternalQuotaDetail {\n label: \"Models URL\".to_string(),\n value: url,\n }],\n })\n}\n\nfn openai_compatible_models_url(base_url: &str) -> Result<String> {\n let mut url = reqwest::Url::parse(base_url.trim())\n .with_context(|| format!(\"invalid local base URL '{}'\", base_url.trim()))?;\n let path = url.path().trim_end_matches('/');\n if path.is_empty() {\n url.set_path(\"/v1/models\");\n } else if !path.ends_with(\"/models\") {\n url.set_path(&format!(\"{path}/models\"));\n }\n Ok(url.to_string())\n}\n\nfn local_openai_compatible_api_key_from_env() -> Option<String> {\n [\"PRODEX_LOCAL_API_KEY\", \"OPENAI_API_KEY\"]\n .into_iter()\n .find_map(|key| {\n env::var(key)\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n })\n}\n"}, "5b074ea421df906052c08d2bf2c94c7476836cba": {"t": 1785757005.225967, "n": 10300, "h": "7a4ddad9001407b5", "c": "use super::external_provider::fetch_agy_quota_info;\nuse super::{\n AuthSummary, ExternalQuotaDetail, ExternalQuotaInfo, ProviderQuotaSnapshot,\n QuotaProviderFilter, QuotaReport, build_upstream_blocking_http_client, format_response_body,\n quota_error_message,\n};\nuse crate::{RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES, read_blocking_response_body_with_limit};\nuse anyhow::{Context, Result, bail};\nuse chrono::Local;\nuse prodex_state::ProfileProvider;\nuse std::env;\n\npub(super) fn collect_virtual_quota_reports(\n base_url: Option<&str>,\n provider_filter: QuotaProviderFilter,\n) -> Vec<QuotaReport> {\n let mut reports = Vec::new();\n if provider_filter == QuotaProviderFilter::DeepSeek {\n reports.extend(collect_deepseek_quota_reports(base_url));\n }\n if provider_filter == QuotaProviderFilter::Local {\n reports.push(collect_local_quota_report(base_url));\n }\n if provider_filter == QuotaProviderFilter::Agy {\n reports.extend(collect_agy_quota_reports());\n }\n reports\n}\n\nfn collect_deepseek_quota_reports(base_url: Option<&str>) -> Vec<QuotaReport> {\n let keys = deepseek_api_keys_from_env();\n let Some(keys) = keys else {\n return vec![virtual_quota_report(\n \"deepseek\",\n \"deepseek-key\",\n Err(\"DeepSeek quota requires DEEPSEEK_API_KEY or DEEPSEEK_API_KEYS\".to_string()),\n )];\n };\n keys.into_iter()\n .enumerate()\n .map(|(index, key)| {\n let name = if index == 0 {\n \"deepseek\".to_string()\n } else {\n format!(\"deepseek-{}\", index + 1)\n };\n let result =\n fetch_deepseek_quota_info(&key, base_url).map_err(|err| quota_error_message(&err));\n virtual_quota_report(\n &name,\n \"deepseek-key\",\n result.map(ProviderQuotaSnapshot::External),\n )\n })\n .collect()\n}\n\nfn collect_local_quota_report(base_url: Option<&str>) -> QuotaReport {\n let result = base_url\n .map(fetch_local_openai_compatible_quota_info)\n .unwrap_or_else(|| {\n Err(anyhow::anyhow!(\n \"local quota view requires --base-url pointing at the OpenAI-compatible server\"\n ))\n })\n .map(ProviderQuotaSnapshot::External)\n .map_err(|err| quota_error_message(&err));\n virtual_quota_report(\"local\", \"local\", result)\n}\n\nfn collect_agy_quota_reports() -> Vec<QuotaReport> {\n match fetch_agy_quota_info(None) {\n Ok(info) => vec![virtual_quota_report_with_provider(\n &info\n .account\n .as_deref()\n .map(|a| format!(\"agy:{a}\"))\n .unwrap_or_else(|| \"agy\".to_string()),\n \"agy\",\n Ok(ProviderQuotaSnapshot::External(info)),\n ProfileProvider::Agy { account: None },\n )],\n Err(err) => vec![virtual_quota_report_with_provider(\n \"agy\",\n \"agy\",\n Err(quota_error_message(&err)),\n ProfileProvider::Agy { account: None },\n )],\n }\n}\n\nfn virtual_quota_report(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n) -> QuotaReport {\n virtual_quota_report_with_provider(name, auth_label, result, ProfileProvider::Openai)\n}\n\nfn virtual_quota_report_with_provider(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n provider: ProfileProvider,\n) -> QuotaReport {\n QuotaReport {\n name: name.to_string(),\n active: false,\n auth: AuthSummary {\n label: auth_label.to_string(),\n quota_compatible: false,\n },\n provider,\n workspace_id: None,\n workspace_name: None,\n result,\n fetched_at: Local::now().timestamp(),\n }\n}\n\nfn deepseek_api_keys_from_env() -> Option<Vec<String>> {\n env::var(\"DEEPSEEK_API_KEYS\")\n .ok()\n .and_then(|value| provider_api_keys_from_list(&value))\n .or_else(|| {\n env::var(\"DEEPSEEK_API_KEY\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .map(|value| vec![value])\n })\n}\n\nfn provider_api_keys_from_list(value: &str) -> Option<Vec<String>> {\n let keys = value\n .split([',', ';', '\\n'])\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string)\n .collect::<Vec<_>>();\n (!keys.is_empty()).then_some(keys)\n}\n\nfn fetch_deepseek_quota_info(api_key: &str, base_url: Option<&str>) -> Result<ExternalQuotaInfo> {\n let value = fetch_deepseek_balance_json(api_key, base_url)?;\n let available = value\n .get(\"is_available\")\n .and_then(serde_json::Value::as_bool)\n .unwrap_or(false);\n let balances = value\n .get(\"balance_infos\")\n .and_then(serde_json::Value::as_array)\n .cloned()\n .unwrap_or_default();\n let mut details = Vec::new();\n let mut parts = Vec::new();\n for balance in balances {\n let currency = balance\n .get(\"currency\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"balance\");\n let total = balance\n .get(\"total_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n parts.push(format!(\"{currency} {total}\"));\n let granted = balance\n .get(\"granted_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n let topped_up = balance\n .get(\"topped_up_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n details.push(ExternalQuotaDetail {\n label: format!(\"{currency} balance\"),\n value: format!(\"total {total}; granted {granted}; topped up {topped_up}\"),\n });\n }\n Ok(ExternalQuotaInfo {\n provider: \"DeepSeek\".to_string(),\n account: None,\n plan: Some(\"api-key\".to_string()),\n status: if available { \"Ready\" } else { \"Blocked\" }.to_string(),\n main: if parts.is_empty() {\n \"balance unavailable\".to_string()\n } else {\n parts.join(\" | \")\n },\n reset: None,\n available: Some(available),\n details,\n })\n}\n\nfn fetch_deepseek_balance_json(api_key: &str, base_url: Option<&str>) -> Result<serde_json::Value> {\n let client = build_upstream_blocking_http_client(\"DeepSeek quota HTTP\", false)?;\n let url = format!(\"{}/user/balance\", deepseek_base_url(base_url));\n let response = client\n .get(&url)\n .header(\"accept\", \"application/json\")\n .bearer_auth(api_key)\n .send()\n .context(\"failed to fetch DeepSeek balance\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read DeepSeek balance response\",\n )?;\n if !status.is_success() {\n bail!(\n \"DeepSeek balance request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n serde_json::from_slice(&body).context(\"failed to parse DeepSeek balance response\")\n}\n\nfn deepseek_base_url(explicit: Option<&str>) -> String {\n let base = explicit\n .map(str::to_string)\n .or_else(|| env::var(\"PRODEX_DEEPSEEK_BASE_URL\").ok())\n .or_else(|| env::var(\"DEEPSEEK_BASE_URL\").ok())\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n base.trim()\n .trim_end_matches('/')\n .trim_end_matches(\"/v1\")\n .to_string()\n}\n\nfn fetch_local_openai_compatible_quota_info(base_url: &str) -> Result<ExternalQuotaInfo> {\n let client = build_upstream_blocking_http_client(\"local OpenAI-compatible quota HTTP\", false)?;\n let url = openai_compatible_models_url(base_url)?;\n let mut request = client.get(&url).header(\"accept\", \"application/json\");\n if let Some(api_key) = local_openai_compatible_api_key_from_env() {\n request = request.bearer_auth(api_key);\n }\n let response = request\n .send()\n .context(\"failed to fetch local OpenAI-compatible models\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read local OpenAI-compatible response\",\n )?;\n if !status.is_success() {\n bail!(\n \"local OpenAI-compatible models request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n let value: serde_json::Value =\n serde_json::from_slice(&body).context(\"failed to parse local models response\")?;\n let model_count = value\n .get(\"data\")\n .and_then(serde_json::Value::as_array)\n .map(Vec::len);\n Ok(ExternalQuotaInfo {\n provider: \"Local OpenAI-compatible\".to_string(),\n account: Some(base_url.trim().to_string()),\n plan: Some(\"local\".to_string()),\n status: \"Reachable\".to_string(),\n main: model_count.map_or_else(\n || \"models endpoint reachable\".to_string(),\n |count| format!(\"{count} model(s)\"),\n ),\n reset: None,\n available: Some(true),\n details: vec![ExternalQuotaDetail {\n label: \"Models URL\".to_string(),\n value: url,\n }],\n })\n}\n\nfn openai_compatible_models_url(base_url: &str) -> Result<String> {\n let mut url = reqwest::Url::parse(base_url.trim())\n .with_context(|| format!(\"invalid local base URL '{}'\", base_url.trim()))?;\n let path = url.path().trim_end_matches('/');\n if path.is_empty() {\n url.set_path(\"/v1/models\");\n } else if !path.ends_with(\"/models\") {\n url.set_path(&format!(\"{path}/models\"));\n }\n Ok(url.to_string())\n}\n\nfn local_openai_compatible_api_key_from_env() -> Option<String> {\n [\"PRODEX_LOCAL_API_KEY\", \"OPENAI_API_KEY\"]\n .into_iter()\n .find_map(|key| {\n env::var(key)\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n })\n}\n"}, "544908098fc78d4f95f477750b48b384c51347eb": {"t": 1786067480.245458, "n": 10300, "h": "7a4ddad9001407b5", "c": "use super::external_provider::fetch_agy_quota_info;\nuse super::{\n AuthSummary, ExternalQuotaDetail, ExternalQuotaInfo, ProviderQuotaSnapshot,\n QuotaProviderFilter, QuotaReport, build_upstream_blocking_http_client, format_response_body,\n quota_error_message,\n};\nuse crate::{RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES, read_blocking_response_body_with_limit};\nuse anyhow::{Context, Result, bail};\nuse chrono::Local;\nuse prodex_state::ProfileProvider;\nuse std::env;\n\npub(super) fn collect_virtual_quota_reports(\n base_url: Option<&str>,\n provider_filter: QuotaProviderFilter,\n) -> Vec<QuotaReport> {\n let mut reports = Vec::new();\n if provider_filter == QuotaProviderFilter::DeepSeek {\n reports.extend(collect_deepseek_quota_reports(base_url));\n }\n if provider_filter == QuotaProviderFilter::Local {\n reports.push(collect_local_quota_report(base_url));\n }\n if provider_filter == QuotaProviderFilter::Agy {\n reports.extend(collect_agy_quota_reports());\n }\n reports\n}\n\nfn collect_deepseek_quota_reports(base_url: Option<&str>) -> Vec<QuotaReport> {\n let keys = deepseek_api_keys_from_env();\n let Some(keys) = keys else {\n return vec![virtual_quota_report(\n \"deepseek\",\n \"deepseek-key\",\n Err(\"DeepSeek quota requires DEEPSEEK_API_KEY or DEEPSEEK_API_KEYS\".to_string()),\n )];\n };\n keys.into_iter()\n .enumerate()\n .map(|(index, key)| {\n let name = if index == 0 {\n \"deepseek\".to_string()\n } else {\n format!(\"deepseek-{}\", index + 1)\n };\n let result =\n fetch_deepseek_quota_info(&key, base_url).map_err(|err| quota_error_message(&err));\n virtual_quota_report(\n &name,\n \"deepseek-key\",\n result.map(ProviderQuotaSnapshot::External),\n )\n })\n .collect()\n}\n\nfn collect_local_quota_report(base_url: Option<&str>) -> QuotaReport {\n let result = base_url\n .map(fetch_local_openai_compatible_quota_info)\n .unwrap_or_else(|| {\n Err(anyhow::anyhow!(\n \"local quota view requires --base-url pointing at the OpenAI-compatible server\"\n ))\n })\n .map(ProviderQuotaSnapshot::External)\n .map_err(|err| quota_error_message(&err));\n virtual_quota_report(\"local\", \"local\", result)\n}\n\nfn collect_agy_quota_reports() -> Vec<QuotaReport> {\n match fetch_agy_quota_info(None) {\n Ok(info) => vec![virtual_quota_report_with_provider(\n &info\n .account\n .as_deref()\n .map(|a| format!(\"agy:{a}\"))\n .unwrap_or_else(|| \"agy\".to_string()),\n \"agy\",\n Ok(ProviderQuotaSnapshot::External(info)),\n ProfileProvider::Agy { account: None },\n )],\n Err(err) => vec![virtual_quota_report_with_provider(\n \"agy\",\n \"agy\",\n Err(quota_error_message(&err)),\n ProfileProvider::Agy { account: None },\n )],\n }\n}\n\nfn virtual_quota_report(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n) -> QuotaReport {\n virtual_quota_report_with_provider(name, auth_label, result, ProfileProvider::Openai)\n}\n\nfn virtual_quota_report_with_provider(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n provider: ProfileProvider,\n) -> QuotaReport {\n QuotaReport {\n name: name.to_string(),\n active: false,\n auth: AuthSummary {\n label: auth_label.to_string(),\n quota_compatible: false,\n },\n provider,\n workspace_id: None,\n workspace_name: None,\n result,\n fetched_at: Local::now().timestamp(),\n }\n}\n\nfn deepseek_api_keys_from_env() -> Option<Vec<String>> {\n env::var(\"DEEPSEEK_API_KEYS\")\n .ok()\n .and_then(|value| provider_api_keys_from_list(&value))\n .or_else(|| {\n env::var(\"DEEPSEEK_API_KEY\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .map(|value| vec![value])\n })\n}\n\nfn provider_api_keys_from_list(value: &str) -> Option<Vec<String>> {\n let keys = value\n .split([',', ';', '\\n'])\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string)\n .collect::<Vec<_>>();\n (!keys.is_empty()).then_some(keys)\n}\n\nfn fetch_deepseek_quota_info(api_key: &str, base_url: Option<&str>) -> Result<ExternalQuotaInfo> {\n let value = fetch_deepseek_balance_json(api_key, base_url)?;\n let available = value\n .get(\"is_available\")\n .and_then(serde_json::Value::as_bool)\n .unwrap_or(false);\n let balances = value\n .get(\"balance_infos\")\n .and_then(serde_json::Value::as_array)\n .cloned()\n .unwrap_or_default();\n let mut details = Vec::new();\n let mut parts = Vec::new();\n for balance in balances {\n let currency = balance\n .get(\"currency\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"balance\");\n let total = balance\n .get(\"total_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n parts.push(format!(\"{currency} {total}\"));\n let granted = balance\n .get(\"granted_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n let topped_up = balance\n .get(\"topped_up_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n details.push(ExternalQuotaDetail {\n label: format!(\"{currency} balance\"),\n value: format!(\"total {total}; granted {granted}; topped up {topped_up}\"),\n });\n }\n Ok(ExternalQuotaInfo {\n provider: \"DeepSeek\".to_string(),\n account: None,\n plan: Some(\"api-key\".to_string()),\n status: if available { \"Ready\" } else { \"Blocked\" }.to_string(),\n main: if parts.is_empty() {\n \"balance unavailable\".to_string()\n } else {\n parts.join(\" | \")\n },\n reset: None,\n available: Some(available),\n details,\n })\n}\n\nfn fetch_deepseek_balance_json(api_key: &str, base_url: Option<&str>) -> Result<serde_json::Value> {\n let client = build_upstream_blocking_http_client(\"DeepSeek quota HTTP\", false)?;\n let url = format!(\"{}/user/balance\", deepseek_base_url(base_url));\n let response = client\n .get(&url)\n .header(\"accept\", \"application/json\")\n .bearer_auth(api_key)\n .send()\n .context(\"failed to fetch DeepSeek balance\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read DeepSeek balance response\",\n )?;\n if !status.is_success() {\n bail!(\n \"DeepSeek balance request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n serde_json::from_slice(&body).context(\"failed to parse DeepSeek balance response\")\n}\n\nfn deepseek_base_url(explicit: Option<&str>) -> String {\n let base = explicit\n .map(str::to_string)\n .or_else(|| env::var(\"PRODEX_DEEPSEEK_BASE_URL\").ok())\n .or_else(|| env::var(\"DEEPSEEK_BASE_URL\").ok())\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n base.trim()\n .trim_end_matches('/')\n .trim_end_matches(\"/v1\")\n .to_string()\n}\n\nfn fetch_local_openai_compatible_quota_info(base_url: &str) -> Result<ExternalQuotaInfo> {\n let client = build_upstream_blocking_http_client(\"local OpenAI-compatible quota HTTP\", false)?;\n let url = openai_compatible_models_url(base_url)?;\n let mut request = client.get(&url).header(\"accept\", \"application/json\");\n if let Some(api_key) = local_openai_compatible_api_key_from_env() {\n request = request.bearer_auth(api_key);\n }\n let response = request\n .send()\n .context(\"failed to fetch local OpenAI-compatible models\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read local OpenAI-compatible response\",\n )?;\n if !status.is_success() {\n bail!(\n \"local OpenAI-compatible models request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n let value: serde_json::Value =\n serde_json::from_slice(&body).context(\"failed to parse local models response\")?;\n let model_count = value\n .get(\"data\")\n .and_then(serde_json::Value::as_array)\n .map(Vec::len);\n Ok(ExternalQuotaInfo {\n provider: \"Local OpenAI-compatible\".to_string(),\n account: Some(base_url.trim().to_string()),\n plan: Some(\"local\".to_string()),\n status: \"Reachable\".to_string(),\n main: model_count.map_or_else(\n || \"models endpoint reachable\".to_string(),\n |count| format!(\"{count} model(s)\"),\n ),\n reset: None,\n available: Some(true),\n details: vec![ExternalQuotaDetail {\n label: \"Models URL\".to_string(),\n value: url,\n }],\n })\n}\n\nfn openai_compatible_models_url(base_url: &str) -> Result<String> {\n let mut url = reqwest::Url::parse(base_url.trim())\n .with_context(|| format!(\"invalid local base URL '{}'\", base_url.trim()))?;\n let path = url.path().trim_end_matches('/');\n if path.is_empty() {\n url.set_path(\"/v1/models\");\n } else if !path.ends_with(\"/models\") {\n url.set_path(&format!(\"{path}/models\"));\n }\n Ok(url.to_string())\n}\n\nfn local_openai_compatible_api_key_from_env() -> Option<String> {\n [\"PRODEX_LOCAL_API_KEY\", \"OPENAI_API_KEY\"]\n .into_iter()\n .find_map(|key| {\n env::var(key)\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n })\n}\n"}, "79de87991fa0c1a4b4574dca046d9d8a3533508b": {"t": 1786068326.6636891, "n": 10300, "h": "7a4ddad9001407b5", "c": "use super::external_provider::fetch_agy_quota_info;\nuse super::{\n AuthSummary, ExternalQuotaDetail, ExternalQuotaInfo, ProviderQuotaSnapshot,\n QuotaProviderFilter, QuotaReport, build_upstream_blocking_http_client, format_response_body,\n quota_error_message,\n};\nuse crate::{RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES, read_blocking_response_body_with_limit};\nuse anyhow::{Context, Result, bail};\nuse chrono::Local;\nuse prodex_state::ProfileProvider;\nuse std::env;\n\npub(super) fn collect_virtual_quota_reports(\n base_url: Option<&str>,\n provider_filter: QuotaProviderFilter,\n) -> Vec<QuotaReport> {\n let mut reports = Vec::new();\n if provider_filter == QuotaProviderFilter::DeepSeek {\n reports.extend(collect_deepseek_quota_reports(base_url));\n }\n if provider_filter == QuotaProviderFilter::Local {\n reports.push(collect_local_quota_report(base_url));\n }\n if provider_filter == QuotaProviderFilter::Agy {\n reports.extend(collect_agy_quota_reports());\n }\n reports\n}\n\nfn collect_deepseek_quota_reports(base_url: Option<&str>) -> Vec<QuotaReport> {\n let keys = deepseek_api_keys_from_env();\n let Some(keys) = keys else {\n return vec![virtual_quota_report(\n \"deepseek\",\n \"deepseek-key\",\n Err(\"DeepSeek quota requires DEEPSEEK_API_KEY or DEEPSEEK_API_KEYS\".to_string()),\n )];\n };\n keys.into_iter()\n .enumerate()\n .map(|(index, key)| {\n let name = if index == 0 {\n \"deepseek\".to_string()\n } else {\n format!(\"deepseek-{}\", index + 1)\n };\n let result =\n fetch_deepseek_quota_info(&key, base_url).map_err(|err| quota_error_message(&err));\n virtual_quota_report(\n &name,\n \"deepseek-key\",\n result.map(ProviderQuotaSnapshot::External),\n )\n })\n .collect()\n}\n\nfn collect_local_quota_report(base_url: Option<&str>) -> QuotaReport {\n let result = base_url\n .map(fetch_local_openai_compatible_quota_info)\n .unwrap_or_else(|| {\n Err(anyhow::anyhow!(\n \"local quota view requires --base-url pointing at the OpenAI-compatible server\"\n ))\n })\n .map(ProviderQuotaSnapshot::External)\n .map_err(|err| quota_error_message(&err));\n virtual_quota_report(\"local\", \"local\", result)\n}\n\nfn collect_agy_quota_reports() -> Vec<QuotaReport> {\n match fetch_agy_quota_info(None) {\n Ok(info) => vec![virtual_quota_report_with_provider(\n &info\n .account\n .as_deref()\n .map(|a| format!(\"agy:{a}\"))\n .unwrap_or_else(|| \"agy\".to_string()),\n \"agy\",\n Ok(ProviderQuotaSnapshot::External(info)),\n ProfileProvider::Agy { account: None },\n )],\n Err(err) => vec![virtual_quota_report_with_provider(\n \"agy\",\n \"agy\",\n Err(quota_error_message(&err)),\n ProfileProvider::Agy { account: None },\n )],\n }\n}\n\nfn virtual_quota_report(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n) -> QuotaReport {\n virtual_quota_report_with_provider(name, auth_label, result, ProfileProvider::Openai)\n}\n\nfn virtual_quota_report_with_provider(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n provider: ProfileProvider,\n) -> QuotaReport {\n QuotaReport {\n name: name.to_string(),\n active: false,\n auth: AuthSummary {\n label: auth_label.to_string(),\n quota_compatible: false,\n },\n provider,\n workspace_id: None,\n workspace_name: None,\n result,\n fetched_at: Local::now().timestamp(),\n }\n}\n\nfn deepseek_api_keys_from_env() -> Option<Vec<String>> {\n env::var(\"DEEPSEEK_API_KEYS\")\n .ok()\n .and_then(|value| provider_api_keys_from_list(&value))\n .or_else(|| {\n env::var(\"DEEPSEEK_API_KEY\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .map(|value| vec![value])\n })\n}\n\nfn provider_api_keys_from_list(value: &str) -> Option<Vec<String>> {\n let keys = value\n .split([',', ';', '\\n'])\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string)\n .collect::<Vec<_>>();\n (!keys.is_empty()).then_some(keys)\n}\n\nfn fetch_deepseek_quota_info(api_key: &str, base_url: Option<&str>) -> Result<ExternalQuotaInfo> {\n let value = fetch_deepseek_balance_json(api_key, base_url)?;\n let available = value\n .get(\"is_available\")\n .and_then(serde_json::Value::as_bool)\n .unwrap_or(false);\n let balances = value\n .get(\"balance_infos\")\n .and_then(serde_json::Value::as_array)\n .cloned()\n .unwrap_or_default();\n let mut details = Vec::new();\n let mut parts = Vec::new();\n for balance in balances {\n let currency = balance\n .get(\"currency\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"balance\");\n let total = balance\n .get(\"total_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n parts.push(format!(\"{currency} {total}\"));\n let granted = balance\n .get(\"granted_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n let topped_up = balance\n .get(\"topped_up_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n details.push(ExternalQuotaDetail {\n label: format!(\"{currency} balance\"),\n value: format!(\"total {total}; granted {granted}; topped up {topped_up}\"),\n });\n }\n Ok(ExternalQuotaInfo {\n provider: \"DeepSeek\".to_string(),\n account: None,\n plan: Some(\"api-key\".to_string()),\n status: if available { \"Ready\" } else { \"Blocked\" }.to_string(),\n main: if parts.is_empty() {\n \"balance unavailable\".to_string()\n } else {\n parts.join(\" | \")\n },\n reset: None,\n available: Some(available),\n details,\n })\n}\n\nfn fetch_deepseek_balance_json(api_key: &str, base_url: Option<&str>) -> Result<serde_json::Value> {\n let client = build_upstream_blocking_http_client(\"DeepSeek quota HTTP\", false)?;\n let url = format!(\"{}/user/balance\", deepseek_base_url(base_url));\n let response = client\n .get(&url)\n .header(\"accept\", \"application/json\")\n .bearer_auth(api_key)\n .send()\n .context(\"failed to fetch DeepSeek balance\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read DeepSeek balance response\",\n )?;\n if !status.is_success() {\n bail!(\n \"DeepSeek balance request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n serde_json::from_slice(&body).context(\"failed to parse DeepSeek balance response\")\n}\n\nfn deepseek_base_url(explicit: Option<&str>) -> String {\n let base = explicit\n .map(str::to_string)\n .or_else(|| env::var(\"PRODEX_DEEPSEEK_BASE_URL\").ok())\n .or_else(|| env::var(\"DEEPSEEK_BASE_URL\").ok())\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n base.trim()\n .trim_end_matches('/')\n .trim_end_matches(\"/v1\")\n .to_string()\n}\n\nfn fetch_local_openai_compatible_quota_info(base_url: &str) -> Result<ExternalQuotaInfo> {\n let client = build_upstream_blocking_http_client(\"local OpenAI-compatible quota HTTP\", false)?;\n let url = openai_compatible_models_url(base_url)?;\n let mut request = client.get(&url).header(\"accept\", \"application/json\");\n if let Some(api_key) = local_openai_compatible_api_key_from_env() {\n request = request.bearer_auth(api_key);\n }\n let response = request\n .send()\n .context(\"failed to fetch local OpenAI-compatible models\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read local OpenAI-compatible response\",\n )?;\n if !status.is_success() {\n bail!(\n \"local OpenAI-compatible models request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n let value: serde_json::Value =\n serde_json::from_slice(&body).context(\"failed to parse local models response\")?;\n let model_count = value\n .get(\"data\")\n .and_then(serde_json::Value::as_array)\n .map(Vec::len);\n Ok(ExternalQuotaInfo {\n provider: \"Local OpenAI-compatible\".to_string(),\n account: Some(base_url.trim().to_string()),\n plan: Some(\"local\".to_string()),\n status: \"Reachable\".to_string(),\n main: model_count.map_or_else(\n || \"models endpoint reachable\".to_string(),\n |count| format!(\"{count} model(s)\"),\n ),\n reset: None,\n available: Some(true),\n details: vec![ExternalQuotaDetail {\n label: \"Models URL\".to_string(),\n value: url,\n }],\n })\n}\n\nfn openai_compatible_models_url(base_url: &str) -> Result<String> {\n let mut url = reqwest::Url::parse(base_url.trim())\n .with_context(|| format!(\"invalid local base URL '{}'\", base_url.trim()))?;\n let path = url.path().trim_end_matches('/');\n if path.is_empty() {\n url.set_path(\"/v1/models\");\n } else if !path.ends_with(\"/models\") {\n url.set_path(&format!(\"{path}/models\"));\n }\n Ok(url.to_string())\n}\n\nfn local_openai_compatible_api_key_from_env() -> Option<String> {\n [\"PRODEX_LOCAL_API_KEY\", \"OPENAI_API_KEY\"]\n .into_iter()\n .find_map(|key| {\n env::var(key)\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n })\n}\n"}}, "crates/prodex-app/src/profile_commands/login/login_menu.rs": {"a085ce4c6c6b6ec19806845714c72109252a6f20": {"t": 1785745878.9782174, "n": 18676, "h": "0ac5d1c74c42cd8b", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\ntype LoginMenuTui = terminal_ui::AlternateScreenTerminal<io::Stderr>;\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::stderr(\"login TUI\") {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n if let Some(action) = apply_login_menu_key(\n read_login_menu_key()?,\n &mut selected,\n layout.visible_items,\n entries,\n )? {\n return Ok(Some(action));\n }\n }\n}\n\nfn read_login_menu_key() -> Result<LoginMenuKey> {\n loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n return Ok(login_menu_key_from_event(key));\n }\n Event::Resize(_, _) => return Ok(LoginMenuKey::Ignore),\n _ => {}\n }\n }\n}\n\nfn apply_login_menu_key(\n key: LoginMenuKey,\n selected: &mut usize,\n visible_items: usize,\n entries: &[LoginMenuEntry],\n) -> Result<Option<LoginMenuAction>> {\n let last = entries.len().saturating_sub(1);\n match key {\n LoginMenuKey::Up => *selected = selected.saturating_sub(1),\n LoginMenuKey::Down => *selected = (*selected + 1).min(last),\n LoginMenuKey::PageUp => {\n *selected = selected.saturating_sub(login_menu_page_step(visible_items))\n }\n LoginMenuKey::PageDown => {\n *selected = (*selected + login_menu_page_step(visible_items)).min(last)\n }\n LoginMenuKey::Home => *selected = 0,\n LoginMenuKey::End => *selected = last,\n LoginMenuKey::Enter => return Ok(Some(entries[*selected].action)),\n LoginMenuKey::Digit(index) => {\n return Ok(entries\n .get(index.saturating_sub(1))\n .map(|entry| entry.action));\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n Ok(None)\n}\n\nfn login_menu_page_step(visible_items: usize) -> usize {\n visible_items.saturating_sub(1).max(1)\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n render_login_menu_header(frame, chunks[0], offset, end, entries.len());\n let items = login_menu_items(entries, selected, offset, end);\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n render_login_menu_detail(frame, chunks[2], entries, selected, layout);\n}\n\nfn render_login_menu_header(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n offset: usize,\n end: usize,\n entry_count: usize,\n) {\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entry_count),\n end,\n entry_count\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, area);\n}\n\nfn login_menu_items(\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n end: usize,\n) -> Vec<ListItem<'static>> {\n if entries.is_empty() {\n return vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))];\n }\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n}\n\nfn render_login_menu_detail(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n entries: &[LoginMenuEntry],\n selected: usize,\n layout: LoginMenuLayout,\n) {\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, area);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}, "d3b8546692d1a5ac582be033258c643dc1218913": {"t": 1785757000.5438979, "n": 18676, "h": "0ac5d1c74c42cd8b", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\ntype LoginMenuTui = terminal_ui::AlternateScreenTerminal<io::Stderr>;\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::stderr(\"login TUI\") {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n if let Some(action) = apply_login_menu_key(\n read_login_menu_key()?,\n &mut selected,\n layout.visible_items,\n entries,\n )? {\n return Ok(Some(action));\n }\n }\n}\n\nfn read_login_menu_key() -> Result<LoginMenuKey> {\n loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n return Ok(login_menu_key_from_event(key));\n }\n Event::Resize(_, _) => return Ok(LoginMenuKey::Ignore),\n _ => {}\n }\n }\n}\n\nfn apply_login_menu_key(\n key: LoginMenuKey,\n selected: &mut usize,\n visible_items: usize,\n entries: &[LoginMenuEntry],\n) -> Result<Option<LoginMenuAction>> {\n let last = entries.len().saturating_sub(1);\n match key {\n LoginMenuKey::Up => *selected = selected.saturating_sub(1),\n LoginMenuKey::Down => *selected = (*selected + 1).min(last),\n LoginMenuKey::PageUp => {\n *selected = selected.saturating_sub(login_menu_page_step(visible_items))\n }\n LoginMenuKey::PageDown => {\n *selected = (*selected + login_menu_page_step(visible_items)).min(last)\n }\n LoginMenuKey::Home => *selected = 0,\n LoginMenuKey::End => *selected = last,\n LoginMenuKey::Enter => return Ok(Some(entries[*selected].action)),\n LoginMenuKey::Digit(index) => {\n return Ok(entries\n .get(index.saturating_sub(1))\n .map(|entry| entry.action));\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n Ok(None)\n}\n\nfn login_menu_page_step(visible_items: usize) -> usize {\n visible_items.saturating_sub(1).max(1)\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n render_login_menu_header(frame, chunks[0], offset, end, entries.len());\n let items = login_menu_items(entries, selected, offset, end);\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n render_login_menu_detail(frame, chunks[2], entries, selected, layout);\n}\n\nfn render_login_menu_header(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n offset: usize,\n end: usize,\n entry_count: usize,\n) {\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entry_count),\n end,\n entry_count\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, area);\n}\n\nfn login_menu_items(\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n end: usize,\n) -> Vec<ListItem<'static>> {\n if entries.is_empty() {\n return vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))];\n }\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n}\n\nfn render_login_menu_detail(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n entries: &[LoginMenuEntry],\n selected: usize,\n layout: LoginMenuLayout,\n) {\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, area);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}, "f2451df6c5f51ff0ef8e83b16b4663c2f12bd855": {"t": 1785757002.0759947, "n": 17947, "h": "da16a57b3aa7f830", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\ntype LoginMenuTui = terminal_ui::AlternateScreenTerminal<io::Stderr>;\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::stderr(\"login TUI\") {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n let key = loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n break login_menu_key_from_event(key);\n }\n Event::Resize(_, _) => {\n break LoginMenuKey::Ignore;\n }\n _ => {}\n }\n };\n match key {\n LoginMenuKey::Up => {\n selected = selected.saturating_sub(1);\n }\n LoginMenuKey::Down => {\n selected = (selected + 1).min(entries.len().saturating_sub(1));\n }\n LoginMenuKey::PageUp => {\n let step = layout.visible_items.saturating_sub(1).max(1);\n selected = selected.saturating_sub(step);\n }\n LoginMenuKey::PageDown => {\n let step = layout.visible_items.saturating_sub(1).max(1);\n selected = (selected + step).min(entries.len().saturating_sub(1));\n }\n LoginMenuKey::Home => {\n selected = 0;\n }\n LoginMenuKey::End => {\n selected = entries.len().saturating_sub(1);\n }\n LoginMenuKey::Enter => return Ok(Some(entries[selected].action)),\n LoginMenuKey::Digit(index) => {\n if let Some(entry) = entries.get(index.saturating_sub(1)) {\n return Ok(Some(entry.action));\n }\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n }\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entries.len()),\n end,\n entries.len()\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, chunks[0]);\n\n let items = if entries.is_empty() {\n vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))]\n } else {\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n };\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, chunks[2]);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}, "5b074ea421df906052c08d2bf2c94c7476836cba": {"t": 1785757003.4932158, "n": 19265, "h": "4547cc848907432c", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::cursor::{Hide, Show};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse crossterm::terminal::{\n EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,\n};\nuse ratatui::Terminal;\nuse ratatui::backend::CrosstermBackend;\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\nstruct LoginMenuTui {\n terminal: Terminal<CrosstermBackend<io::Stderr>>,\n}\n\nimpl LoginMenuTui {\n fn new() -> Result<Self> {\n enable_raw_mode().context(\"failed to enable login TUI raw mode\")?;\n let mut stderr = io::stderr();\n if let Err(err) = crossterm::execute!(stderr, EnterAlternateScreen, Hide) {\n let _ = disable_raw_mode();\n return Err(err).context(\"failed to enter login TUI alternate screen\");\n }\n let backend = CrosstermBackend::new(stderr);\n let terminal = match Terminal::new(backend) {\n Ok(terminal) => terminal,\n Err(err) => {\n let mut stderr = io::stderr();\n let _ = crossterm::execute!(stderr, Show, LeaveAlternateScreen);\n let _ = disable_raw_mode();\n return Err(err).context(\"failed to initialize login TUI terminal\");\n }\n };\n Ok(Self { terminal })\n }\n}\n\nimpl Drop for LoginMenuTui {\n fn drop(&mut self) {\n let _ = disable_raw_mode();\n let _ = crossterm::execute!(self.terminal.backend_mut(), Show, LeaveAlternateScreen);\n let _ = self.terminal.show_cursor();\n }\n}\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::new() {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n let key = loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n break login_menu_key_from_event(key);\n }\n Event::Resize(_, _) => {\n break LoginMenuKey::Ignore;\n }\n _ => {}\n }\n };\n match key {\n LoginMenuKey::Up => {\n selected = selected.saturating_sub(1);\n }\n LoginMenuKey::Down => {\n selected = (selected + 1).min(entries.len().saturating_sub(1));\n }\n LoginMenuKey::PageUp => {\n let step = layout.visible_items.saturating_sub(1).max(1);\n selected = selected.saturating_sub(step);\n }\n LoginMenuKey::PageDown => {\n let step = layout.visible_items.saturating_sub(1).max(1);\n selected = (selected + step).min(entries.len().saturating_sub(1));\n }\n LoginMenuKey::Home => {\n selected = 0;\n }\n LoginMenuKey::End => {\n selected = entries.len().saturating_sub(1);\n }\n LoginMenuKey::Enter => return Ok(Some(entries[selected].action)),\n LoginMenuKey::Digit(index) => {\n if let Some(entry) = entries.get(index.saturating_sub(1)) {\n return Ok(Some(entry.action));\n }\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n }\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entries.len()),\n end,\n entries.len()\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, chunks[0]);\n\n let items = if entries.is_empty() {\n vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))]\n } else {\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n };\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, chunks[2]);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}, "544908098fc78d4f95f477750b48b384c51347eb": {"t": 1786067493.85796, "n": 18676, "h": "0ac5d1c74c42cd8b", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\ntype LoginMenuTui = terminal_ui::AlternateScreenTerminal<io::Stderr>;\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::stderr(\"login TUI\") {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n if let Some(action) = apply_login_menu_key(\n read_login_menu_key()?,\n &mut selected,\n layout.visible_items,\n entries,\n )? {\n return Ok(Some(action));\n }\n }\n}\n\nfn read_login_menu_key() -> Result<LoginMenuKey> {\n loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n return Ok(login_menu_key_from_event(key));\n }\n Event::Resize(_, _) => return Ok(LoginMenuKey::Ignore),\n _ => {}\n }\n }\n}\n\nfn apply_login_menu_key(\n key: LoginMenuKey,\n selected: &mut usize,\n visible_items: usize,\n entries: &[LoginMenuEntry],\n) -> Result<Option<LoginMenuAction>> {\n let last = entries.len().saturating_sub(1);\n match key {\n LoginMenuKey::Up => *selected = selected.saturating_sub(1),\n LoginMenuKey::Down => *selected = (*selected + 1).min(last),\n LoginMenuKey::PageUp => {\n *selected = selected.saturating_sub(login_menu_page_step(visible_items))\n }\n LoginMenuKey::PageDown => {\n *selected = (*selected + login_menu_page_step(visible_items)).min(last)\n }\n LoginMenuKey::Home => *selected = 0,\n LoginMenuKey::End => *selected = last,\n LoginMenuKey::Enter => return Ok(Some(entries[*selected].action)),\n LoginMenuKey::Digit(index) => {\n return Ok(entries\n .get(index.saturating_sub(1))\n .map(|entry| entry.action));\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n Ok(None)\n}\n\nfn login_menu_page_step(visible_items: usize) -> usize {\n visible_items.saturating_sub(1).max(1)\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n render_login_menu_header(frame, chunks[0], offset, end, entries.len());\n let items = login_menu_items(entries, selected, offset, end);\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n render_login_menu_detail(frame, chunks[2], entries, selected, layout);\n}\n\nfn render_login_menu_header(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n offset: usize,\n end: usize,\n entry_count: usize,\n) {\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entry_count),\n end,\n entry_count\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, area);\n}\n\nfn login_menu_items(\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n end: usize,\n) -> Vec<ListItem<'static>> {\n if entries.is_empty() {\n return vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))];\n }\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n}\n\nfn render_login_menu_detail(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n entries: &[LoginMenuEntry],\n selected: usize,\n layout: LoginMenuLayout,\n) {\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, area);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}}, "crates/prodex-app/src/app_commands/runtime_launch/providers_mode.rs": {"79de87991fa0c1a4b4574dca046d9d8a3533508b": {"t": 1786068335.0279164, "n": 8307, "h": "344ca241225ee0b5", "c": "use super::{\n RuntimeLaunchRequest, RuntimeLaunchSelection, runtime_anthropic_api_keys_from_request_or_env,\n runtime_copilot_api_keys_from_request_or_env, runtime_deepseek_api_keys_from_request_or_env,\n runtime_external_provider_oauth_profile_count, runtime_gemini_api_keys_from_request_or_env,\n};\nuse crate::{\n AppState, CodexModelProviderSetting, SUPER_ANTHROPIC_PROVIDER_ID, SUPER_COPILOT_PROVIDER_ID,\n SUPER_DEEPSEEK_PROVIDER_ID, SUPER_GEMINI_PROVIDER_ID, SUPER_KIRO_PROVIDER_ID,\n SUPER_LOCAL_PROVIDER_ID,\n};\n\npub(crate) fn runtime_launch_model_provider_uses_local_rewrite(\n provider: &CodexModelProviderSetting,\n) -> bool {\n provider\n .provider_id\n .eq_ignore_ascii_case(SUPER_LOCAL_PROVIDER_ID)\n || provider\n .provider_id\n .eq_ignore_ascii_case(SUPER_DEEPSEEK_PROVIDER_ID)\n || provider\n .provider_id\n .eq_ignore_ascii_case(SUPER_GEMINI_PROVIDER_ID)\n || provider\n .provider_id\n .eq_ignore_ascii_case(SUPER_KIRO_PROVIDER_ID)\n || provider\n .provider_id\n .eq_ignore_ascii_case(SUPER_ANTHROPIC_PROVIDER_ID)\n || provider\n .provider_id\n .eq_ignore_ascii_case(SUPER_COPILOT_PROVIDER_ID)\n}\n\npub(crate) fn runtime_local_rewrite_model_provider_id<'a>(\n selection: &'a RuntimeLaunchSelection,\n request: &'a RuntimeLaunchRequest<'_>,\n) -> Option<&'a str> {\n request\n .external_provider\n .and_then(runtime_external_provider_local_rewrite_id)\n .or_else(|| {\n selection\n .non_openai_model_provider\n .as_ref()\n .map(|provider| provider.provider_id.as_str())\n })\n}\n\npub(crate) fn runtime_provider_mode_uses_single_api_key(\n external_provider: Option<&str>,\n external_provider_api_key: Option<&str>,\n) -> bool {\n external_provider.is_some_and(|provider| {\n ((provider.eq_ignore_ascii_case(\"anthropic\") || provider.eq_ignore_ascii_case(\"claude\"))\n && runtime_anthropic_api_keys_from_request_or_env(external_provider_api_key)\n .ok()\n .flatten()\n .is_some())\n || provider.eq_ignore_ascii_case(\"deepseek\")\n || ((provider.eq_ignore_ascii_case(\"copilot\")\n || provider.eq_ignore_ascii_case(\"github-copilot\")\n || provider.eq_ignore_ascii_case(\"github_copilot\"))\n && runtime_copilot_api_keys_from_request_or_env(external_provider_api_key)\n .ok()\n .flatten()\n .is_some())\n || (provider.eq_ignore_ascii_case(\"gemini\")\n && runtime_gemini_api_keys_from_request_or_env(external_provider_api_key)\n .ok()\n .flatten()\n .is_some())\n || provider.eq_ignore_ascii_case(\"kiro\")\n })\n}\n\npub(crate) fn runtime_external_provider_rotation_summary(\n state: &AppState,\n selected_profile_name: &str,\n external_provider: &str,\n external_provider_api_key: Option<&str>,\n allow_auto_rotate: bool,\n) -> String {\n if let Some(key_count) =\n runtime_external_provider_api_key_count(external_provider, external_provider_api_key)\n {\n if key_count > 1 {\n return format!(\n \"API-key rotation is enabled across {key_count} keys; quota preflight stays disabled.\"\n );\n }\n return \"Using one provider API key; API-key rotation is skipped and quota preflight stays disabled.\".to_string();\n }\n if external_provider.eq_ignore_ascii_case(\"gemini\")\n || external_provider.eq_ignore_ascii_case(\"gemini-oauth\")\n {\n return crate::GEMINI_OAUTH_DISABLED_GUIDANCE.to_string();\n }\n if !allow_auto_rotate {\n return \"Account rotation is disabled by launch flags; quota preflight stays disabled.\"\n .to_string();\n }\n let profile_count = runtime_external_provider_oauth_profile_count(\n state,\n selected_profile_name,\n external_provider,\n );\n if profile_count > 1 {\n format!(\n \"OAuth account rotation is enabled across {profile_count} profiles for fresh requests; continuations stay pinned to their owning profile and quota preflight stays disabled.\"\n )\n } else {\n \"Using one OAuth profile; account rotation is skipped and quota preflight stays disabled.\"\n .to_string()\n }\n}\n\nfn runtime_external_provider_local_rewrite_id(provider: &str) -> Option<&'static str> {\n if provider.eq_ignore_ascii_case(\"deepseek\") {\n Some(SUPER_DEEPSEEK_PROVIDER_ID)\n } else if provider.eq_ignore_ascii_case(\"gemini\")\n || provider.eq_ignore_ascii_case(\"gemini-oauth\")\n {\n Some(SUPER_GEMINI_PROVIDER_ID)\n } else if provider.eq_ignore_ascii_case(\"anthropic\") || provider.eq_ignore_ascii_case(\"claude\")\n {\n Some(SUPER_ANTHROPIC_PROVIDER_ID)\n } else if provider.eq_ignore_ascii_case(\"copilot\")\n || provider.eq_ignore_ascii_case(\"github-copilot\")\n || provider.eq_ignore_ascii_case(\"github_copilot\")\n {\n Some(SUPER_COPILOT_PROVIDER_ID)\n } else if provider.eq_ignore_ascii_case(\"kiro\") {\n Some(SUPER_KIRO_PROVIDER_ID)\n } else {\n None\n }\n}\n\nfn runtime_external_provider_api_key_count(\n external_provider: &str,\n external_provider_api_key: Option<&str>,\n) -> Option<usize> {\n if external_provider.eq_ignore_ascii_case(\"anthropic\")\n || external_provider.eq_ignore_ascii_case(\"claude\")\n {\n return runtime_anthropic_api_keys_from_request_or_env(external_provider_api_key)\n .ok()\n .flatten()\n .map(|keys| keys.len());\n }\n if external_provider.eq_ignore_ascii_case(\"deepseek\") {\n return runtime_deepseek_api_keys_from_request_or_env(external_provider_api_key)\n .ok()\n .flatten()\n .map(|keys| keys.len());\n }\n if external_provider.eq_ignore_ascii_case(\"copilot\")\n || external_provider.eq_ignore_ascii_case(\"github-copilot\")\n || external_provider.eq_ignore_ascii_case(\"github_copilot\")\n {\n return runtime_copilot_api_keys_from_request_or_env(external_provider_api_key)\n .ok()\n .flatten()\n .map(|keys| keys.len());\n }\n if external_provider.eq_ignore_ascii_case(\"gemini\") {\n return runtime_gemini_api_keys_from_request_or_env(external_provider_api_key)\n .ok()\n .flatten()\n .map(|keys| keys.len());\n }\n None\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn provider_mode_detects_explicit_api_key_auth() {\n assert!(runtime_provider_mode_uses_single_api_key(\n Some(\"anthropic\"),\n Some(\"sk-anthropic\")\n ));\n assert!(runtime_provider_mode_uses_single_api_key(\n Some(\"copilot\"),\n Some(\"copilot-token\")\n ));\n assert!(runtime_provider_mode_uses_single_api_key(\n Some(\"gemini\"),\n Some(\"gemini-token\")\n ));\n assert!(!runtime_provider_mode_uses_single_api_key(\n Some(\"copilot\"),\n None\n ));\n }\n\n #[test]\n fn local_rewrite_model_provider_id_maps_known_aliases() {\n assert_eq!(\n runtime_external_provider_local_rewrite_id(\"gemini-oauth\"),\n Some(SUPER_GEMINI_PROVIDER_ID)\n );\n assert_eq!(\n runtime_external_provider_local_rewrite_id(\"github_copilot\"),\n Some(SUPER_COPILOT_PROVIDER_ID)\n );\n assert_eq!(\n runtime_external_provider_local_rewrite_id(\"claude\"),\n Some(SUPER_ANTHROPIC_PROVIDER_ID)\n );\n assert_eq!(runtime_external_provider_local_rewrite_id(\"unknown\"), None);\n }\n\n #[test]\n fn disabled_gemini_oauth_rotation_reports_migration_guidance() {\n for provider in [\"gemini\", \"gemini-oauth\"] {\n let summary = runtime_external_provider_rotation_summary(\n &AppState::default(),\n \"profile\",\n provider,\n None,\n true,\n );\n assert!(summary.contains(\"Gemini API key\"));\n assert!(summary.contains(\"Vertex AI\"));\n }\n }\n}\n"}}, "crates/prodex-cli/src/help.rs": {"79de87991fa0c1a4b4574dca046d9d8a3533508b": {"t": 1786068335.7640672, "n": 13667, "h": "2e7bd88854909240", "c": "pub const RUNTIME_PROXY_DOCTOR_TAIL_BYTES: usize = 128 * 1024;\npub const CLI_TOP_LEVEL_AFTER_HELP: &str = \"\\\nTips:\n Bare `prodex` invocation defaults to `prodex run`.\n Use `prodex status` for the live quota, token, and resource dashboard.\n Use `prodex quota --all --detail` for the clearest quota view across profiles.\n Use `prodex <command> -h` to see every parameter for that command.\n\nExamples:\n prodex\n prodex exec \\\"review this repo\\\"\n prodex profile list\n prodex status\n prodex quota --all --detail\n prodex redeem main\n prodex run --profile main\";\npub const CLI_PROFILE_AFTER_HELP: &str = \"\\\nExamples:\n prodex profile list\n prodex profile add main --activate\n prodex profile export\n prodex profile export backup.json\n prodex profile import backup.json\n prodex profile import claude\n prodex profile import copilot\n prodex profile import kiro\n prodex profile import copilot --name copilot-main --activate\n prodex profile import-current main\n prodex profile remove main\n prodex profile remove --all\n\nNotes:\n `prodex profile import kiro` reads the installed Kiro CLI auth store and profile metadata.\n Prodex prefers `kiro-cli-chat`, falls back to `kiro-cli`, and accepts `PRODEX_KIRO_BIN` for an explicit path override.\";\npub const CLI_LOGIN_AFTER_HELP: &str = \"\\\nExamples:\n prodex login\n prodex login --profile main\n prodex login --device-auth\n prodex login --with-claude\n prodex login --with-antigravity\n\nNotes:\n OpenAI/Codex, Claude, and API-key login paths create or update Prodex profiles.\n Google Gemini OAuth is unsupported. Use a Gemini API key for the Codex bridge, or native `prodex s gemini --cli gemini` for supported Vertex AI authentication.\n Antigravity login delegates to `agy auth login` and does not create a Prodex profile.\";\npub const CLI_QUOTA_AFTER_HELP: &str = \"\\\nBest practice:\n Use `prodex quota --all --detail` for the clearest live quota view across profiles.\n\nExamples:\n prodex quota\n prodex quota --profile main --detail\n prodex quota --all --detail\n prodex quota --all --detail --provider openai\n prodex quota --all --provider deepseek --base-url https://api.deepseek.com --once\n prodex quota --all --provider local --base-url http://127.0.0.1:8131/v1 --once\n prodex quota --all --once\n prodex quota --all --auth no-auth --once\n prodex quota --raw --profile main\n\nNotes:\n `prodex quota` supports OpenAI/Codex, Anthropic OAuth, imported Copilot, DeepSeek API-key, Antigravity CLI, local OpenAI-compatible, and custom provider snapshots.\n Use `--provider` with `--all` to filter by provider: `openai`, `gemini`, `anthropic`, `copilot`, `kiro`, `deepseek`, `local`, or `agy`.\n Use `--auth` with `--all` to filter by auth label or compatibility, for example `no-auth` or `quota-compatible`.\n If a profile's `config.toml` sets `model_provider` to a non-OpenAI backend such as `amazon-bedrock`, prodex shows a provider snapshot instead of failing the quota view.\";\npub const CLI_REDEEM_AFTER_HELP: &str = \"\\\nExamples:\n prodex redeem main\n prodex redeem user_example.com\n prodex redeem main --yes\n prodex redeem main --base-url https://chatgpt.com/backend-api\n\nNotes:\n Manual redeem sends one explicit reset-credit consume request for the named OpenAI/Codex profile.\n If the 5h or weekly quota window resets within 1 hour, prodex asks before consuming a credit; pass `--yes` to skip that prompt.\n The backend decides whether a credit can be applied and may return nothing-to-reset, no-credit, or already-redeemed.\";\npub const CLI_RUN_AFTER_HELP: &str = \"\\\nExamples:\n prodex\n prodex run\n prodex super\n prodex exec \\\"review this repo\\\"\n prodex run --profile main\n prodex run --web-search indexed\n prodex run --rollout-budget-tokens 100000\n prodex run --current-time-reminder\n prodex run --respect-system-proxy\n prodex run exec \\\"review this repo\\\"\n prodex run 019c9e3d-45a0-7ad0-a6ee-b194ac2d44f9\n\nNotes:\n Eligible pre-commit rotation is allowed by default when another supported profile or key is available.\n Bare `prodex <args>` is treated as `prodex run <args>`.\n A lone session id is forwarded as `codex resume <session-id>`.\n Codex runtime feature overrides are passed through with `-c`: `--web-search disabled|cached|indexed|live`, `--rollout-budget-tokens`, `--current-time-reminder`, and `--respect-system-proxy` / `--no-respect-system-proxy`.\n If the selected profile's `config.toml` sets `model_provider` to a non-OpenAI backend, prodex launches Codex directly without quota preflight or the local auto-rotate proxy.\";\npub const CLI_CLAUDE_AFTER_HELP: &str = \"\\\nExamples:\n prodex claude --print \\\"summarize this repo\\\"\n prodex claude caveman\n prodex claude caveman -- -p \\\"summarize this repo briefly\\\"\n prodex claude --profile main --print \\\"review the latest changes\\\"\n prodex claude --skip-quota-check -- --help\n\nNotes:\n Prodex injects a local Anthropic-compatible proxy via `ANTHROPIC_BASE_URL`.\n Prefix Claude args with `caveman` to load the Caveman plugin for that session only.\n Use `PRODEX_CLAUDE_BIN` to point prodex at a specific Claude Code binary.\n `prodex claude` requires the default OpenAI/Codex provider; profiles that set `model_provider` to a non-OpenAI backend are not supported on this path.\n Claude defaults to the current Codex model from `config.toml` when available.\n Use `PRODEX_CLAUDE_MODEL` to override the upstream Responses model mapping.\n Use `PRODEX_CLAUDE_REASONING_EFFORT` to force the upstream Responses reasoning effort.\n Use `PRODEX_CLAUDE_NATIVE_CLIENT_TOOLS=shell,computer` to opt into native client-tool translation on supported models.\";\npub const CLI_CAVEMAN_AFTER_HELP: &str = \"\\\nExamples:\n prodex caveman\n prodex rtk\n prodex playwright\n prodex ponytail\n prodex super\n prodex caveman --profile main\n prodex caveman --web-search cached\n prodex caveman exec \\\"review latest diff in caveman mode\\\"\n prodex caveman 019c9e3d-45a0-7ad0-a6ee-b194ac2d44f9\n\nNotes:\n Prodex launches Codex from a temporary Prodex overlay `CODEX_HOME`, then activates Caveman for that session.\n The selected profile's auth, shared sessions, and quota behavior stay the same as `prodex run`.\n Codex runtime feature overrides from `prodex run` also work here.\n If the selected profile's `config.toml` sets `model_provider` to a non-OpenAI backend, prodex launches Caveman directly without quota preflight or the local auto-rotate proxy.\n Add prefixes before Codex args to enable session tools in the Prodex overlay: `rtk`, `playwright`, `ponytail`, `presidio`.\n Top-level shortcuts `prodex rtk`, `prodex playwright`, and `prodex ponytail` map to `prodex caveman <prefix>`.\n Caveman must be installed externally as a validated, versioned optional tool; Prodex never downloads it during launch.\";\npub const CLI_SUPER_AFTER_HELP: &str = \"\\\nExamples:\n prodex super\n prodex super --model gpt-5.3-codex\n prodex super --url http://127.0.0.1:8131\n prodex super deepseek --model deepseek-v4-pro\n prodex super gemini\n prodex super gemini --cli gemini\n prodex super --provider copilot --cli copilot\n prodex super --cli kiro --profile kiro-main\n prodex super gemini --cli agy\n prodex super --sub-agent --sub-agent-provider openai --sub-agent-model gpt-5.3-codex\n prodex super --sub-agent --sub-agent-model-reasoning-effort xhigh\n prodex super --sub-agent --sub-agent-max-concurrency 8\n prodex super --no-sub-agent\n prodex super doctor\n prodex super doctor --json --strict\n prodex super exec \\\"review latest diff in super mode\\\"\n prodex super 019c9e3d-45a0-7ad0-a6ee-b194ac2d44f9\n prodex super --profile main\n prodex super --web-search indexed\n prodex super --rollout-budget-tokens 100000\n\nNotes:\n `prodex super` enables Smart Context and the available typed optimizer tools in the temporary Prodex overlay.\n Missing optional tools are skipped unless named with `--require-tool`; use `prodex super doctor` to inspect readiness.\n Super is the explicit YOLO path: it bypasses approvals and the sandbox, bypasses hook-trust confirmation, and trusts the current workspace for this invocation.\n Codex runtime feature overrides from `prodex run` also work here; explicit `--web-search` overrides Super provider defaults.\n Interactive Super launches ask whether to enable Presidio. Use `--presidio` or `--no-presidio` to answer non-interactively. With default endpoints, Presidio opt-in best-effort starts local Docker services unless PRODEX_PRESIDIO_AUTO_START=0.\n Use `prodex run` instead when normal Codex approvals and workspace-trust prompts are desired.\n Use `prodex super doctor` or `prodex s doctor` to inspect local optimizer readiness without launching Codex.\n Use `--url` to point Codex directly at a local OpenAI-compatible /v1 endpoint, for example a llama-server on port 8131.\n When `--url` is set, Prodex injects a temporary `prodex-local` model provider, skips quota/rotation, and uses a local Smart Context rewrite proxy.\n Use `--provider anthropic` to route through Anthropic's OpenAI-compatible Chat Completions API. Sign in with `prodex login --with-claude`, or supply `--api-key`, ANTHROPIC_API_KEY, or ANTHROPIC_API_KEYS.\n Use `--provider copilot` to keep Codex/Super and route through a local Responses-to-Copilot adapter. Import Copilot profiles first for account routing/rotation, or supply `--api-key`, GITHUB_COPILOT_API_KEY, or GITHUB_COPILOT_API_KEYS.\n Add `--cli copilot` with `--provider copilot` to launch GitHub Copilot CLI through the same Prodex Responses proxy. Override the binary with PRODEX_COPILOT_BIN.\n Use `deepseek` or `--provider deepseek` to keep Codex/Super and route through a local Responses-to-DeepSeek adapter. Supply `--api-key`, DEEPSEEK_API_KEY, or DEEPSEEK_API_KEYS.\n Use `gemini` or `--provider gemini` to route Codex through Gemini with `--api-key`, GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY, or GOOGLE_API_KEYS.\n Google Gemini OAuth profile routing is unsupported and disabled. Existing OAuth profiles remain readable for migration diagnostics but cannot launch.\n Add `--cli gemini` to launch the native Gemini CLI with authentication, including supported Vertex AI configuration, owned by that CLI or its environment. Prodex does not inject or reuse the removed OAuth client. Override the binary with PRODEX_GEMINI_BIN.\n Add `--cli kiro` to launch Kiro CLI from an imported Kiro profile snapshot through an authenticated loopback CONNECT tunnel. Kiro's TLS payload stays opaque; `--provider kiro` is the application-level Codex-to-Kiro ACP bridge. Override the binary with PRODEX_KIRO_BIN.\n Add `--cli agy` to launch Antigravity CLI with `--dangerously-skip-permissions`. Antigravity owns its keyring auth and currently cannot use Prodex account rotation. Override the binary with PRODEX_AGY_BIN.\n Local mode defaults to a 16k context window; use `--context-window` and `--auto-compact-token-limit` if your server is configured larger.\n --sub-agent explicitly enables sub-agents; --no-sub-agent explicitly disables them.\n --sub-agent-provider, --sub-agent-model, --sub-agent-model-reasoning-effort, --sub-agent-url, and --sub-agent-max-concurrency require --sub-agent.\n Sub-agent provider names use canonical ProviderId values and default to openai; model ids are arbitrary nonempty strings.\n Sub-agent reasoning efforts are none, minimal, low, medium, high, xhigh, or max.\n Maximum active sub-agents defaults to 4; presets are 4, 8, 16, and 32, with custom values from 1 through 64.\n Additional Codex args are appended after the implied optimizer prefixes.\";\npub const CLI_DOCTOR_AFTER_HELP: &str = \"\\\nExamples:\n prodex doctor\n prodex doctor --install\n prodex doctor --quota\n prodex doctor --runtime\n prodex doctor --runtime --json\n prodex doctor --bundle ./prodex-doctor.json --redacted\";\npub const CLI_SETUP_AFTER_HELP: &str = \"\\\nExamples:\n prodex setup --dry-run\n prodex setup --verify-tools\n prodex setup --dry-run --json\n\n`--verify-tools` checks Caveman, RTK, Codebase Memory MCP, Playwright MCP, and Ponytail.\";\npub const CLI_CAPABILITY_AFTER_HELP: &str = \"\\\nExamples:\n prodex capability list\n prodex capability list --json\";\npub const CLI_AUDIT_AFTER_HELP: &str = \"\\\nExamples:\n prodex audit\n prodex audit --tail 50\n prodex audit --component profile --action use\n prodex audit --json\";\npub const CLI_CONTEXT_AFTER_HELP: &str = \"\\\nExamples:\n prodex context audit\n prodex context audit --limit 30\n prodex context audit --json\n prodex context export 019c9e3d-45a0-7ad0-a6ee-b194ac2d44f9\n prodex context export 019c9e3d ./context_session.md\n prodex context compress ~/.codex/AGENTS.md --dry-run\n prodex context compress ~/.codex/AGENTS.md\n prodex context replay-report smart-context-replay.json\n prodex context replay-report smart-context-replay.json --json\n prodex context replay-report smart-context-replay.json --strict\";\npub const CLI_SESSION_AFTER_HELP: &str = \"\\\nExamples:\n prodex session list\n prodex session list --json\n prodex session list --id-only\n prodex session list --resume-command\n prodex session list --parent-only\n prodex session list --profile main --query triage\n prodex session current\n prodex session current --resume-command\n prodex session current --limit 20\n prodex session resume 1234abcd\";\npub const CLI_CLEANUP_AFTER_HELP: &str = \"\\\nExamples:\n prodex cleanup\n prodex cleanup --older-than 1d\n prodex cleanup --aggressive\n\nNotes:\n Removes stale local artifacts. Non-blocking automatic housekeeping already clears stale runtime logs, temp homes, stale root temp files, and dead broker artifacts. --older-than controls only orphaned managed profile homes; --aggressive is equivalent to --older-than 0d. Codex/Claude chat histories are left to the upstream runtimes.\";\n"}}, "crates/prodex-app/src/runtime_config/config/gateway_helpers.rs": {"79de87991fa0c1a4b4574dca046d9d8a3533508b": {"t": 1786068394.4561763, "n": 8022, "h": "2b02f28946bee0bc", "c": "use super::super::{ConfigError, RuntimeConfigParser};\nuse crate::{RuntimeDeepSeekWebSearchMode, RuntimeGeminiModelResolution, SuperExternalProvider};\nuse prodex_cli::GatewayArgs;\nuse std::collections::BTreeSet;\nuse std::path::Path;\n\npub(super) struct RuntimeGatewayConfigInput<'a> {\n pub(super) service_mode: prodex_runtime_policy::RuntimePolicyServiceMode,\n pub(super) provider_override: Option<SuperExternalProvider>,\n pub(super) base_url_override: Option<&'a str>,\n}\n\nimpl<'a> RuntimeGatewayConfigInput<'a> {\n pub(super) fn new(\n service_mode: prodex_runtime_policy::RuntimePolicyServiceMode,\n args: &'a GatewayArgs,\n ) -> Self {\n Self {\n service_mode,\n provider_override: args.provider,\n base_url_override: args.base_url.as_deref(),\n }\n }\n}\n\npub(super) fn runtime_gateway_launch_environment(\n parser: &mut RuntimeConfigParser,\n policy: Option<&prodex_runtime_policy::RuntimePolicyConfig>,\n input: RuntimeGatewayConfigInput<'_>,\n gemini: &super::super::RuntimeGeminiConfig,\n) -> super::super::RuntimeGatewayLaunchEnvironment {\n if input.service_mode == prodex_runtime_policy::RuntimePolicyServiceMode::ControlPlane {\n return super::super::RuntimeGatewayLaunchEnvironment::ControlPlane;\n }\n let gateway = policy.map(|policy| &policy.gateway);\n let provider = input.provider_override.or_else(|| {\n gateway\n .and_then(|gateway| gateway.provider.as_deref())\n .and_then(runtime_gateway_policy_provider)\n });\n let upstream_base_url = runtime_gateway_upstream_base_url(\n parser,\n input.base_url_override,\n gateway.and_then(|gateway| gateway.base_url.as_deref()),\n provider,\n );\n let deepseek = (provider == Some(SuperExternalProvider::DeepSeek)).then(|| {\n let strict_environment = parser\n .environment\n .get(\"PRODEX_DEEPSEEK_STRICT_TOOLS\")\n .cloned();\n let beta_environment = parser\n .environment\n .get(\"PRODEX_DEEPSEEK_BETA_BASE_URL\")\n .cloned();\n let search_environment = parser\n .environment\n .get(\"PRODEX_DEEPSEEK_WEB_SEARCH_MODE\")\n .cloned();\n let strict_tools = runtime_gateway_capture(\n parser,\n \"PRODEX_DEEPSEEK_STRICT_TOOLS\",\n crate::runtime_deepseek_gateway_strict_tools(\n Path::new(\"\"),\n strict_environment.as_deref(),\n ),\n false,\n );\n let beta_base_url = runtime_gateway_capture(\n parser,\n \"PRODEX_DEEPSEEK_BETA_BASE_URL\",\n crate::runtime_deepseek_gateway_beta_base_url(\n Path::new(\"\"),\n beta_environment.as_deref(),\n ),\n \"https://api.deepseek.com/beta\".to_string(),\n );\n let web_search_mode = runtime_gateway_capture(\n parser,\n \"PRODEX_DEEPSEEK_WEB_SEARCH_MODE\",\n crate::runtime_deepseek_gateway_web_search_mode(\n Path::new(\"\"),\n search_environment.as_deref(),\n ),\n RuntimeDeepSeekWebSearchMode::default(),\n );\n super::super::RuntimeGatewayDeepSeekConfig {\n strict_tools,\n beta_base_url,\n web_search_mode,\n }\n });\n let gemini_model_resolution = (provider == Some(SuperExternalProvider::Gemini)).then(|| {\n runtime_gateway_capture(\n parser,\n \"runtime.gemini.model_catalog\",\n RuntimeGeminiModelResolution::from_runtime_config(gemini),\n RuntimeGeminiModelResolution::default(),\n )\n });\n let production = policy.is_some_and(|policy| policy.secrets.production);\n let present_secret_env = if production {\n BTreeSet::new()\n } else {\n parser.environment.present_gateway_secret_env()\n };\n super::super::RuntimeGatewayLaunchEnvironment::DataPlane {\n upstream_base_url,\n deepseek,\n gemini_model_resolution,\n present_secret_env,\n }\n}\n\npub(super) fn runtime_gateway_policy_provider(value: &str) -> Option<SuperExternalProvider> {\n match value.to_ascii_lowercase().as_str() {\n \"anthropic\" | \"claude\" => Some(SuperExternalProvider::Anthropic),\n \"copilot\" | \"github-copilot\" | \"github_copilot\" => Some(SuperExternalProvider::Copilot),\n \"deepseek\" => Some(SuperExternalProvider::DeepSeek),\n \"gemini\" => Some(SuperExternalProvider::Gemini),\n \"kiro\" => Some(SuperExternalProvider::Kiro),\n _ => None,\n }\n}\n\npub(super) fn runtime_gateway_upstream_base_url(\n parser: &mut RuntimeConfigParser,\n cli: Option<&str>,\n policy: Option<&str>,\n provider: Option<SuperExternalProvider>,\n) -> String {\n let (key, raw) = if let Some(value) = cli {\n (\"gateway --base-url\", value)\n } else if let Some(value) = policy {\n (\"gateway.base_url\", value)\n } else if let Some(provider) = provider {\n (\"gateway provider base URL\", provider.default_base_url())\n } else if let Some(value) = parser.environment.get(\"OPENAI_BASE_URL\") {\n let Some(value) = value.to_str() else {\n parser.errors.push(ConfigError {\n key: \"OPENAI_BASE_URL\",\n message: \"must be valid Unicode\".to_string(),\n });\n return \"https://api.openai.com/v1\".to_string();\n };\n (\"OPENAI_BASE_URL\", value)\n } else {\n (\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n };\n if raw.is_empty() {\n parser.errors.push(ConfigError {\n key,\n message: \"cannot be empty\".to_string(),\n });\n return \"https://api.openai.com/v1\".to_string();\n }\n if raw.chars().any(char::is_whitespace) {\n parser.errors.push(ConfigError {\n key,\n message: \"must not contain whitespace\".to_string(),\n });\n return \"https://api.openai.com/v1\".to_string();\n }\n let normalized = raw.trim_end_matches('/');\n let parsed = reqwest::Url::parse(normalized);\n let valid = parsed.as_ref().is_ok_and(|url| {\n matches!(url.scheme(), \"http\" | \"https\")\n && url.host_str().is_some()\n && url.username().is_empty()\n && url.password().is_none()\n && url.query().is_none()\n && url.fragment().is_none()\n });\n if !valid {\n parser.errors.push(ConfigError {\n key,\n message: \"must be an http(s) URL with host and no credentials, query, or fragment\"\n .to_string(),\n });\n return \"https://api.openai.com/v1\".to_string();\n }\n if provider.is_none()\n && parsed\n .ok()\n .is_some_and(|url| url.path().trim_matches('/').is_empty())\n {\n format!(\"{normalized}/v1\")\n } else {\n normalized.to_string()\n }\n}\n\npub(super) fn runtime_gateway_capture<T>(\n parser: &mut RuntimeConfigParser,\n key: &'static str,\n result: anyhow::Result<T>,\n default: T,\n) -> T {\n match result {\n Ok(value) => value,\n Err(error) => {\n let message = error.to_string();\n let message = message\n .strip_prefix(key)\n .and_then(|message| message.strip_prefix(' '))\n .unwrap_or(&message)\n .to_string();\n parser.errors.push(ConfigError { key, message });\n default\n }\n }\n}\n\npub(super) struct ParsedWebsocketTuning {\n pub(super) connect_timeout_ms: u64,\n pub(super) happy_eyeballs_delay_ms: u64,\n pub(super) precommit_progress_timeout_ms: u64,\n pub(super) connect_worker_count: usize,\n pub(super) connect_queue_capacity: usize,\n pub(super) connect_overflow_capacity: usize,\n pub(super) dns_worker_count: usize,\n pub(super) dns_queue_capacity: usize,\n pub(super) dns_overflow_capacity: usize,\n}\n\npub(super) fn nonzero(value: usize) -> Option<usize> {\n (value > 0).then_some(value)\n}\n"}}}}