Merge remote-tracking branch 'origin/feat/comfy-cloud-adapter' into _merge_test

# Conflicts:
#	src/routes/admin/compat.rs
#	src/routes/admin/downstream_keys.rs
#	src/routes/auth.rs
#	src/routes/comfyui/mod.rs
#	src/routes/comfyui_agent.rs
#	src/routes/events.rs
#	src/routes/money_printer.rs
#	src/routes/oauth.rs
#	src/routes/proxy/mod.rs
#	src/routes/video_agent_legacy.rs
This commit is contained in:
boos
2026-06-22 10:14:52 +08:00
160 changed files with 26496 additions and 11151 deletions
+2
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
pub mod templates;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CliProfileKind {
Codex,
+152
View File
@@ -0,0 +1,152 @@
//! Client integration templates — copy-ready config snippets for pointing
//! popular AI clients at this BoosAPI gateway.
//!
//! Ported from AQBot's `list_gateway_templates` (`commands/gateway.rs:665-730`).
//! AQBot is a desktop app whose gateway listens on `localhost:8080`, so its
//! templates hardcode that origin. BoosAPI is a server-side SaaS, so the origin
//! is injected at request time (public domain / port) — see
//! `routes::admin::cli_templates`.
//!
//! Scope guard (per the integration analysis): this module only returns config
//! **strings + copy hints**. It deliberately does NOT rewrite any local CLI
//! config files (AQBot's `cli_config.rs` desktop behavior) — that has no meaning
//! on a server and is explicitly out of scope.
use serde::Serialize;
/// A copy-ready integration snippet for one client.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GatewayTemplate {
/// Stable identifier (e.g. `"cursor"`).
pub id: String,
/// Human-facing name (e.g. `"Cursor"`).
pub name: String,
/// Target client slug, mirrors `id` (kept for frontend parity with AQBot).
pub target: String,
/// Content format hint for the UI renderer: `"json"` | `"markdown"`.
pub format: String,
/// The snippet itself, with the live gateway origin already substituted.
pub content: String,
/// Short instruction on where to put the snippet.
pub copy_hint: Option<String>,
}
/// Build the full template list for a given gateway `origin`
/// (scheme + host + optional port, no trailing slash, e.g.
/// `https://api.boos.lat`).
///
/// URL conventions differ per client:
/// - OpenAI-compatible clients (Cursor, VS Code Continue, generic) want the
/// `/v1` base (`{origin}/v1`).
/// - Claude Code's `ANTHROPIC_BASE_URL` must be the bare origin — it appends
/// `/v1/messages` itself, so a `/v1` suffix would double it.
pub fn list_templates(origin: &str) -> Vec<GatewayTemplate> {
let origin = origin.trim_end_matches('/');
let openai_base = format!("{origin}/v1");
vec![
GatewayTemplate {
id: "cursor".to_string(),
name: "Cursor".to_string(),
target: "cursor".to_string(),
format: "json".to_string(),
content: format!(
"{{\n \"openai.apiKey\": \"YOUR_KEY\",\n \"openai.apiBaseUrl\": \"{openai_base}\"\n}}"
),
copy_hint: Some("Add to Cursor User settings.json".to_string()),
},
GatewayTemplate {
id: "vscode".to_string(),
name: "VS Code Continue".to_string(),
target: "vscode".to_string(),
format: "json".to_string(),
content: format!(
"{{\n \"models\": [{{\n \"title\": \"BoosAPI Gateway\",\n \"provider\": \"openai\",\n \"apiBase\": \"{openai_base}\",\n \"apiKey\": \"YOUR_KEY\"\n }}]\n}}"
),
copy_hint: Some("Add to ~/.continue/config.json".to_string()),
},
GatewayTemplate {
id: "claude_code".to_string(),
name: "Claude Code".to_string(),
target: "claude_code".to_string(),
format: "markdown".to_string(),
// Claude Code appends `/v1/messages` to ANTHROPIC_BASE_URL, so the
// base is the bare origin (no `/v1`).
content: format!(
"export ANTHROPIC_AUTH_TOKEN=\"YOUR_KEY\"\nexport ANTHROPIC_BASE_URL=\"{origin}\""
),
copy_hint: Some("Add to your shell profile (~/.bashrc or ~/.zshrc)".to_string()),
},
GatewayTemplate {
id: "openai_compatible".to_string(),
name: "OpenAI Compatible".to_string(),
target: "openai_compatible".to_string(),
format: "json".to_string(),
content: format!(
"{{\n \"api_key\": \"YOUR_KEY\",\n \"base_url\": \"{openai_base}\",\n \"model\": \"gpt-4\"\n}}"
),
copy_hint: Some("Use these settings in any OpenAI-compatible client".to_string()),
},
]
}
/// Look up a single template's content by id for the given `origin`.
/// Ports AQBot's `copy_gateway_template`.
pub fn find_template(id: &str, origin: &str) -> Option<GatewayTemplate> {
list_templates(origin).into_iter().find(|t| t.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_all_four_templates() {
let templates = list_templates("https://api.boos.lat");
let ids: Vec<&str> = templates.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["cursor", "vscode", "claude_code", "openai_compatible"]);
}
#[test]
fn openai_clients_get_v1_base() {
let templates = list_templates("https://api.boos.lat");
for id in ["cursor", "vscode", "openai_compatible"] {
let t = templates.iter().find(|t| t.id == id).unwrap();
assert!(
t.content.contains("https://api.boos.lat/v1"),
"{id} should embed the /v1 base, got: {}",
t.content
);
}
}
#[test]
fn claude_code_base_has_no_v1_suffix() {
// Claude Code appends /v1/messages itself; a /v1 base would double it.
let t = find_template("claude_code", "https://api.boos.lat").unwrap();
assert!(t.content.contains("ANTHROPIC_BASE_URL=\"https://api.boos.lat\""));
assert!(!t.content.contains("api.boos.lat/v1"));
}
#[test]
fn trailing_slash_in_origin_is_normalized() {
let t = find_template("cursor", "https://api.boos.lat/").unwrap();
assert!(t.content.contains("https://api.boos.lat/v1"));
assert!(!t.content.contains("//v1"));
}
#[test]
fn unknown_id_returns_none() {
assert!(find_template("nope", "https://api.boos.lat").is_none());
}
#[test]
fn templates_serialize_to_expected_shape() {
let t = find_template("cursor", "http://localhost:4000").unwrap();
let value = serde_json::to_value(&t).unwrap();
assert_eq!(value["id"], "cursor");
assert_eq!(value["format"], "json");
assert!(value["content"].as_str().unwrap().contains("localhost:4000/v1"));
assert!(value["copy_hint"].is_string());
}
}
+122 -35
View File
@@ -589,7 +589,8 @@ pub async fn execute_video_generation(
}
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
route.base_url
route
.base_url
.strip_prefix("comfy://")
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
.or_else(|| route.model.strip_prefix("comfy://"))
@@ -614,14 +615,16 @@ async fn execute_comfy_video_generation(
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
.await
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
let mut prompt = workflow_prompt_from_graph(&workflow.graph).ok_or_else(|| {
AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into())
})?;
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
return Err(AppError::BadRequest(
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
));
}
let mut mapping = comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
let mut mapping =
comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
mapping = infer_comfy_mapping(&prompt);
}
@@ -644,7 +647,9 @@ async fn execute_comfy_video_generation(
.or_else(|| response.pointer("/data/prompt_id"))
.or_else(|| response.pointer("/data/promptId")),
)
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}")))?;
.ok_or_else(|| {
AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}"))
})?;
Ok(json!({
"id": prompt_id,
@@ -732,7 +737,8 @@ fn scalar_string(value: Option<&Value>) -> Option<String> {
}
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
graph.get("prompt")
graph
.get("prompt")
.cloned()
.or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned())
@@ -741,7 +747,8 @@ fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
}
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
graph.pointer("/metadata/moneyPrinter/nodeMapping")
graph
.pointer("/metadata/moneyPrinter/nodeMapping")
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
.or_else(|| graph.pointer("/video/nodeMapping"))
@@ -772,11 +779,24 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
.unwrap_or_default();
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, _from_port)) = from.split_once('.') else { continue; };
let Some((to_id, to_field)) = to.split_once('.') else { continue; };
for edge in graph
.get("edges")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(from) = edge.get("from").and_then(Value::as_str) else {
continue;
};
let Some(to) = edge.get("to").and_then(Value::as_str) else {
continue;
};
let Some((from_id, _from_port)) = from.split_once('.') else {
continue;
};
let Some((to_id, to_field)) = to.split_once('.') else {
continue;
};
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
@@ -806,7 +826,12 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
mapping_value(mapping, keys).and_then(|value| {
if value.is_object() {
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
scalar_string(
value
.get("nodeId")
.or_else(|| value.get("node_id"))
.or(Some(value)),
)
} else {
scalar_string(Some(value))
}
@@ -829,7 +854,10 @@ fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
}
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
fn first_node_by_class(
prompt: &serde_json::Map<String, Value>,
class_type: &str,
) -> Option<String> {
prompt
.iter()
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
@@ -843,11 +871,22 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
let prompt_node = object
.iter()
.find(|(_, node)| {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
let class_type = node
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
let inputs = node
.get("inputs")
.and_then(Value::as_object)
.map(|value| value.keys().cloned().collect::<Vec<_>>().join(" ").to_ascii_lowercase())
.map(|value| {
value
.keys()
.cloned()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
})
.unwrap_or_default();
class_type.contains("text")
|| class_type.contains("prompt")
@@ -875,19 +914,29 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
if node_id.trim().is_empty() {
return Err(AppError::BadRequest("Comfy 视频模型节点映射包含空 nodeId".into()));
return Err(AppError::BadRequest(
"Comfy 视频模型节点映射包含空 nodeId".into(),
));
}
let Some(node) = prompt.get_mut(node_id) else {
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
return Err(AppError::BadRequest(format!(
"Comfy prompt missing node {node_id}"
)));
};
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
return Err(AppError::BadRequest(format!(
"Comfy node {node_id} has no inputs"
)));
};
inputs.insert(field.to_string(), value);
Ok(())
}
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value) -> AppResult<String> {
fn inject_comfy_video_inputs(
prompt: &mut Value,
mapping: &Value,
body: &Value,
) -> AppResult<String> {
let video_prompt = scalar_string(body.get("prompt"))
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
@@ -895,27 +944,56 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value)
apply_node_input(
prompt,
&prompt_node,
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
&mapping_field(
mapping,
&["prompt", "positive", "text", "promptNode"],
"prompt",
),
json!(video_prompt),
)?;
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
if let Some(negative_node) =
mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"])
{
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
.unwrap_or_else(|| {
"low quality, blurry, distorted, watermark, text artifacts".to_string()
});
apply_node_input(
prompt,
&negative_node,
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
&mapping_field(
mapping,
&["negativePrompt", "negative", "negativeNode"],
"text",
),
json!(negative),
)?;
}
for (keys, request_keys, default_field) in [
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
(
&["ratio", "ratioNode"][..],
&["ratio", "aspectRatio", "aspect_ratio"][..],
"ratio",
),
(
&["resolution", "resolutionNode"][..],
&["resolution", "size"][..],
"resolution",
),
(
&["duration", "durationNode"][..],
&["duration"][..],
"duration",
),
] {
if let Some(node_id) = mapping_node_id(mapping, keys) {
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
apply_node_input(
prompt,
&node_id,
&mapping_field(mapping, keys, default_field),
value,
)?;
}
}
}
@@ -929,9 +1007,13 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
.get("class_type")
.and_then(Value::as_str)
.is_some_and(|value| value == class_type)
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|| object
.values()
.any(|value| prompt_contains_class_type(value, class_type))
}
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
Value::Array(items) => items
.iter()
.any(|value| prompt_contains_class_type(value, class_type)),
_ => false,
}
}
@@ -1024,7 +1106,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
if matches!(
status.as_str(),
"failed" | "error" | "cancelled" | "canceled"
) {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
@@ -1038,10 +1123,12 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
fn comfy_output_objects(value: &Value) -> bool {
match value {
Value::Object(object) => {
["videos", "gifs", "images"]
.iter()
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|| object.values().any(comfy_output_objects)
["videos", "gifs", "images"].iter().any(|key| {
object
.get(*key)
.and_then(Value::as_array)
.is_some_and(|items| !items.is_empty())
}) || object.values().any(comfy_output_objects)
}
Value::Array(items) => items.iter().any(comfy_output_objects),
_ => false,