synckey 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synckey/__init__.py +3 -0
- synckey/__main__.py +4 -0
- synckey/bucket.py +140 -0
- synckey/cli.py +1481 -0
- synckey/config.py +150 -0
- synckey/context.py +75 -0
- synckey/crypto.py +41 -0
- synckey/db.py +426 -0
- synckey/deferred.py +41 -0
- synckey/keypool.py +139 -0
- synckey/prefs.py +82 -0
- synckey/providers.py +189 -0
- synckey/router.py +291 -0
- synckey/server.py +672 -0
- synckey/state.py +152 -0
- synckey/tiers.py +157 -0
- synckey/usage.py +99 -0
- synckey-0.1.0.dist-info/METADATA +799 -0
- synckey-0.1.0.dist-info/RECORD +21 -0
- synckey-0.1.0.dist-info/WHEEL +4 -0
- synckey-0.1.0.dist-info/entry_points.txt +2 -0
synckey/config.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Configuration and paths.
|
|
2
|
+
|
|
3
|
+
State lives under $SYNCKEY_HOME (default ~/.synckey):
|
|
4
|
+
secret.key Fernet key for sealing provider credentials (0600)
|
|
5
|
+
synckey.db SQLite: keys, usage, events, key state, metadata
|
|
6
|
+
config.toml user settings and custom providers
|
|
7
|
+
|
|
8
|
+
The unified gateway key is generated once at init time; only its SHA-256 hash
|
|
9
|
+
is persisted, so the plaintext is shown exactly once.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
UNIFIED_KEY_PREFIX = "sk-synckey-"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def home() -> Path:
|
|
24
|
+
return Path(os.environ.get("SYNCKEY_HOME", Path.home() / ".synckey"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def secret_key_path() -> Path:
|
|
28
|
+
return home() / "secret.key"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def db_path() -> Path:
|
|
32
|
+
return Path(os.environ.get("SYNCKEY_DB", home() / "synckey.db"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_path() -> Path:
|
|
36
|
+
return home() / "config.toml"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Settings:
|
|
41
|
+
host: str = "127.0.0.1"
|
|
42
|
+
port: int = 8787
|
|
43
|
+
request_timeout: float = 120.0
|
|
44
|
+
max_connections: int = 600
|
|
45
|
+
max_keepalive: int = 300
|
|
46
|
+
# Fallback: ordered preference when several providers serve the same model.
|
|
47
|
+
provider_priority: list[str] = field(default_factory=list)
|
|
48
|
+
cross_provider_fallback: bool = True
|
|
49
|
+
# Per-request attempt budget across the key pool.
|
|
50
|
+
max_retries: int = 6
|
|
51
|
+
default_cooldown: float = 20.0
|
|
52
|
+
# Tier fallback: allow cross-tier fallback to same-tier alternatives.
|
|
53
|
+
tier_fallback_enabled: bool = True
|
|
54
|
+
# Seconds of cooldown remaining before we skip to tier alternatives entirely.
|
|
55
|
+
deep_cooling_threshold: float = 60.0
|
|
56
|
+
# User-defined model -> tier overrides, e.g. {"my-model": "frontier"}.
|
|
57
|
+
tier_overrides: dict[str, str] = field(default_factory=dict)
|
|
58
|
+
# User-defined model pricing overrides, e.g. {"my-model": [1.0, 3.0]}.
|
|
59
|
+
price_overrides: dict[str, list] = field(default_factory=dict)
|
|
60
|
+
custom_providers: list[dict] = field(default_factory=list)
|
|
61
|
+
# Deferred queue: when all keys are cooling, accept the request, return a
|
|
62
|
+
# request id, run it when capacity frees, and serve the result on a later GET.
|
|
63
|
+
deferred_enabled: bool = True
|
|
64
|
+
deferred_ttl: float = 3600.0 # keep a finished response this long, then purge
|
|
65
|
+
deferred_poll: float = 5.0 # worker scan interval (seconds)
|
|
66
|
+
deferred_max_queue: int = 1000 # reject new deferrals beyond this many queued
|
|
67
|
+
deferred_max_queue_age: float = 86400.0 # drop a job that never ran within this window
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def load(cls) -> "Settings":
|
|
71
|
+
path = config_path()
|
|
72
|
+
if not path.exists():
|
|
73
|
+
return cls()
|
|
74
|
+
data = tomllib.loads(path.read_text())
|
|
75
|
+
gw = data.get("gateway", {})
|
|
76
|
+
routing = data.get("routing", {})
|
|
77
|
+
tiers = data.get("tiers", {})
|
|
78
|
+
deferred = data.get("deferred", {})
|
|
79
|
+
return cls(
|
|
80
|
+
host=gw.get("host", cls.host),
|
|
81
|
+
port=gw.get("port", cls.port),
|
|
82
|
+
request_timeout=gw.get("request_timeout", cls.request_timeout),
|
|
83
|
+
max_connections=gw.get("max_connections", cls.max_connections),
|
|
84
|
+
max_keepalive=gw.get("max_keepalive", cls.max_keepalive),
|
|
85
|
+
provider_priority=routing.get("priority", []),
|
|
86
|
+
cross_provider_fallback=routing.get("cross_provider_fallback", True),
|
|
87
|
+
max_retries=routing.get("max_retries", cls.max_retries),
|
|
88
|
+
default_cooldown=routing.get("default_cooldown", cls.default_cooldown),
|
|
89
|
+
tier_fallback_enabled=routing.get("tier_fallback", True),
|
|
90
|
+
deep_cooling_threshold=routing.get("deep_cooling_threshold", cls.deep_cooling_threshold),
|
|
91
|
+
tier_overrides=tiers.get("overrides", {}),
|
|
92
|
+
price_overrides=tiers.get("prices", {}),
|
|
93
|
+
custom_providers=data.get("providers", []),
|
|
94
|
+
deferred_enabled=deferred.get("enabled", True),
|
|
95
|
+
deferred_ttl=deferred.get("ttl", cls.deferred_ttl),
|
|
96
|
+
deferred_poll=deferred.get("poll", cls.deferred_poll),
|
|
97
|
+
deferred_max_queue=deferred.get("max_queue", cls.deferred_max_queue),
|
|
98
|
+
deferred_max_queue_age=deferred.get("max_queue_age", cls.deferred_max_queue_age),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def save(self) -> None:
|
|
102
|
+
"""Write current settings back to config.toml."""
|
|
103
|
+
import tomllib
|
|
104
|
+
|
|
105
|
+
path = config_path()
|
|
106
|
+
data = {}
|
|
107
|
+
if path.exists():
|
|
108
|
+
try:
|
|
109
|
+
data = tomllib.loads(path.read_text())
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
data.setdefault("gateway", {})["host"] = self.host
|
|
113
|
+
data.setdefault("gateway", {})["port"] = self.port
|
|
114
|
+
data.setdefault("gateway", {})["request_timeout"] = self.request_timeout
|
|
115
|
+
data.setdefault("gateway", {})["max_connections"] = self.max_connections
|
|
116
|
+
data.setdefault("gateway", {})["max_keepalive"] = self.max_keepalive
|
|
117
|
+
data.setdefault("routing", {})["priority"] = self.provider_priority
|
|
118
|
+
data.setdefault("routing", {})["cross_provider_fallback"] = self.cross_provider_fallback
|
|
119
|
+
data.setdefault("routing", {})["max_retries"] = self.max_retries
|
|
120
|
+
data.setdefault("routing", {})["default_cooldown"] = self.default_cooldown
|
|
121
|
+
data.setdefault("routing", {})["tier_fallback"] = self.tier_fallback_enabled
|
|
122
|
+
data.setdefault("routing", {})["deep_cooling_threshold"] = self.deep_cooling_threshold
|
|
123
|
+
data.setdefault("tiers", {})["overrides"] = self.tier_overrides
|
|
124
|
+
data.setdefault("tiers", {})["prices"] = self.price_overrides
|
|
125
|
+
data["providers"] = self.custom_providers
|
|
126
|
+
data.setdefault("deferred", {})["enabled"] = self.deferred_enabled
|
|
127
|
+
data.setdefault("deferred", {})["ttl"] = self.deferred_ttl
|
|
128
|
+
data.setdefault("deferred", {})["poll"] = self.deferred_poll
|
|
129
|
+
data.setdefault("deferred", {})["max_queue"] = self.deferred_max_queue
|
|
130
|
+
data.setdefault("deferred", {})["max_queue_age"] = self.deferred_max_queue_age
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
import tomli
|
|
134
|
+
except ImportError:
|
|
135
|
+
import toml as tomli
|
|
136
|
+
path.write_text(tomli.dumps(data))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ensure_home() -> Path:
|
|
140
|
+
h = home()
|
|
141
|
+
h.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
try:
|
|
143
|
+
os.chmod(h, 0o700)
|
|
144
|
+
except OSError:
|
|
145
|
+
pass
|
|
146
|
+
return h
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def generate_unified_key() -> str:
|
|
150
|
+
return UNIFIED_KEY_PREFIX + secrets.token_urlsafe(32)
|
synckey/context.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Shared runtime wiring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .config import Settings, db_path, secret_key_path
|
|
8
|
+
from .crypto import SecretBox
|
|
9
|
+
from .db import Database
|
|
10
|
+
from .keypool import KeyPool
|
|
11
|
+
from .prefs import Prefs
|
|
12
|
+
from .providers import BUILTIN, Provider
|
|
13
|
+
from .router import Router
|
|
14
|
+
from .state import StateStore
|
|
15
|
+
from .tiers import load_overrides
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def merged_providers(settings: Settings) -> dict[str, Provider]:
|
|
19
|
+
providers = dict(BUILTIN)
|
|
20
|
+
for spec in settings.custom_providers:
|
|
21
|
+
try:
|
|
22
|
+
prov = Provider(
|
|
23
|
+
id=spec["id"],
|
|
24
|
+
name=spec.get("name", spec["id"]),
|
|
25
|
+
base_url=spec["base_url"],
|
|
26
|
+
models_path=spec.get("models_path", "/models"),
|
|
27
|
+
env=tuple(spec.get("env", ())),
|
|
28
|
+
patterns=tuple(spec.get("patterns", ())),
|
|
29
|
+
auth=spec.get("auth", "bearer"),
|
|
30
|
+
signup=spec.get("signup", ""),
|
|
31
|
+
notes=spec.get("notes", ""),
|
|
32
|
+
)
|
|
33
|
+
providers[prov.id] = prov
|
|
34
|
+
except KeyError:
|
|
35
|
+
continue
|
|
36
|
+
return providers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Context:
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self.settings = Settings.load()
|
|
42
|
+
self.providers = merged_providers(self.settings)
|
|
43
|
+
self.box = SecretBox(secret_key_path())
|
|
44
|
+
self.db = Database(db_path())
|
|
45
|
+
self.prefs = Prefs(self.db)
|
|
46
|
+
# Tier overrides come from two places: config.toml (static, versioned) and
|
|
47
|
+
# the DB (set via `synckey tier set`). DB wins on conflict.
|
|
48
|
+
merged_tiers = {**self.settings.tier_overrides, **self.prefs.tier_overrides()}
|
|
49
|
+
load_overrides(merged_tiers, self.settings.price_overrides)
|
|
50
|
+
self.states = StateStore(self.db.path)
|
|
51
|
+
self.pool = KeyPool(
|
|
52
|
+
self.db,
|
|
53
|
+
self.box,
|
|
54
|
+
self.states,
|
|
55
|
+
default_cooldown=self.settings.default_cooldown,
|
|
56
|
+
)
|
|
57
|
+
self.router = Router(self.db, self.providers, self.settings, self.prefs)
|
|
58
|
+
|
|
59
|
+
def first_secret(self, provider_id: str) -> str | None:
|
|
60
|
+
keys = self.db.list_keys(provider=provider_id, enabled_only=True)
|
|
61
|
+
if keys:
|
|
62
|
+
return self.box.open(keys[0].secret)
|
|
63
|
+
prov = self.providers.get(provider_id)
|
|
64
|
+
if prov:
|
|
65
|
+
for env in prov.env:
|
|
66
|
+
if os.environ.get(env):
|
|
67
|
+
return os.environ[env]
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def unified_key_hash(self) -> str | None:
|
|
71
|
+
return self.db.get_meta("unified_key_hash")
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
self.states.close()
|
|
75
|
+
self.db.close()
|
synckey/crypto.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""At-rest encryption for stored provider secrets.
|
|
2
|
+
|
|
3
|
+
One symmetric key lives in ~/.synckey/secret.key with 0600 perms. Provider
|
|
4
|
+
credentials are sealed with Fernet so the database never holds plaintext. Lose
|
|
5
|
+
the secret file and the stored credentials are unrecoverable by design.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SecretBox:
|
|
17
|
+
def __init__(self, key_path: Path):
|
|
18
|
+
self.key_path = key_path
|
|
19
|
+
self._fernet = Fernet(self._load_or_create())
|
|
20
|
+
|
|
21
|
+
def _load_or_create(self) -> bytes:
|
|
22
|
+
if self.key_path.exists():
|
|
23
|
+
return self.key_path.read_bytes()
|
|
24
|
+
self.key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
key = Fernet.generate_key()
|
|
26
|
+
fd = os.open(self.key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
27
|
+
with os.fdopen(fd, "wb") as fh:
|
|
28
|
+
fh.write(key)
|
|
29
|
+
return key
|
|
30
|
+
|
|
31
|
+
def seal(self, plaintext: str) -> bytes:
|
|
32
|
+
return self._fernet.encrypt(plaintext.encode("utf-8"))
|
|
33
|
+
|
|
34
|
+
def open(self, token: bytes) -> str:
|
|
35
|
+
try:
|
|
36
|
+
return self._fernet.decrypt(token).decode("utf-8")
|
|
37
|
+
except InvalidToken as exc:
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"Could not decrypt a stored secret. The secret.key file may have "
|
|
40
|
+
"changed. Re-add affected keys with `synckey setup`."
|
|
41
|
+
) from exc
|
synckey/db.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""SQLite persistence: credentials, usage records, events, metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SCHEMA = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
14
|
+
name TEXT PRIMARY KEY,
|
|
15
|
+
value TEXT NOT NULL
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS keys (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
provider TEXT NOT NULL,
|
|
21
|
+
label TEXT NOT NULL,
|
|
22
|
+
secret BLOB NOT NULL,
|
|
23
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
24
|
+
weight INTEGER NOT NULL DEFAULT 1,
|
|
25
|
+
rpm_limit REAL,
|
|
26
|
+
tpm_limit REAL,
|
|
27
|
+
created_at REAL NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS usage (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
ts REAL NOT NULL,
|
|
33
|
+
provider TEXT NOT NULL,
|
|
34
|
+
model TEXT NOT NULL,
|
|
35
|
+
key_id INTEGER,
|
|
36
|
+
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
cost_usd REAL,
|
|
40
|
+
status_code INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
stream INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
tier INTEGER,
|
|
44
|
+
error TEXT
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
48
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
|
+
ts REAL NOT NULL,
|
|
50
|
+
type TEXT NOT NULL,
|
|
51
|
+
key_id INTEGER,
|
|
52
|
+
provider TEXT,
|
|
53
|
+
model TEXT,
|
|
54
|
+
fallback_to TEXT,
|
|
55
|
+
tier_from INTEGER,
|
|
56
|
+
tier_to INTEGER,
|
|
57
|
+
chain TEXT,
|
|
58
|
+
message TEXT
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS deferred (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
created_at REAL NOT NULL,
|
|
64
|
+
status TEXT NOT NULL, -- queued | running | done | error
|
|
65
|
+
path TEXT NOT NULL, -- e.g. /chat/completions
|
|
66
|
+
body TEXT NOT NULL, -- JSON request body (non-stream)
|
|
67
|
+
model TEXT, -- original model string (may be null)
|
|
68
|
+
provider_hint TEXT,
|
|
69
|
+
floor INTEGER,
|
|
70
|
+
eta REAL, -- approx unix time the call can run
|
|
71
|
+
response BLOB, -- stored upstream bytes when done
|
|
72
|
+
status_code INTEGER,
|
|
73
|
+
error TEXT,
|
|
74
|
+
completed_at REAL,
|
|
75
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage(ts);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_usage_provider ON usage(provider);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_keys_provider ON keys(provider);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_deferred_status ON deferred(status);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS routing_chains (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
ts REAL NOT NULL,
|
|
87
|
+
model TEXT NOT NULL,
|
|
88
|
+
provider TEXT NOT NULL,
|
|
89
|
+
key_id INTEGER,
|
|
90
|
+
chain TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
USAGE_COLS = (
|
|
95
|
+
"ts, provider, model, key_id, prompt_tokens, completion_tokens, "
|
|
96
|
+
"total_tokens, cost_usd, status_code, latency_ms, stream, tier, error"
|
|
97
|
+
)
|
|
98
|
+
USAGE_INSERT = f"INSERT INTO usage({USAGE_COLS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def tune(conn: sqlite3.Connection) -> None:
|
|
102
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
103
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
104
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
105
|
+
conn.execute("PRAGMA temp_store=MEMORY")
|
|
106
|
+
conn.execute("PRAGMA cache_size=-8000")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class KeyRecord:
|
|
111
|
+
id: int
|
|
112
|
+
provider: str
|
|
113
|
+
label: str
|
|
114
|
+
secret: bytes
|
|
115
|
+
enabled: bool
|
|
116
|
+
weight: int
|
|
117
|
+
rpm_limit: float | None
|
|
118
|
+
tpm_limit: float | None
|
|
119
|
+
created_at: float
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sha256(text: str) -> str:
|
|
123
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Database:
|
|
127
|
+
def __init__(self, path: Path):
|
|
128
|
+
self.path = Path(path)
|
|
129
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
self.conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
131
|
+
self.conn.row_factory = sqlite3.Row
|
|
132
|
+
tune(self.conn)
|
|
133
|
+
self.conn.executescript(SCHEMA)
|
|
134
|
+
self.conn.commit()
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
self.conn.close()
|
|
138
|
+
|
|
139
|
+
# meta
|
|
140
|
+
def set_meta(self, name: str, value: str) -> None:
|
|
141
|
+
self.conn.execute(
|
|
142
|
+
"INSERT INTO meta(name, value) VALUES(?,?) "
|
|
143
|
+
"ON CONFLICT(name) DO UPDATE SET value=excluded.value",
|
|
144
|
+
(name, value),
|
|
145
|
+
)
|
|
146
|
+
self.conn.commit()
|
|
147
|
+
|
|
148
|
+
def get_meta(self, name: str) -> str | None:
|
|
149
|
+
row = self.conn.execute("SELECT value FROM meta WHERE name=?", (name,)).fetchone()
|
|
150
|
+
return row["value"] if row else None
|
|
151
|
+
|
|
152
|
+
def get_json(self, name: str, default=None):
|
|
153
|
+
raw = self.get_meta(name)
|
|
154
|
+
if not raw:
|
|
155
|
+
return default
|
|
156
|
+
try:
|
|
157
|
+
return json.loads(raw)
|
|
158
|
+
except ValueError:
|
|
159
|
+
return default
|
|
160
|
+
|
|
161
|
+
def set_json(self, name: str, obj) -> None:
|
|
162
|
+
self.set_meta(name, json.dumps(obj))
|
|
163
|
+
|
|
164
|
+
# keys
|
|
165
|
+
def add_key(
|
|
166
|
+
self,
|
|
167
|
+
provider: str,
|
|
168
|
+
label: str,
|
|
169
|
+
secret: bytes,
|
|
170
|
+
weight: int = 1,
|
|
171
|
+
rpm_limit: float | None = None,
|
|
172
|
+
tpm_limit: float | None = None,
|
|
173
|
+
) -> int:
|
|
174
|
+
cur = self.conn.execute(
|
|
175
|
+
"INSERT INTO keys(provider, label, secret, weight, rpm_limit, tpm_limit, created_at) "
|
|
176
|
+
"VALUES(?,?,?,?,?,?,?)",
|
|
177
|
+
(provider, label, secret, weight, rpm_limit, tpm_limit, time.time()),
|
|
178
|
+
)
|
|
179
|
+
self.conn.commit()
|
|
180
|
+
return int(cur.lastrowid)
|
|
181
|
+
|
|
182
|
+
def remove_key(self, key_id: int) -> bool:
|
|
183
|
+
cur = self.conn.execute("DELETE FROM keys WHERE id=?", (key_id,))
|
|
184
|
+
self.conn.commit()
|
|
185
|
+
return cur.rowcount > 0
|
|
186
|
+
|
|
187
|
+
def set_key_enabled(self, key_id: int, enabled: bool) -> None:
|
|
188
|
+
self.conn.execute("UPDATE keys SET enabled=? WHERE id=?", (1 if enabled else 0, key_id))
|
|
189
|
+
self.conn.commit()
|
|
190
|
+
|
|
191
|
+
def set_key_limits(self, key_id: int, rpm: float | None, tpm: float | None) -> None:
|
|
192
|
+
self.conn.execute(
|
|
193
|
+
"UPDATE keys SET rpm_limit=?, tpm_limit=? WHERE id=?", (rpm, tpm, key_id)
|
|
194
|
+
)
|
|
195
|
+
self.conn.commit()
|
|
196
|
+
|
|
197
|
+
def list_keys(self, provider: str | None = None, enabled_only: bool = False) -> list[KeyRecord]:
|
|
198
|
+
sql = "SELECT * FROM keys"
|
|
199
|
+
clauses, params = [], []
|
|
200
|
+
if provider:
|
|
201
|
+
clauses.append("provider=?")
|
|
202
|
+
params.append(provider)
|
|
203
|
+
if enabled_only:
|
|
204
|
+
clauses.append("enabled=1")
|
|
205
|
+
if clauses:
|
|
206
|
+
sql += " WHERE " + " AND ".join(clauses)
|
|
207
|
+
sql += " ORDER BY provider, id"
|
|
208
|
+
return [
|
|
209
|
+
KeyRecord(
|
|
210
|
+
id=r["id"],
|
|
211
|
+
provider=r["provider"],
|
|
212
|
+
label=r["label"],
|
|
213
|
+
secret=r["secret"],
|
|
214
|
+
enabled=bool(r["enabled"]),
|
|
215
|
+
weight=r["weight"],
|
|
216
|
+
rpm_limit=r["rpm_limit"],
|
|
217
|
+
tpm_limit=r["tpm_limit"],
|
|
218
|
+
created_at=r["created_at"],
|
|
219
|
+
)
|
|
220
|
+
for r in self.conn.execute(sql, params).fetchall()
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
def providers_with_keys(self) -> list[str]:
|
|
224
|
+
rows = self.conn.execute(
|
|
225
|
+
"SELECT DISTINCT provider FROM keys WHERE enabled=1 ORDER BY provider"
|
|
226
|
+
).fetchall()
|
|
227
|
+
return [r["provider"] for r in rows]
|
|
228
|
+
|
|
229
|
+
def key_stats(self) -> dict[int, dict]:
|
|
230
|
+
"""Per-key request/error/token/cost totals from usage log."""
|
|
231
|
+
rows = self.conn.execute(
|
|
232
|
+
"SELECT key_id, COUNT(*) reqs, "
|
|
233
|
+
"SUM(CASE WHEN status_code>=400 OR error IS NOT NULL THEN 1 ELSE 0 END) errs, "
|
|
234
|
+
"SUM(total_tokens) tokens, SUM(cost_usd) cost "
|
|
235
|
+
"FROM usage WHERE key_id IS NOT NULL GROUP BY key_id"
|
|
236
|
+
).fetchall()
|
|
237
|
+
return {
|
|
238
|
+
r["key_id"]: {
|
|
239
|
+
"requests": r["reqs"],
|
|
240
|
+
"errors": r["errs"] or 0,
|
|
241
|
+
"tokens": r["tokens"] or 0,
|
|
242
|
+
"cost": r["cost"] or 0.0,
|
|
243
|
+
}
|
|
244
|
+
for r in rows
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
# events
|
|
248
|
+
def record_event(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
type: str,
|
|
252
|
+
key_id: int | None = None,
|
|
253
|
+
provider: str | None = None,
|
|
254
|
+
model: str | None = None,
|
|
255
|
+
fallback_to: str | None = None,
|
|
256
|
+
tier_from: int | None = None,
|
|
257
|
+
tier_to: int | None = None,
|
|
258
|
+
chain: str | None = None,
|
|
259
|
+
message: str | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
self.conn.execute(
|
|
262
|
+
"INSERT INTO events(ts,type,key_id,provider,model,fallback_to,tier_from,tier_to,chain,message) "
|
|
263
|
+
"VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
264
|
+
(time.time(), type, key_id, provider, model, fallback_to, tier_from, tier_to, chain, message),
|
|
265
|
+
)
|
|
266
|
+
self.conn.commit()
|
|
267
|
+
|
|
268
|
+
def record_routing_chain(
|
|
269
|
+
self,
|
|
270
|
+
*,
|
|
271
|
+
model: str,
|
|
272
|
+
provider: str,
|
|
273
|
+
key_id: int,
|
|
274
|
+
chain: str,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Record the full routing chain for a request for TUI view 4."""
|
|
277
|
+
self.conn.execute(
|
|
278
|
+
"INSERT INTO routing_chains(ts, model, provider, key_id, chain) VALUES(?,?,?,?,?)",
|
|
279
|
+
(time.time(), model, provider, key_id, chain),
|
|
280
|
+
)
|
|
281
|
+
self.conn.commit()
|
|
282
|
+
|
|
283
|
+
def recent_events(self, limit: int = 50, event_type: str | None = None) -> list[sqlite3.Row]:
|
|
284
|
+
if event_type:
|
|
285
|
+
return self.conn.execute(
|
|
286
|
+
"SELECT * FROM events WHERE type=? ORDER BY ts DESC LIMIT ?", (event_type, limit)
|
|
287
|
+
).fetchall()
|
|
288
|
+
return self.conn.execute(
|
|
289
|
+
"SELECT * FROM events ORDER BY ts DESC LIMIT ?", (limit,)
|
|
290
|
+
).fetchall()
|
|
291
|
+
|
|
292
|
+
def recent_routing_chains(self, limit: int = 20) -> list[sqlite3.Row]:
|
|
293
|
+
"""Return the most recent routing chains for TUI view 4."""
|
|
294
|
+
return self.conn.execute(
|
|
295
|
+
"SELECT * FROM routing_chains ORDER BY ts DESC LIMIT ?", (limit,)
|
|
296
|
+
).fetchall()
|
|
297
|
+
|
|
298
|
+
def routing_chain_by_id(self, chain_id: int) -> sqlite3.Row | None:
|
|
299
|
+
return self.conn.execute(
|
|
300
|
+
"SELECT * FROM routing_chains WHERE id=?", (chain_id,)
|
|
301
|
+
).fetchone()
|
|
302
|
+
|
|
303
|
+
# usage reads
|
|
304
|
+
def usage_summary(self, since: float | None = None) -> list[sqlite3.Row]:
|
|
305
|
+
sql = (
|
|
306
|
+
"SELECT provider, model, tier, COUNT(*) requests, "
|
|
307
|
+
"SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens, "
|
|
308
|
+
"SUM(total_tokens) total_tokens, SUM(cost_usd) cost_usd, "
|
|
309
|
+
"SUM(CASE WHEN status_code>=400 OR error IS NOT NULL THEN 1 ELSE 0 END) errors, "
|
|
310
|
+
"AVG(latency_ms) avg_latency FROM usage"
|
|
311
|
+
)
|
|
312
|
+
params: list = []
|
|
313
|
+
if since is not None:
|
|
314
|
+
sql += " WHERE ts>=?"
|
|
315
|
+
params.append(since)
|
|
316
|
+
sql += " GROUP BY provider, model ORDER BY total_tokens DESC"
|
|
317
|
+
return self.conn.execute(sql, params).fetchall()
|
|
318
|
+
|
|
319
|
+
def usage_totals(self, since: float | None = None) -> sqlite3.Row:
|
|
320
|
+
sql = (
|
|
321
|
+
"SELECT COUNT(*) requests, SUM(total_tokens) total_tokens, "
|
|
322
|
+
"SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens, "
|
|
323
|
+
"SUM(cost_usd) cost_usd, "
|
|
324
|
+
"SUM(CASE WHEN status_code>=400 OR error IS NOT NULL THEN 1 ELSE 0 END) errors "
|
|
325
|
+
"FROM usage"
|
|
326
|
+
)
|
|
327
|
+
params: list = []
|
|
328
|
+
if since is not None:
|
|
329
|
+
sql += " WHERE ts>=?"
|
|
330
|
+
params.append(since)
|
|
331
|
+
return self.conn.execute(sql, params).fetchone()
|
|
332
|
+
|
|
333
|
+
def recent_usage(self, limit: int = 25) -> list[sqlite3.Row]:
|
|
334
|
+
return self.conn.execute(
|
|
335
|
+
"SELECT * FROM usage ORDER BY ts DESC LIMIT ?", (limit,)
|
|
336
|
+
).fetchall()
|
|
337
|
+
|
|
338
|
+
# deferred queue
|
|
339
|
+
def create_deferred(
|
|
340
|
+
self,
|
|
341
|
+
*,
|
|
342
|
+
id: str,
|
|
343
|
+
path: str,
|
|
344
|
+
body: str,
|
|
345
|
+
model: str | None,
|
|
346
|
+
provider_hint: str | None,
|
|
347
|
+
floor: int | None,
|
|
348
|
+
eta: float | None,
|
|
349
|
+
) -> None:
|
|
350
|
+
self.conn.execute(
|
|
351
|
+
"INSERT INTO deferred(id, created_at, status, path, body, model, "
|
|
352
|
+
"provider_hint, floor, eta) VALUES(?,?,?,?,?,?,?,?,?)",
|
|
353
|
+
(id, time.time(), "queued", path, body, model, provider_hint, floor, eta),
|
|
354
|
+
)
|
|
355
|
+
self.conn.commit()
|
|
356
|
+
|
|
357
|
+
def get_deferred(self, id: str) -> sqlite3.Row | None:
|
|
358
|
+
return self.conn.execute("SELECT * FROM deferred WHERE id=?", (id,)).fetchone()
|
|
359
|
+
|
|
360
|
+
def queued_deferred(self, limit: int = 50) -> list[sqlite3.Row]:
|
|
361
|
+
return self.conn.execute(
|
|
362
|
+
"SELECT * FROM deferred WHERE status='queued' ORDER BY created_at LIMIT ?",
|
|
363
|
+
(limit,),
|
|
364
|
+
).fetchall()
|
|
365
|
+
|
|
366
|
+
def count_deferred(self, status: str | None = None) -> int:
|
|
367
|
+
if status:
|
|
368
|
+
row = self.conn.execute(
|
|
369
|
+
"SELECT COUNT(*) c FROM deferred WHERE status=?", (status,)
|
|
370
|
+
).fetchone()
|
|
371
|
+
else:
|
|
372
|
+
row = self.conn.execute("SELECT COUNT(*) c FROM deferred").fetchone()
|
|
373
|
+
return row["c"]
|
|
374
|
+
|
|
375
|
+
def recent_deferred(self, limit: int = 50) -> list[sqlite3.Row]:
|
|
376
|
+
return self.conn.execute(
|
|
377
|
+
"SELECT * FROM deferred ORDER BY created_at DESC LIMIT ?", (limit,)
|
|
378
|
+
).fetchall()
|
|
379
|
+
|
|
380
|
+
def set_deferred_running(self, id: str) -> None:
|
|
381
|
+
self.conn.execute(
|
|
382
|
+
"UPDATE deferred SET status='running', attempts=attempts+1 WHERE id=?", (id,)
|
|
383
|
+
)
|
|
384
|
+
self.conn.commit()
|
|
385
|
+
|
|
386
|
+
def requeue_deferred(self, id: str, eta: float | None) -> None:
|
|
387
|
+
self.conn.execute(
|
|
388
|
+
"UPDATE deferred SET status='queued', eta=? WHERE id=?", (eta, id)
|
|
389
|
+
)
|
|
390
|
+
self.conn.commit()
|
|
391
|
+
|
|
392
|
+
def complete_deferred(self, id: str, status_code: int, response: bytes) -> None:
|
|
393
|
+
self.conn.execute(
|
|
394
|
+
"UPDATE deferred SET status='done', status_code=?, response=?, completed_at=? "
|
|
395
|
+
"WHERE id=?",
|
|
396
|
+
(status_code, response, time.time(), id),
|
|
397
|
+
)
|
|
398
|
+
self.conn.commit()
|
|
399
|
+
|
|
400
|
+
def fail_deferred(self, id: str, status_code: int, error: str) -> None:
|
|
401
|
+
self.conn.execute(
|
|
402
|
+
"UPDATE deferred SET status='error', status_code=?, error=?, completed_at=? "
|
|
403
|
+
"WHERE id=?",
|
|
404
|
+
(status_code, error[:500], time.time(), id),
|
|
405
|
+
)
|
|
406
|
+
self.conn.commit()
|
|
407
|
+
|
|
408
|
+
def reset_running_deferred(self) -> int:
|
|
409
|
+
"""On startup, return any 'running' jobs (from a crash) to the queue."""
|
|
410
|
+
cur = self.conn.execute(
|
|
411
|
+
"UPDATE deferred SET status='queued' WHERE status='running'"
|
|
412
|
+
)
|
|
413
|
+
self.conn.commit()
|
|
414
|
+
return cur.rowcount
|
|
415
|
+
|
|
416
|
+
def purge_deferred(self, ttl: float, max_queue_age: float) -> int:
|
|
417
|
+
"""Delete finished jobs older than `ttl` and queued jobs stuck past `max_queue_age`."""
|
|
418
|
+
now = time.time()
|
|
419
|
+
cur = self.conn.execute(
|
|
420
|
+
"DELETE FROM deferred WHERE "
|
|
421
|
+
"(status IN ('done','error') AND completed_at IS NOT NULL AND completed_at < ?) "
|
|
422
|
+
"OR (status='queued' AND created_at < ?)",
|
|
423
|
+
(now - ttl, now - max_queue_age),
|
|
424
|
+
)
|
|
425
|
+
self.conn.commit()
|
|
426
|
+
return cur.rowcount
|