Zum Hauptinhalt springen
Die meisten KI-Anwendungen beginnen damit, eine Modell-API direkt aufzurufen. Für Prototypen funktioniert das gut, doch sobald mehrere Anwendungen, Dienste oder Kunden Zugriff benötigen, werden direkte Provider-Aufrufe schwerer zu verwalten. Jeder Dienst braucht einen Provider-Schlüssel, jeder Client muss providerspezifisches Verhalten lernen, und jedes Team löst Authentifizierung, Limits und Observability leicht unterschiedlich. Ein LLM-Gateway bietet uns einen zentralen Ort, an dem wir Aufrufer authentifizieren, Rate-Limits durchsetzen, vorgelagerte Provider-Schlüssel verbergen, Telemetrie aufzeichnen und eine stabile API für unsere eigenen Anwendungen bereitstellen können. In diesem Tutorial bauen wir eines in Rust mit Axum, Postgres, SQLx und der Venice-AI-API. Am Ende haben Sie ein Gateway, das einen OpenAI-kompatiblen /v1/chat/completions-Endpunkt bereitstellt, Ihre eigenen Bearer-Token akzeptiert, Anfragen an Venice weiterleitet, Streaming-Antworten unterstützt und nützliche OpenTelemetry-Spans und -Metriken ausgibt. Interessiert an der vollständigen Code-Implementierung? Schauen Sie sich das GitHub-Repo an.

Voraussetzungen

  • Rust 1.92+
  • Docker und Docker Compose
  • Ein Venice-API-Schlüssel
  • curl
  • Grundlegende Vertrautheit mit Rust-Web-Services
Bevor wir starten, exportieren Sie Ihren Venice-API-Schlüssel:
export VENICE_API_KEY="your-venice-api-key"
Wir werden diesen Schlüssel niemals gegenüber Client-Anwendungen offenlegen. Das Gateway hält ihn serverseitig, und Clients authentifizieren sich stattdessen mit gateway-spezifischen API-Schlüsseln.

Was wir bauen

Die Referenzimplementierung ist ein kleiner Rust-Dienst mit einigen klar abgegrenzten Teilen:
TeilWas er tut
Axum-RouterBedient /healthz und /v1/chat/completions
Auth-ExtractorValidiert Gateway-Bearer-Token gegen gehashte Schlüssel in Postgres
Rate-LimiterVerwendet feste Fenster, die in Postgres gespeichert sind
Venice-ClientLeitet nicht-streaming und streaming Chat-Completion-Anfragen weiter
TelemetrieZeichnet GenAI-Spans, Token-Nutzung, Venice-Abrechnungskosten, Latenz und Streaming-Timings auf
Docker ComposeFührt Postgres und das Gateway lokal aus
Architekturdiagramm, das einen Client zeigt, der das Rust-Gateway, Postgres, Venice AI und OpenTelemetry aufruft Ein Client sendet eine OpenAI-kompatible Anfrage an das Gateway. Das Gateway authentifiziert den Aufrufer, prüft Rate-Limits, leitet die Anfrage an Venice weiter und zeichnet dabei Telemetriedaten auf. Als Teil des Gateways stellen wir sicher, dass dieser Dienst horizontal skalierbar bleibt und die API selbst eine möglichst geringe Angriffsfläche bietet. Dafür gibt es mehrere Gründe – einer der wichtigsten ist, dass Sie bei sehr hohem Durchsatz mit hoher Wahrscheinlichkeit Replicas verwenden möchten (also mehr als eine Instanz desselben Dienstes hochfahren). Das bedeutet, dass Sie architektonisch – falls noch nicht geschehen – Ihren ursprünglichen Dienst und die Replicas hinter einem Load Balancer platzieren möchten, damit der gesamte Dienst nicht ausfällt, wenn ein Container oder Dienst ausfällt. Darüber hinaus gehen wir davon aus, dass wir die API-Schlüsselerstellung in irgendeiner Form selbst besitzen, obwohl der Gateway-Dienst diese nicht isoliert erzeugen sollte. Dies wird als Postgres-Tabelle dargestellt, die wir bei lokaler Nutzung mit Testdaten befüllen. In der Produktion würde dies typischerweise vom Authentifizierungsdienst gehandhabt. Zwar ist es möglich, die Erstellung eines API-Schlüssels stromaufwärts für jeden Benutzer Ihres LLM-Gateways zu übernehmen, in der Praxis ist dies jedoch generell nicht empfehlenswert. Indem Sie diese Verantwortung an den vorgelagerten Dienst auslagern, geben Sie auch jegliche Kontrolle ab, die Sie normalerweise hätten – was bedeutet, dass Sie Dinge wie Rate-Limits und Ausgabenobergrenzen nicht vollständig durchsetzen können. Der Quellcode-Baum bleibt bewusst klein:
.
├── Cargo.toml
├── Dockerfile
├── docker-compose.yml
├── migrations/
│   └── 0001_api_keys.sql
├── scripts/
│   ├── seed_api_key.sh
│   └── smoke_chat.sh
└── src/
    ├── auth.rs
    ├── config.rs
    ├── error.rs
    ├── main.rs
    ├── rate_limit.rs
    ├── router.rs
    ├── sse.rs
    ├── state.rs
    ├── telemetry.rs
    └── venice.rs
Ohne weitere Umschweife legen wir los.

Erstellen des Rust-Dienstes

Beginnen Sie mit einem neuen Rust-Binärprojekt:
cargo new llm-gateway
cd llm-gateway
Fügen Sie die benötigten Abhängigkeiten in Cargo.toml hinzu – Erklärungen sind im Codeausschnitt ergänzt:
[dependencies]
# Web backend
axum = "0.8.9"
tower = "0.5.3"
tower-http = { version = "0.6.11", features = ["trace", "request-id", "sensitive-headers", "timeout", "limit"] }

## Observability
opentelemetry = "0.32.0"
opentelemetry-otlp = "0.32.0"
opentelemetry_sdk = "0.32.1"
tracing = "0.1.44"
tracing-opentelemetry = "0.33.0"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }

# HTTP request/response handling
reqwest = { version = "0.12.28", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "charset"] }
reqwest-eventsource = "0.6.0"
bytes = "1.11.1"
futures-util = "0.3.32"

# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"

