|
| 1 | +# open-llm-proxy on cirrus (self-hosted k3s) |
| 2 | + |
| 3 | +A second deployment of the same proxy, on the lab's own k3s cluster instead of |
| 4 | +NRP. Public endpoint: |
| 5 | + |
| 6 | + https://llm-proxy.carlboettiger.info |
| 7 | + |
| 8 | +## Config-only, by design |
| 9 | + |
| 10 | +**This directory contains no application code and changes nothing the NRP |
| 11 | +deployment reads.** The pods clone `main` and run the *same* `llm_proxy.py` as |
| 12 | +NRP; everything cirrus-specific is expressed as deployment config: |
| 13 | + |
| 14 | +- the provider set comes from a **ConfigMap mounted over `/app/config.json`** |
| 15 | + (`config-configmap.yaml`) — no `config.json` edit, no new config file in the |
| 16 | + repo, no code change; |
| 17 | +- the log destination comes from `LOG_BUCKET` + `AWS_S3_ENDPOINT_URL`, env vars |
| 18 | + the app already supported; |
| 19 | +- ingress/CORS/timeouts are Traefik CRDs, entirely outside the app. |
| 20 | + |
| 21 | +The upside is that **there is nothing to diverge**: cirrus cannot drift from |
| 22 | +NRP's code, because it has none of its own, and a change made for cirrus cannot |
| 23 | +regress NRP, because there is nothing to change. The cost is that cirrus gets |
| 24 | +only what upstream `llm_proxy.py` already does — see *Known limitations* below. |
| 25 | +That trade is deliberate: features cirrus wants should land in the shared app |
| 26 | +through the normal release cycle, not as a cirrus fork. |
| 27 | + |
| 28 | +Keep it that way. If a future cirrus need seems to require an app change, the |
| 29 | +right move is to propose that change on its own merits for both deployments — |
| 30 | +not to point these manifests at a branch. |
| 31 | + |
| 32 | +## How it differs from NRP |
| 33 | + |
| 34 | +| | NRP (`../deployment.yaml`) | cirrus (this directory) | |
| 35 | +|---|---|---| |
| 36 | +| Host | `open-llm-proxy.nrp-nautilus.io` | `llm-proxy.carlboettiger.info` | |
| 37 | +| Namespace | `biodiversity` | `llm-proxy` | |
| 38 | +| Providers | nrp ELLM, OpenRouter, Anthropic, nimbus, gemma4-nimbus, qwen3-cirrus | **OpenRouter + DSE-nimbus only** | |
| 39 | +| Ingress / CORS | HAProxy annotations | Traefik `Middleware` CRD (`middleware.yaml`) | |
| 40 | +| Log store | NRP Ceph `s3://logs-open-llm-proxy` | in-cluster MinIO `s3://logs-open-llm-proxy` | |
| 41 | +| Log tiers | raw JSONL → daily Parquet → monthly Parquet + `sessions/**` | **raw JSONL only** (see below) | |
| 42 | +| Replicas | 3, spread across nodes | 2, pinned to `cirrus` | |
| 43 | + |
| 44 | +## Models |
| 45 | + |
| 46 | +Routing is exact-match-then-prefix over the mounted config: |
| 47 | + |
| 48 | +- `qwen` → DSE-nimbus (`https://vllm-nimbus.carlboettiger.info`, |
| 49 | + `nvidia/Qwen3.6-35B-A3B-NVFP4`), with `enable_thinking` supported. |
| 50 | +- `anthropic/…`, `openai/…`, `qwen/…`, `deepseek/…` and the other vendor |
| 51 | + prefixes, plus `~…` floating aliases → OpenRouter. |
| 52 | + |
| 53 | +## Known limitations |
| 54 | + |
| 55 | +Both follow directly from the config-only rule, and both are fixable upstream |
| 56 | +whenever the shared app is touched for other reasons: |
| 57 | + |
| 58 | +1. **An unrecognized model id returns `500`, not a helpful `400`.** Upstream |
| 59 | + `get_provider_for_model` falls back to `PROVIDERS["nrp"]` unconditionally, |
| 60 | + and cirrus has no `nrp` provider, so the lookup raises. It only affects |
| 61 | + typo'd/unrouted model names; correct ids are unaffected. (Fixing it properly |
| 62 | + means making the fallback provider configurable in the shared app.) |
| 63 | +2. **No Parquet consolidation.** The daily/monthly rollup and the `sessions/**` |
| 64 | + per-turn view are implemented as ~550 lines of Python embedded in the NRP |
| 65 | + CronJob manifests, which cannot be reused without extracting them into the |
| 66 | + repo — an NRP-affecting change. So cirrus keeps **raw JSONL indefinitely**: |
| 67 | + nothing is lost, queries just get slower as volume grows, and the |
| 68 | + query-ready session view isn't available. Revisit when that extraction |
| 69 | + happens upstream. |
| 70 | + |
| 71 | +## Deploy |
| 72 | + |
| 73 | +```bash |
| 74 | +kubectl apply -f cirrus/namespace.yaml |
| 75 | +# secrets — see below, one time |
| 76 | +kubectl apply -f cirrus/config-configmap.yaml |
| 77 | +kubectl apply -f cirrus/middleware.yaml |
| 78 | +kubectl apply -f cirrus/service.yaml |
| 79 | +kubectl apply -f cirrus/deployment.yaml |
| 80 | +kubectl apply -f cirrus/ingress.yaml |
| 81 | +``` |
| 82 | + |
| 83 | +The app is **git-cloned at pod boot** (no image build), so shipping a change is: |
| 84 | + |
| 85 | +```bash |
| 86 | +kubectl -n llm-proxy rollout restart deployment/open-llm-proxy |
| 87 | +``` |
| 88 | + |
| 89 | +That picks up whatever is on `main` — the same code NRP runs. A ConfigMap edit |
| 90 | +also needs a restart (subPath mounts don't live-update). |
| 91 | + |
| 92 | +## Secrets (one time) |
| 93 | + |
| 94 | +Four secrets in the `llm-proxy` namespace: |
| 95 | + |
| 96 | +```bash |
| 97 | +# Client auth. Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))" |
| 98 | +kubectl -n llm-proxy create secret generic open-llm-proxy-secrets \ |
| 99 | + --from-literal=proxy-key='...' |
| 100 | +# optional additional revocable keys, comma-separated: |
| 101 | +# --from-literal=proxy-keys-extra='key1,key2' |
| 102 | + |
| 103 | +kubectl -n llm-proxy create secret generic openrouter-key \ |
| 104 | + --from-literal=OPENROUTER_KEY='sk-or-v1-...' |
| 105 | + |
| 106 | +# Same vLLM API key the DSE-nimbus endpoint uses (also in vllm/vllm-api-key) |
| 107 | +kubectl -n llm-proxy create secret generic nimbus-api-key \ |
| 108 | + --from-literal=NIMBUS_API_KEY='...' |
| 109 | + |
| 110 | +# MinIO service account for the log bucket (see next section) |
| 111 | +kubectl -n llm-proxy create secret generic logs-s3 \ |
| 112 | + --from-literal=AWS_ACCESS_KEY_ID='llm-proxy-logs' \ |
| 113 | + --from-literal=AWS_SECRET_ACCESS_KEY='...' |
| 114 | +``` |
| 115 | + |
| 116 | +Read the current client key back with: |
| 117 | + |
| 118 | +```bash |
| 119 | +kubectl -n llm-proxy get secret open-llm-proxy-secrets \ |
| 120 | + -o jsonpath='{.data.proxy-key}' | base64 -d; echo |
| 121 | +``` |
| 122 | + |
| 123 | +## Log bucket + MinIO service account (one time) |
| 124 | + |
| 125 | +Logs go to the in-cluster MinIO mirror, in a **private** bucket, written by a |
| 126 | +service account scoped to that bucket alone — not the MinIO root user. Created |
| 127 | +via `mc` inside the MinIO pod: |
| 128 | + |
| 129 | +```bash |
| 130 | +POD=$(kubectl -n minio get pod -l k8s-app=minio-app -o name | head -1) |
| 131 | +kubectl -n minio exec "$POD" -- sh -c ' |
| 132 | + mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" |
| 133 | + mc mb --ignore-existing local/logs-open-llm-proxy |
| 134 | + mc anonymous set none local/logs-open-llm-proxy |
| 135 | + cat > /tmp/p.json <<EOF |
| 136 | +{"Version":"2012-10-17","Statement":[ |
| 137 | + {"Effect":"Allow","Action":["s3:ListBucket","s3:GetBucketLocation"], |
| 138 | + "Resource":["arn:aws:s3:::logs-open-llm-proxy"]}, |
| 139 | + {"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"], |
| 140 | + "Resource":["arn:aws:s3:::logs-open-llm-proxy/*"]}]} |
| 141 | +EOF |
| 142 | + mc admin policy create local llm-proxy-logs-rw /tmp/p.json |
| 143 | + mc admin user add local llm-proxy-logs "<SECRET>" |
| 144 | + mc admin policy attach local llm-proxy-logs-rw --user llm-proxy-logs |
| 145 | +' |
| 146 | +``` |
| 147 | + |
| 148 | +(`DeleteObject` is not needed today — nothing prunes the raw tier — but is |
| 149 | +included so a future consolidation job can delete chunks after rolling them up.) |
| 150 | + |
| 151 | +## Querying the logs |
| 152 | + |
| 153 | +Raw JSONL only, same record format as NRP (see [../LOGGING.md](../LOGGING.md)): |
| 154 | + |
| 155 | +``` |
| 156 | +logs-open-llm-proxy/ |
| 157 | +└── 2026-08-06/ |
| 158 | + ├── 02-08-45-<pod>-<pid>-<uuid>.jsonl |
| 159 | + └── ... |
| 160 | +``` |
| 161 | + |
| 162 | +MinIO is reachable at `https://minio.carlboettiger.info` from outside the |
| 163 | +cluster: |
| 164 | + |
| 165 | +```bash |
| 166 | +export AWS_ACCESS_KEY_ID=llm-proxy-logs |
| 167 | +export AWS_SECRET_ACCESS_KEY=... # from the logs-s3 secret |
| 168 | + |
| 169 | +duckdb -s " |
| 170 | +CREATE SECRET minio (TYPE S3, KEY_ID getenv('AWS_ACCESS_KEY_ID'), |
| 171 | + SECRET getenv('AWS_SECRET_ACCESS_KEY'), |
| 172 | + ENDPOINT 'minio.carlboettiger.info', URL_STYLE 'path'); |
| 173 | +SELECT timestamp, model, user_message_this_turn, latency_ms |
| 174 | +FROM read_ndjson_auto('s3://logs-open-llm-proxy/2026-*/*.jsonl', union_by_name=true) |
| 175 | +ORDER BY timestamp DESC LIMIT 20; |
| 176 | +" |
| 177 | +``` |
| 178 | + |
| 179 | +`union_by_name=true` matters here: request and response records have different |
| 180 | +field sets, and the raw tier never went through the schema-flattening step. |
| 181 | + |
| 182 | +Live tail: |
| 183 | + |
| 184 | +```bash |
| 185 | +kubectl -n llm-proxy logs deployment/open-llm-proxy -f |
| 186 | +``` |
| 187 | + |
| 188 | +## Smoke test |
| 189 | + |
| 190 | +```bash |
| 191 | +KEY=$(kubectl -n llm-proxy get secret open-llm-proxy-secrets \ |
| 192 | + -o jsonpath='{.data.proxy-key}' | base64 -d) |
| 193 | + |
| 194 | +curl -s https://llm-proxy.carlboettiger.info/health | jq |
| 195 | +curl -s https://llm-proxy.carlboettiger.info/v1/chat/completions \ |
| 196 | + -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ |
| 197 | + -d '{"model":"qwen","messages":[{"role":"user","content":"Say hi."}]}' | jq -r '.choices[0].message.content' |
| 198 | +``` |
0 commit comments