mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
feat(models): import external client configs
Add a model client import flow that scans local Codex, Claude Code, Gemini CLI, and common environment variable configurations without reading or copying OAuth tokens.
The new backend command returns safe import candidates with provider metadata, model IDs, and API key environment-variable references. Tauri and Web/dev-api both implement the scanner, and Web mode keeps the scan local even when a remote instance is active.
The Models page now offers an import wizard that lets users select importable candidates, adds providers without overwriting existing keys, preserves secrets as ${ENV_VAR} references, and leaves OAuth-only Codex entries as guidance rather than direct OpenClaw imports.
## Verification
- node --check src/pages/models.js
- node --check src/lib/tauri-api.js
- node --check src/locales/modules/models.js
- node --check scripts/dev-api.js
- cargo fmt --check
- cargo check
- npm run build
This commit is contained in:
@@ -5290,6 +5290,368 @@ fn model_env_values() -> HashMap<String, String> {
|
||||
values
|
||||
}
|
||||
|
||||
fn home_path(parts: &[&str]) -> Option<PathBuf> {
|
||||
let mut path = dirs::home_dir()?;
|
||||
for part in parts {
|
||||
path.push(part);
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
fn strip_config_value(raw: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
for ch in raw.trim().chars() {
|
||||
if ch == '"' || ch == '\'' {
|
||||
if quote == Some(ch) {
|
||||
quote = None;
|
||||
} else if quote.is_none() {
|
||||
quote = Some(ch);
|
||||
}
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
if ch == '#' && quote.is_none() {
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
let value = out.trim().trim_end_matches(',').trim();
|
||||
if value.len() >= 2 {
|
||||
let bytes = value.as_bytes();
|
||||
if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
|
||||
|| (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
|
||||
{
|
||||
return value[1..value.len() - 1].to_string();
|
||||
}
|
||||
}
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
fn parse_simple_config_blocks(raw: &str) -> HashMap<String, HashMap<String, String>> {
|
||||
let mut blocks: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
let mut current = String::from("");
|
||||
blocks.entry(current.clone()).or_default();
|
||||
for line in raw.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
current = trimmed.trim_matches(&['[', ']'][..]).trim().to_string();
|
||||
blocks.entry(current.clone()).or_default();
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = trimmed.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
blocks
|
||||
.entry(current.clone())
|
||||
.or_default()
|
||||
.insert(key.trim().to_string(), strip_config_value(value));
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
fn first_env_ref(keys: &[&str]) -> (String, String) {
|
||||
for key in keys {
|
||||
if std::env::var(key)
|
||||
.map(|v| !v.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (format!("${{{key}}}"), "found".into());
|
||||
}
|
||||
}
|
||||
if let Some(key) = keys.first() {
|
||||
(format!("${{{key}}}"), "missing".into())
|
||||
} else {
|
||||
(String::new(), "none".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn find_json_string(value: &Value, keys: &[&str], depth: usize) -> Option<String> {
|
||||
if depth > 5 {
|
||||
return None;
|
||||
}
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for key in keys {
|
||||
if let Some(v) = map.get(*key).and_then(|v| v.as_str()) {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in map.values() {
|
||||
if let Some(found) = find_json_string(v, keys, depth + 1) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(list) => {
|
||||
for v in list {
|
||||
if let Some(found) = find_json_string(v, keys, depth + 1) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn push_client_candidate(
|
||||
out: &mut Vec<Value>,
|
||||
id: &str,
|
||||
source: &str,
|
||||
source_path: &str,
|
||||
provider_key: &str,
|
||||
display_name: &str,
|
||||
base_url: &str,
|
||||
api: &str,
|
||||
api_key: &str,
|
||||
api_key_status: &str,
|
||||
models: Vec<String>,
|
||||
importable: bool,
|
||||
auth_hint: &str,
|
||||
warning: &str,
|
||||
) {
|
||||
out.push(json!({
|
||||
"id": id,
|
||||
"source": source,
|
||||
"sourcePath": source_path,
|
||||
"providerKey": provider_key,
|
||||
"displayName": display_name,
|
||||
"baseUrl": base_url,
|
||||
"api": api,
|
||||
"apiKey": api_key,
|
||||
"apiKeyStatus": api_key_status,
|
||||
"models": models,
|
||||
"importable": importable,
|
||||
"authHint": auth_hint,
|
||||
"warning": warning,
|
||||
}));
|
||||
}
|
||||
|
||||
fn scan_json_client_file(
|
||||
out: &mut Vec<Value>,
|
||||
id: &str,
|
||||
source: &str,
|
||||
parts: &[&str],
|
||||
provider_key: &str,
|
||||
display_name: &str,
|
||||
base_url: &str,
|
||||
api: &str,
|
||||
env_keys: &[&str],
|
||||
default_model: &str,
|
||||
) {
|
||||
let Some(path) = home_path(parts) else {
|
||||
return;
|
||||
};
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let model = fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
|
||||
.and_then(|value| find_json_string(&value, &["model", "defaultModel", "modelName"], 0))
|
||||
.unwrap_or_else(|| default_model.to_string());
|
||||
let (api_key, status) = first_env_ref(env_keys);
|
||||
let warning = if status == "missing" {
|
||||
"未在当前进程环境中检测到对应 API Key 环境变量,导入后需要在 OpenClaw env 或 .env 中补齐。"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
push_client_candidate(
|
||||
out,
|
||||
id,
|
||||
source,
|
||||
&path.to_string_lossy(),
|
||||
provider_key,
|
||||
display_name,
|
||||
base_url,
|
||||
api,
|
||||
&api_key,
|
||||
&status,
|
||||
vec![model],
|
||||
true,
|
||||
"",
|
||||
warning,
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn scan_model_client_configs() -> Result<Value, String> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = home_path(&[".codex", "config.toml"]) {
|
||||
if let Ok(raw) = fs::read_to_string(&path) {
|
||||
let blocks = parse_simple_config_blocks(&raw);
|
||||
let root = blocks.get("").cloned().unwrap_or_default();
|
||||
let provider_id = root
|
||||
.get("model_provider")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "openai".into());
|
||||
let section = blocks
|
||||
.get(&format!("model_providers.{provider_id}"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let model = root
|
||||
.get("model")
|
||||
.cloned()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| "gpt-5.1-codex-mini".into());
|
||||
let base_url = section.get("base_url").cloned().unwrap_or_else(|| {
|
||||
if provider_id.contains("codex") {
|
||||
"https://chatgpt.com/backend-api/codex".into()
|
||||
} else {
|
||||
"https://api.openai.com/v1".into()
|
||||
}
|
||||
});
|
||||
let wire_api = section.get("wire_api").cloned().unwrap_or_default();
|
||||
let explicit_env_key = section
|
||||
.get("env_key")
|
||||
.cloned()
|
||||
.filter(|v| is_valid_env_key(v));
|
||||
let env_key = explicit_env_key.or_else(|| {
|
||||
if provider_id == "openai" {
|
||||
Some("OPENAI_API_KEY".into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let is_external_codex =
|
||||
provider_id.contains("codex") || base_url.contains("chatgpt.com/backend-api/codex");
|
||||
let api = if is_external_codex {
|
||||
"openai-codex-responses"
|
||||
} else if wire_api.contains("responses") {
|
||||
"openai-responses"
|
||||
} else {
|
||||
"openai-completions"
|
||||
};
|
||||
let (api_key, status) = if let Some(key) = env_key.as_deref() {
|
||||
if std::env::var(key)
|
||||
.map(|v| !v.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
(format!("${{{key}}}"), "found")
|
||||
} else {
|
||||
(format!("${{{key}}}"), "missing")
|
||||
}
|
||||
} else {
|
||||
(String::new(), "none")
|
||||
};
|
||||
let provider_key = if provider_id == "openai" {
|
||||
"codex-openai".to_string()
|
||||
} else {
|
||||
format!("codex-{provider_id}")
|
||||
};
|
||||
let warning = if is_external_codex {
|
||||
"ChatGPT/Codex OAuth 令牌不会导入到 OpenClaw。请优先使用 Hermes 的 openai-codex 登录。"
|
||||
} else if status == "none" {
|
||||
"Codex 配置没有声明可安全引用的 env_key,无法自动导入 API Key。请在 Codex 配置中添加 env_key,或在 OpenClaw 中手动配置服务商密钥。"
|
||||
} else if status == "missing" {
|
||||
"未在当前进程环境中检测到 Codex 配置引用的 API Key 环境变量,导入后需要在 OpenClaw env 或 .env 中补齐。"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
push_client_candidate(
|
||||
&mut candidates,
|
||||
"codex-cli",
|
||||
"Codex CLI",
|
||||
&path.to_string_lossy(),
|
||||
&provider_key,
|
||||
&format!("Codex CLI / {provider_id}"),
|
||||
&base_url,
|
||||
api,
|
||||
&api_key,
|
||||
status,
|
||||
vec![model],
|
||||
!is_external_codex && status != "none",
|
||||
if is_external_codex {
|
||||
"hermes auth login openai-codex"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
scan_json_client_file(
|
||||
&mut candidates,
|
||||
"claude-code",
|
||||
"Claude Code",
|
||||
&[".claude", "settings.json"],
|
||||
"anthropic",
|
||||
"Anthropic / Claude Code",
|
||||
"https://api.anthropic.com/v1",
|
||||
"anthropic-messages",
|
||||
&["ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN"],
|
||||
"claude-sonnet-4-5-20250514",
|
||||
);
|
||||
scan_json_client_file(
|
||||
&mut candidates,
|
||||
"gemini-cli",
|
||||
"Gemini CLI",
|
||||
&[".gemini", "settings.json"],
|
||||
"google",
|
||||
"Google Gemini CLI",
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"google-generative-ai",
|
||||
&["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
||||
"gemini-2.5-pro",
|
||||
);
|
||||
for (env_key, provider_key, display_name, base_url, api, model) in [
|
||||
(
|
||||
"OPENAI_API_KEY",
|
||||
"openai-env",
|
||||
"OpenAI 环境变量",
|
||||
std::env::var("OPENAI_BASE_URL").unwrap_or_else(|_| "https://api.openai.com/v1".into()),
|
||||
"openai-completions",
|
||||
std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o".into()),
|
||||
),
|
||||
(
|
||||
"ANTHROPIC_API_KEY",
|
||||
"anthropic-env",
|
||||
"Anthropic 环境变量",
|
||||
"https://api.anthropic.com/v1".into(),
|
||||
"anthropic-messages",
|
||||
std::env::var("ANTHROPIC_MODEL")
|
||||
.unwrap_or_else(|_| "claude-sonnet-4-5-20250514".into()),
|
||||
),
|
||||
(
|
||||
"GEMINI_API_KEY",
|
||||
"gemini-env",
|
||||
"Gemini 环境变量",
|
||||
"https://generativelanguage.googleapis.com/v1beta".into(),
|
||||
"google-generative-ai",
|
||||
std::env::var("GEMINI_MODEL").unwrap_or_else(|_| "gemini-2.5-pro".into()),
|
||||
),
|
||||
] {
|
||||
if std::env::var(env_key)
|
||||
.map(|v| !v.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
push_client_candidate(
|
||||
&mut candidates,
|
||||
provider_key,
|
||||
"Environment",
|
||||
env_key,
|
||||
provider_key,
|
||||
display_name,
|
||||
&base_url,
|
||||
api,
|
||||
&format!("${{{env_key}}}"),
|
||||
"found",
|
||||
vec![model],
|
||||
true,
|
||||
"",
|
||||
"",
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(json!({ "candidates": candidates }))
|
||||
}
|
||||
|
||||
fn resolve_model_api_key(api_key: &str) -> Result<String, String> {
|
||||
let Some(key) = model_api_key_env_ref(api_key)? else {
|
||||
return Ok(api_key.to_string());
|
||||
|
||||
@@ -97,6 +97,7 @@ pub fn run() {
|
||||
config::test_model,
|
||||
config::test_model_verbose,
|
||||
config::list_remote_models,
|
||||
config::scan_model_client_configs,
|
||||
config::list_openclaw_versions,
|
||||
config::upgrade_openclaw,
|
||||
config::uninstall_openclaw,
|
||||
|
||||
Reference in New Issue
Block a user