# llm-gateway-proxy

OpenAI 準拠の上流 LLM サーバー（llama-server）に対するリバース プロキシ。
発行キーによる認証・レート制限・モデル許可を担い、それ以外はバイト単位で無改変中継する。

管理画面（`llm-gateway-admin`）とは control API のみで結合する。
admin は SQLite ファイルを直接触らない。

## 1. 構成

```mermaid
flowchart LR
    C["外部クライアント"] -->|"Bearer 発行キー"| GW["公開リスナー :3000"]
    ADM["admin"] -->|"X-Control-Token"| CTL["control リスナー :3001"]
    GW --- DB[("SQLite WAL")]
    CTL --- DB
    GW -->|"Bearer 内部キーへ差し替え<br/>本文はバイト列のまま"| UP["llama-server"]
```

1 コンテナ 1 プロセスのまま、2 つのリスナーを同一イベント ループへ並べる。
どちらかが終了したらプロセス全体を終了させ、再起動はコンテナに委ねる。
control リスナーはホストへ公開せず、Compose ネットワーク内のみで到達可能とする。

### ファイル構成

| ファイル | 役割 |
|---|---|
| `app/config.py` | 環境変数の読み込みと検証 |
| `app/errors.py` | OpenAI 互換のエラー エンベロープ |
| `app/logging_utils.py` | 内部キーと `Authorization` のマスク |
| `app/db.py` | SQLite。WAL、`PRAGMA user_version` による移行、索引、ログの非同期書き込み |
| `app/keys.py` | キー生成・SHA-256 照合・インメモリ キャッシュ |
| `app/limits.py` | レート制限（毎分・毎日・同時実行・毎分トークン） |
| `app/policy.py` | パス方針・モデル検査・アドミッション コントロール |
| `app/upstream.py` | 上流クライアントとモデル一覧キャッシュ |
| `app/proxy.py` | 公開用 Starlette アプリ |
| `app/control.py` | control 用 FastAPI アプリ |
| `app/main.py` | 2 リスナーの起動と終了処理 |

## 2. 無改変中継の規約

改変するのは次のヘッダのみで、それ以外はボディを含めてバイト単位で維持する。

| 項目 | 扱い |
|---|---|
| `Authorization` | 発行キーから内部キーへ差し替え |
| `Host` | 上流ホストへ書き換え |
| hop-by-hop | 除去。`Connection` `Keep-Alive` `TE` `Trailer` `Transfer-Encoding` `Upgrade` と `Proxy-*`、および `Connection` に列挙された名前 |
| `X-Forwarded-*` | クライアント由来の値は除去。上流へは付与しない |
| `Content-Length` | 除去し、受信した本文の実長で付け直す |
| `Accept-Encoding` | クライアントが送っていない場合のみ `identity` を明示 |
| その他のヘッダ | 順序と重複を保ったまま転送 |
| リクエスト ボディ | 受信した `bytes` をそのまま送出。JSON として読み直して再シリアライズしない |
| レスポンス | `aiter_raw()` で受け、自動解凍せず `Content-Encoding` を保って素通し |

モデル検査のために JSON を読むが、読むのは検査用のコピーであり、上流へ送るのは受信した元のバイト列である。

### 本文へ触れる 2 つの例外

いずれも意図的なもので、それ以外の経路では本文を読み書きしない。

| 経路 | 内容 | 切替 |
|---|---|---|
| `/v1/models` の応答 | キーの許可モデルで `data` を絞り込む | `FILTER_MODELS_RESPONSE`（既定は有効） |
| 4xx / 5xx の小さな応答 | 内部キーが混入していれば伏字へ置換する | 常時。64 KiB 未満かつ非ストリーミングのみ |

## 3. 拒否と検査

### パス方針

| 方針 | 内容 |
|---|---|
| `denylist`（既定） | `/slots` `/props` `/metrics` `/lora-adapters` とその配下を 404 で拒否 |
| `allowlist` | `/v1/` 始まりと生成系パスのみ許可し、他は 404 |

判定は正規化後のパスに対して行う。`%2e%2e` や `%2f` による多重符号化、`..`、連続スラッシュ、
末尾スラッシュ、大文字小文字の差では迂回できない。前方一致ではなくパス境界で判定するため、
`/slots` と `/slots/0` は拒否し、`/slotsfoo` は拒否しない。

スロット照会エンドポイントは、そのとき処理中の他利用者のプロンプトと生成中テキストを返す。
上流は内部キー 1 本で全利用者分を処理しており利用者の区別を持たないため、既定で塞ぐ。

### モデル検査はフェイル クローズ

| 条件 | 挙動 |
|---|---|
| キーの許可リストが空 | 無制限キーとして扱い、検査を省略 |
| 許可リストあり、モデル名を取得できた | 許可リストと照合。許可外は 403 |
| 許可リストあり、検査不能 | 403 で拒否 |

検査対象は `model` を持ちうる全エンドポイント。旧来の別名を漏らすとそこが迂回路になる。

