commit cc3be179942864f02722bb78db405771d2f1ee1d Author: Markus Fritsche Date: Fri Jul 24 08:15:18 2026 +0200 Initial commit: llm-proxy (de-hardcoded, portable) Zero-dependency OpenAI-compatible router: OpenRouter cloud + local model-aware fanout, merged /v1/models catalog with cost tags, background free/cheap-tier auto-curation. Extracted from the fleet's unversioned /opt/llm-proxy.py (kept only as cp-backups) and de-hardcoded for portability: - config dir via LLM_PROXY_CONFIG_DIR (default /etc/llm-proxy) - default backends -> localhost (no fleet hosts); real ones in local-backends.json - friendly-name aliases -> friendly-names.json (none baked in code) - OpenRouter Referer/Title via LLM_PROXY_REFERER / LLM_PROXY_TITLE - removed the ds4-escher one-off filter and the fleet-specific helper scripts Sanitized: no fleet hostnames, IPs, names, or secrets (key comes from env). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7ecf4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.log +*.bak +*.bak-* +# real config + runtime state (only *.example.json is committed) +local-backends.json +friendly-names.json +cloud-catalog.json +last-known-models.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c5d21d --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# llm-proxy + +An OpenAI-compatible HTTP proxy that routes `/v1` requests to either OpenRouter +(cloud) or local model servers. One file, Python standard library only, no +dependencies. + +## What it does + +- Presents a single OpenAI-compatible `/v1` API in front of many backends. +- Routes each request by model id: cloud-prefixed ids go to OpenRouter; other + ids go to the local backend that advertises them. +- Serves a merged `/v1/models` list — the OpenRouter catalog plus every + reachable local server's models — with each id tagged by source and cost tier. +- Maintains the cloud catalog in the background: adds newly-free models, drops + models that stopped being free, marks unavailable ones, and discovers cheap + paid models. + +## Requirements + +- Python 3, standard library only. +- `OPENROUTER_API_KEY` in the environment for cloud routing. Without it, only + local routing works. + +## Run + + OPENROUTER_API_KEY=sk-or-... python3 llm-proxy.py 8082 + +The listen port is the first argument (default `8082`). The server binds `0.0.0.0`. + +## Configuration + +### Environment + +| Variable | Default | Purpose | +|---|---|---| +| `OPENROUTER_API_KEY` | — | Bearer key for OpenRouter. Substituted server-side; clients never send it. | +| `LLM_PROXY_CONFIG_DIR` | `/etc/llm-proxy` | Directory holding the files below. | +| `LLM_PROXY_REFERER` | `http://localhost` | `HTTP-Referer` sent to OpenRouter. | +| `LLM_PROXY_TITLE` | `llm-proxy` | `X-Title` sent to OpenRouter. | + +### Files (in `LLM_PROXY_CONFIG_DIR`) + +| File | In repo | Purpose | +|---|---|---| +| `local-backends.json` | example only | Local backends: `{name, host, port, slots}`. Copy `local-backends.example.json`. | +| `friendly-names.json` | example only | Optional display aliases `{model-id: label}`. Copy `friendly-names.example.json`. | +| `cloud-catalog.json` | no (state) | Persisted OpenRouter catalog + status. Written by the proxy. | +| `last-known-models.json` | no (state) | Last-seen model list. Written by the proxy. | + +If `local-backends.json` is absent, the proxy falls back to one `127.0.0.1:8080` +backend. + +## Endpoints + +- `POST /v1/chat/completions` — routed by model id (see Routing); streaming supported. +- `GET /v1/models` — merged local + cloud catalog. + +Resolved requests are forwarded to the chosen backend and the response streamed back. + +## Routing + +1. A **cloud-prefixed** model id (`openai/`, `anthropic/`, `google/`, + `deepseek/`, `qwen/`, `x-ai/`, …) goes to OpenRouter over HTTPS with the + server-side key. +2. A **known local** model goes to the backend whose `/v1/models` advertises it + (cached 30 s). +3. Unknown model or empty body: the first reachable local backend. + +The `/v1/models` ids carry display tags — `[$]` paid cloud, `[free]` free cloud, +`[local]` local. The tag is stripped before routing, so a client may send back +the exact id it was shown. + +## Background catalog maintenance + +A thread runs every 3 hours and: + +- adds newly-free models over 7 B parameters to the free catalog, +- removes models that are no longer free, +- probes each free model with a 1-token request and suffixes `-na` on HTTP 429, +- discovers paid models under `$0.50` / M-token from known prefixes. + +Paid models added by hand in `cloud-catalog.json` are never auto-removed. + +## Signals + +- `SIGHUP` — reload `local-backends.json`. +- `SIGUSR1` — drop discovery caches and re-check the cloud catalog now. + +## Scope + +llm-proxy has no virtual keys, budgets, caching, observability callbacks, admin +UI, or native non-OpenRouter providers. It trades those for a single +dependency-free file plus OpenRouter free/cheap-tier auto-curation. For the +larger feature set, use a full gateway such as litellm. + +## bullpen + +Contributed as an optional component of +[bullpen](https://git.reauktion.de/marfrit/bullpen). bullpen depends only on a +generic OpenAI-compatible `/v1` endpoint; llm-proxy is one way to provide it, +not a requirement. diff --git a/friendly-names.example.json b/friendly-names.example.json new file mode 100644 index 0000000..1b0512b --- /dev/null +++ b/friendly-names.example.json @@ -0,0 +1,4 @@ +{ + "some-provider/very-long-model-id-2507": "short-label", + "another-local-model-q4-k-m": "another" +} diff --git a/llm-proxy.py b/llm-proxy.py new file mode 100644 index 0000000..5030cd5 --- /dev/null +++ b/llm-proxy.py @@ -0,0 +1,1099 @@ +#!/usr/bin/env python3 +"""LLM router: model-prefix → OpenRouter (cloud) or local model-aware fanout. + +Local backends: model-name routed when possible, first-up failover otherwise. + - On POST with a known model id, the request goes to the backend whose + /v1/models advertises it (cached 30s). + - On unknown model or empty body, falls back to first-up wins, + preserving legacy semantics. + +Cloud routing: HTTPS to openrouter.ai, server-side Bearer auth from OPENROUTER_API_KEY. + +/v1/models: merged cloud catalog (live-filtered against OpenRouter's +/api/v1/models) + union of every reachable local backend's model list. +Entries are visually tagged in their id: + - "[$] vendor/model" paid cloud (manually curated or auto-discovered) + - "[free] vendor/model" free cloud (auto-detected by :free suffix) + - "[local] alias" local backend + +A periodic background thread (every 3 h) re-fetches the full OpenRouter model +list and: + a) adds newly free models >7 B parameters to the curated free catalog + b) removes models that are no longer free + c) probes each free model with a tiny 1-token request; if it returns 429 the + model id gets a "-na" suffix (not available). + d) auto-discovers cheap paid models (prompt < $0.50/MTok) from known + prefixes and adds them to the paid catalog. + +Paid models can also be added manually by editing cloud-catalog.json's "paid" +section — the background checker never removes them. + +Inbound requests have the tag + optional "-na" suffix stripped before routing, +so OWUI sending back the displayed id Just Works. + +Config: + /etc/llm-proxy/local-backends.json — list of {name, host, port} dicts + /etc/llm-proxy/cloud-catalog.json — persisted cloud model catalog + status + +Signals: + SIGHUP reload local-backends.json + SIGUSR1 drop discover caches + trigger an immediate background re-check +""" +import http.server +import http.client +import ssl +import select +import socket +import sys +import os +import os.path +import time +import json +import threading +import signal +import re +import copy +import traceback + +CONFIG_DIR = os.environ.get("LLM_PROXY_CONFIG_DIR", "/etc/llm-proxy") +LOCAL_BACKENDS_FILE = os.path.join(CONFIG_DIR, "local-backends.json") +CLOUD_CATALOG_FILE = os.path.join(CONFIG_DIR, "cloud-catalog.json") + +# Fallback only, when local-backends.json is absent. Real backends belong in +# $LLM_PROXY_CONFIG_DIR/local-backends.json (see local-backends.example.json). +DEFAULT_LOCAL_BACKENDS = [ + ("local", "127.0.0.1", 8080), +] +# Optional display aliases {"": "