Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,6 @@ watch.txt
config.yaml
# Serena MCP tool state
.serena/

# Runtime state (rate-limit windows, key cool-downs) persisted across restarts
.nya_state.json
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q -O - http://127.0.0.1:8080/health || exit 1

ENTRYPOINT ["nyaproxy"]
CMD ["--config", "/app/config.yaml", "--host", "0.0.0.0", "--no-reload"]
# Hot-reload stays on: restarting is how a configuration change is applied, so
# --no-reload silently strands every edit made through the config UI. Rate-limit
# windows and key cool-downs survive the restart (see nya/services/state.py).
CMD ["--config", "/app/config.yaml", "--host", "0.0.0.0"]
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,21 @@ nyaproxy # or: python -m nya
### Docker

```bash
cp configs/openai.yaml config.yaml # then replace every placeholder key
mkdir -p data
cp configs/openai.yaml data/config.yaml # then replace every placeholder key

docker run -d \
-p 8080:8080 \
-v ${PWD}/config.yaml:/app/config.yaml:ro \
k3scat/nya-proxy:latest --config config.yaml --host 0.0.0.0 --no-reload
-v ${PWD}/data:/app \
--user "$(id -u):$(id -g)" \
k3scat/nya-proxy:latest --config /app/config.yaml --host 0.0.0.0
```

The bind-mounted file is the persistent source of truth. Edit it on the host and restart the container. Do not publish the port until `server.api_key` is set.
Mount the **directory**, not `config.yaml` itself. Saving from the `/config` UI writes a temporary file and renames it over the target, and that rename fails with `EBUSY` against a bind-mounted file, so every save returns a 500. The mount must also be writable — a read-only mount blocks saves for the same reason.

`--user` runs the container as you, since the image runs as uid 100 and could not otherwise write a directory you own. Docker Desktop on macOS and Windows maps ownership for you, so you can drop that flag there.

The directory holds `config.yaml` and `.nya_state.json`, which carries rate-limit windows and key cool-downs across restarts so a config change does not reset your quotas. Edit configuration on the host or through the `/config` UI; either way NyaProxy restarts itself to apply it. Do not publish the port until `server.api_key` is set.

### CLI Reference

Expand Down
14 changes: 12 additions & 2 deletions README_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,22 @@ nyaproxy
### Docker

```bash
mkdir -p data
cp configs/openai.yaml data/config.yaml # プレースホルダーのキーをすべて置き換えてください

docker run -d \
-p 8080:8080 \
-v ${PWD}/config.yaml:/app/config.yaml:ro \
k3scat/nya-proxy:latest --config config.yaml --host 0.0.0.0 --no-reload
-v ${PWD}/data:/app \
--user "$(id -u):$(id -g)" \
k3scat/nya-proxy:latest --config /app/config.yaml --host 0.0.0.0
```

`config.yaml` そのものではなく、**ディレクトリ**をマウントしてください。`/config` UI は一時ファイルを書き出してから対象ファイルへリネームして保存しますが、ファイル単位でバインドマウントされたパスではこのリネームが `EBUSY` で失敗し、保存のたびに 500 が返ります。同じ理由で、読み取り専用マウントでも保存できません。

イメージは uid 100 で動作するため、そのままでは自分が所有するディレクトリに書き込めません。`--user` を指定してコンテナを現在のユーザーとして実行します。macOS と Windows の Docker Desktop は所有者を自動的にマッピングするため、このフラグは省略できます。

このディレクトリには `config.yaml` と `.nya_state.json` が置かれます。後者はレート制限のウィンドウとキーのクールダウンを再起動をまたいで保持するため、設定変更でクォータがリセットされることはありません。設定はホスト側でも `/config` UI からでも編集でき、いずれの場合も NyaProxy が自動的に再起動して反映します。`server.api_key` を設定するまでは、ポートを外部に公開しないでください。

## 設定

NyaProxy は YAML で設定します。追加例は [configs](configs/) を参照してください。
Expand Down
14 changes: 12 additions & 2 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,22 @@ nyaproxy
### Docker

```bash
mkdir -p data
cp configs/openai.yaml data/config.yaml # 然后替换其中的占位密钥

docker run -d \
-p 8080:8080 \
-v ${PWD}/config.yaml:/app/config.yaml:ro \
k3scat/nya-proxy:latest --config config.yaml --host 0.0.0.0 --no-reload
-v ${PWD}/data:/app \
--user "$(id -u):$(id -g)" \
k3scat/nya-proxy:latest --config /app/config.yaml --host 0.0.0.0
```

请挂载**目录**,而不是 `config.yaml` 文件本身。`/config` 界面保存配置时,会先写入临时文件再重命名覆盖目标文件;对于以文件方式绑定挂载的路径,该重命名会因 `EBUSY` 失败,导致每次保存都返回 500。挂载还必须可写,只读挂载会因同样的原因导致保存失败。