# Hashing/crypto
sha2 = "0.11.0"
subtle = "2.6.1"

# SQL
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "time", "macros", "migrate"] }

# utils
thiserror = "2.0.18" # a crate for writing ergonomic errors
time = { version = "0.3.47", features = ["serde", "formatting", "macros"] } # time
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "net", "time"] } # the most popular async Rust runtime
uuid = { version = "1.23.3", features = ["serde", "v4"] }

Laden der Konfiguration

Das Gateway liest alles aus Umgebungsvariablen. Für Infrastrukturcode wie diesen sind Umgebungsvariablen eine gute Standardwahl, weil dieselbe Binärdatei lokal, in Docker Compose oder in einer gehosteten Umgebung ausgeführt werden kann, ohne dass ein separates Konfigurationsformat nötig ist. Zudem erlauben viele Anbieter, eigene Umgebungsvariablen als Secrets in ihrer Container-Laufzeit zu hinterlegen. Das ist oft deutlich sicherer, als etwas wie dotenv (oder dotenvy in Rust, da die ursprüngliche dotenv-Crate weitgehend veraltet ist) zu verwenden. Erstellen Sie src/config.rs:
use std::{env, net::SocketAddr, str::FromStr, time::Duration};

#[derive(Clone, Debug)]
pub struct Config {
    pub bind_addr: SocketAddr,
    pub database_url: String,
    pub venice_api_key: String,
    pub venice_base_url: String,
    pub service_name: String,
    pub otlp_endpoint: Option<String>,
    pub request_timeout: Duration,
    pub request_body_limit_bytes: usize,
    pub venice_max_retries: u32,
    pub capture_genai_content: bool,
}

impl Config {
    pub fn from_env() -> Result<Self, ConfigError> {
        Ok(Self {
            bind_addr: parse_env("BIND_ADDR", "0.0.0.0:3000")?,
            database_url: required_env("DATABASE_URL")?,
            venice_api_key: required_env("VENICE_API_KEY")?,
            venice_base_url: env::var("VENICE_BASE_URL")
                .unwrap_or_else(|_| "https://api.venice.ai/api/v1".to_owned()),
            service_name: env::var("OTEL_SERVICE_NAME")
                .unwrap_or_else(|_| "llm-gateway".to_owned()),
            otlp_endpoint: optional_nonempty_env("OTEL_EXPORTER_OTLP_ENDPOINT"),
            request_timeout: Duration::from_secs(parse_env("REQUEST_TIMEOUT_SECONDS", "120")?),
            request_body_limit_bytes: parse_env("REQUEST_BODY_LIMIT_BYTES", "1048576")?,
            venice_max_retries: parse_env("VENICE_MAX_RETRIES", "2")?,
            capture_genai_content: parse_env("CAPTURE_GENAI_CONTENT", "false")?,
        })
    }
}
Zwar werden hier viele mögliche Werte aus Umgebungsvariablen geparst, aber im Allgemeinen benötigen Sie nur zwei:
  • Die Datenbank-URL
  • Ihren Venice-API-Schlüssel
Zwei Standardwerte sind hier wichtig. VENICE_BASE_URL zeigt auf https://api.venice.ai/api/v1, und CAPTURE_GENAI_CONTENT steht standardmäßig auf false, sodass Prompt-Inhalte nicht protokolliert werden, es sei denn, Sie aktivieren dies bewusst. Der zweite Standardwert ist der wichtigere. Ein Gateway kann jeden durchfließenden Prompt und jede Antwort sehen, aber Observability sollte nicht automatisch zur Inhaltserfassung werden. In den meisten Produktionssystemen sind Token-Zahlen, Latenz, Modellnamen, Statuscodes und Abrechnungsmetadaten für den Betrieb ausreichend. Ganz allgemein kann das Protokollieren von Prompts und Konversationen in der Produktion nicht nur eine Datenschutzhaftung darstellen – es kann auch eine Speicherhaftung sein. Ihre Aufnahme führt zur Erstellung von Spans und Traces mit extrem hoher Kardinalität (also der Einzigartigkeit der Daten innerhalb eines Datensatzes). Das kann das Durchsuchen Ihrer Observability-Daten sehr kostspielig machen und die Performance beim Durchsuchen der Daten möglicherweise beeinträchtigen.

Erstellen des Datenbankschemas

Erstellen Sie als Nächstes migrations/0001_api_keys.sql. Wir speichern nur die ersten 12 Zeichen jedes Gateway-API-Schlüssels als Nachschlage-Präfix sowie den SHA-256-Hash des vollständigen Schlüssels. So kann das Gateway eine Kandidatenzeile schnell finden, ohne rohe Zugangsdaten zu speichern. Der Präfix ist nicht geheim. Er existiert zur Indizierung. Der Hash ist das, was beweist, dass der Aufrufer den vollständigen Schlüssel vorgelegt hat. Dies ist die gleiche Grundform, die viele API-Schlüsselsysteme verwenden: Zeigen Sie den rohen Schlüssel einmal, speichern Sie eine nicht umkehrbare Darstellung und halten Sie einen kurzen Präfix für Nachschlage- und Support-Workflows bereit.
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE api_keys
  ( api_key_id uuid PRIMARY KEY DEFAULT gen_random_uuid()
  , subject_id uuid NOT NULL
  , name text NOT NULL
  , key_prefix text NOT NULL
  , key_hash bytea NOT NULL
  , rate_limit_requests integer NOT NULL DEFAULT 60
  , rate_limit_window_seconds integer NOT NULL DEFAULT 60
  , is_active boolean NOT NULL DEFAULT true
  , created_at timestamptz NOT NULL DEFAULT now()
  , last_used_at timestamptz
  , revoked_at timestamptz
  , CONSTRAINT api_keys_prefix_length CHECK (length(key_prefix) = 12)
  , CONSTRAINT api_keys_hash_sha256 CHECK (length(key_hash) = 32)
  , CONSTRAINT api_keys_rate_limit_requests_positive CHECK (rate_limit_requests > 0)
  , CONSTRAINT api_keys_rate_limit_window_positive CHECK (rate_limit_window_seconds > 0)
  , CONSTRAINT api_keys_revoked_inactive CHECK (revoked_at IS NULL OR NOT is_active)
  );

CREATE UNIQUE INDEX api_keys_key_prefix_key
ON api_keys (key_prefix);

