feat: merge opus comfyui and login updates
This commit is contained in:
+309
-13
@@ -17,7 +17,7 @@ use crate::{
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
extract::{Multipart, State},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response, Sse},
|
||||
routing::{get, post},
|
||||
@@ -25,7 +25,7 @@ use axum::{
|
||||
};
|
||||
use futures::{stream, FutureExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
use std::{convert::Infallible, sync::Arc, time::Instant};
|
||||
use tracing::info;
|
||||
|
||||
// ── Auth types ────────────────────────────────────────────────────────
|
||||
@@ -58,6 +58,7 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/v1/audio/translations", post(audio_translations))
|
||||
.route("/v1/audio/speech", post(audio_speech))
|
||||
.route("/v1/images/generations", post(images_generations))
|
||||
.route("/v1/images/edits", post(images_edits))
|
||||
.route("/v1/videos", post(videos_handler))
|
||||
.route("/v1/models", get(list_models))
|
||||
// ── ComfyUI shim aliases ──────────────────────────────────────
|
||||
@@ -70,6 +71,10 @@ pub fn routes() -> Router<AppState> {
|
||||
"/api/comfyui-agent/proxy/openai/images/generations",
|
||||
post(images_generations),
|
||||
)
|
||||
.route(
|
||||
"/api/comfyui-agent/proxy/openai/images/edits",
|
||||
post(images_edits),
|
||||
)
|
||||
.route(
|
||||
"/api/comfyui-agent/proxy/openai/v1/responses",
|
||||
post(responses_handler),
|
||||
@@ -321,6 +326,98 @@ async fn images_generations(
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/images/edits
|
||||
///
|
||||
/// `multipart/form-data` request: one or more image binaries
|
||||
/// (`image`, `image[]`, `mask`) plus text fields (`prompt`, `model`,
|
||||
/// `n`, `size`, `response_format`, `user`, `quality`, …). Mirrors
|
||||
/// /images/generations for auth + routing + billing, but rebuilds a
|
||||
/// fresh upstream multipart form rather than streaming the raw body —
|
||||
/// that lets the route override `model` with the conductor-resolved
|
||||
/// upstream model and skip frontend-only fields.
|
||||
async fn images_edits(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/images/edits");
|
||||
|
||||
let key_id = check_auth(&state, &headers).await?;
|
||||
|
||||
let mut fields: Vec<executors::MultipartField> = Vec::new();
|
||||
let mut model: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("invalid multipart body: {e}")))?
|
||||
{
|
||||
let name = field
|
||||
.name()
|
||||
.ok_or_else(|| AppError::BadRequest("multipart field missing name".into()))?
|
||||
.to_string();
|
||||
let filename = field.file_name().map(str::to_string);
|
||||
let content_type = field.content_type().map(str::to_string);
|
||||
|
||||
// OpenAI's spec lists exactly which fields carry binaries; everything
|
||||
// else is a text field. We tag a field as binary when it has a
|
||||
// filename _or_ a non-text content-type — covers both well-formed
|
||||
// SDKs and minimal curl uploads.
|
||||
let is_file = filename.is_some()
|
||||
|| content_type
|
||||
.as_deref()
|
||||
.map(|ct| !ct.starts_with("text/") && ct != "application/x-www-form-urlencoded")
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_file {
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("failed to read upload: {e}")))?
|
||||
.to_vec();
|
||||
fields.push(executors::MultipartField {
|
||||
name,
|
||||
bytes: Some(bytes),
|
||||
filename,
|
||||
content_type,
|
||||
text: None,
|
||||
});
|
||||
} else {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("failed to read field: {e}")))?;
|
||||
if name == "model" {
|
||||
model = Some(text.clone());
|
||||
}
|
||||
fields.push(executors::MultipartField {
|
||||
name,
|
||||
bytes: None,
|
||||
filename: None,
|
||||
content_type: None,
|
||||
text: Some(text),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let model = model.ok_or_else(|| AppError::BadRequest("model is required".into()))?;
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
|
||||
// Override the multipart `model` field with the conductor-resolved
|
||||
// upstream model name (route may map "dall-e-3" → upstream-specific id).
|
||||
if route.model != model {
|
||||
for field in fields.iter_mut() {
|
||||
if field.name == "model" && field.bytes.is_none() {
|
||||
field.text = Some(route.model.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = executors::execute_image_edit(state.clone(), route, fields).await?;
|
||||
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /v1/models
|
||||
async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
info!("GET /v1/models");
|
||||
@@ -401,6 +498,91 @@ async fn record_billing(
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_proxy_log_success(
|
||||
state: &AppState,
|
||||
key_id: Option<i64>,
|
||||
selected: &SelectedChannel,
|
||||
requested_model: &str,
|
||||
latency_ms: Option<i64>,
|
||||
prompt_tokens: i64,
|
||||
completion_tokens: i64,
|
||||
cost: f64,
|
||||
attempts: usize,
|
||||
is_stream: bool,
|
||||
) {
|
||||
if let Err(err) = sqlx::query(
|
||||
"INSERT INTO proxy_logs (
|
||||
route_id, channel_id, account_id, downstream_api_key_id,
|
||||
model_requested, model_actual, status, http_status,
|
||||
latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost,
|
||||
retry_count, is_stream
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'success', 200, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(selected.route_id)
|
||||
.bind(selected.channel_id)
|
||||
.bind(selected.account_id)
|
||||
.bind(key_id)
|
||||
.bind(requested_model)
|
||||
.bind(&selected.actual_model)
|
||||
.bind(latency_ms)
|
||||
.bind(prompt_tokens)
|
||||
.bind(completion_tokens)
|
||||
.bind(prompt_tokens + completion_tokens)
|
||||
.bind(cost)
|
||||
.bind(attempts.saturating_sub(1) as i64)
|
||||
.bind(if is_stream { 1 } else { 0 })
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
%err,
|
||||
model = requested_model,
|
||||
channel_id = selected.channel_id,
|
||||
"failed to record proxy_logs success entry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_proxy_log_failure(
|
||||
state: &AppState,
|
||||
key_id: Option<i64>,
|
||||
selected: Option<&SelectedChannel>,
|
||||
requested_model: &str,
|
||||
status: Option<u16>,
|
||||
latency_ms: Option<i64>,
|
||||
error_message: Option<&str>,
|
||||
attempts: usize,
|
||||
is_stream: bool,
|
||||
) {
|
||||
if let Err(err) = sqlx::query(
|
||||
"INSERT INTO proxy_logs (
|
||||
route_id, channel_id, account_id, downstream_api_key_id,
|
||||
model_requested, model_actual, status, http_status,
|
||||
latency_ms, error_message, retry_count, is_stream
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'error', ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(selected.and_then(|item| item.route_id))
|
||||
.bind(selected.map(|item| item.channel_id))
|
||||
.bind(selected.and_then(|item| item.account_id))
|
||||
.bind(key_id)
|
||||
.bind(requested_model)
|
||||
.bind(selected.map(|item| item.actual_model.as_str()))
|
||||
.bind(status.map(i64::from))
|
||||
.bind(latency_ms)
|
||||
.bind(error_message.unwrap_or("proxy request failed"))
|
||||
.bind(attempts.saturating_sub(1) as i64)
|
||||
.bind(if is_stream { 1 } else { 0 })
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
%err,
|
||||
model = requested_model,
|
||||
"failed to record proxy_logs failure entry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `usage` block out of an OpenAI-shaped response. Returns
|
||||
/// (prompt_tokens, completion_tokens). Missing fields default to 0.
|
||||
fn extract_usage(response: &Value) -> (i64, i64) {
|
||||
@@ -419,6 +601,10 @@ fn extract_usage(response: &Value) -> (i64, i64) {
|
||||
(prompt, completion)
|
||||
}
|
||||
|
||||
fn elapsed_ms_since(started_at: Instant) -> i64 {
|
||||
started_at.elapsed().as_millis().min(i64::MAX as u128) as i64
|
||||
}
|
||||
|
||||
// ── Route Resolution ──────────────────────────────────────────────────
|
||||
|
||||
/// Resolve upstream endpoint + API key for a given model.
|
||||
@@ -491,6 +677,7 @@ async fn execute_chat_completions_with_conductor(
|
||||
model: String,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let started_at = Instant::now();
|
||||
let conductor = build_conductor(&state);
|
||||
let billing_state = state.clone();
|
||||
let billing_model = model.clone();
|
||||
@@ -527,10 +714,55 @@ async fn execute_chat_completions_with_conductor(
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = conductor_result_to_app_result(result)?;
|
||||
let (prompt, completion) = extract_usage(&response);
|
||||
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await;
|
||||
Ok(response)
|
||||
match result {
|
||||
ExecuteResult::Success {
|
||||
selected,
|
||||
response,
|
||||
attempts,
|
||||
} => {
|
||||
let (prompt, completion) = extract_usage(&response);
|
||||
record_proxy_log_success(
|
||||
&billing_state,
|
||||
key_id,
|
||||
&selected,
|
||||
&billing_model,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
prompt,
|
||||
completion,
|
||||
0.0,
|
||||
attempts,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await;
|
||||
Ok(response)
|
||||
}
|
||||
ExecuteResult::Failure {
|
||||
reason,
|
||||
selected,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
} => {
|
||||
record_proxy_log_failure(
|
||||
&billing_state,
|
||||
key_id,
|
||||
selected.as_ref(),
|
||||
&billing_model,
|
||||
status,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
raw_error_text.as_deref(),
|
||||
attempts,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
Err(AppError::UpstreamError(format!(
|
||||
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
|
||||
status,
|
||||
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_chat_completions_stream_with_conductor(
|
||||
@@ -539,6 +771,7 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
model: String,
|
||||
body: Value,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let started_at = Instant::now();
|
||||
let conductor = build_conductor(&state);
|
||||
let billing_state = state.clone();
|
||||
let billing_model = model.clone();
|
||||
@@ -575,19 +808,50 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
})
|
||||
.await;
|
||||
|
||||
let chunks = match conductor_result_to_app_result(result)? {
|
||||
Value::Array(chunks) => chunks
|
||||
let (selected, attempts, chunks) = match result {
|
||||
ExecuteResult::Success {
|
||||
selected,
|
||||
response,
|
||||
attempts,
|
||||
} => match response {
|
||||
Value::Array(chunks) => (selected, attempts, chunks
|
||||
.into_iter()
|
||||
.map(|chunk| {
|
||||
chunk.as_str().map(str::to_string).ok_or_else(|| {
|
||||
AppError::UpstreamError("Invalid streaming response from conductor".into())
|
||||
})
|
||||
})
|
||||
.collect::<AppResult<Vec<String>>>()?,
|
||||
_ => {
|
||||
return Err(AppError::UpstreamError(
|
||||
"Invalid streaming response from conductor".into(),
|
||||
))
|
||||
.collect::<AppResult<Vec<String>>>()?),
|
||||
_ => {
|
||||
return Err(AppError::UpstreamError(
|
||||
"Invalid streaming response from conductor".into(),
|
||||
))
|
||||
}
|
||||
},
|
||||
ExecuteResult::Failure {
|
||||
reason,
|
||||
selected,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
} => {
|
||||
record_proxy_log_failure(
|
||||
&billing_state,
|
||||
key_id,
|
||||
selected.as_ref(),
|
||||
&billing_model,
|
||||
status,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
raw_error_text.as_deref(),
|
||||
attempts,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
|
||||
status,
|
||||
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -611,6 +875,19 @@ async fn execute_chat_completions_stream_with_conductor(
|
||||
}
|
||||
})
|
||||
.unwrap_or((0, 0));
|
||||
record_proxy_log_success(
|
||||
&billing_state,
|
||||
key_id,
|
||||
&selected,
|
||||
&billing_model,
|
||||
Some(elapsed_ms_since(started_at)),
|
||||
prompt,
|
||||
completion,
|
||||
0.0,
|
||||
attempts,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await;
|
||||
Ok(chunks)
|
||||
}
|
||||
@@ -881,6 +1158,25 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// The channel-selection query joins through site_api_endpoints to
|
||||
// pick a per-site override URL. We don't need any rows — the
|
||||
// COALESCE falls back to sites.url when this table is empty —
|
||||
// but the table itself must exist or the SELECT errors out and
|
||||
// the conductor sees `None` (NoChannel) instead of an attempt
|
||||
// failure that bumps fail_count.
|
||||
sqlx::query(
|
||||
"CREATE TABLE site_api_endpoints (
|
||||
id INTEGER PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
cooldown_until TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO downstream_api_keys (id, name, key, enabled) VALUES (1, 'test', 'sk-test', 1)")
|
||||
.execute(&pool)
|
||||
|
||||
Reference in New Issue
Block a user