feat: Proxy extensions — /v1/completions + /v1/embeddings

- Added generic execute_proxy_request executor for any path suffix
- POST /v1/completions — forwards to upstream /completions
- POST /v1/embeddings — forwards to upstream /embeddings
- Both use existing auth + route resolution infra
- 18/18 tests pass
This commit is contained in:
boos
2026-05-17 16:59:43 +08:00
parent 4ac9c2385f
commit 84b4b16ebd
2 changed files with 85 additions and 0 deletions
+41
View File
@@ -120,6 +120,47 @@ pub async fn execute_chat_completions_stream(
Ok(chunks) Ok(chunks)
} }
/// Generic non-streaming proxy request to any upstream endpoint path.
pub async fn execute_proxy_request(
_state: AppState,
route: UpstreamRoute,
path_suffix: &str,
body: Value,
) -> AppResult<Value> {
let client = http_client();
let upstream_url = format!(
"{}/{}",
route.base_url.trim_end_matches('/'),
path_suffix.trim_start_matches('/')
);
let resp = client
.post(&upstream_url)
.header("Authorization", format!("Bearer {}", route.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
if !status.is_success() {
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
let json: Value = serde_json::from_str(&text)
.map_err(|e| AppError::UpstreamError(format!("Invalid JSON from upstream: {e}")))?;
Ok(json)
}
/// Execute an image generation request. /// Execute an image generation request.
pub async fn execute_image_generation( pub async fn execute_image_generation(
_state: AppState, _state: AppState,
+44
View File
@@ -45,6 +45,8 @@ pub enum DownstreamSource {
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/v1/chat/completions", post(chat_completions)) .route("/v1/chat/completions", post(chat_completions))
.route("/v1/completions", post(completions_handler))
.route("/v1/embeddings", post(embeddings_handler))
.route("/v1/images/generations", post(images_generations)) .route("/v1/images/generations", post(images_generations))
.route("/v1/models", get(list_models)) .route("/v1/models", get(list_models))
} }
@@ -82,6 +84,48 @@ async fn chat_completions(
} }
} }
/// POST /v1/completions
async fn completions_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/completions");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_proxy_request(state, route, "/completions", body).await?;
Ok(Json(result))
}
/// POST /v1/embeddings
async fn embeddings_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/embeddings");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_proxy_request(state, route, "/embeddings", body).await?;
Ok(Json(result))
}
/// POST /v1/images/generations /// POST /v1/images/generations
async fn images_generations( async fn images_generations(
State(state): State<AppState>, State(state): State<AppState>,