CREATE UNIQUE INDEX api_keys_key_hash_key
ON api_keys (key_hash);
Fügen Sie nun eine Tabelle für Rate-Limiting mit festen Fenstern hinzu:
CREATE TABLE api_key_rate_limit_windows
  ( api_key_id uuid NOT NULL
      REFERENCES api_keys (api_key_id)
      ON DELETE RESTRICT
      ON UPDATE RESTRICT
  , window_start_at timestamptz NOT NULL
  , window_seconds integer NOT NULL
  , request_count integer NOT NULL
  , updated_at timestamptz NOT NULL DEFAULT now()
  , PRIMARY KEY (api_key_id, window_start_at, window_seconds)
  , CONSTRAINT api_key_rate_limit_windows_window_positive CHECK (window_seconds > 0)
  , CONSTRAINT api_key_rate_limit_windows_request_count_nonnegative CHECK (request_count >= 0)
  );
Dieses Schema ist klein, gibt uns aber die wichtigen Invarianten:
  • API-Schlüssel werden niemals im Klartext gespeichert.
  • Rate-Limit-Einstellungen müssen positiv sein.
  • Widerrufene Schlüssel können nicht aktiv bleiben.
  • Ein Rate-Limit-Fenster ist eindeutig durch Schlüssel, Startzeit und Fensterlänge identifiziert.
Diese Invarianten in Postgres zu halten ist nützlich, weil jeder Aufrufer diesen Datenbankzustand durchlaufen muss. Selbst wenn wir später eine Admin-API, einen Hintergrund-Job zur Schlüsselrotation oder eine Migration hinzufügen, die Schlüssel aus einem anderen System importiert, weist die Datenbank unmögliche Zustände wie einen aktiven Schlüssel mit einem Widerrufszeitstempel weiterhin ab.

Bauen des Venice-Clients

Als Nächstes erstellen wir src/venice.rs. Der Client muss nur die vorgelagerte Chat-Completions-URL kennen, den Venice-API-Schlüssel und wie oft transiente Fehler wiederholt werden sollen. Diesen Wrapper klein zu halten, ist Absicht – das Gateway sollte nicht die gesamte Venice-API neu implementieren. Auf grundlegender Ebene besteht die Aufgabe des Gateways darin, die serverseitige Anmeldung anzuhängen, einen Timeout anzuwenden, Anfragen, die sicher wiederholt werden können, erneut zu senden, und die vorgelagerte Antwort in einer Form zurückzugeben, die der Router weiterleiten kann.
use std::time::Duration;

use reqwest::{Client, RequestBuilder, Response, StatusCode};
use reqwest_eventsource::{EventSource, RequestBuilderExt};
use serde_json::Value;
use tokio::time::sleep;

use crate::{config::Config, error::AppError};

#[derive(Clone)]
pub struct VeniceClient {
    http: Client,
    api_key: String,
    chat_completions_url: String,
    max_retries: u32,
}

impl VeniceClient {
    pub fn new(config: &Config) -> Result<Self, AppError> {
        let http = Client::builder()
            .timeout(config.request_timeout)
            .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
            .build()?;

        Ok(Self {
            http,
            api_key: config.venice_api_key.clone(),
            chat_completions_url: format!(
                "{}/chat/completions",
                config.venice_base_url.trim_end_matches('/')
            ),
            max_retries: config.venice_max_retries,
        })
    }
}
Für nicht-streaming Anfragen können wir Verbindungsfehler, Timeouts und transiente HTTP-Statuscodes wiederholen:
impl VeniceClient {
    pub async fn chat_completions(&self, payload: &Value) -> Result<Response, AppError> {
        let mut attempt = 0;

        loop {
            let result = self.chat_request(payload).send().await;

            match result {
                Ok(response)
                    if should_retry_status(response.status()) && attempt < self.max_retries =>
                {
                    attempt += 1;
                    sleep(retry_delay(attempt)).await;
                }
                Ok(response) => return Ok(response),
                Err(error) if should_retry_error(&error) && attempt < self.max_retries => {
                    attempt += 1;
                    sleep(retry_delay(attempt)).await;
                }
                Err(error) => return Err(error.into()),
            }
        }
    }
}

fn should_retry_status(status: StatusCode) -> bool {
    matches!(
        status,
        StatusCode::REQUEST_TIMEOUT
            | StatusCode::TOO_MANY_REQUESTS
            | StatusCode::INTERNAL_SERVER_ERROR
            | StatusCode::BAD_GATEWAY
            | StatusCode::SERVICE_UNAVAILABLE
            | StatusCode::GATEWAY_TIMEOUT
    )
}
Wiederholungen werden nur auf den nicht-streaming Pfad angewendet. Sobald eine Streaming-Antwort begonnen hat, könnte eine Wiederholung innerhalb des Gateways teilweise Ausgaben duplizieren oder Clients verwirren, die bereits Chunks empfangen haben. Für Streaming ist der bessere Standard, den Fehler nach außen zu geben und den Aufrufer entscheiden zu lassen, ob die gesamte Anfrage wiederholt werden soll. Für Streaming erstellen wir eine EventSource aus derselben Anfrage:
impl VeniceClient {
    pub fn chat_completions_eventsource(&self, payload: &Value) -> Result<EventSource, AppError> {
        self.chat_request(payload)
            .eventsource()
            .map_err(|error| AppError::EventSourceSetup(error.to_string()))
    }

    fn chat_request(&self, payload: &Value) -> RequestBuilder {
        self.http
            .post(&self.chat_completions_url)
            .bearer_auth(&self.api_key)
            .json(payload)
    }
}
Der Chat-Completions-Endpunkt von Venice ist OpenAI-kompatibel, sodass das Gateway einen vertrauten Body akzeptieren kann:
{
  "model": "zai-org-glm-5-1",
  "messages": [
    {
      "role": "user",
      "content": "Say hello from behind a Rust gateway."
    }
  ]
}
Sie können das Modell gegen jedes chatfähige Modell austauschen, das für Ihr Venice-Konto verfügbar ist. Beachten Sie, dass der Anfrage-Body weiterhin ein serde_json::Value ist. Das ist eine bewusste Kompatibilitätsentscheidung. Wenn wir jedes mögliche Chat-Completion-Feld in Rust modellieren, müssen wir das Gateway jedes Mal aktualisieren, wenn die vorgelagerte API eine nützliche Option hinzufügt. Indem wir nur das parsen, was wir anderswo benötigen, lassen wir neuere Venice-Parameter ohne ein Gateway-Release durchreichen.

