Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/core/model/provider_catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -2200,25 +2200,55 @@
"name": "Kimi K2.5",
"kind": "llm"
},
{
"id": "kimi-k2.6:cloud",
"name": "Kimi K2.6 (Cloud)",
"kind": "llm"
},
{
"id": "glm-5",
"name": "GLM 5",
"kind": "llm"
},
{
"id": "glm-5.1:cloud",
"name": "GLM 5.1 (Cloud)",
"kind": "llm"
},
{
"id": "minimax-m2.5",
"name": "MiniMax M2.5",
"kind": "llm"
},
{
"id": "minimax-m2.7:cloud",
"name": "MiniMax M2.7 (Cloud)",
"kind": "llm"
},
{
"id": "glm-4.7-flash",
"name": "GLM 4.7 Flash",
"kind": "llm"
},
{
"id": "deepseek-v4-flash:cloud",
"name": "DeepSeek V4 Flash (Cloud)",
"kind": "llm"
},
{
"id": "deepseek-v4-pro:cloud",
"name": "DeepSeek V4 Pro (Cloud)",
"kind": "llm"
},
{
"id": "qwen3.5",
"name": "Qwen3.5",
"kind": "llm"
},
{
"id": "qwen3.5:cloud",
"name": "Qwen3.5 (Cloud)",
"kind": "llm"
}
]
},
Expand Down
39 changes: 37 additions & 2 deletions src/core/model/sources/9router.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"source": "9router",
"ref": "v0.4.55",
"generatedAt": "2026-05-19T14:31:36.624Z",
"ref": "v0.4.59-1-ge1b821d",
"generatedAt": "2026-05-21T15:40:41.710Z",
"providerIdToAlias": {
"claude": "cc",
"gemini": "gemini",
Expand Down Expand Up @@ -3209,25 +3209,55 @@
"name": "Kimi K2.5",
"kind": "llm"
},
{
"id": "kimi-k2.6:cloud",
"name": "Kimi K2.6 (Cloud)",
"kind": "llm"
},
{
"id": "glm-5",
"name": "GLM 5",
"kind": "llm"
},
{
"id": "glm-5.1:cloud",
"name": "GLM 5.1 (Cloud)",
"kind": "llm"
},
{
"id": "minimax-m2.5",
"name": "MiniMax M2.5",
"kind": "llm"
},
{
"id": "minimax-m2.7:cloud",
"name": "MiniMax M2.7 (Cloud)",
"kind": "llm"
},
{
"id": "glm-4.7-flash",
"name": "GLM 4.7 Flash",
"kind": "llm"
},
{
"id": "deepseek-v4-flash:cloud",
"name": "DeepSeek V4 Flash (Cloud)",
"kind": "llm"
},
{
"id": "deepseek-v4-pro:cloud",
"name": "DeepSeek V4 Pro (Cloud)",
"kind": "llm"
},
{
"id": "qwen3.5",
"name": "Qwen3.5",
"kind": "llm"
},
{
"id": "qwen3.5:cloud",
"name": "Qwen3.5 (Cloud)",
"kind": "llm"
}
]
},
Expand Down Expand Up @@ -4330,6 +4360,11 @@
"id": "grok-3",
"name": "Grok 3",
"kind": "llm"
},
{
"id": "grok-2-image-1212",
"name": "Grok 2 Image",
"kind": "image"
}
]
},
Expand Down
19 changes: 17 additions & 2 deletions src/server/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,10 +389,25 @@ async fn list_providers_api(State(state): State<AppState>, headers: HeaderMap) -
}