```text
/v1/chat/completions  /v1/completions  /v1/embeddings  /v1/rerank  /v1/reranking
/completion  /completions  /infill  /v1/infill  /apply-template  /v1/audio/transcriptions
```

`Content-Type` を変える、本文を膨らませる、旧来のパスを使う、のいずれでも迂回できない。
`multipart/form-data` の場合は `model` フォーム項目から取り出す。

### アドミッション コントロール

値の書き換えは行わず、逸脱値は拒否する。

| 項目 | 超過時 |
|---|---|
| `MAX_REQUEST_BODY_BYTES` | 413 |
| `MAX_GRAMMAR_BYTES` | 400 |
| `MAX_N_PROBS` | 400 |
| `MAX_REQUEST_DURATION` | 504 |
| `IDLE_TIMEOUT` | 504（httpx の read timeout として作用） |

## 4. レート制限

取得順は「全キー横断の同時実行 → キー単位の同時実行 → 毎分・毎日・毎分トークン」で固定する。
横断上限を先に取ることで、1 本のキーが上流のスロットを占有して他の全利用者を止める事態を防ぐ。
取得したスロットは必ず `try/finally` で解放するため、クライアント切断でも漏れない。

| 軸 | 単位 | 実装 | 超過時 |
|---|---|---|---|
| 同時実行 | 全キー横断 | `asyncio.Semaphore`。`GLOBAL_QUEUE_TIMEOUT` だけ待つ | 429 |
| 同時実行 | キー | `asyncio.Semaphore`。待たずに判定 | 429 |
| 毎分リクエスト | キー | スライディング ウィンドウ | 429 |
| 毎日リクエスト | キー | JST 境界の固定ウィンドウ | 429 |
| 毎分トークン | キー | 応答の `usage` から集計（ベスト エフォート） | 429 |

超過時は `Retry-After` を添える。カウンタはプロセス メモリに持ち、30 秒ごとに SQLite へ
スナップショットして起動時に復元する。プロセス異常終了による制限のリセットを悪用させないため。

トークン計測のための項目注入は行わない。非ストリーミング応答は `usage` をそのまま読み、
ストリーミング応答はクライアントが自ら要求した場合にのみ記録する。

## 5. キーの形式と保管

- 形式は `sk-aig-` + 英数 32 文字。生成には `secrets.choice` を使う
- 保管は SHA-256 のみ。平文は保存しない。`KEY_PEPPER` を設定すると HMAC-SHA-256 になる
- 表示は先頭 12 文字のプレフィックスのみ。平文は発行応答でだけ一度返る
- 失効は無効化フラグと有効期限の両方を持つ

全キーはインメモリ キャッシュに載せ、control API の更新時と 60 秒ごとに読み直す。
ホット パスで SQLite を引かない。

## 6. 環境変数

| 変数 | 既定 | 用途 |
|---|---|---|
| `HOST` | `0.0.0.0` | 待ち受けアドレス |
| `PORT` | `3000` | 公開リスナー |
| `CONTROL_PORT` | `3001` | control リスナー |
| `UPSTREAM_BASE_URL` | 必須 | 上流のベース URL |
| `UPSTREAM_API_KEY` | 空 | 上流の内部キー。空なら `Authorization` を付けない |
| `CONTROL_TOKEN` | 必須 | control API の共有シークレット。16 文字以上 |
| `DB_PATH` | `/data/gateway.db` | SQLite の配置 |
| `PATH_POLICY` | `denylist` | `denylist` または `allowlist` |
| `GLOBAL_CONCURRENCY` | `4` | 全キー横断の同時実行上限。上流の並列スロット数以下にする |
| `GLOBAL_QUEUE_TIMEOUT` | `30` | 横断上限の空き待ち秒数。`0` で待たずに 429 |
| `FILTER_MODELS_RESPONSE` | `true` | `/v1/models` を許可モデルで絞る |
| `LOG_RETENTION_DAYS` | `90` | 利用ログの保持日数 |
| `MAX_REQUEST_BODY_BYTES` | `33554432` | リクエスト本文の上限 |
| `MAX_GRAMMAR_BYTES` | `65536` | `grammar` の上限 |
| `MAX_N_PROBS` | `20` | `n_probs` の上限 |
| `MAX_REQUEST_DURATION` | `1800` | 1 リクエストの最大所要秒数 |
| `IDLE_TIMEOUT` | `300` | ストリーム中の無通信許容秒数 |
| `CONNECT_TIMEOUT` | `10` | 上流への接続確立の上限秒数 |
| `GRACEFUL_TIMEOUT` | `30` | 終了時に進行中のストリームを待つ秒数 |
| `KEY_CACHE_TTL` | `60` | キー キャッシュの読み直し間隔 |
| `RATE_SNAPSHOT_INTERVAL` | `30` | レート制限カウンタの書き出し間隔 |
| `MODELS_CACHE_TTL` | `60` | 上流モデル一覧のキャッシュ寿命 |
| `KEY_PEPPER` | 空 | 設定すると鍵付きハッシュになる |