Gemeinsamen Anwendungszustand teilen

Erstellen Sie src/state.rs:
use std::sync::Arc;

use sqlx::PgPool;

use crate::{config::Config, error::AppError, venice::VeniceClient};

#[derive(Clone)]
pub struct AppState {
    pub config: Arc<Config>,
    pub db: PgPool,
    pub venice: VeniceClient,
}

impl AppState {
    pub fn new(config: Config, db: PgPool) -> Result<Self, AppError> {
        let venice = VeniceClient::new(&config)?;

        Ok(Self {
            config: Arc::new(config),
            db,
            venice,
        })
    }
}
Axum kloniert den State in Handler hinein, daher sollte der State selbst günstig zu klonen sein. PgPool ist bereits ein gemeinsam genutztes Pool-Handle, und Arc<Config> hält die Konfiguration ebenfalls günstig. Dies gibt jedem Handler Zugriff auf dieselben drei Dinge: unveränderliche Konfiguration, gepoolte Datenbankverbindungen und den Venice-Client. Sie in einem AppState zu bündeln, erleichtert später auch das Testen, weil Handler ihre Abhängigkeiten über den Axum-State erhalten, statt Globals zu lesen.

Authentifizierung von Gateway-API-Schlüsseln

Der Client sendet seinen Gateway-Schlüssel so:
Authorization: Bearer llmg_dev_0123456789abcdef
Erstellen Sie src/auth.rs und implementieren Sie einen Axum-Extractor. Der Extractor erlaubt geschützten Handlern, zu deklarieren, dass sie einen authentifizierten Schlüssel benötigen:
use axum::{
    extract::FromRequestParts,
    http::{HeaderMap, header, request::Parts},
};
use sha2::{Digest, Sha256};
use sqlx::{FromRow, PgPool};
use subtle::ConstantTimeEq;
use uuid::Uuid;

use crate::{error::AppError, state::AppState};

const KEY_PREFIX_BYTES: usize = 12;

#[derive(Clone, Debug)]
pub struct AuthenticatedApiKey {
    pub api_key_id: Uuid,
    pub subject_id: Uuid,
    pub rate_limit_requests: i32,
    pub rate_limit_window_seconds: i32,
}

impl FromRequestParts<AppState> for AuthenticatedApiKey {
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        authenticate(&state.db, &parts.headers).await
    }
}
Der eigentliche Authentifizierungsablauf ist:
  1. Bearer-Token parsen.
  2. Die ersten 12 Bytes als Schlüsselpräfix nehmen.
  3. Das vollständige Kandidaten-Token mit SHA-256 hashen.
  4. Die aktive Schlüsselzeile per Präfix laden.
  5. Gespeicherten Hash und Kandidaten-Hash in konstanter Zeit vergleichen.
async fn authenticate(db: &PgPool, headers: &HeaderMap) -> Result<AuthenticatedApiKey, AppError> {
    let token = bearer_token(headers)?;
    let key_prefix = token
        .get(..KEY_PREFIX_BYTES)
        .ok_or(AppError::Unauthorized)?;
    let candidate_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();

    let stored = sqlx::query_as::<_, StoredApiKey>(
        r#"
        SELECT api_key_id, subject_id, key_hash, rate_limit_requests, rate_limit_window_seconds
        FROM api_keys
        WHERE key_prefix = $1
          AND is_active
          AND revoked_at IS NULL
        "#,
    )
    .bind(key_prefix)
    .fetch_optional(db)
    .await?;

    let stored = stored.ok_or(AppError::Unauthorized)?;

    if stored.key_hash.ct_eq(candidate_hash.as_slice()).unwrap_u8() != 1 {
        return Err(AppError::Unauthorized);
    }

    Ok(AuthenticatedApiKey {
        api_key_id: stored.api_key_id,
        subject_id: stored.subject_id,
        rate_limit_requests: stored.rate_limit_requests,
        rate_limit_window_seconds: stored.rate_limit_window_seconds,
    })
}
Dadurch bleiben vorgelagerte und Gateway-Zugangsdaten getrennt. Ihre Produktionsanwendungen können Gateway-Schlüssel rotieren, ohne den Venice-API-Schlüssel zu ändern, und der Venice-Schlüssel muss den Server niemals verlassen. Das Extractor-Muster ist hilfreich, weil die Authentifizierung Teil der Typsignatur des Handlers wird. Eine Route, die AuthenticatedApiKey akzeptiert, kann die Authentifizierung nicht versehentlich innerhalb des Funktionskörpers überspringen; Axum muss diesen Wert konstruieren, bevor der Handler läuft. Das macht den geschützten Pfad leicht überprüfbar.

Feste-Fenster-Rate-Limits hinzufügen

Erstellen Sie src/rate_limit.rs. Der Rate-Limiter verwendet eine SQL-Anweisung, um ein neues Fenster einzufügen oder das bestehende zu inkrementieren:
pub async fn check(db: &PgPool, api_key: &AuthenticatedApiKey) -> Result<(), AppError> {
    let window = sqlx::query_as::<_, RateLimitWindow>(
        r#"
        WITH current_window AS
          ( SELECT
              to_timestamp(
                floor(extract(epoch FROM now()) / $2::double precision) * $2
              )::timestamptz AS window_start_at
          )
        INSERT INTO api_key_rate_limit_windows
          (api_key_id, window_start_at, window_seconds, request_count)
        SELECT $1, current_window.window_start_at, $2, 1
        FROM current_window
        ON CONFLICT (api_key_id, window_start_at, window_seconds)
        DO UPDATE
        SET request_count = api_key_rate_limit_windows.request_count + 1,
            updated_at = now()
        WHERE api_key_rate_limit_windows.request_count < $3
        RETURNING request_count, window_start_at
        "#,
    )
    .bind(api_key.api_key_id)
    .bind(api_key.rate_limit_window_seconds)
    .bind(api_key.rate_limit_requests)
    .fetch_optional(db)
    .await?;

    let _window = window.ok_or_else(|| AppError::RateLimited {
            retry_after: retry_after(api_key.rate_limit_window_seconds),
    })?;

    Ok(())
}
Die WHERE api_key_rate_limit_windows.request_count < $3-Klausel ist der entscheidende Teil. Wenn das Fenster bereits voll ist, aktualisiert Postgres die Zeile nicht und RETURNING liefert keine Zeile. Der Handler kann das in eine 429 Too Many Requests-Antwort mit einem Retry-After-Header umwandeln. Ein festes Fenster ist nicht der ausgefeilteste Rate-Limiter, aber er ist leicht zu erklären, leicht zu inspizieren und für ein Gateway-Tutorial gut genug. Der Kompromiss ist, dass sich Traffic um die Fenstergrenzen herum stauen kann. Wenn Sie bei Skalierung ein gleichmäßigeres Verhalten benötigen, ist ein Token-Bucket- oder Sliding-Window-Limiter mit Redis-Backing ein natürlicher nächster Schritt.

