mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 15:01:15 +00:00
test merge
* Improve OpenClaw provider alias migration compatibility * Fix local provider env mapping regression in migration * test(migrate): cover json5 default_model provider/env mapping * test(migrate): add JSON5 agent provider mapping integration tests * test(migrate): add legacy YAML provider alias integration coverage * fix(migrate): harden JSON5 provider catalog resolution * chore(migrate): scope split_model_ref helper to tests --------- Co-authored-by: root <root@LAPTOP-NGAQG9OH.localdomain>
This commit is contained in:
co-authored by
root
parent
d2ea030f03
commit
c122e1ddd7
@@ -549,15 +549,43 @@ fn build_channel_table(
|
||||
toml::Value::Table(table)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResolvedModelRef {
|
||||
provider: String,
|
||||
model: String,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Split an OpenClaw model reference like `"provider/model"` into `(provider, model)`.
|
||||
/// If there's no slash, returns `("anthropic", input)` as a fallback.
|
||||
#[cfg(test)]
|
||||
fn split_model_ref(model_ref: &str) -> (String, String) {
|
||||
let resolved = split_model_ref_with_context(model_ref, None);
|
||||
(resolved.provider, resolved.model)
|
||||
}
|
||||
|
||||
/// Split a model ref and resolve provider/base URL using optional OpenClaw
|
||||
/// `models.providers` metadata for higher-fidelity migration.
|
||||
fn split_model_ref_with_context(
|
||||
model_ref: &str,
|
||||
provider_catalog: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> ResolvedModelRef {
|
||||
if let Some(pos) = model_ref.find('/') {
|
||||
let provider = &model_ref[..pos];
|
||||
let raw_provider = &model_ref[..pos];
|
||||
let model = &model_ref[pos + 1..];
|
||||
(map_provider(provider), model.to_string())
|
||||
let (provider, base_url) =
|
||||
resolve_provider_with_models_context(raw_provider, provider_catalog);
|
||||
ResolvedModelRef {
|
||||
provider,
|
||||
model: model.to_string(),
|
||||
base_url,
|
||||
}
|
||||
} else {
|
||||
("anthropic".to_string(), model_ref.to_string())
|
||||
ResolvedModelRef {
|
||||
provider: "anthropic".to_string(),
|
||||
model: model_ref.to_string(),
|
||||
base_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,10 +691,27 @@ fn map_provider(openclaw_provider: &str) -> String {
|
||||
"together" => "together".to_string(),
|
||||
"mistral" => "mistral".to_string(),
|
||||
"fireworks" => "fireworks".to_string(),
|
||||
// Gemini aliases
|
||||
"google" | "gemini" => "google".to_string(),
|
||||
// Chinese provider aliases (including common OpenClaw custom IDs)
|
||||
"qwen" | "dashscope" | "qwencode" => "qwen".to_string(),
|
||||
"moonshot" | "kimi" | "kimicode" => "moonshot".to_string(),
|
||||
"minimax" => "minimax".to_string(),
|
||||
"zhipu" | "glm" => "zhipu".to_string(),
|
||||
"zhipu_coding" | "codegeex" => "zhipu_coding".to_string(),
|
||||
"qianfan" | "baidu" => "qianfan".to_string(),
|
||||
"xai" | "grok" => "xai".to_string(),
|
||||
"cerebras" => "cerebras".to_string(),
|
||||
"sambanova" => "sambanova".to_string(),
|
||||
// Additional OpenFang-supported providers and aliases
|
||||
"perplexity" => "perplexity".to_string(),
|
||||
"cohere" => "cohere".to_string(),
|
||||
"ai21" => "ai21".to_string(),
|
||||
"huggingface" => "huggingface".to_string(),
|
||||
"replicate" => "replicate".to_string(),
|
||||
"github-copilot" | "copilot" => "github-copilot".to_string(),
|
||||
"vllm" => "vllm".to_string(),
|
||||
"lmstudio" => "lmstudio".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -683,14 +728,156 @@ fn default_api_key_env(provider: &str) -> String {
|
||||
"mistral" => "MISTRAL_API_KEY".to_string(),
|
||||
"fireworks" => "FIREWORKS_API_KEY".to_string(),
|
||||
"google" => "GOOGLE_API_KEY".to_string(),
|
||||
"qwen" => "DASHSCOPE_API_KEY".to_string(),
|
||||
"moonshot" => "MOONSHOT_API_KEY".to_string(),
|
||||
"minimax" => "MINIMAX_API_KEY".to_string(),
|
||||
"zhipu" | "zhipu_coding" => "ZHIPU_API_KEY".to_string(),
|
||||
"qianfan" => "QIANFAN_API_KEY".to_string(),
|
||||
"xai" => "XAI_API_KEY".to_string(),
|
||||
"cerebras" => "CEREBRAS_API_KEY".to_string(),
|
||||
"sambanova" => "SAMBANOVA_API_KEY".to_string(),
|
||||
"perplexity" => "PERPLEXITY_API_KEY".to_string(),
|
||||
"cohere" => "COHERE_API_KEY".to_string(),
|
||||
"ai21" => "AI21_API_KEY".to_string(),
|
||||
"huggingface" => "HF_API_KEY".to_string(),
|
||||
"replicate" => "REPLICATE_API_TOKEN".to_string(),
|
||||
"github-copilot" => "GITHUB_TOKEN".to_string(),
|
||||
"ollama" => String::new(), // Ollama doesn't need an API key
|
||||
// Keep explicit env names for local OpenAI-compatible providers to avoid
|
||||
// falling back to default_model.api_key_env in kernel driver resolution.
|
||||
"vllm" => "VLLM_API_KEY".to_string(),
|
||||
"lmstudio" => "LMSTUDIO_API_KEY".to_string(),
|
||||
_ => format!("{}_API_KEY", provider.to_uppercase()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_known_openfang_provider_id(provider: &str) -> bool {
|
||||
matches!(
|
||||
provider,
|
||||
"anthropic"
|
||||
| "claude"
|
||||
| "openai"
|
||||
| "gpt"
|
||||
| "groq"
|
||||
| "grok"
|
||||
| "openrouter"
|
||||
| "deepseek"
|
||||
| "together"
|
||||
| "mistral"
|
||||
| "fireworks"
|
||||
| "google"
|
||||
| "gemini"
|
||||
| "ollama"
|
||||
| "vllm"
|
||||
| "lmstudio"
|
||||
| "perplexity"
|
||||
| "cohere"
|
||||
| "ai21"
|
||||
| "cerebras"
|
||||
| "sambanova"
|
||||
| "huggingface"
|
||||
| "xai"
|
||||
| "replicate"
|
||||
| "github-copilot"
|
||||
| "copilot"
|
||||
| "moonshot"
|
||||
| "kimi"
|
||||
| "qwen"
|
||||
| "dashscope"
|
||||
| "minimax"
|
||||
| "zhipu"
|
||||
| "glm"
|
||||
| "zhipu_coding"
|
||||
| "codegeex"
|
||||
| "qianfan"
|
||||
| "baidu"
|
||||
)
|
||||
}
|
||||
|
||||
fn json_get_string_case_insensitive(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
obj.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||
.and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
fn lookup_json5_provider_entry<'a>(
|
||||
catalog: Option<&'a serde_json::Map<String, serde_json::Value>>,
|
||||
provider: &str,
|
||||
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
|
||||
let catalog = catalog?;
|
||||
if let Some(exact) = catalog
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(provider))
|
||||
.and_then(|(_, v)| v.as_object())
|
||||
{
|
||||
return Some(exact);
|
||||
}
|
||||
|
||||
// Alias/canonical fallback: allow model refs that use aliases (e.g. `gpt`)
|
||||
// while provider catalog keys use canonical IDs (e.g. `openai`), and vice versa.
|
||||
let canonical = map_provider(&provider.to_lowercase());
|
||||
catalog
|
||||
.iter()
|
||||
.find(|(k, _)| map_provider(&k.to_lowercase()) == canonical)
|
||||
.and_then(|(_, v)| v.as_object())
|
||||
}
|
||||
|
||||
fn resolve_provider_with_models_context(
|
||||
openclaw_provider: &str,
|
||||
provider_catalog: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> (String, Option<String>) {
|
||||
let raw = openclaw_provider.to_lowercase();
|
||||
let mapped = map_provider(&raw);
|
||||
let Some(entry) = lookup_json5_provider_entry(provider_catalog, openclaw_provider) else {
|
||||
return (mapped, None);
|
||||
};
|
||||
|
||||
let base_url = json_get_string_case_insensitive(entry, "baseUrl")
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
let api_hint = json_get_string_case_insensitive(entry, "api")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
|
||||
let raw_is_known = is_known_openfang_provider_id(&raw);
|
||||
let mapped_is_known = is_known_openfang_provider_id(&mapped);
|
||||
|
||||
let provider = if api_hint.contains("anthropic") {
|
||||
"anthropic".to_string()
|
||||
} else if api_hint.contains("gemini") || api_hint.contains("google") {
|
||||
"google".to_string()
|
||||
} else if api_hint.contains("openai") {
|
||||
if raw_is_known {
|
||||
mapped.clone()
|
||||
} else if base_url.is_some() {
|
||||
// Preserve custom IDs only when runtime can route them via base_url.
|
||||
raw.clone()
|
||||
} else if mapped_is_known {
|
||||
mapped.clone()
|
||||
} else {
|
||||
// Unknown custom ID without base_url would fail at runtime; default to
|
||||
// OpenAI-compatible driver.
|
||||
"openai".to_string()
|
||||
}
|
||||
} else if raw_is_known {
|
||||
mapped.clone()
|
||||
} else if base_url.is_some() {
|
||||
// Unknown API type + explicit custom provider metadata:
|
||||
// keep custom provider id and rely on base_url for runtime routing.
|
||||
raw.clone()
|
||||
} else if mapped_is_known {
|
||||
mapped.clone()
|
||||
} else {
|
||||
// No API hint and no base URL; choose a known OpenAI-compatible driver.
|
||||
"openai".to_string()
|
||||
};
|
||||
|
||||
(provider, base_url)
|
||||
}
|
||||
|
||||
/// Derive capability grants from the tool list.
|
||||
fn derive_capabilities(tools: &[String]) -> AgentCapabilities {
|
||||
let mut caps = AgentCapabilities::default();
|
||||
@@ -816,15 +1003,21 @@ fn scan_from_json5(base: &Path, config_path: &Path, result: &mut ScanResult) {
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let provider_catalog = root.models.as_ref().and_then(|m| m.providers.as_ref());
|
||||
|
||||
// Agents from JSON config
|
||||
if let Some(ref agents) = root.agents {
|
||||
for entry in &agents.list {
|
||||
let id = entry.id.clone();
|
||||
let name = entry.name.clone().unwrap_or_else(|| id.clone());
|
||||
|
||||
let (provider, model) = extract_primary_model(entry, agents.defaults.as_ref())
|
||||
.map(|m| split_model_ref(&m))
|
||||
.unwrap_or_else(|| ("anthropic".to_string(), String::new()));
|
||||
let resolved = extract_primary_model(entry, agents.defaults.as_ref())
|
||||
.map(|m| split_model_ref_with_context(&m, provider_catalog))
|
||||
.unwrap_or(ResolvedModelRef {
|
||||
provider: "anthropic".to_string(),
|
||||
model: String::new(),
|
||||
base_url: None,
|
||||
});
|
||||
|
||||
let tool_count = entry
|
||||
.tools
|
||||
@@ -853,8 +1046,8 @@ fn scan_from_json5(base: &Path, config_path: &Path, result: &mut ScanResult) {
|
||||
result.agents.push(ScannedAgent {
|
||||
name,
|
||||
description: String::new(),
|
||||
provider,
|
||||
model,
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
tool_count,
|
||||
has_memory,
|
||||
has_sessions,
|
||||
@@ -1159,8 +1352,10 @@ fn migrate_config_from_json(
|
||||
dry_run: bool,
|
||||
report: &mut MigrationReport,
|
||||
) -> Result<(), MigrateError> {
|
||||
let provider_catalog = root.models.as_ref().and_then(|m| m.providers.as_ref());
|
||||
|
||||
// Extract default model from agents.defaults.model
|
||||
let (provider, model) = root
|
||||
let resolved = root
|
||||
.agents
|
||||
.as_ref()
|
||||
.and_then(|a| a.defaults.as_ref())
|
||||
@@ -1169,25 +1364,24 @@ fn migrate_config_from_json(
|
||||
OpenClawAgentModel::Simple(s) => Some(s.clone()),
|
||||
OpenClawAgentModel::Detailed(d) => d.primary.clone(),
|
||||
})
|
||||
.map(|m| split_model_ref(&m))
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
"anthropic".to_string(),
|
||||
"claude-sonnet-4-20250514".to_string(),
|
||||
)
|
||||
.map(|m| split_model_ref_with_context(&m, provider_catalog))
|
||||
.unwrap_or_else(|| ResolvedModelRef {
|
||||
provider: "anthropic".to_string(),
|
||||
model: "claude-sonnet-4-20250514".to_string(),
|
||||
base_url: None,
|
||||
});
|
||||
|
||||
let api_key_env = default_api_key_env(&provider);
|
||||
let api_key_env = default_api_key_env(&resolved.provider);
|
||||
|
||||
// Extract channels (writes secrets.env)
|
||||
let channels = migrate_channels_from_json(root, target, dry_run, report);
|
||||
|
||||
let of_config = OpenFangConfig {
|
||||
default_model: OpenFangModelConfig {
|
||||
provider,
|
||||
model,
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
api_key_env,
|
||||
base_url: None,
|
||||
base_url: resolved.base_url,
|
||||
},
|
||||
memory: OpenFangMemorySection { decay_rate: 0.05 },
|
||||
network: OpenFangNetworkSection {
|
||||
@@ -1731,6 +1925,7 @@ fn migrate_agents_from_json(
|
||||
};
|
||||
|
||||
let defaults = agents.defaults.as_ref();
|
||||
let provider_catalog = root.models.as_ref().and_then(|m| m.providers.as_ref());
|
||||
|
||||
for entry in &agents.list {
|
||||
let id = &entry.id;
|
||||
@@ -1738,7 +1933,7 @@ fn migrate_agents_from_json(
|
||||
continue;
|
||||
}
|
||||
|
||||
match convert_agent_from_json(entry, defaults) {
|
||||
match convert_agent_from_json(entry, defaults, provider_catalog) {
|
||||
Ok((toml_str, unmapped_tools)) => {
|
||||
let dest_dir = target.join("agents").join(id);
|
||||
let dest_file = dest_dir.join("agent.toml");
|
||||
@@ -1779,6 +1974,7 @@ fn migrate_agents_from_json(
|
||||
fn convert_agent_from_json(
|
||||
entry: &OpenClawAgentEntry,
|
||||
defaults: Option<&OpenClawAgentDefaults>,
|
||||
provider_catalog: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Result<(String, Vec<String>), MigrateError> {
|
||||
let id = &entry.id;
|
||||
let display_name = entry.name.clone().unwrap_or_else(|| id.clone());
|
||||
@@ -1786,7 +1982,11 @@ fn convert_agent_from_json(
|
||||
// Resolve model
|
||||
let primary_ref = extract_primary_model(entry, defaults)
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".to_string());
|
||||
let (provider, model) = split_model_ref(&primary_ref);
|
||||
let ResolvedModelRef {
|
||||
provider,
|
||||
model,
|
||||
base_url: primary_base_url,
|
||||
} = split_model_ref_with_context(&primary_ref, provider_catalog);
|
||||
|
||||
// Resolve fallback models
|
||||
let fallbacks = extract_fallback_models(entry, defaults);
|
||||
@@ -1879,10 +2079,15 @@ fn convert_agent_from_json(
|
||||
if let Some(ref api_key) = api_key_env {
|
||||
toml_str.push_str(&format!("api_key_env = \"{api_key}\"\n"));
|
||||
}
|
||||
if let Some(base_url) = primary_base_url {
|
||||
toml_str.push_str(&format!("base_url = \"{base_url}\"\n"));
|
||||
}
|
||||
|
||||
// Fallback models
|
||||
for fb in &fallbacks {
|
||||
let (fb_provider, fb_model) = split_model_ref(fb);
|
||||
let fallback = split_model_ref_with_context(fb, provider_catalog);
|
||||
let fb_provider = fallback.provider;
|
||||
let fb_model = fallback.model;
|
||||
let fb_api_key = default_api_key_env(&fb_provider);
|
||||
toml_str.push_str("\n[[fallback_models]]\n");
|
||||
toml_str.push_str(&format!("provider = \"{fb_provider}\"\n"));
|
||||
@@ -1890,6 +2095,9 @@ fn convert_agent_from_json(
|
||||
if !fb_api_key.is_empty() {
|
||||
toml_str.push_str(&format!("api_key_env = \"{fb_api_key}\"\n"));
|
||||
}
|
||||
if let Some(base_url) = fallback.base_url {
|
||||
toml_str.push_str(&format!("base_url = \"{base_url}\"\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Capabilities section
|
||||
@@ -3070,6 +3278,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), std::io::Error> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ===== Helper: create legacy YAML workspace =====
|
||||
@@ -3807,6 +4016,102 @@ mod tests {
|
||||
assert_eq!(m, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_ref_split_with_catalog_preserves_custom_openai_provider() {
|
||||
let providers = json!({
|
||||
"qwencode": {
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"api": "openai-completions"
|
||||
}
|
||||
});
|
||||
|
||||
let catalog = providers.as_object().unwrap();
|
||||
let resolved = split_model_ref_with_context("qwencode/glm-5", Some(catalog));
|
||||
|
||||
assert_eq!(resolved.provider, "qwencode");
|
||||
assert_eq!(resolved.model, "glm-5");
|
||||
assert_eq!(
|
||||
resolved.base_url.as_deref(),
|
||||
Some("https://coding.dashscope.aliyuncs.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_ref_split_with_catalog_uses_api_hint_for_driver() {
|
||||
let providers = json!({
|
||||
"kimicode": {
|
||||
"baseUrl": "https://api.kimi.com/coding",
|
||||
"api": "anthropic-messages"
|
||||
}
|
||||
});
|
||||
|
||||
let catalog = providers.as_object().unwrap();
|
||||
let resolved = split_model_ref_with_context("kimicode/kimi-k2.5", Some(catalog));
|
||||
|
||||
assert_eq!(resolved.provider, "anthropic");
|
||||
assert_eq!(resolved.model, "kimi-k2.5");
|
||||
assert_eq!(
|
||||
resolved.base_url.as_deref(),
|
||||
Some("https://api.kimi.com/coding")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_ref_split_with_catalog_unknown_openai_without_base_url_falls_back_to_openai() {
|
||||
let providers = json!({
|
||||
"mycompany": {
|
||||
"api": "openai-completions"
|
||||
}
|
||||
});
|
||||
|
||||
let catalog = providers.as_object().unwrap();
|
||||
let resolved = split_model_ref_with_context("mycompany/custom-model", Some(catalog));
|
||||
|
||||
assert_eq!(resolved.provider, "openai");
|
||||
assert_eq!(resolved.model, "custom-model");
|
||||
assert_eq!(resolved.base_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_ref_split_with_catalog_alias_gpt_maps_to_openai() {
|
||||
let providers = json!({
|
||||
"gpt": {
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"api": "openai-completions"
|
||||
}
|
||||
});
|
||||
|
||||
let catalog = providers.as_object().unwrap();
|
||||
let resolved = split_model_ref_with_context("gpt/gpt-4.1-mini", Some(catalog));
|
||||
|
||||
assert_eq!(resolved.provider, "openai");
|
||||
assert_eq!(resolved.model, "gpt-4.1-mini");
|
||||
assert_eq!(
|
||||
resolved.base_url.as_deref(),
|
||||
Some("https://api.openai.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_ref_split_with_catalog_alias_key_mismatch_uses_catalog_metadata() {
|
||||
let providers = json!({
|
||||
"openai": {
|
||||
"baseUrl": "https://proxy.example/v1",
|
||||
"api": "openai-completions"
|
||||
}
|
||||
});
|
||||
|
||||
let catalog = providers.as_object().unwrap();
|
||||
let resolved = split_model_ref_with_context("gpt/gpt-4.1-mini", Some(catalog));
|
||||
|
||||
assert_eq!(resolved.provider, "openai");
|
||||
assert_eq!(resolved.model, "gpt-4.1-mini");
|
||||
assert_eq!(
|
||||
resolved.base_url.as_deref(),
|
||||
Some("https://proxy.example/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json5_unknown_provider_passthrough() {
|
||||
let source = TempDir::new().unwrap();
|
||||
@@ -3954,6 +4259,37 @@ mod tests {
|
||||
assert_eq!(map_provider("gemini"), "google");
|
||||
assert_eq!(map_provider("xai"), "xai");
|
||||
assert_eq!(map_provider("grok"), "xai");
|
||||
assert_eq!(map_provider("qwen"), "qwen");
|
||||
assert_eq!(map_provider("dashscope"), "qwen");
|
||||
assert_eq!(map_provider("qwencode"), "qwen");
|
||||
assert_eq!(map_provider("moonshot"), "moonshot");
|
||||
assert_eq!(map_provider("kimi"), "moonshot");
|
||||
assert_eq!(map_provider("kimicode"), "moonshot");
|
||||
assert_eq!(map_provider("zhipu"), "zhipu");
|
||||
assert_eq!(map_provider("glm"), "zhipu");
|
||||
assert_eq!(map_provider("codegeex"), "zhipu_coding");
|
||||
assert_eq!(map_provider("baidu"), "qianfan");
|
||||
assert_eq!(map_provider("copilot"), "github-copilot");
|
||||
assert_eq!(map_provider("github-copilot"), "github-copilot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_api_key_env_mapping() {
|
||||
assert_eq!(default_api_key_env("qwen"), "DASHSCOPE_API_KEY");
|
||||
assert_eq!(default_api_key_env("moonshot"), "MOONSHOT_API_KEY");
|
||||
assert_eq!(default_api_key_env("minimax"), "MINIMAX_API_KEY");
|
||||
assert_eq!(default_api_key_env("zhipu"), "ZHIPU_API_KEY");
|
||||
assert_eq!(default_api_key_env("zhipu_coding"), "ZHIPU_API_KEY");
|
||||
assert_eq!(default_api_key_env("qianfan"), "QIANFAN_API_KEY");
|
||||
assert_eq!(default_api_key_env("perplexity"), "PERPLEXITY_API_KEY");
|
||||
assert_eq!(default_api_key_env("cohere"), "COHERE_API_KEY");
|
||||
assert_eq!(default_api_key_env("ai21"), "AI21_API_KEY");
|
||||
assert_eq!(default_api_key_env("huggingface"), "HF_API_KEY");
|
||||
assert_eq!(default_api_key_env("replicate"), "REPLICATE_API_TOKEN");
|
||||
assert_eq!(default_api_key_env("github-copilot"), "GITHUB_TOKEN");
|
||||
assert!(default_api_key_env("ollama").is_empty());
|
||||
assert_eq!(default_api_key_env("vllm"), "VLLM_API_KEY");
|
||||
assert_eq!(default_api_key_env("lmstudio"), "LMSTUDIO_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use openfang_migrate::{run_migration, MigrateOptions, MigrateSource};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn migrate_with_json5(json5_content: &str) -> (TempDir, TempDir) {
|
||||
let source = TempDir::new().expect("create source tempdir");
|
||||
let target = TempDir::new().expect("create target tempdir");
|
||||
|
||||
std::fs::write(source.path().join("openclaw.json"), json5_content)
|
||||
.expect("write openclaw.json");
|
||||
|
||||
let options = MigrateOptions {
|
||||
source: MigrateSource::OpenClaw,
|
||||
source_dir: source.path().to_path_buf(),
|
||||
target_dir: target.path().to_path_buf(),
|
||||
dry_run: false,
|
||||
};
|
||||
|
||||
run_migration(&options).expect("run migration");
|
||||
(source, target)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json5_agent_provider_and_fallback_api_key_env_mapping() {
|
||||
let json5_content = r#"{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "provider-case",
|
||||
model: {
|
||||
primary: "qwencode/glm-5",
|
||||
fallbacks: [
|
||||
"kimicode/kimi-k2.5",
|
||||
"copilot/gpt-4.1",
|
||||
"vllm/llama3"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let (_source, target) = migrate_with_json5(json5_content);
|
||||
|
||||
let agent_toml = std::fs::read_to_string(
|
||||
target
|
||||
.path()
|
||||
.join("agents")
|
||||
.join("provider-case")
|
||||
.join("agent.toml"),
|
||||
)
|
||||
.expect("read migrated agent.toml");
|
||||
|
||||
let parsed: toml::Value = toml::from_str(&agent_toml).expect("parse agent.toml");
|
||||
|
||||
let model = parsed
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("[model] exists");
|
||||
|
||||
assert_eq!(
|
||||
model.get("provider").and_then(toml::Value::as_str),
|
||||
Some("qwen")
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some("DASHSCOPE_API_KEY")
|
||||
);
|
||||
|
||||
let fallback_models = parsed
|
||||
.get("fallback_models")
|
||||
.and_then(toml::Value::as_array)
|
||||
.expect("[[fallback_models]] exists");
|
||||
|
||||
assert_eq!(fallback_models.len(), 3);
|
||||
|
||||
let expected = [
|
||||
("moonshot", "MOONSHOT_API_KEY"),
|
||||
("github-copilot", "GITHUB_TOKEN"),
|
||||
("vllm", "VLLM_API_KEY"),
|
||||
];
|
||||
|
||||
for (index, (expected_provider, expected_api_key_env)) in expected.iter().enumerate() {
|
||||
let fallback = fallback_models[index]
|
||||
.as_table()
|
||||
.expect("fallback entry is table");
|
||||
|
||||
assert_eq!(
|
||||
fallback.get("provider").and_then(toml::Value::as_str),
|
||||
Some(*expected_provider)
|
||||
);
|
||||
assert_eq!(
|
||||
fallback.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some(*expected_api_key_env)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json5_lmstudio_provider_api_key_env_mapping() {
|
||||
let json5_content = r#"{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "lmstudio-case",
|
||||
model: "lmstudio/llama3"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let (_source, target) = migrate_with_json5(json5_content);
|
||||
|
||||
let agent_toml = std::fs::read_to_string(
|
||||
target
|
||||
.path()
|
||||
.join("agents")
|
||||
.join("lmstudio-case")
|
||||
.join("agent.toml"),
|
||||
)
|
||||
.expect("read migrated lmstudio agent.toml");
|
||||
|
||||
let parsed: toml::Value = toml::from_str(&agent_toml).expect("parse agent.toml");
|
||||
|
||||
let model = parsed
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("[model] exists");
|
||||
|
||||
assert_eq!(
|
||||
model.get("provider").and_then(toml::Value::as_str),
|
||||
Some("lmstudio")
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some("LMSTUDIO_API_KEY")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use openfang_migrate::{openclaw, MigrateOptions, MigrateSource};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn assert_default_model_mapping(
|
||||
model_ref: &str,
|
||||
expected_provider: &str,
|
||||
expected_api_key_env: &str,
|
||||
) {
|
||||
let source = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
|
||||
let openclaw_json = format!(
|
||||
r#"{{
|
||||
agents: {{
|
||||
defaults: {{ model: "{model_ref}" }},
|
||||
list: [
|
||||
{{ id: "tester" }}
|
||||
]
|
||||
}}
|
||||
}}"#
|
||||
);
|
||||
std::fs::write(source.path().join("openclaw.json"), openclaw_json).unwrap();
|
||||
|
||||
let options = MigrateOptions {
|
||||
source: MigrateSource::OpenClaw,
|
||||
source_dir: source.path().to_path_buf(),
|
||||
target_dir: target.path().to_path_buf(),
|
||||
dry_run: false,
|
||||
};
|
||||
openclaw::migrate(&options).unwrap();
|
||||
|
||||
let config_toml = std::fs::read_to_string(target.path().join("config.toml")).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&config_toml).unwrap();
|
||||
let default_model = parsed
|
||||
.get("default_model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("config.toml should contain [default_model]");
|
||||
|
||||
assert_eq!(
|
||||
default_model.get("provider").and_then(toml::Value::as_str),
|
||||
Some(expected_provider)
|
||||
);
|
||||
assert_eq!(
|
||||
default_model
|
||||
.get("api_key_env")
|
||||
.and_then(toml::Value::as_str),
|
||||
Some(expected_api_key_env)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json5_default_model_qwencode_maps_to_qwen_and_dashscope_key() {
|
||||
assert_default_model_mapping("qwencode/glm-5", "qwen", "DASHSCOPE_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json5_default_model_kimicode_maps_to_moonshot_and_moonshot_key() {
|
||||
assert_default_model_mapping("kimicode/kimi-k2.5", "moonshot", "MOONSHOT_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json5_default_model_copilot_maps_to_github_copilot_and_github_token() {
|
||||
assert_default_model_mapping("copilot/gpt-4.1", "github-copilot", "GITHUB_TOKEN");
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use openfang_migrate::{run_migration, MigrateOptions, MigrateSource};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn json5_catalog_preserves_custom_openai_provider_and_base_url() {
|
||||
let source = TempDir::new().expect("create source tempdir");
|
||||
let target = TempDir::new().expect("create target tempdir");
|
||||
|
||||
let json5 = r#"{
|
||||
models: {
|
||||
providers: {
|
||||
qwencode: {
|
||||
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
|
||||
api: "openai-completions"
|
||||
}
|
||||
}
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "qwencode/glm-5"
|
||||
},
|
||||
list: [
|
||||
{ id: "coder" }
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
std::fs::write(source.path().join("openclaw.json"), json5).expect("write openclaw.json");
|
||||
|
||||
let options = MigrateOptions {
|
||||
source: MigrateSource::OpenClaw,
|
||||
source_dir: source.path().to_path_buf(),
|
||||
target_dir: target.path().to_path_buf(),
|
||||
dry_run: false,
|
||||
};
|
||||
run_migration(&options).expect("migration succeeds");
|
||||
|
||||
let cfg = std::fs::read_to_string(target.path().join("config.toml")).expect("read config.toml");
|
||||
let cfg_val: toml::Value = toml::from_str(&cfg).expect("parse config.toml");
|
||||
let dm = cfg_val
|
||||
.get("default_model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("default_model table");
|
||||
|
||||
assert_eq!(
|
||||
dm.get("provider").and_then(toml::Value::as_str),
|
||||
Some("qwencode")
|
||||
);
|
||||
assert_eq!(dm.get("model").and_then(toml::Value::as_str), Some("glm-5"));
|
||||
assert_eq!(
|
||||
dm.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some("QWENCODE_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
dm.get("base_url").and_then(toml::Value::as_str),
|
||||
Some("https://coding.dashscope.aliyuncs.com/v1")
|
||||
);
|
||||
|
||||
let agent = std::fs::read_to_string(target.path().join("agents/coder/agent.toml"))
|
||||
.expect("read migrated agent");
|
||||
let agent_val: toml::Value = toml::from_str(&agent).expect("parse agent.toml");
|
||||
let model = agent_val
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("model table");
|
||||
|
||||
assert_eq!(
|
||||
model.get("provider").and_then(toml::Value::as_str),
|
||||
Some("qwencode")
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("base_url").and_then(toml::Value::as_str),
|
||||
Some("https://coding.dashscope.aliyuncs.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json5_catalog_api_hint_maps_custom_provider_to_anthropic_driver() {
|
||||
let source = TempDir::new().expect("create source tempdir");
|
||||
let target = TempDir::new().expect("create target tempdir");
|
||||
|
||||
let json5 = r#"{
|
||||
models: {
|
||||
providers: {
|
||||
kimicode: {
|
||||
baseUrl: "https://api.kimi.com/coding",
|
||||
api: "anthropic-messages"
|
||||
}
|
||||
}
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "writer",
|
||||
model: {
|
||||
primary: "kimicode/kimi-k2.5",
|
||||
fallbacks: ["kimicode/kimi-k2.5"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
std::fs::write(source.path().join("openclaw.json"), json5).expect("write openclaw.json");
|
||||
|
||||
let options = MigrateOptions {
|
||||
source: MigrateSource::OpenClaw,
|
||||
source_dir: source.path().to_path_buf(),
|
||||
target_dir: target.path().to_path_buf(),
|
||||
dry_run: false,
|
||||
};
|
||||
run_migration(&options).expect("migration succeeds");
|
||||
|
||||
let agent = std::fs::read_to_string(target.path().join("agents/writer/agent.toml"))
|
||||
.expect("read migrated agent");
|
||||
let agent_val: toml::Value = toml::from_str(&agent).expect("parse agent.toml");
|
||||
|
||||
let model = agent_val
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("model table");
|
||||
assert_eq!(
|
||||
model.get("provider").and_then(toml::Value::as_str),
|
||||
Some("anthropic")
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some("ANTHROPIC_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("base_url").and_then(toml::Value::as_str),
|
||||
Some("https://api.kimi.com/coding")
|
||||
);
|
||||
|
||||
let fallback_models = agent_val
|
||||
.get("fallback_models")
|
||||
.and_then(toml::Value::as_array)
|
||||
.expect("fallback models exist");
|
||||
let fb = fallback_models
|
||||
.first()
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("first fallback table");
|
||||
|
||||
assert_eq!(
|
||||
fb.get("provider").and_then(toml::Value::as_str),
|
||||
Some("anthropic")
|
||||
);
|
||||
assert_eq!(
|
||||
fb.get("base_url").and_then(toml::Value::as_str),
|
||||
Some("https://api.kimi.com/coding")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json5_catalog_unknown_openai_without_base_url_falls_back_to_openai() {
|
||||
let source = TempDir::new().expect("create source tempdir");
|
||||
let target = TempDir::new().expect("create target tempdir");
|
||||
|
||||
let json5 = r#"{
|
||||
models: {
|
||||
providers: {
|
||||
mygateway: {
|
||||
api: "openai-completions"
|
||||
}
|
||||
}
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "mygateway/gpt-like"
|
||||
},
|
||||
list: [
|
||||
{ id: "fallback-check" }
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
std::fs::write(source.path().join("openclaw.json"), json5).expect("write openclaw.json");
|
||||
|
||||
let options = MigrateOptions {
|
||||
source: MigrateSource::OpenClaw,
|
||||
source_dir: source.path().to_path_buf(),
|
||||
target_dir: target.path().to_path_buf(),
|
||||
dry_run: false,
|
||||
};
|
||||
run_migration(&options).expect("migration succeeds");
|
||||
|
||||
let cfg = std::fs::read_to_string(target.path().join("config.toml")).expect("read config.toml");
|
||||
let cfg_val: toml::Value = toml::from_str(&cfg).expect("parse config.toml");
|
||||
let dm = cfg_val
|
||||
.get("default_model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("default_model table");
|
||||
|
||||
assert_eq!(
|
||||
dm.get("provider").and_then(toml::Value::as_str),
|
||||
Some("openai")
|
||||
);
|
||||
assert_eq!(
|
||||
dm.get("model").and_then(toml::Value::as_str),
|
||||
Some("gpt-like")
|
||||
);
|
||||
assert!(dm.get("base_url").is_none());
|
||||
|
||||
let agent = std::fs::read_to_string(target.path().join("agents/fallback-check/agent.toml"))
|
||||
.expect("read migrated agent");
|
||||
let agent_val: toml::Value = toml::from_str(&agent).expect("parse agent.toml");
|
||||
let model = agent_val
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("model table");
|
||||
|
||||
assert_eq!(
|
||||
model.get("provider").and_then(toml::Value::as_str),
|
||||
Some("openai")
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some("OPENAI_API_KEY")
|
||||
);
|
||||
assert!(model.get("base_url").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json5_catalog_alias_key_mismatch_still_uses_catalog_metadata() {
|
||||
let source = TempDir::new().expect("create source tempdir");
|
||||
let target = TempDir::new().expect("create target tempdir");
|
||||
|
||||
let json5 = r#"{
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
api: "openai-completions"
|
||||
}
|
||||
}
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "alias-check",
|
||||
model: "gpt/gpt-4.1-mini"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
std::fs::write(source.path().join("openclaw.json"), json5).expect("write openclaw.json");
|
||||
|
||||
let options = MigrateOptions {
|
||||
source: MigrateSource::OpenClaw,
|
||||
source_dir: source.path().to_path_buf(),
|
||||
target_dir: target.path().to_path_buf(),
|
||||
dry_run: false,
|
||||
};
|
||||
run_migration(&options).expect("migration succeeds");
|
||||
|
||||
let agent = std::fs::read_to_string(target.path().join("agents/alias-check/agent.toml"))
|
||||
.expect("read migrated agent");
|
||||
let agent_val: toml::Value = toml::from_str(&agent).expect("parse agent.toml");
|
||||
let model = agent_val
|
||||
.get("model")
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("model table");
|
||||
|
||||
assert_eq!(
|
||||
model.get("provider").and_then(toml::Value::as_str),
|
||||
Some("openai")
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some("OPENAI_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("base_url").and_then(toml::Value::as_str),
|
||||
Some("https://proxy.example/v1")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use openfang_migrate::{run_migration, MigrateOptions, MigrateSource};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_legacy_workspace(
|
||||
source_dir: &Path,
|
||||
config_provider: &str,
|
||||
config_model: &str,
|
||||
agent_id: &str,
|
||||
agent_provider: &str,
|
||||
agent_model: &str,
|
||||
) {
|
||||
fs::write(
|
||||
source_dir.join("config.yaml"),
|
||||
format!("provider: {config_provider}\nmodel: {config_model}\n"),
|
||||
)
|
||||
.expect("write config.yaml");
|
||||
|
||||
let agent_dir = source_dir.join("agents").join(agent_id);
|
||||
fs::create_dir_all(&agent_dir).expect("create agent dir");
|
||||
fs::write(
|
||||
agent_dir.join("agent.yaml"),
|
||||
format!(
|
||||
"name: {agent_id}\n\
|
||||
description: provider alias mapping test\n\
|
||||
provider: {agent_provider}\n\
|
||||
model: {agent_model}\n"
|
||||
),
|
||||
)
|
||||
.expect("write agent.yaml");
|
||||
}
|
||||
|
||||
fn migrate_legacy_workspace(source_dir: &Path, target_dir: &Path) {
|
||||
let options = MigrateOptions {
|
||||
source: MigrateSource::OpenClaw,
|
||||
source_dir: source_dir.to_path_buf(),
|
||||
target_dir: target_dir.to_path_buf(),
|
||||
dry_run: false,
|
||||
};
|
||||
run_migration(&options).expect("legacy YAML migration should succeed");
|
||||
}
|
||||
|
||||
fn assert_config_model_mapping(
|
||||
target_dir: &Path,
|
||||
expected_provider: &str,
|
||||
expected_api_key_env: &str,
|
||||
) {
|
||||
let config_toml = fs::read_to_string(target_dir.join("config.toml")).expect("read config.toml");
|
||||
let config: toml::Value = toml::from_str(&config_toml).expect("parse config.toml");
|
||||
let model = config
|
||||
.get("default_model")
|
||||
.expect("config.toml should have [default_model]");
|
||||
|
||||
assert_eq!(
|
||||
model.get("provider").and_then(toml::Value::as_str),
|
||||
Some(expected_provider)
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some(expected_api_key_env)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_agent_model_mapping(
|
||||
target_dir: &Path,
|
||||
agent_id: &str,
|
||||
expected_provider: &str,
|
||||
expected_api_key_env: &str,
|
||||
) {
|
||||
let agent_toml =
|
||||
fs::read_to_string(target_dir.join("agents").join(agent_id).join("agent.toml"))
|
||||
.expect("read agent.toml");
|
||||
let agent: toml::Value = toml::from_str(&agent_toml).expect("parse agent.toml");
|
||||
let model = agent.get("model").expect("agent.toml should have [model]");
|
||||
|
||||
assert_eq!(
|
||||
model.get("provider").and_then(toml::Value::as_str),
|
||||
Some(expected_provider)
|
||||
);
|
||||
assert_eq!(
|
||||
model.get("api_key_env").and_then(toml::Value::as_str),
|
||||
Some(expected_api_key_env)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_yaml_provider_alias_mapping_kimicode_and_copilot() {
|
||||
let source = TempDir::new().expect("create source tempdir");
|
||||
let target = TempDir::new().expect("create target tempdir");
|
||||
let agent_id = "coder";
|
||||
|
||||
create_legacy_workspace(
|
||||
source.path(),
|
||||
"kimicode",
|
||||
"kimi-k2",
|
||||
agent_id,
|
||||
"copilot",
|
||||
"gpt-4.1",
|
||||
);
|
||||
migrate_legacy_workspace(source.path(), target.path());
|
||||
|
||||
assert_config_model_mapping(target.path(), "moonshot", "MOONSHOT_API_KEY");
|
||||
assert_agent_model_mapping(target.path(), agent_id, "github-copilot", "GITHUB_TOKEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_yaml_provider_alias_mapping_qwencode_and_lmstudio() {
|
||||
let source = TempDir::new().expect("create source tempdir");
|
||||
let target = TempDir::new().expect("create target tempdir");
|
||||
let agent_id = "assistant";
|
||||
|
||||
create_legacy_workspace(
|
||||
source.path(),
|
||||
"qwencode",
|
||||
"qwen-plus",
|
||||
agent_id,
|
||||
"lmstudio",
|
||||
"local-model",
|
||||
);
|
||||
migrate_legacy_workspace(source.path(), target.path());
|
||||
|
||||
assert_config_model_mapping(target.path(), "qwen", "DASHSCOPE_API_KEY");
|
||||
assert_agent_model_mapping(target.path(), agent_id, "lmstudio", "LMSTUDIO_API_KEY");
|
||||
}
|
||||
Reference in New Issue
Block a user