镜像以 uid 100 运行,无法写入属于你的目录,因此使用 `--user` 让容器以当前用户身份运行。macOS 与 Windows 上的 Docker Desktop 会自动映射属主,可以省略该参数。

该目录存放 `config.yaml` 与 `.nya_state.json`,后者在重启后保留限流窗口与密钥冷却状态,因此修改配置不会重置配额。你可以在宿主机上编辑配置,也可以通过 `/config` 界面修改,NyaProxy 都会自动重启以应用变更。在设置 `server.api_key` 之前,请勿对外暴露端口。

## 配置

NyaProxy 使用 YAML 配置。更多示例见 [configs](configs/)。
Expand Down
4 changes: 4 additions & 0 deletions configs/gemini.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ default_settings:
retry_after_seconds: 1
retry_request_methods: [ POST, GET, PUT, DELETE, PATCH, OPTIONS ]
retry_status_codes: [ 429, 500, 502, 503, 504 ]
key_blocking:
enabled: false # Set to true to take a key out of rotation after the statuses below
status_codes: [ 403 ]
duration_seconds: 300 # How long a quarantined key stays unavailable
timeouts:
request_timeout_seconds: 300

Expand Down
4 changes: 4 additions & 0 deletions configs/novelai.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ default_settings:
retry_after_seconds: 1
retry_request_methods: [ POST, GET, PUT, DELETE, PATCH, OPTIONS ]
retry_status_codes: [ 429, 500, 502, 503, 504 ]
key_blocking:
enabled: false # Set to true to take a key out of rotation after the statuses below
status_codes: [ 403 ]
duration_seconds: 300 # How long a quarantined key stays unavailable
timeouts:
request_timeout_seconds: 300

Expand Down
4 changes: 4 additions & 0 deletions configs/openai.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ default_settings:
retry_after_seconds: 1
retry_request_methods: [ POST, GET, PUT, DELETE, PATCH, OPTIONS ]
retry_status_codes: [ 429, 500, 502, 503, 504 ]
key_blocking:
enabled: false # Set to true to take a key out of rotation after the statuses below
status_codes: [ 403 ]
duration_seconds: 300 # How long a quarantined key stays unavailable
timeouts:
request_timeout_seconds: 300

Expand Down
16 changes: 14 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,17 @@ services:
ports:
- "127.0.0.1:8080:8080"
volumes:
# Create config.yaml first and replace every placeholder credential.
- ./config.yaml:/app/config.yaml:ro
# Mount the DIRECTORY, not config.yaml itself. Saving from the config UI
# writes a temporary file and renames it over the target; that rename
# fails with EBUSY against a bind-mounted file, so every save through the
# UI returns a 500. Read-write for the same reason.
#
# Put your config.yaml in ./data/ before starting, and replace every
# placeholder credential. NyaProxy also writes .nya_state.json here so
# rate-limit windows and key cool-downs survive the restart that applies
# a configuration change.
- ./data:/app
# The image runs as uid 100, which cannot write a directory owned by you.
# This runs the container as your own user instead; the alternative is
# `sudo chown -R 100:101 ./data`.
user: "${UID:-1000}:${GID:-1000}"
4 changes: 4 additions & 0 deletions nya/common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,7 @@
}

WATCH_FILE = "watch.txt"

# A single save in the config UI can emit several change events. Restarts drop
# in-flight requests, so a burst of edits is collapsed into one reload.
RELOAD_DEBOUNCE_SECONDS = 2.0
47 changes: 46 additions & 1 deletion nya/core/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

import asyncio
import time
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union

from ..common.exceptions import APIKeyNotConfiguredError
from ..services.lb import LoadBalancer
from ..services.limit import RateLimiter
from ..services.state import state_key

if TYPE_CHECKING:
from ..config.manager import ConfigManager
Expand Down Expand Up @@ -40,6 +41,9 @@ def __init__(self, config: "ConfigManager"):
self.rate_limiters: Dict[str, RateLimiter] = {}
self._limiter_prune_interval_seconds = 60.0
self._last_limiter_prune = 0.0
# Limiter state carried over from a previous process, applied lazily
# as each limiter is recreated. See import_state.
self._restorable_state: Dict[str, Any] = {}

self._lock = asyncio.Lock()

Expand Down Expand Up @@ -69,12 +73,53 @@ def get_or_create_limiter(self, name: str, rate_limit) -> RateLimiter:
self._prune_idle_limiters()
limiter = self.rate_limiters.get(name)
if not limiter:
# The rate always comes from the current configuration; only the
# consumed window and cool-down are carried over from a previous
# run, so a limit edited across a restart takes effect.
limiter = RateLimiter(rate_limit=rate_limit)
restored = self._restorable_state.pop(state_key(name), None)
if restored:
limiter.restore_state(restored)
self.rate_limiters[name] = limiter
else:
limiter.touch()
return limiter

