mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-22 08:00:51 +00:00
Admin UI UX fixes plus the route-dispatch feature set: - Providers: selecting a provider shows the full key/options form immediately instead of hiding it behind a "+ Add key" button; "Cancel" becomes "Reset". - Routes: enabled routes show a ready-to-run curl snippet (endpoint URL from the new proxy_port on /admin/api/status, route name as the model) with a copy button. - Settings: live proxy status badge (running/unreachable) backed by a proxy_running TCP liveness check on /admin/api/status, plus a per-save "applied live" toast. - Backend: plumb proxy listen_port into SharedState; expose proxy_port + proxy_running. - Also includes the RouteRouter dispatch layer, per-route option overrides, local-LLM backend support, favorites, and model discovery (see CHANGELOG [Unreleased]). Ignore .gate/ security-scanner scratch artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
6.3 KiB
Rust
184 lines
6.3 KiB
Rust
// Integration test for admin-DB route dispatch: a request whose `model` is
|
|
// served by a route must be dispatched to that route's managed backend, not the
|
|
// default backend. Exercises the real HTTP path through
|
|
// `AppState::resolve_model` -> `RouteRouter` -> managed_backends client.
|
|
|
|
use anyllm_proxy::admin::state::SharedState;
|
|
use anyllm_proxy::backend::BackendClient;
|
|
use anyllm_proxy::config::route_router::RouteRouter;
|
|
use anyllm_proxy::config::{
|
|
BackendAuth, BackendConfig, BackendKind, ModelMapping, MultiConfig, OpenAIApiFormat, TlsConfig,
|
|
};
|
|
use anyllm_proxy::server::routes;
|
|
use axum::{extract::State, response::IntoResponse, routing::post, Json, Router};
|
|
use indexmap::IndexMap;
|
|
use serde_json::{json, Value};
|
|
use std::sync::{Arc, RwLock};
|
|
use tokio::net::TcpListener;
|
|
|
|
/// Spawn a stub OpenAI chat-completions backend that always answers with `content`.
|
|
async fn spawn_stub(content: &'static str) -> String {
|
|
async fn handler(
|
|
State(content): State<&'static str>,
|
|
Json(_): Json<Value>,
|
|
) -> impl IntoResponse {
|
|
Json(json!({
|
|
"id": "chatcmpl-stub",
|
|
"object": "chat.completion",
|
|
"created": 1_700_000_000u64,
|
|
"model": "gpt-4o",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": content},
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
|
}))
|
|
}
|
|
let app = Router::new()
|
|
.route("/v1/chat/completions", post(handler))
|
|
.with_state(content);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
fn openai_backend_config(base_url: String) -> BackendConfig {
|
|
BackendConfig {
|
|
kind: BackendKind::OpenAI,
|
|
provider_id: Some("openai".to_string()),
|
|
api_key: "test-key".to_string(),
|
|
base_url,
|
|
api_format: OpenAIApiFormat::Chat,
|
|
model_mapping: ModelMapping {
|
|
big_model: String::new(),
|
|
small_model: String::new(),
|
|
},
|
|
tls: TlsConfig::default(),
|
|
backend_auth: BackendAuth::BearerToken("test-key".to_string()),
|
|
log_bodies: false,
|
|
omit_stream_options: false,
|
|
stream_timeout_secs: 900,
|
|
bedrock_credentials: None,
|
|
allow_local_ssrf: true,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn route_dispatches_to_managed_backend_over_default() {
|
|
std::env::set_var("PROXY_OPEN_RELAY", "true");
|
|
|
|
let default_url = spawn_stub("DEFAULT-WRONG").await;
|
|
let routed_url = spawn_stub("ROUTED-OK").await;
|
|
|
|
// Build shared state with a route that serves "routed-model" via a managed backend.
|
|
let shared = SharedState::new_for_test();
|
|
let routed_bc = openai_backend_config(routed_url.clone());
|
|
let row = anyllm_proxy::admin::db::ManagedBackendRow {
|
|
id: "be-routed".to_string(),
|
|
name: "routed-be".to_string(),
|
|
provider_id: "openai".to_string(),
|
|
api_key: Some("test-key".to_string()),
|
|
api_base: Some(routed_url.clone()),
|
|
deployment: None,
|
|
api_version: None,
|
|
project: None,
|
|
region: None,
|
|
aws_access_key_id: None,
|
|
aws_secret_access_key: None,
|
|
aws_session_token: None,
|
|
rpm: None,
|
|
tpm: None,
|
|
enabled: true,
|
|
created_at: "t".to_string(),
|
|
updated_at: "t".to_string(),
|
|
};
|
|
let route = anyllm_proxy::admin::db::RouteRow {
|
|
id: "rt-1".to_string(),
|
|
name: "primary".to_string(),
|
|
description: None,
|
|
strategy: "failover".to_string(),
|
|
rpm: None,
|
|
tpm: None,
|
|
budget_usd: None,
|
|
enabled: true,
|
|
guardrail_mode: None,
|
|
pxpipe_compress: None,
|
|
pxpipe_models: None,
|
|
redact_secrets: None,
|
|
position: 0,
|
|
created_at: "t".to_string(),
|
|
updated_at: "t".to_string(),
|
|
};
|
|
|
|
let route_router = {
|
|
let conn = shared.db.lock().unwrap();
|
|
anyllm_proxy::admin::db::insert_managed_backend(&conn, &row).unwrap();
|
|
anyllm_proxy::admin::db::insert_route(&conn, &route).unwrap();
|
|
anyllm_proxy::admin::db::add_route_provider(
|
|
&conn,
|
|
"rt-1",
|
|
"be-routed",
|
|
&["routed-model".to_string()],
|
|
0,
|
|
true,
|
|
)
|
|
.unwrap();
|
|
RouteRouter::build_from_db(&conn).unwrap()
|
|
};
|
|
|
|
let mut shared = shared;
|
|
shared.route_router = Some(Arc::new(RwLock::new(route_router)));
|
|
{
|
|
let mut map = shared.managed_backends.write().unwrap();
|
|
map.insert(
|
|
"routed-be".to_string(),
|
|
(row.clone(), BackendClient::from_backend_config(&routed_bc)),
|
|
);
|
|
}
|
|
|
|
// Default backend points at the "wrong" stub; routing must override it.
|
|
let mut backends = IndexMap::new();
|
|
backends.insert("default".to_string(), openai_backend_config(default_url));
|
|
let config = MultiConfig {
|
|
listen_port: 0,
|
|
log_bodies: false,
|
|
redact_secrets: false,
|
|
anthropic_thinking_repair: false,
|
|
pxpipe_compress: false,
|
|
forward_client_auth: false,
|
|
default_backend: "default".to_string(),
|
|
backends,
|
|
expose_degradation_warnings: false,
|
|
};
|
|
|
|
let app = routes::app_multi_with_shared(config, Some(shared), None, None, None, None);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
let proxy = format!("http://{addr}");
|
|
|
|
let resp = reqwest::Client::new()
|
|
.post(format!("{proxy}/v1/messages"))
|
|
.header("x-api-key", "test")
|
|
.json(&json!({
|
|
"model": "routed-model",
|
|
"max_tokens": 32,
|
|
"messages": [{"role": "user", "content": "hi"}]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let body = resp.text().await.unwrap();
|
|
assert!(
|
|
body.contains("ROUTED-OK"),
|
|
"route should dispatch to the managed backend; got: {body}"
|
|
);
|
|
assert!(
|
|
!body.contains("DEFAULT-WRONG"),
|
|
"route must override the default backend; got: {body}"
|
|
);
|
|
}
|