Fehler im OpenAI-Stil zurückgeben

Erstellen Sie src/error.rs und lassen Sie Anwendungsfehler IntoResponse implementieren:
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("missing or invalid bearer token")]
    Unauthorized,
    #[error("rate limit exceeded")]
    RateLimited { retry_after: Duration },
    #[error("bad request: {0}")]
    BadRequest(String),
    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),
    #[error("upstream request failed: {0}")]
    UpstreamTransport(#[from] reqwest::Error),
    #[error("failed to create upstream event source: {0}")]
    EventSourceSetup(String),
    #[error("upstream event source failed: {0}")]
    EventSource(String),
    #[error("upstream returned an error")]
    Upstream {
        status: StatusCode,
        body: serde_json::Value,
    },
}
Für vom Gateway generierte Fehler geben Sie einen JSON-Body im Stil gängiger Modell-API-Fehler zurück:
{
  "error": {
    "message": "rate limit exceeded",
    "type": "rate_limit_error",
    "code": null
  }
}
Für vorgelagerte Venice-Fehler bewahren Sie den vorgelagerten Statuscode und Body. Das erleichtert das Debugging für Clients erheblich, weil provider-seitige Validierungsfehler weiterhin wie provider-seitige Validierungsfehler aussehen. Diese Aufteilung hält das Gateway ehrlich darüber, woher ein Fehler kam. Wenn das Gateway eine Anfrage ablehnt, weil das Bearer-Token fehlt oder der Aufrufer über dem Limit ist, gibt es einen Gateway-geformten Fehler zurück. Wenn Venice die Modellanfrage ablehnt, bewahren wir den vorgelagerten Body, damit Client-Entwickler die Validierungsnachricht des Providers sehen können, anstatt einen generischen Proxy-Fehler.

Bauen des Routers

Nun können wir die HTTP-Routen in src/router.rs verdrahten:
use axum::{
    Json, Router,
    body::Body,
    extract::{DefaultBodyLimit, State},
    http::{StatusCode, header::{CACHE_CONTROL, CONTENT_TYPE}},
    response::{IntoResponse, Response},
    routing::{get, post},
};
use serde::Serialize;
use serde_json::Value;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;

use crate::{
    auth,
    error::AppError,
    rate_limit, sse,
    state::AppState,
    telemetry::{self, GenAiRequest},
};

pub fn build(state: AppState) -> Router {
    let body_limit = state.config.request_body_limit_bytes;

    Router::new()
        .route("/healthz", get(healthz))
        .route("/v1/chat/completions", post(chat_completions))
        .with_state(state)
        .layer(DefaultBodyLimit::max(body_limit))
        .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
}

async fn healthz() -> impl IntoResponse {
    Json(Health { status: "ok" })
}

#[derive(Serialize)]
struct Health {
    status: &'static str,
}
Der Chat-Handler beginnt damit, einen AuthenticatedApiKey zu verlangen. Wenn die Authentifizierung fehlschlägt, betritt Axum den Handler-Body niemals:
async fn chat_completions(
    api_key: auth::AuthenticatedApiKey,
    State(state): State<AppState>,
    Json(payload): Json<Value>,
) -> Result<Response, AppError> {
    rate_limit::check(&state.db, &api_key).await?;

    let request = GenAiRequest::from_payload(&payload).map_err(AppError::BadRequest)?;
    let span = telemetry::span_for_request(&request);

    if request.stream {
        let eventsource = state.venice.chat_completions_eventsource(&payload)?;
        let stream = sse::observe_eventsource(
            eventsource,
            request,
            span,
            None,
            api_key.api_key_id,
        );

        return Ok(Response::builder()
            .status(StatusCode::OK)
            .header(CONTENT_TYPE, "text/event-stream")
            .header(CACHE_CONTROL, "no-cache")
            .body(Body::from_stream(stream))
            .expect("static streaming response headers are valid"));
    }

    let upstream = state.venice.chat_completions(&payload).await?;
    let status = upstream.status();

    if !status.is_success() {
        let bytes = upstream.bytes().await?;
        return Err(AppError::Upstream {
            status,
            body: upstream_body_from_bytes(&bytes),
        });
    }

    let bytes = upstream.bytes().await?;
    let metadata = telemetry::metadata_from_bytes(&bytes);
    span.in_scope(|| {
        telemetry::record_response(&request, &span, &metadata, None);
        telemetry::record_billing(api_key.api_key_id, &metadata);
    });

    Ok(Response::builder()
        .status(StatusCode::OK)
        .header(CONTENT_TYPE, "application/json")
        .body(Body::from(bytes))
        .expect("static json response headers are valid"))
}
Das Gateway validiert nur die Felder, die es für das Gateway-Verhalten benötigt: model, messages und stream. Alles andere im JSON-Body wird an Venice weitergereicht. Das hält das Gateway kompatibel mit Provider-Funktionen, die Sie später verwenden möchten. Der Handler macht auch die beiden Antwortmodi explizit. Nicht-streaming Anfragen warten, bis Venice eine vollständige JSON-Antwort zurückgibt, und zeichnen dann die Antwortmetadaten auf, bevor sie die Bytes stromabwärts senden. Streaming-Anfragen geben sofort mit einem text/event-stream-Body zurück, der von einem async Stream unterstützt wird. Diese Aufteilung hält den nicht-streaming Pfad einfach und gibt dem Streaming-Pfad genug Kontrolle, um Chunks während des Durchflusses zu beobachten.

