lib.rs
Language Rust10.8 KB260 lines
//! A stand-in for DeepInfra (or any OpenAI-compatible server).//!//! The model id picks the behavior, so one server covers every path://!//! | model | behavior |//! |---------------------------|-------------------------------------------------------------|//! | `standin/echo` | "You said: ..." word by word; usage in a final empty chunk |//! | `standin/cached` | like echo, with 40 cached prompt tokens and a cache price |//! | `standin/inline-usage` | like echo, usage on the last content chunk |//! | `standin/slow` | 200 words, one every 50 ms (for cancelling) |//! | `standin/no-usage` | like echo, no usage at all (as most local servers) |//! | `standin/reasoning` | reasoning deltas, then the answer |//! | `standin/vision` | "I see N image(s)." |//! | `standin/midstream-error` | three words, then an error event |//! | `standin/402` `/429` `/500` | that HTTP status (429 with Retry-After: 7) |//!//! The key `bad-key` gets a 401 in DeepInfra's shape. Every request is recorded,//! and a stream the client abandons is counted in `disconnects`.use std::convert::Infallible;use std::net::SocketAddr;use std::sync::atomic::{AtomicUsize, Ordering};use std::sync::{Arc, Mutex};use std::time::Duration;use axum::body::Body;use axum::extract::State;use axum::http::{HeaderMap, StatusCode};use axum::response::{IntoResponse, Response};use axum::routing::{get, post};use axum::{Json, Router};use bytes::Bytes;use serde_json::{json, Value};use tokio::sync::mpsc;use tokio_stream::wrappers::ReceiverStream;#[derive(Debug, Clone)]pub struct Recorded {pub authorization: Option<String>,pub body: Value,}#[derive(Default)]pub struct Shared {pub requests: Mutex<Vec<Recorded>>,pub disconnects: AtomicUsize,}pub struct Standin {pub addr: SocketAddr,pub shared: Arc<Shared>,}impl Standin {/// The base URL a client should use, e.g. `http://127.0.0.1:PORT/v1`.pub fn base_url(&self) -> String {format!("http://{}/v1", self.addr)}pub fn requests(&self) -> Vec<Recorded> {self.shared.requests.lock().unwrap().clone()}pub fn disconnects(&self) -> usize {self.shared.disconnects.load(Ordering::SeqCst)}}pub fn models() -> Value {let m = |id: &str, i: f64, o: f64, cache: Option<f64>, tags: &[&str]| {let mut pricing = json!({"input_tokens": i, "output_tokens": o});if let Some(c) = cache {pricing["cache_read_tokens"] = json!(c);}json!({"id": id, "object": "model", "owned_by": "standin","metadata": {"context_length": 32768, "max_tokens": 4096, "pricing": pricing, "tags": tags}})};json!({"object": "list", "data": [m("standin/echo", 0.5, 1.5, None, &["chat"]),m("standin/cached", 0.5, 1.5, Some(0.1), &["chat", "prompt_cache"]),m("standin/inline-usage", 0.5, 1.5, None, &["chat"]),m("standin/slow", 0.5, 1.5, None, &["chat"]),m("standin/no-usage", 0.5, 1.5, None, &["chat"]),m("standin/reasoning", 0.5, 1.5, None, &["chat", "reasoning"]),m("standin/vision", 0.5, 1.5, None, &["chat", "vision"]),m("standin/midstream-error", 0.5, 1.5, None, &["chat"]),m("standin/402", 0.5, 1.5, None, &["chat"]),m("standin/429", 0.5, 1.5, None, &["chat"]),m("standin/500", 0.5, 1.5, None, &["chat"]),json!({"id": "standin/embed", "object": "model", "metadata": {"pricing": {"input_tokens": 0.01}, "tags": ["embed"]}}),json!({"id": "standin/image", "object": "model", "metadata": {"pricing": {"per_image_unit": 0.005}, "tags": ["image-gen"]}}),]})}pub fn router(shared: Arc<Shared>) -> Router {Router::new().route("/v1/models", get(list_models)).route("/v1/chat/completions", post(chat)).with_state(shared)}pub async fn spawn() -> Standin {spawn_on("127.0.0.1:0").await}pub async fn spawn_on(bind: &str) -> Standin {let shared = Arc::new(Shared::default());let listener = tokio::net::TcpListener::bind(bind).await.expect("bind");let addr = listener.local_addr().unwrap();let app = router(shared.clone());tokio::spawn(async move {axum::serve(listener, app).await.ok();});Standin { addr, shared }}fn text_of(content: &Value) -> (String, usize) {match content {Value::String(s) => (s.clone(), 0),Value::Array(parts) => {let mut text = String::new();let mut images = 0;for p in parts {match p.get("type").and_then(Value::as_str) {Some("text") => text.push_str(p.get("text").and_then(Value::as_str).unwrap_or("")),Some("image_url") => images += 1,_ => {}}}(text, images)}_ => (String::new(), 0),}}fn chunk(model: &str, delta: Value, finish: Option<&str>) -> String {json!({"id": "standin-1", "object": "chat.completion.chunk", "model": model,"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}).to_string()}/// DeepInfra's list needs no key, but a bad one gets a 401.async fn list_models(headers: HeaderMap) -> Response {if headers.get("authorization").and_then(|v| v.to_str().ok()) == Some("Bearer bad-key") {let e = json!({"detail": "User is not authorized to access this resource"});return (StatusCode::UNAUTHORIZED, Json(e)).into_response();}Json(models()).into_response()}async fn chat(State(shared): State<Arc<Shared>>, headers: HeaderMap, Json(body): Json<Value>) -> Response {let authorization = headers.get("authorization").and_then(|v| v.to_str().ok()).map(str::to_string);shared.requests.lock().unwrap().push(Recorded { authorization: authorization.clone(), body: body.clone() });if authorization.as_deref() == Some("Bearer bad-key") {let e = json!({"error": {"message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key"}});return (StatusCode::UNAUTHORIZED, Json(e)).into_response();}let model = body.get("model").and_then(Value::as_str).unwrap_or("").to_string();match model.as_str() {"standin/402" => {return (StatusCode::PAYMENT_REQUIRED, Json(json!({"error": {"message": "Insufficient balance"}}))).into_response()}"standin/429" => {return (StatusCode::TOO_MANY_REQUESTS,[("retry-after", "7")],Json(json!({"error": {"message": "Rate limited"}})),).into_response()}"standin/500" => {return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"detail": "upstream exploded"}))).into_response()}_ => {}}let messages = body.get("messages").and_then(Value::as_array).cloned().unwrap_or_default();let mut prompt_words = 0usize;let mut images = 0usize;let mut last_user = String::new();for m in &messages {let (t, n) = text_of(m.get("content").unwrap_or(&Value::Null));prompt_words += t.split_whitespace().count();images += n;if m.get("role").and_then(Value::as_str) == Some("user") {last_user = t;}}let prompt_tokens = (prompt_words + 85 * images + 4 * messages.len()) as i64;let reply: String = match model.as_str() {"standin/vision" => format!("I see {images} image{}.", if images == 1 { "" } else { "s" }),"standin/slow" => (1..=200).map(|i| format!("word{i}")).collect::<Vec<_>>().join(" "),_ => format!("You said: {last_user}"),};let words: Vec<String> = {let parts: Vec<&str> = reply.split(' ').collect();parts.iter().enumerate().map(|(i, w)| if i == 0 { w.to_string() } else { format!(" {w}") }).collect()};let completion_tokens = words.len() as i64;let want_usage = body.pointer("/stream_options/include_usage").and_then(Value::as_bool).unwrap_or(false);let (tx, rx) = mpsc::channel::<Result<Bytes, Infallible>>(8);let shared2 = shared.clone();tokio::spawn(async move {let send = |s: String| {let tx = tx.clone();async move { tx.send(Ok(Bytes::from(format!("data: {s}\n\n")))).await.is_ok() }};let usage = |cached: i64| {json!({"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens,"total_tokens": prompt_tokens + completion_tokens,"prompt_tokens_details": {"cached_tokens": cached}})};let mut ok = send(chunk(&model, json!({"role": "assistant", "content": ""}), None)).await;if model == "standin/reasoning" && ok {for r in ["Checking", " the", " question."] {ok = ok && send(chunk(&model, json!({"reasoning_content": r}), None)).await;}}let n = words.len();for (i, w) in words.into_iter().enumerate() {if !ok {break;}if model == "standin/midstream-error" && i == 3 {let _ = send(json!({"error": {"message": "model overloaded"}}).to_string()).await;return;}if model == "standin/slow" {tokio::time::sleep(Duration::from_millis(50)).await;}let last = i + 1 == n;if last && model == "standin/inline-usage" {let mut c: Value = serde_json::from_str(&chunk(&model, json!({"content": w}), Some("stop"))).unwrap();c["usage"] = usage(0);ok = send(c.to_string()).await;} else {ok = send(chunk(&model, json!({"content": w}), if last { Some("stop") } else { None })).await;}}if ok && want_usage && model != "standin/no-usage" && model != "standin/inline-usage" {let cached = if model == "standin/cached" { 40.min(prompt_tokens) } else { 0 };let mut c =json!({"id": "standin-1", "object": "chat.completion.chunk", "choices": [], "usage": usage(cached)});if model == "standin/cached" {c["usage"]["estimated_cost"] = json!(0.0000123);}ok = send(c.to_string()).await;}if ok {ok = send("[DONE]".into()).await;}if !ok {shared2.disconnects.fetch_add(1, Ordering::SeqCst);}});Response::builder().header("content-type", "text/event-stream").header("cache-control", "no-cache").body(Body::from_stream(ReceiverStream::new(rx))).unwrap()}