let snapshot = state.db.snapshot();
let connections: Vec<_> = snapshot
let connections: Vec<Value> = snapshot
.provider_connections
.iter()
.map(redact_provider_connection)
.map(|c| {
// Stamp `hasApiKey` so the dashboard can render an "on file"
// pill without needing the secret itself. The key is still
// redacted out of the payload via redact_provider_connection.
let has_api_key = c.api_key.as_deref().is_some_and(|k| !k.is_empty())
|| c.provider_specific_data
.get("apiKey")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty());
let mut value =
serde_json::to_value(redact_provider_connection(c)).unwrap_or_else(|_| json!({}));
if let Some(obj) = value.as_object_mut() {
obj.insert("hasApiKey".into(), Value::Bool(has_api_key));
}
value
})
.collect();
Json(json!({ "connections": connections })).into_response()
}
Expand Down
54 changes: 51 additions & 3 deletions src/server/api/provider_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,57 @@ async fn validate_provider(
if base_url.ends_with("/messages") {
base_url = base_url[..base_url.len()-9].to_string();
}
let url = if base_url.is_empty() { "https://api.anthropic.com/v1/messages".to_string() } else { format!("{}/messages", base_url) };
match client.post(&url).header("x-api-key", &api_key).header("anthropic-version", "2023-06-01").header("Authorization", format!("Bearer {api_key}")).send().await {
Ok(resp) => (resp.status().is_success(), None),
// Fall back to the provider's well-known Anthropic-compatible
// endpoint so a user who hasn't configured a custom node still
// gets a real validation instead of being routed at Anthropic.
let url = if !base_url.is_empty() {
format!("{}/messages", base_url)
} else {
match p {
"glm" => "https://api.z.ai/api/anthropic/v1/messages".to_string(),
"kimi" => "https://api.kimi.com/coding/v1/messages".to_string(),
"minimax" => "https://api.minimax.io/anthropic/v1/messages".to_string(),
"minimax-cn" => "https://api.minimaxi.com/anthropic/v1/messages".to_string(),
_ => "https://api.anthropic.com/v1/messages".to_string(),
}
};
// GLM/Kimi/MiniMax accept either x-api-key or Bearer; send a
// minimal `ping` so the upstream actually executes auth (a HEAD
// or empty POST tends to return 4xx that isn't auth-related).
let body = json!({
"model": match p {
"glm" => "glm-4.5-flash",
"kimi" => "kimi-k2.5",
"minimax" | "minimax-cn" => "minimax-m2",
_ => "claude-3-5-haiku-20241022",
},
"max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
});
match client.post(&url)
.header("x-api-key", &api_key)
.header("anthropic-version", "2023-06-01")
.header("Authorization", format!("Bearer {api_key}"))
.json(&body)
.send().await {
Ok(resp) => {
let status = resp.status();
// Treat any non-auth 2xx/4xx as proof the key works:
// some upstreams 400 on the dummy body but still validate
// the key. Only 401/403 mean the key is wrong.
let code = status.as_u16();
if code == 401 || code == 403 { (false, Some("Invalid API key".into())) }
else if status.is_success() || (400..500).contains(&code) && code != 429 {
let body_text = resp.text().await.unwrap_or_default();
let body_lower = body_text.to_lowercase();
let looks_auth = body_lower.contains("invalid api key")
|| body_lower.contains("unauthorized")
|| body_lower.contains("authentication failed");
(!looks_auth, if looks_auth { Some("Invalid API key".into()) } else { None })
} else {
(status.is_success(), None)
}
},
Err(e) => (false, Some(e.to_string())),
}
}
Expand Down
25 changes: 23 additions & 2 deletions src/server/api/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,17 +552,38 @@ async fn test_model(
.into_response();
}

// OpenAI-style response: { choices: [...] }
let has_choices = parsed
.as_ref()
.and_then(|value| value.get("choices"))
.and_then(Value::as_array)
.map(|choices| !choices.is_empty())
.unwrap_or(false);

// Claude / Anthropic-style response: { content: [...], stop_reason }
// forwarded through the proxy as-is. Treat a non-empty `content`
// array as a successful round-trip too (Bug 8).
let has_anthropic_content = parsed
.as_ref()
.and_then(|value| value.get("content"))
.and_then(Value::as_array)
.map(|content| !content.is_empty())
.unwrap_or(false);

// Gemini-style response: { candidates: [...] }
let has_gemini_candidates = parsed
.as_ref()
.and_then(|value| value.get("candidates"))
.and_then(Value::as_array)
.map(|c| !c.is_empty())
.unwrap_or(false);

let ok_completion = has_choices || has_anthropic_content || has_gemini_candidates;

Json(TestModelResponse {
ok: has_choices,
ok: ok_completion,
latency_ms: Some(latency_ms),
error: (!has_choices)
error: (!ok_completion)
.then(|| "Provider returned no completion choices for this model".to_string()),
status: Some(status),
})
Expand Down
Loading
Loading