Streaming-Antworten unterstützen

Streaming-Chat-Completions verwenden Server-Sent Events. Venice sendet SSE-Daten, und das Gateway leitet diese Daten an den Client zurück. Das Gateway sollte es vermeiden, den gesamten Stream zu puffern, weil das den Zweck des Streamings zunichtemachen würde. Nutzern ist die Zeit bis zum ersten Token wichtig, nicht nur die Zeit bis zum letzten Token. Indem jedes vorgelagerte Event beim Eintreffen weitergeleitet wird, können Clients Teilausgaben rendern, während das Modell noch generiert. Erstellen Sie src/sse.rs:
use bytes::Bytes;
use futures_util::{Stream, StreamExt, stream};
use reqwest_eventsource::{Event, EventSource};
use uuid::Uuid;

use crate::{
    error::AppError,
    telemetry::{self, GenAiRequest, GenAiResponseMetadata},
};

pub fn observe_eventsource(
    eventsource: EventSource,
    request: GenAiRequest,
    span: tracing::Span,
    pending_event: Option<Event>,
    api_key_id: Uuid,
) -> impl Stream<Item = Result<Bytes, AppError>> + Send + 'static {
    let state = StreamState {
        eventsource,
        pending_event,
        request,
        span,
        api_key_id,
        metadata: GenAiResponseMetadata::default(),
        first_chunk_at: None,
        previous_chunk_at: None,
    };

    stream::unfold(state, |mut state| async move {
        loop {
            let event = match state.pending_event.take() {
                Some(event) => Some(Ok(event)),
                None => state.eventsource.next().await,
            };

            match event {
                Some(Ok(Event::Open)) => continue,
                Some(Ok(Event::Message(message))) => {
                    let bytes = state.observe_message(&message.data);
                    if message.data == "[DONE]" {
                        state.eventsource.close();
                    }
                    return Some((Ok(bytes), state));
                }
                Some(Err(error)) => {
                    return Some((Err(AppError::EventSource(error.to_string())), state));
                }
                None => {
                    telemetry::record_response(&state.request, &state.span, &state.metadata, None);
                    telemetry::record_billing(state.api_key_id, &state.metadata);
                    return None;
                }
            }
        }
    })
}
Jede Nachricht wird zurück ins SSE-Format kodiert:
fn encode_sse_data(data: &str) -> Bytes {
    let mut encoded = String::new();
    for line in data.lines() {
        encoded.push_str("data: ");
        encoded.push_str(line);
        encoded.push('\n');
    }
    if data.is_empty() {
        encoded.push_str("data: \n");
    }
    encoded.push('\n');
    Bytes::from(encoded)
}
Dies bewahrt das Client-Erlebnis, das OpenAI-kompatible SDKs erwarten: Chunks kommen als data: ...-Events an, und der Stream endet mit data: [DONE]. Der Stream-Beobachter ist auch der Ort, an dem wir Metadaten sammeln können, ohne zu ändern, was der Client sieht. Jeder Chunk wird im SSE-Format weitergeleitet, aber das Gateway kann trotzdem auf Antwort-IDs, Finish-Reasons, Token-Nutzung, Kostenfelder und Timing-Informationen achten, während diese Chunks durchfließen.

Aufzeichnen von GenAI-Telemetrie

