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:
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod templates;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum CliProfileKind {
|
||||
Codex,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user