Skip to content

Commit 0f65d73

Browse files
committed
infra(litellm): /gpd/log endpoint with virtual-key auth + GCS streaming
Ships a tiny Dockerfile on top of ghcr.io/berriai/litellm-database: main-stable that registers POST /gpd/log via LITELLM_WORKER_STARTUP_HOOKS (PR anomalyco#22931 in v1.82.3-stable). The route: 1. Content-Length gate in BaseHTTPMiddleware — rejects >64 MB or missing header before the body touches RAM. 2. Depends(user_api_key_auth) — LiteLLM's native validator. Handles revocation, expiry, team/user/org budget via common_checks. Per- virtual-key USD budget deliberately skipped (non-LLM route) so log writes never consume the user's LLM spend cap. 3. proxy_logging_obj.pre_call_hook(call_type="pass_through_endpoint") — reuses LiteLLM's RPM/TPM limiter descriptors (shared with /v1/*). 4. Redis per-key daily byte quota (GPD_LOG_BYTES_PER_DAY, default 1GB) via gpd:log:bytes:<hashed_token>:<yyyymmdd> counter. Fail-closed on Redis outage so quotas can never bypass. 5. Server-derived user_hash = sha256(user_api_key_dict.user_id)[:16] — clients cannot write under another user's prefix. 6. Idempotent GCS upload via if_generation_match=0. Treats 412 as success so client-side spill retries don't duplicate data. Per-flush object names use 26-char ULIDs (lexicographic == chronological) to sidestep GCS's 1-write/sec-per-object ceiling during subagent fan-out. compactor.py fuses parts/*.jsonl.gz → root.jsonl.gz nightly via Objects.compose() (32-at-a-time, two-phase for >32 parts). Deploy: point Railway service at infra/litellm/Dockerfile, add GOOGLE_APPLICATION_CREDENTIALS_JSON + GPD_LOG_BUCKET env vars, Redeploy. See infra/litellm/README.md for step-by-step. SA key never leaves Railway. Desktop never gets GCS credentials.
1 parent 79ff9b5 commit 0f65d73

9 files changed

Lines changed: 629 additions & 0 deletions

File tree

infra/litellm/Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# GPD's LiteLLM image: stock upstream + our custom /gpd/log route.
2+
#
3+
# Rolling tag: each Railway redeploy rebuilds from the latest
4+
# ghcr.io/berriai/litellm-database:main-stable. That's how LiteLLM
5+
# upgrades flow through — you don't have to touch anything locally,
6+
# just click "Redeploy" in Railway.
7+
FROM ghcr.io/berriai/litellm-database:main-stable
8+
9+
# Our hook module — see infra/litellm/gpd_log/README.md for architecture.
10+
COPY gpd_log /app/gpd_log
11+
12+
# Activate the hook. LiteLLM iterates this comma-separated list during
13+
# worker startup (proxy_server.py:777-803, PR #22931) and calls each
14+
# entry as `module:function`.
15+
ENV LITELLM_WORKER_STARTUP_HOOKS=gpd_log.hook:register
16+
ENV PYTHONPATH=/app

infra/litellm/README.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# GPD's LiteLLM image
2+
3+
Ships stock `ghcr.io/berriai/litellm-database:main-stable` + one custom
4+
route, `POST /gpd/log`, for session-log ingest. The SA key that writes
5+
to `gs://gpd-desktop-logs` lives only on Railway — desktop clients
6+
authenticate with their existing LiteLLM virtual key.
7+
8+
## What's in here
9+
10+
| File | What it does |
11+
|---|---|
12+
| `Dockerfile` | 3-line layer on top of stock LiteLLM |
13+
| `gpd_log/hook.py` | `register()` entry point for `LITELLM_WORKER_STARTUP_HOOKS` |
14+
| `gpd_log/middleware.py` | Rejects requests with missing / oversized Content-Length before the body hits memory |
15+
| `gpd_log/handler.py` | The route: auth → rate-limit → byte-quota → GCS upload |
16+
| `gpd_log/gcs_writer.py` | `upload_from_string(if_generation_match=0)` idempotent write |
17+
| `gpd_log/quota.py` | Per-key daily byte counter in Redis, fail-closed |
18+
19+
## One-time GCP setup
20+
21+
```bash
22+
# SA scoped to write-only on one bucket — no list, no read, no delete.
23+
gcloud iam service-accounts create gpd-log-writer --project=gpd-desktop \
24+
--display-name="GPD LiteLLM-side log writer"
25+
26+
gcloud storage buckets add-iam-policy-binding gs://gpd-desktop-logs \
27+
--member=serviceAccount:gpd-log-writer@gpd-desktop.iam.gserviceaccount.com \
28+
--role=roles/storage.objectCreator
29+
30+
gcloud iam service-accounts keys create /tmp/gpd-log-writer-key.json \
31+
--iam-account=gpd-log-writer@gpd-desktop.iam.gserviceaccount.com \
32+
--project=gpd-desktop
33+
34+
# Print for pasting into Railway:
35+
cat /tmp/gpd-log-writer-key.json | jq -c . # single-line JSON for env var
36+
shred -u /tmp/gpd-log-writer-key.json # destroy local copy
37+
```
38+
39+
## Railway configuration
40+
41+
1. **Switch the LiteLLM service from "Docker image" mode to "Dockerfile" mode.**
42+
Settings → Source → point at this repo, root directory `infra/litellm/`.
43+
2. **Add env vars** (Settings → Variables):
44+
```
45+
GOOGLE_APPLICATION_CREDENTIALS_JSON = <paste the JSON from step above>
46+
GPD_LOG_BUCKET = gpd-desktop-logs
47+
GPD_LOG_BYTES_PER_DAY = 1073741824 # 1 GiB/day per key (optional; default)
48+
# Existing vars stay untouched: DATABASE_URL, LITELLM_MASTER_KEY,
49+
# REDIS_URL (or REDIS_HOST / REDIS_PASSWORD), STORE_MODEL_IN_DB, etc.
50+
```
51+
`LITELLM_WORKER_STARTUP_HOOKS` is baked into the Dockerfile, so don't
52+
set it as a Railway env var (would double-register the route).
53+
3. **Click Redeploy.** Railway rebuilds the image (~30s), the hook fires
54+
during worker startup, and the `/gpd/log` route becomes live.
55+
56+
## Verification
57+
58+
After deploy, with a valid LiteLLM virtual key:
59+
60+
```bash
61+
KEY=sk-<your-key>
62+
BASE=https://litellm-production-46bb.up.railway.app
63+
SEQ=$(python -c 'import secrets, time; import string; \
64+
alpha="0123456789ABCDEFGHJKMNPQRSTVWXYZ"; \
65+
print("".join(secrets.choice(alpha) for _ in range(26)))')
66+
67+
# Small test body (gzipped NDJSON)
68+
echo '{"kind":"test","ts":1}' | gzip | curl -sS -X POST \
69+
"$BASE/gpd/log?session=ses_test&root_session=ses_test&seq=$SEQ" \
70+
-H "Authorization: Bearer $KEY" \
71+
-H "Content-Encoding: gzip" \
72+
-H "Content-Type: application/x-ndjson" \
73+
--data-binary @- \
74+
| jq
75+
76+
# Expected: {"ok": true, "path": "user=.../session=ses_test/parts/<SEQ>.jsonl.gz", "bytes": N}
77+
78+
# Check the object landed
79+
gcloud storage cat "gs://gpd-desktop-logs/user=*/date=$(date -u +%Y-%m-%d)/session=ses_test/parts/$SEQ.jsonl.gz" \
80+
| gunzip
81+
```
82+
83+
Negative tests:
84+
85+
```bash
86+
# Missing key → 401
87+
curl -sS -X POST "$BASE/gpd/log?session=s&seq=$SEQ" -H "Content-Length: 10" -d abc
88+
# Revoked key → 401 (after LiteLLM cache TTL)
89+
# Missing Content-Length → 411
90+
# Oversize Content-Length (>64MB) → 413
91+
# Malformed seq → 400
92+
# Over daily byte quota → 429
93+
```
94+
95+
## Client-side mapping
96+
97+
Client POSTs `POST /gpd/log` with:
98+
99+
- `Authorization: Bearer <virtual key>`
100+
- `Content-Encoding: gzip`
101+
- `Content-Type: application/x-ndjson`
102+
- Query: `session=<id>&root_session=<id>&seq=<ULID>`
103+
- Body: gzipped NDJSON — one `GpdLog.Event` per line
104+
105+
The server writes the object to:
106+
```
107+
gs://gpd-desktop-logs/user=<hashed_user_id>/date=YYYY-MM-DD/session=<root_id>/parts/<seq>.jsonl.gz
108+
```
109+
or for subagents:
110+
```
111+
gs://gpd-desktop-logs/user=<hashed_user_id>/date=YYYY-MM-DD/session=<root_id>/subagents/agent-<child_id>/parts/<seq>.jsonl.gz
112+
```
113+
114+
A nightly compactor (separate service) fuses `parts/*.jsonl.gz` into
115+
a single `root.jsonl.gz` per session per day, using GCS `Objects.compose()`
116+
in 32-at-a-time batches.

infra/litellm/gpd_log/__init__.py

Whitespace-only changes.

infra/litellm/gpd_log/compactor.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Nightly compactor: fuse parts/*.jsonl.gz into one root.jsonl.gz per session-day.
2+
3+
Why: client flushes every ~1s (during active subagent fan-out, much faster).
4+
Per-flush objects hit GCS's 1-write/sec-per-object ceiling if we wrote to
5+
a single file. So client writes `parts/<ULID>.jsonl.gz` (unique object per
6+
flush), and this job fuses them at rest.
7+
8+
Fusing strategy: lexicographic part-name order == chronological (ULID is
9+
timestamp-prefixed). Simple concat of gzipped streams works because gzip
10+
is concatenation-safe per RFC 1952 §2.2.
11+
12+
Run modes:
13+
- Railway cron: `python -m gpd_log.compactor --yesterday`
14+
- Cloud Function: `compact_all()` as the entry point, HTTP trigger
15+
- Ad-hoc: `python -m gpd_log.compactor --date 2026-04-20`
16+
17+
What survives: `parts/` is deleted after a successful fuse, leaving just
18+
`root.jsonl.gz`. The lifecycle policy (30d nearline, 90d coldline) then
19+
flows on the compacted object as normal.
20+
"""
21+
from __future__ import annotations
22+
23+
import argparse
24+
import io
25+
import logging
26+
import os
27+
import sys
28+
import time
29+
from datetime import datetime, timedelta, timezone
30+
31+
from google.cloud import storage
32+
33+
from .gcs_writer import _get_bucket
34+
35+
log = logging.getLogger("gpd_log.compactor")
36+
37+
# GCS limits: compose() takes up to 32 components. If a session has
38+
# more than 32 parts (subagent-heavy day), we do two passes.
39+
COMPOSE_MAX = 32
40+
41+
42+
def _list_sessions_for_date(bucket: storage.Bucket, date: str) -> list[str]:
43+
"""Return list of object prefixes that look like
44+
`user=*/date=<date>/session=*/` (one per session).
45+
"""
46+
# `Client.list_blobs(prefix=..., delimiter="/")` returns prefixes in
47+
# the response's `prefixes` attr when called via list_blobs with
48+
# delimiter. We iterate two levels of prefix to enumerate sessions.
49+
prefixes: set[str] = set()
50+
for user_prefix in _list_subdirs(bucket, ""):
51+
if not user_prefix.startswith("user="):
52+
continue
53+
date_prefix = f"{user_prefix}date={date}/"
54+
for session_prefix in _list_subdirs(bucket, date_prefix):
55+
if session_prefix.startswith(f"{date_prefix}session="):
56+
prefixes.add(session_prefix)
57+
# Nested subagent dirs are compacted per-subagent, not per-root.
58+
# Handle them by walking the tree another level:
59+
subagents_prefix = f"{session_prefix}subagents/"
60+
for sub in _list_subdirs(bucket, subagents_prefix):
61+
prefixes.add(sub)
62+
return sorted(prefixes)
63+
64+
65+
def _list_subdirs(bucket: storage.Bucket, prefix: str) -> list[str]:
66+
client = bucket.client
67+
it = client.list_blobs(bucket, prefix=prefix, delimiter="/")
68+
# Exhaust iterator to populate `prefixes`.
69+
list(it)
70+
return sorted(it.prefixes or [])
71+
72+
73+
def _list_parts(bucket: storage.Bucket, session_prefix: str) -> list[storage.Blob]:
74+
parts_prefix = f"{session_prefix}parts/"
75+
blobs = list(bucket.client.list_blobs(bucket, prefix=parts_prefix))
76+
# Lexicographic == chronological (ULID prefix).
77+
return sorted(blobs, key=lambda b: b.name)
78+
79+
80+
def _compose_batch(
81+
bucket: storage.Bucket,
82+
destination_name: str,
83+
sources: list[storage.Blob],
84+
) -> storage.Blob:
85+
dest = bucket.blob(destination_name)
86+
dest.content_encoding = "gzip"
87+
dest.content_type = "application/x-ndjson"
88+
dest.compose(sources)
89+
return dest
90+
91+
92+
def compact_session(bucket: storage.Bucket, session_prefix: str) -> dict:
93+
"""Fuse parts/*.jsonl.gz → root.jsonl.gz for one session-day.
94+
95+
Returns {'session': prefix, 'parts': N, 'bytes': M, 'skipped': bool}.
96+
Idempotent: if root.jsonl.gz already exists and is newer than the
97+
oldest part, skip.
98+
"""
99+
parts = _list_parts(bucket, session_prefix)
100+
if not parts:
101+
return {"session": session_prefix, "parts": 0, "skipped": True}
102+
103+
root_name = f"{session_prefix}root.jsonl.gz"
104+
root = bucket.blob(root_name)
105+
if root.exists(bucket.client):
106+
root.reload()
107+
if root.updated and parts[-1].updated and root.updated > parts[-1].updated:
108+
return {"session": session_prefix, "parts": len(parts), "skipped": True}
109+
110+
# Two-phase compose for > 32 parts.
111+
intermediates: list[storage.Blob] = []
112+
if len(parts) <= COMPOSE_MAX:
113+
final_sources = parts
114+
else:
115+
batch_idx = 0
116+
for start in range(0, len(parts), COMPOSE_MAX):
117+
batch = parts[start : start + COMPOSE_MAX]
118+
tmp_name = f"{session_prefix}_compact/tmp-{batch_idx:04d}.jsonl.gz"
119+
intermediates.append(_compose_batch(bucket, tmp_name, batch))
120+
batch_idx += 1
121+
final_sources = intermediates
122+
123+
total_bytes = sum((p.size or 0) for p in parts)
124+
_compose_batch(bucket, root_name, final_sources)
125+
126+
# Delete parts only after the compose succeeded — makes the operation
127+
# resumable if compose fails halfway.
128+
for p in parts:
129+
p.delete()
130+
for i in intermediates:
131+
i.delete()
132+
133+
return {"session": session_prefix, "parts": len(parts), "bytes": total_bytes, "skipped": False}
134+
135+
136+
def compact_all(date: str) -> dict:
137+
bucket = _get_bucket()
138+
results = []
139+
for session_prefix in _list_sessions_for_date(bucket, date):
140+
try:
141+
r = compact_session(bucket, session_prefix)
142+
results.append(r)
143+
log.info("compacted", extra={"result": r})
144+
except Exception as e:
145+
log.exception("compact failed", extra={"session": session_prefix})
146+
results.append({"session": session_prefix, "error": str(e)})
147+
return {"date": date, "sessions": len(results), "details": results}
148+
149+
150+
def main() -> int:
151+
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
152+
parser = argparse.ArgumentParser()
153+
parser.add_argument("--date", help="YYYY-MM-DD (defaults to yesterday UTC)")
154+
parser.add_argument("--yesterday", action="store_true")
155+
args = parser.parse_args()
156+
157+
if args.date:
158+
date = args.date
159+
else:
160+
date = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
161+
162+
t0 = time.time()
163+
summary = compact_all(date)
164+
dt = time.time() - t0
165+
log.info(f"done date={date} sessions={summary['sessions']} elapsed={dt:.1f}s")
166+
return 0
167+
168+
169+
if __name__ == "__main__":
170+
sys.exit(main())
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Upload the /gpd/log request body to GCS.
2+
3+
Idempotent: `if_generation_match=0` means "create iff object does not
4+
exist". A client-side spill retry that duplicates the object path gets
5+
a 412 Precondition Failed, which we treat as success. Combined with
6+
monotonic per-flush ULID object names, this gives at-least-once
7+
delivery with exactly-once storage.
8+
9+
Credentials: `GOOGLE_APPLICATION_CREDENTIALS_JSON` env var (inline SA
10+
JSON, Railway-standard). Falls back to Application Default Credentials
11+
if unset, for local testing against gcloud auth login.
12+
"""
13+
from __future__ import annotations
14+
15+
import asyncio
16+
import json
17+
import os
18+
19+
from google.cloud import storage
20+
from google.oauth2 import service_account
21+
22+
_client: storage.Client | None = None
23+
_bucket: storage.Bucket | None = None
24+
25+
26+
def _get_bucket() -> storage.Bucket:
27+
global _client, _bucket
28+
if _bucket is not None:
29+
return _bucket
30+
31+
bucket_name = os.environ.get("GPD_LOG_BUCKET")
32+
if not bucket_name:
33+
raise RuntimeError("GPD_LOG_BUCKET env var required")
34+
35+
sa_json = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS_JSON")
36+
if sa_json:
37+
info = json.loads(sa_json)
38+
creds = service_account.Credentials.from_service_account_info(
39+
info,
40+
scopes=["https://www.googleapis.com/auth/devstorage.read_write"],
41+
)
42+
_client = storage.Client(credentials=creds, project=info.get("project_id"))
43+
else:
44+
_client = storage.Client()
45+
46+
_bucket = _client.bucket(bucket_name)
47+
return _bucket
48+
49+
50+
async def stream_to_gcs(object_path: str, body: bytes) -> int:
51+
"""Upload body as a GCS object at `object_path`.
52+
53+
Returns bytes written. Treats 412 (object exists) as success to make
54+
client-side retry-from-spill safe.
55+
"""
56+
bucket = _get_bucket()
57+
blob = bucket.blob(object_path)
58+
# Client sends gzipped NDJSON — preserve that encoding on the stored object
59+
# so BigQuery's external-table reader (NEWLINE_DELIMITED_JSON) auto-decodes
60+
# transparently via the .gz suffix.
61+
blob.content_encoding = "gzip"
62+
blob.content_type = "application/x-ndjson"
63+
64+
def _upload() -> int:
65+
try:
66+
blob.upload_from_string(
67+
body,
68+
content_type="application/x-ndjson",
69+
if_generation_match=0,
70+
retry=None,
71+
)
72+
return len(body)
73+
except Exception as e:
74+
msg = str(e)
75+
# 412 Precondition Failed → object already exists. Client is
76+
# retrying a previously-successful write. Idempotent.
77+
if "preconditionFailed" in msg or "412" in msg:
78+
return len(body)
79+
raise
80+
81+
return await asyncio.to_thread(_upload)

0 commit comments

Comments
 (0)