Gateways sind nützlich, weil jede Anfrage einen zentralen Ort passiert. Das macht sie zu einem großartigen Ort, um Modell, Latenz, Token-Nutzung, Finish-Reasons, Abrechnungskosten und Streaming-Timings aufzuzeichnen. Erstellen Sie src/telemetry.rs und beginnen Sie mit dem Parsen der Anfrage:
#[derive(Clone, Debug)]
pub struct GenAiRequest {
    pub model: String,
    pub stream: bool,
    pub started_at: Instant,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct GenAiResponseMetadata {
    pub response_id: Option<String>,
    pub response_model: Option<String>,
    pub finish_reasons: Vec<String>,
    pub input_tokens: Option<u64>,
    pub output_tokens: Option<u64>,
    pub cost_diem: Option<f64>,
    pub cost_usd: Option<f64>,
}

impl GenAiRequest {
    pub fn from_payload(payload: &Value) -> Result<Self, String> {
        let model = payload
            .get("model")
            .and_then(Value::as_str)
            .filter(|model| !model.trim().is_empty())
            .ok_or_else(|| "request body must include a non-empty model".to_owned())?;

        let messages = payload
            .get("messages")
            .and_then(Value::as_array)
            .filter(|messages| !messages.is_empty())
            .ok_or_else(|| "request body must include at least one message".to_owned())?;

        if messages
            .iter()
            .any(|message| message.get("role").and_then(Value::as_str).is_none())
        {
            return Err("every message must include a role".to_owned());
        }

        Ok(Self {
            model: model.to_owned(),
            stream: payload
                .get("stream")
                .and_then(Value::as_bool)
                .unwrap_or(false),
            started_at: Instant::now(),
        })
    }
}
Erstellen Sie anschließend einen Span mit GenAI-Semantik-Attributen:
pub fn span_for_request(request: &GenAiRequest) -> Span {
    tracing::info_span!(
        "gen_ai.client",
        "otel.name" = %format!("chat {}", request.model),
        "otel.kind" = "client",
        "gen_ai.operation.name" = "chat",
        "gen_ai.provider.name" = "venice",
        "gen_ai.request.model" = %request.model,
        "gen_ai.request.stream" = request.stream,
        "server.address" = "api.venice.ai",
        "gen_ai.response.id" = tracing::field::Empty,
        "gen_ai.response.model" = tracing::field::Empty,
        "gen_ai.response.finish_reasons" = tracing::field::Empty,
        "gen_ai.response.time_to_first_chunk" = tracing::field::Empty,
        "gen_ai.usage.input_tokens" = tracing::field::Empty,
        "gen_ai.usage.output_tokens" = tracing::field::Empty,
        "venice.cost.diem" = tracing::field::Empty,
        "venice.cost.usd" = tracing::field::Empty,
        "error.type" = tracing::field::Empty,
        "otel.status_code" = tracing::field::Empty,
    )
}
Wenn eine nicht-streaming Antwort zurückkommt, deserialisieren Sie die bekannten Antwort-Metadatenfelder in Structs. Das Gateway leitet weiterhin die ursprünglichen Bytes an den Client weiter, aber die Telemetrie muss kein beliebiges JSON durchwandern. Das Abrechnungs-Log verwendet die UUID des Gateway-Schlüssels statt des Klartext-Bearer-Tokens, und die Anfrage-ID stammt aus der id der Venice-Antwort:
#[derive(Debug, Default, Deserialize)]
struct ChatCompletionResponse {
    id: Option<String>,
    model: Option<String>,
    #[serde(default)]
    choices: Vec<ChatCompletionChoice>,
    usage: Option<ChatCompletionUsage>,
    cost: Option<VeniceCost>,
}

#[derive(Debug, Deserialize)]
struct ChatCompletionChoice {
    finish_reason: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ChatCompletionUsage {
    prompt_tokens: Option<u64>,
    input_tokens: Option<u64>,
    completion_tokens: Option<u64>,
    output_tokens: Option<u64>,
}

#[derive(Debug, Deserialize)]
struct VeniceCost {
    #[serde(default, deserialize_with = "optional_finite_f64")]
    diem: Option<f64>,
    #[serde(default, deserialize_with = "optional_finite_f64")]
    usd: Option<f64>,
}

pub fn metadata_from_bytes(bytes: &[u8]) -> GenAiResponseMetadata {
    serde_json::from_slice::<ChatCompletionResponse>(bytes)
        .map(GenAiResponseMetadata::from)
        .unwrap_or_default()
}

pub fn record_billing(api_key_id: Uuid, metadata: &GenAiResponseMetadata) {
    let (Some(cost_diem), Some(cost_usd)) = (metadata.cost_diem, metadata.cost_usd) else {
        return;
    };

    tracing::info!(
        api_key_id = %api_key_id,
        request_id = metadata.response_id.as_deref().unwrap_or("unknown"),
        cost_diem,
        cost_usd,
        "venice.billing"
    );
}
Für Streaming-Antworten zeichnen Sie die Zeit bis zum ersten Chunk und die Zeit zwischen Ausgabe-Chunks auf, während der SSE-Stream weitergeleitet wird. Diese Metriken sind besonders nützlich, wenn Ihnen die wahrgenommene Latenz wichtig ist, nicht nur die Gesamtanfragedauer. Telemetrie ist der Punkt, an dem ein Gateway mehr wird als ein Proxy. Sobald Spans das angeforderte Modell, das vorgelagerte Modell, Token-Zahlen, Finish-Reasons, Status und Abrechnungs-Logs pro Schlüssel enthalten, können Sie praktische betriebliche Fragen beantworten: Welche Clients geben am meisten aus, welche Modelle sind am langsamsten, verbessert Streaming die wahrgenommene Latenz, und kommen Fehler von Auth, Rate-Limits, Transport oder dem Modell-Provider.

Server starten

Nun verdrahten wir alles in src/main.rs:
mod auth;
mod config;
mod error;
mod rate_limit;
mod router;
mod sse;
mod state;
mod telemetry;
mod venice;

use sqlx::postgres::PgPoolOptions;
use tokio::net::TcpListener;
use tracing::info;

use crate::{config::Config, state::AppState};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let config = Config::from_env()?;
    let _telemetry = telemetry::init(&config)?;

    let db = PgPoolOptions::new()
        .max_connections(10)
        .connect(&config.database_url)
        .await?;

    sqlx::migrate!("./migrations").run(&db).await?;

    let bind_addr = config.bind_addr;
    let state = AppState::new(config, db)?;
    let app = router::build(state);
    let listener = TcpListener::bind(bind_addr).await?;

    info!(%bind_addr, "starting gateway");

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    Ok(())
}
Beim Start führt das Gateway Folgendes durch:
  1. Konfiguration lesen.
  2. Telemetrie initialisieren.
  3. Mit Postgres verbinden.
  4. SQLx-Migrationen ausführen.
  5. Gemeinsamen App-State bauen.
  6. Axum-Server starten.
Migrationen beim Start auszuführen, ist für dieses Tutorial praktisch, weil docker compose up den gesamten Stack in einen funktionierenden Zustand bringen kann. In einem größeren Produktions-Deployment bevorzugen Sie es möglicherweise, Migrationen als separaten Release-Schritt auszuführen, sodass Schemaänderungen überprüft und angewendet werden, bevor neue Gateway-Instanzen starten.

Einen lokalen Gateway-Schlüssel seeden

Für die lokale Entwicklung erstellen Sie scripts/seed_api_key.sh. Das Skript fügt einen Gateway-API-Schlüssel in Postgres ein, indem es dessen Präfix und SHA-256-Hash speichert:
#!/bin/sh
set -eu

DATABASE_URL="${DATABASE_URL:-postgres://llm_gateway:llm_gateway@localhost:5432/llm_gateway}"
GATEWAY_API_KEY="${GATEWAY_API_KEY:-llmg_dev_0123456789abcdef}"
SUBJECT_ID="${SUBJECT_ID:-00000000-0000-0000-0000-000000000001}"

psql "$DATABASE_URL" \
  -v ON_ERROR_STOP=1 \
  -v gateway_api_key="$GATEWAY_API_KEY" \
  -v subject_id="$SUBJECT_ID" <<'SQL'
INSERT INTO api_keys
  (subject_id, name, key_prefix, key_hash, rate_limit_requests, rate_limit_window_seconds)
VALUES
  ( :'subject_id'::uuid
  , 'local development key'
  , left(:'gateway_api_key', 12)
  , digest(:'gateway_api_key', 'sha256')
  , 60
  , 60
  )
ON CONFLICT (key_prefix)
DO UPDATE
SET key_hash = EXCLUDED.key_hash,
    is_active = true,
    revoked_at = NULL;
SQL
Der Standard-Schlüssel für die lokale Nutzung lautet:
llmg_dev_0123456789abcdef
Für ein echtes Deployment generieren Sie längere zufällige Schlüssel, zeigen sie dem Aufrufer einmal an und speichern nur den Hash. Das Seed-Skript ist bewusst langweilig, weil lokale Zugangsdaten leicht neu erstellbar sein sollten. Die Produktionsversion ist dort, wo Sie stärkere Schlüsselerzeugung, Audit-Logging, Ablauf und einen einmaligen Anzeigefluss hinzufügen würden.

Lokal ausführen

Für die lokale Ausführung verwenden wir Docker Compose, um sowohl das Gateway als auch Postgres zu starten. Dadurch bleibt das Tutorial reproduzierbar: Leser benötigen keine manuell konfigurierte Datenbank, und das Gateway kann dieselbe DATABASE_URL-Form verwenden, die es in einem containerisierten Deployment verwenden würde.
name: llm-gateway

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: llm_gateway
      POSTGRES_USER: llm_gateway
      POSTGRES_PASSWORD: llm_gateway
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U llm_gateway -d llm_gateway"]
      interval: 5s
      timeout: 5s
      retries: 10