def export_state(self) -> Dict[str, Any]:
"""
Snapshot limiter state for the next process.

Restarting is how NyaProxy applies configuration changes, so without
this every edit would hand out a fresh burst allowance and release
every quarantined key.

Entries are keyed by a hash of the limiter name, because the name
itself contains the upstream credential or a client IP.
"""
limiters = {}
for name, limiter in self.rate_limiters.items():
state = limiter.export_state()
if state:
limiters[state_key(name)] = state
return {"rate_limiters": limiters}

def import_state(self, state: Dict[str, Any]) -> int:
"""
Stage limiter state from a previous run.

Nothing is applied here: limiters are rebuilt lazily on first use so
they pick up the current configuration, and the staged entry is
applied at that point. Entries for limiters that are never touched
again are simply dropped.
"""
staged = state.get("rate_limiters") or {}
if not isinstance(staged, dict):
return 0
self._restorable_state = {
name: value for name, value in staged.items() if isinstance(value, dict)
}
return len(self._restorable_state)

def _prune_idle_limiters(self) -> None:
"""
Remove stale per-client limiters to avoid unbounded growth.
Expand Down
74 changes: 74 additions & 0 deletions nya/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import contextlib
import os
import sys
import time
from pathlib import Path

import uvicorn
from fastapi import FastAPI, Request
Expand All @@ -20,13 +22,15 @@
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_SCHEMA_NAME,
RELOAD_DEBOUNCE_SECONDS,
WATCH_FILE,
)
from ..common.models import ProxyRequest
from ..config.manager import ConfigManager
from ..core.proxy import NyaProxyCore
from ..dashboard.api import DashboardAPI
from ..services.metrics import PROMETHEUS_CONTENT_TYPE, MetricsCollector
from ..services.state import load_state, resolve_state_path, save_state
from .auth import AuthManager, AuthMiddleware

logger.remove()
Expand Down Expand Up @@ -237,6 +241,9 @@ async def init_nya_services(self):
self.init_metrics_collector()

self.init_core()
# Rate-limit windows and key cool-downs must survive the restart
# that applies a configuration change.
self.restore_runtime_state()
# Mount sub-applications for NyaProxy if available
self.init_config_ui()

Expand Down Expand Up @@ -378,13 +385,63 @@ def setup_proxy_routes(self):
async def proxy_request(request: Request):
return await self.generic_proxy_request(request)

def _state_path(self) -> Path:
"""
Location of the file holding state that outlives the process.

The configured path is taken from the config manager rather than the
environment: the path can also arrive as a constructor argument, and
falling back to the working directory would make two instances
started from the same directory share one another's quotas.
"""
config_path = getattr(self.config, "config_path", None) or os.environ.get(
"CONFIG_PATH"
)
return resolve_state_path(config_path)

def restore_runtime_state(self) -> int:
"""
Re-apply rate-limit windows and key cool-downs from the previous run.

Returns the number of staged limiter entries. Failure here is never
fatal: cold counters are a degradation, refusing to start is an
outage.
"""
if not self.core or not getattr(self.core, "control", None):
return 0
try:
state = load_state(self._state_path())
if not state:
return 0
staged = self.core.control.import_state(state)
if staged:
logger.info(f"Restored rate-limit state for {staged} limiters")
return staged
except Exception as e:
logger.warning(f"Could not restore runtime state: {e}")
return 0

def persist_runtime_state(self) -> bool:
"""Write rate-limit windows and key cool-downs for the next run."""
if not self.core or not getattr(self.core, "control", None):
return False
try:
return save_state(self._state_path(), self.core.control.export_state())
except Exception as e:
logger.warning(f"Could not persist runtime state: {e}")
return False

async def shutdown(self):
"""
Clean up resources on shutdown.
"""

logger.info("Shutting down NyaProxy")

# Persist before tearing anything down, so a restart triggered by a
# config change does not reset quotas or release quarantined keys.
self.persist_runtime_state()

# Close proxy handler client
if self.core and hasattr(self.core, "request_executor"):
try:
Expand Down Expand Up @@ -449,16 +506,33 @@ def create_app():
return nya_proxy_app.app


_last_reload_trigger = 0.0


def trigger_reload(**kwargs):
"""
Trigger a reload of the application by touching the file uvicorn watches.

Restarting is how a configuration change is applied, so this is on the
path of every edit made through the config UI.
"""
global _last_reload_trigger

if os.environ.get("DISABLE_HOT_RELOAD"):
logger.warning(
"Configuration changed but hot-reload is disabled; "
"restart NyaProxy to apply the new settings"
)
return

# A UI save can emit several change events in quick succession, and each
# restart drops in-flight requests. Collapse a burst into one reload.
now = time.monotonic()
if now - _last_reload_trigger < RELOAD_DEBOUNCE_SECONDS:
logger.debug("Configuration changed again; reload already pending")
return
_last_reload_trigger = now

logger.info("Configuration changed, triggering reload...")
with open(WATCH_FILE, "a") as f:
f.write("reload\n")
Expand Down
Loading
Loading