Skip to content

Commit d7fc3f1

Browse files
authored
feat(cirrus): config-only deployment of the proxy on the self-hosted k3s cluster (#102)
* feat(cirrus): deploy the proxy on the self-hosted k3s cluster OpenRouter + DSE-nimbus only, logging to in-cluster MinIO. * docs(cirrus): README/LOGGING/AGENTS/CHANGELOG for the cirrus deployment * refactor(cirrus): make the cirrus deployment config-only, touching nothing NRP reads Drops the shared-code changes (PROXY_CONFIG, APP_CORS, AWS_S3_ADDRESSING_STYLE, default_provider) and the consolidate_logs.py extraction from both NRP CronJobs. The provider set now arrives as a ConfigMap mounted over /app/config.json, and the log flush uses the pre-existing LOG_BUCKET / AWS_S3_ENDPOINT_URL env vars (boto3 already path-styles a custom endpoint). Parquet consolidation is dropped for now: raw JSONL only. Diff outside cirrus/ is documentation.
1 parent 0773972 commit d7fc3f1

11 files changed

Lines changed: 585 additions & 0 deletions

AGENTS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,28 @@ This is an LLM proxy service that routes chat completion requests to NRP, OpenRo
1212
1313
> **CORS gotcha:** browser CORS is enforced by the **haproxy ingress** (`ingress.yaml` annotations), *not* the app's `CORSMiddleware` (which is effectively dead config). `cors-allow-headers` is an explicit list — a new custom request header (e.g. `X-Client`) must be added there or the browser preflight blocks the whole request. See the comment in `ingress.yaml`.
1414
15+
## Two deployments
16+
17+
The canonical deployment is on **NRP** (`open-llm-proxy.nrp-nautilus.io`,
18+
namespace `biodiversity`) — that is what the manifests at the repo root, and
19+
everything below, describe. A second, narrower deployment runs on the lab's
20+
**self-hosted k3s cluster** (`llm-proxy.carlboettiger.info`, namespace
21+
`llm-proxy`): OpenRouter + DSE-nimbus only, raw-JSONL logs in the in-cluster
22+
MinIO mirror, Traefik instead of HAProxy. See [cirrus/](cirrus/README.md).
23+
24+
Two rules for agents working on it:
25+
26+
1. **cirrus is config-only.** Everything cirrus-specific lives in `cirrus/` as
27+
deployment config (a ConfigMap mounted over `config.json`, env vars, Traefik
28+
CRDs). It runs this repo's unmodified code. Do **not** make an app change
29+
"for cirrus" and do **not** point its manifests at a branch — both deployments
30+
git-clone `main` at pod boot, so a shared-code edit reaches NRP at its next
31+
restart whether or not that was the intent. If cirrus genuinely needs an app
32+
change, propose it on its own merits for both deployments.
33+
2. **Check which cluster `kubectl` is pointed at** (`kubectl config
34+
current-context`) before touching anything — a laptop configured for cirrus
35+
generally cannot reach NRP, and vice versa.
36+
1537
## Evaluating Logs
1638

1739
Logs land in three tiers by age (see [LOGGING.md](LOGGING.md) for the full spec):

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ See [Releases](README.md#releases) for how a release is cut.
3434
dialect (as `kimi` does, not `qwen3`'s `enable_thinking`, which it ignores), so its
3535
reasoning can actually be toggled. Prefix matching remains provider-declaration-ordered;
3636
the `gemma4-nimbus` / `qwen3-cirrus` shadowing noted in #105 is left as-is.
37+
- **Second deployment on the self-hosted k3s cluster (cirrus):
38+
`https://llm-proxy.carlboettiger.info`.** Manifests in `cirrus/` (namespace
39+
`llm-proxy`), documented in [cirrus/README.md](cirrus/README.md).
40+
**Config-only and NRP-inert:** it runs this repo's unmodified `llm_proxy.py`
41+
from `main`, and adds no file the NRP deployment reads — no application code,
42+
no `config.json` change, no CronJob change. Everything cirrus-specific is
43+
deployment config:
44+
- **Providers: OpenRouter + DSE-nimbus (`qwen`) only**, from a ConfigMap
45+
mounted over `/app/config.json` in the pod. No NRP ELLM, no Anthropic direct.
46+
- **Logs to the in-cluster MinIO mirror** (`minio-svc.minio.svc.cluster.local:9000`,
47+
bucket `logs-open-llm-proxy`), via the existing `LOG_BUCKET` /
48+
`AWS_S3_ENDPOINT_URL` env vars and a MinIO service account scoped to that
49+
bucket — not the MinIO root user. **Raw JSONL tier only:** the Parquet rollup
50+
and `sessions/**` view live inside the NRP CronJob manifests and can't be
51+
reused without extracting them, which would touch NRP. Deferred.
52+
- **Traefik replaces HAProxy** for ingress: CORS and the 600s backend timeouts
53+
become `Middleware` / `ServersTransport` CRDs (`cirrus/middleware.yaml`), with
54+
cert-manager + external-dns provisioning TLS and the A record.
55+
- No dns-cache sidecar / `dnsPolicy: None` (that works around NRP CoreDNS
56+
flakiness, #28); pinned to the `cirrus` node, where MinIO and Traefik live.
57+
- Known limitation, inherent to staying config-only: an unrouted model id
58+
returns `500` rather than a useful `400`, because
59+
`get_provider_for_model`'s fallback is a hard-coded `PROVIDERS["nrp"]` and
60+
cirrus has no `nrp` provider. Correct model ids are unaffected.
61+
3762
- **Route OpenRouter floating aliases (`~…`).** OpenRouter publishes always-latest
3863
aliases whose model id carries a literal leading tilde — e.g.
3964
`~deepseek/deepseek-v4-flash-latest` (canonical slug identical), as distinct from

LOGGING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Logs are written to two places:
77
1. **Pod stdout** — available immediately via `kubectl`, lost on pod restart
88
2. **S3 bucket `logs-open-llm-proxy`** — flushed every 60 seconds (configurable via `FLUSH_INTERVAL` env var) as JSONL chunk files, persisted indefinitely
99

10+
> This document describes the **NRP** deployment (bucket on NRP Ceph). The cirrus
11+
> deployment writes the same record format to a same-named bucket on the
12+
> in-cluster MinIO mirror, but has **only the raw JSONL tier** — the Parquet
13+
> rollup and `sessions/**` view below are NRP-only for now. See
14+
> [cirrus/README.md](cirrus/README.md).
15+
1016
### S3 layout (tiered rollup)
1117

1218
Three tiers, each holding a different age of data. A daily CronJob rolls

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ OpenAI-compatible LLM proxy for NRP Nautilus. Routes requests to LLM providers,
44

55
The canonical deployment lives at `https://open-llm-proxy.nrp-nautilus.io` in the `biodiversity` namespace, but the proxy is designed to be deployed independently into any namespace — each instance writes logs to its own namespace-scoped S3 bucket.
66

7+
A second deployment runs on the lab's self-hosted k3s cluster at `https://llm-proxy.carlboettiger.info` (OpenRouter + DSE-nimbus only, logging to the in-cluster MinIO mirror). It is **config-only** — it runs this repo's unmodified code from `main` with a ConfigMap mounted over `config.json`, and changes nothing the NRP deployment reads. Manifests and operations: [cirrus/](cirrus/README.md).
8+
79
## Architecture
810

911
```

cirrus/README.md

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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+
```

cirrus/config-configmap.yaml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Provider set for the cirrus deployment, mounted OVER /app/config.json in the
2+
# pod (see deployment.yaml). This is why the cirrus deployment needs no code
3+
# change and no repo-level config change: `llm_proxy.py` always reads
4+
# `config.json` next to itself, and this ConfigMap replaces that one file after
5+
# the git-sync initContainer has laid down the checkout.
6+
#
7+
# Keep this list a strict subset of the NRP config.json's providers — it exists
8+
# to *narrow* the surface (OpenRouter + DSE-nimbus), not to diverge from it. If
9+
# a provider's endpoint or model list changes upstream, mirror it here.
10+
apiVersion: v1
11+
kind: ConfigMap
12+
metadata:
13+
name: open-llm-proxy-config
14+
namespace: llm-proxy
15+
data:
16+
config.json: |
17+
{
18+
"providers": {
19+
"openrouter": {
20+
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
21+
"api_key_env": "OPENROUTER_KEY",
22+
"models": [
23+
"anthropic/",
24+
"mistralai/",
25+
"amazon/",
26+
"openai/",
27+
"qwen/",
28+
"nvidia/",
29+
"z-ai/",
30+
"minimax/",
31+
"moonshotai/",
32+
"deepseek/",
33+
"~"
34+
],
35+
"extra_headers": {
36+
"HTTP-Referer": "https://llm-proxy.carlboettiger.info",
37+
"X-Title": "Open LLM Proxy (cirrus)"
38+
}
39+
},
40+
"nimbus": {
41+
"endpoint": "https://vllm-nimbus.carlboettiger.info/v1/chat/completions",
42+
"api_key_env": "NIMBUS_API_KEY",
43+
"models": [
44+
"qwen"
45+
],
46+
"thinking_models": {
47+
"qwen": "enable_thinking"
48+
}
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)