  gateway:
    build: .
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      BIND_ADDR: 0.0.0.0:3000
      DATABASE_URL: postgres://llm_gateway:llm_gateway@postgres:5432/llm_gateway
      OTEL_SERVICE_NAME: llm-gateway
      VENICE_API_KEY: ${VENICE_API_KEY:?set VENICE_API_KEY in your shell or .env}
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
      interval: 5s
      timeout: 5s
      retries: 10
Wir benötigen außerdem ein kleines Dockerfile, das die Rust-Binärdatei baut und in ein kleineres Runtime-Image kopiert:
FROM rust:1.92-bookworm AS builder

WORKDIR /app

COPY Cargo.toml Cargo.lock ./
COPY migrations ./migrations
COPY src ./src

RUN cargo build --release

FROM debian:bookworm-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/llm-gateway /usr/local/bin/llm-gateway

EXPOSE 3000

CMD ["llm-gateway"]
Um den Stack auszuführen, verwenden Sie den folgenden Befehl:
docker compose up --build
Vergessen Sie nicht, dass Sie dies auch mit dem -d-Flag im Hintergrund ausführen können, falls Sie Ihr Terminal danach für andere Dinge nutzen möchten (und dann docker compose down verwenden, um ihn zu entfernen). Seeden Sie in einem anderen Terminal den Entwicklungs-Gateway-Schlüssel:
docker compose --profile seed run --rm seed
Falls auf Ihrem Rechner bereits Postgres auf Port 5432 läuft, entfernen Sie das Host-Port-Mapping für den Compose-Postgres-Service. Das Gateway muss Postgres nur im internen Docker-Netzwerk erreichen. Das Wichtige ist, dass der Venice-API-Schlüssel nur in die Gateway-Umgebung gehört. Client-Anfragen sollten den geseedeten Gateway-Schlüssel verwenden. Diese Trennung ist der eigentliche Sinn, ein Gateway vor den Modell-Provider zu setzen.

Das Gateway testen

Prüfen Sie zunächst die Gesundheit:
curl http://localhost:3000/healthz
Sie sollten sehen:
{"status":"ok"}
Senden Sie nun eine nicht-streaming Chat-Completion-Anfrage:
curl http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer llmg_dev_0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama-3.3-70b",
    "messages": [
      {
        "role": "user",
        "content": "Reply with one short sentence saying the gateway works."
      }
    ]
  }'
Die Antwort sollte wie eine OpenAI-kompatible Chat-Completion aussehen:
{
  "id": "chatcmpl-...",
  "object": "chat.completion",
  "model": "llama-3.3-70b",
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "The gateway is working."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 123,
    "completion_tokens": 5,
    "total_tokens": 128
  },
  "cost": {
    "diem": 0.42,
    "usd": 0.0012
  }
}
Für Streaming:
curl -N http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer llmg_dev_0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama-3.3-70b",
    "stream": true,
    "messages": [
      {
        "role": "user",
        "content": "Write one short sentence confirming streaming works."
      }
    ]
  }'
Sie sollten SSE-Chunks sehen:
data: {"id":"chatcmpl-...","object":"chat.completion.chunk",...}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk",...}

data: [DONE]
Das Repo enthält auch ein Smoke-Test-Skript:
sh ./scripts/smoke_chat.sh
Für lokale Code-Qualitätsprüfungen führen Sie aus:
cargo fmt --check
cargo test
cargo clippy --all-targets --all-features -- -D warnings
Beide Antwortmodi zu testen ist wichtig, weil sie unterschiedliche Proxy-Pfade beanspruchen. Der nicht-streaming Test beweist, dass Auth, Rate-Limiting, vorgelagerte Weiterleitung und JSON-Antwort-Telemetrie funktionieren. Der Streaming-Test beweist, dass das Gateway eine SSE-Verbindung offenhalten und Chunks weiterleiten kann, ohne zuvor die endgültige Antwort zu puffern.

Dieses Gateway erweitern

Dieses Gateway ist bewusst klein, gibt Ihnen aber ein solides Fundament. Gute nächste Schritte sind:
  • Budgets pro Subjekt und monatliche Ausgabenlimits hinzufügen.
  • Unterstützung mehrerer vorgelagerter Provider hinter derselben OpenAI-kompatiblen API.
  • Anfragemetadaten für Audit-Logs speichern, wobei das Logging von Prompts standardmäßig deaktiviert bleibt.
  • Eine Admin-API zum Erstellen, Widerrufen und Rotieren von Gateway-Schlüsseln hinzufügen.
  • Modell-Allowlists pro API-Schlüssel hinzufügen.
  • Redis oder einen anderen gemeinsamen Speicher hinzufügen, wenn Sie ein Rate-Limiting mit geringerer Latenz über viele Gateway-Instanzen benötigen.
Der zentrale Designgedanke ist, Policy im Gateway und Inference in Venice zu halten. Das lässt Client-Anwendungen eine vertraute API nutzen, während Ihre Plattform die Kontrolle über Schlüssel, Nutzung, Limits und Observability behält.

Abschluss

Vielen Dank fürs Lesen! Hoffentlich hat Ihnen dies geholfen zu sehen, wie man ein praktisches LLM-Gateway in Rust baut, ohne es in ein riesiges Plattform-Projekt zu verwandeln. Durch die Kombination von Axum, Postgres, SQLx, OpenTelemetry und Venices OpenAI-kompatibler Chat-Completions-API können wir ein Gateway bauen, das klein genug ist, um verstanden zu werden, und nützlich genug, um vor echten Anwendungen zu stehen.