`GLOBAL_QUEUE_TIMEOUT` は実装計画に列挙されていない追加項目である。
横断上限を「待たせる」か「即座に 429 とする」かを制御するために設けた。
既定の 30 秒は、短い待ちは吸収しつつ、それを超える混雑には `Retry-After` を返す設定である。

`.env` はイメージへ焼き込まない。権限 600 で配置し、所有者をデプロイ実行ユーザーに限定する。

## 7. control API

契約は `control-api-contract.md` に従う。admin との唯一の結合点であり、本実装はその契約に一致する。

| メソッド | パス | 用途 |
|---|---|---|
| GET | `/healthz` | ヘルスチェック（認証不要）。Docker healthcheck が使用 |
| GET / POST | `/keys` | 一覧 / 発行 |
| PATCH / DELETE | `/keys/{id}` | 更新 / 削除 |
| GET | `/usage` | 使用状況集計 |
| GET | `/models` | 上流の実モデル一覧（キャッシュ付き） |
| GET | `/audit` | 監査ログ |

認証は `X-Control-Token` ヘッダの共有シークレット。これは論理的な境界であり、
実質的な境界は Compose ネットワークの分離である。
更新系操作と認証失敗はすべて監査ログに記録する。

すべての 4xx / 5xx は同一のエラー エンベロープを返す。
入力検証エラーは 422 ではなく 400 として同じ形で返す。

## 8. 起動

### Docker

```bash
docker build -t llm-gateway-proxy .
docker run --rm \
  -p 127.0.0.1:3000:3000 \
  -v gateway-data:/data \
  --env-file .env \
  llm-gateway-proxy
```

control ポートは `ports` に載せない。イメージは非 root（uid 10001）で動く。

### ローカル

```bash
python -m venv .venv
.venv/bin/python -m pip install -r requirements-dev.txt
UPSTREAM_BASE_URL=http://192.168.0.13 \
CONTROL_TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))") \
DB_PATH=./gateway.db \
.venv/bin/python -m app.main
```

## 9. 試験

```bash
.venv/bin/python -m pytest -q
```

上流は `httpx.MockTransport` に差し替え、ゲートウェイが実際に送出したバイト列とヘッダを検証する。

| ファイル | 対象 |
|---|---|
| `tests/test_headers.py` | hop-by-hop と `x-forwarded-*` の除去、`Authorization` 差し替え、httpx 既定ヘッダの抑止 |
| `tests/test_policy.py` | パス正規化と拒否境界、モデル抽出、アドミッション |
| `tests/test_model_check.py` | フェイル クローズ。`Content-Type` 変更・本文肥大・旧来パスの 3 経路 |
| `tests/test_limits.py` | スライディング ウィンドウの境界、同時実行の取得順と解放、スナップショット |
| `tests/test_masking.py` | 内部キーのマスク |
| `tests/test_proxy.py` | バイト単位の中継、圧縮の素通し、SSE、`/v1/models` 絞り込み、伏字化 |
| `tests/test_control.py` | control API の契約 |
| `tests/test_storage.py` | スキーマ移行と索引、キー保管、ログ書き込み、起動配線 |

## 10. 既知の差分と制約

| 項目 | 内容 |
|---|---|
| ヘッダ名の大文字小文字 | httpcore が正規化するため、上流が受け取る綴りはクライアントの送出と一致しない場合がある。値と順序は保つ |
| `Content-Length` の再計算 | クライアントが分割送信（chunked）した場合、上流へは実長を伴う非分割の要求として届く |
| httpx の既定ヘッダ | `Accept` `Accept-Encoding` `Connection` `User-Agent` は httpx が自動で補うが、クライアントが送っていないものは組み立て後に取り除く。`Accept-Encoding` のみ `identity` を明示する |
| `Forwarded`（RFC 7239） | 除去対象は `X-Forwarded-*` のみ。`Forwarded` は他のヘッダと同様にそのまま転送する |
| 本文のメモリ保持 | モデル検査のためリクエスト本文を全て読み込む。同時接続数 × `MAX_REQUEST_BODY_BYTES` がメモリの上界になる。コンテナへ 1 GiB を割り当てる場合は `MAX_REQUEST_BODY_BYTES` を下げること |
| ストリーミングのトークン計測 | クライアントが `usage` を要求していない場合は記録できない。件数のみが課金軸になる |
| 圧縮されたエラー本文 | 内部キーの走査は `identity` `gzip` `deflate` のみ復号する。それ以外の符号化では走査を見送る |
| `/admin` | 本ドメインでは管理画面へ割り当てるため、上流の `/admin` 配下へは到達できない |
| 単一プロセス前提 | レート制限カウンタとログ書き込みはプロセス内に閉じている。水平分散時は共有化の再設計が必要 |

## 11. 依存関係

`fastapi` `starlette` `pydantic` `httpx` `uvicorn` を固定版で使う。
admin と同一スタックのため、共通依存の脆弱性は両方へ同時に波及する。
依存更新は両リポジトリで同時に行う。
