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/state.py ADDED
@@ -0,0 +1,152 @@
1
+ """Durable key health state.
2
+
3
+ In-memory dict is the hot path (zero I/O on reads). SQLite is the durability
4
+ layer: written immediately on every health transition so a restart never forgets
5
+ which keys are cooling or dead.
6
+
7
+ Health transitions:
8
+ LIVE -> COOLING on 429 (auto-recovers after cooldown_until)
9
+ LIVE -> DEAD on 401/403 or explicit quota exhaustion
10
+ COOLING -> LIVE automatically when cooldown_until passes (no write needed)
11
+ DEAD -> LIVE only via `synckey key enable` (user action)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from dataclasses import dataclass
18
+ from enum import IntEnum
19
+
20
+ import sqlite3
21
+ from pathlib import Path
22
+
23
+
24
+ class Health(IntEnum):
25
+ LIVE = 0
26
+ COOLING = 1
27
+ DEAD = 2
28
+
29
+
30
+ @dataclass
31
+ class KeyState:
32
+ health: Health = Health.LIVE
33
+ cooldown_until: float = 0.0
34
+ dead_reason: str = ""
35
+ rpm_observed: float | None = None
36
+ tpm_observed: float | None = None
37
+
38
+ def is_available(self) -> bool:
39
+ if self.health == Health.DEAD:
40
+ return False
41
+ if self.health == Health.COOLING and time.time() < self.cooldown_until:
42
+ return False
43
+ return True
44
+
45
+ def cooldown_remaining(self) -> float:
46
+ if self.health == Health.COOLING:
47
+ return max(0.0, self.cooldown_until - time.time())
48
+ return 0.0
49
+
50
+
51
+ _CREATE = """
52
+ CREATE TABLE IF NOT EXISTS key_state (
53
+ key_id INTEGER PRIMARY KEY,
54
+ health INTEGER NOT NULL DEFAULT 0,
55
+ cooldown_until REAL NOT NULL DEFAULT 0,
56
+ dead_reason TEXT NOT NULL DEFAULT '',
57
+ rpm_observed REAL,
58
+ tpm_observed REAL,
59
+ updated_at REAL NOT NULL DEFAULT 0
60
+ );
61
+ """
62
+
63
+ _UPSERT = """
64
+ INSERT INTO key_state(key_id, health, cooldown_until, dead_reason, rpm_observed, tpm_observed, updated_at)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?)
66
+ ON CONFLICT(key_id) DO UPDATE SET
67
+ health = excluded.health,
68
+ cooldown_until = excluded.cooldown_until,
69
+ dead_reason = excluded.dead_reason,
70
+ rpm_observed = excluded.rpm_observed,
71
+ tpm_observed = excluded.tpm_observed,
72
+ updated_at = excluded.updated_at
73
+ """
74
+
75
+
76
+ class StateStore:
77
+ """In-memory key health store backed by SQLite for durability."""
78
+
79
+ def __init__(self, db_path: Path):
80
+ self._mem: dict[int, KeyState] = {}
81
+ self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
82
+ self._conn.execute("PRAGMA journal_mode=WAL")
83
+ self._conn.execute("PRAGMA synchronous=NORMAL")
84
+ self._conn.execute(_CREATE)
85
+ self._conn.commit()
86
+ self._load()
87
+
88
+ def _load(self) -> None:
89
+ now = time.time()
90
+ for row in self._conn.execute("SELECT * FROM key_state").fetchall():
91
+ key_id, health, cooldown_until, dead_reason, rpm_obs, tpm_obs, _ = row
92
+ h = Health(health)
93
+ # Expired cooldowns auto-recover to LIVE on load.
94
+ if h == Health.COOLING and cooldown_until <= now:
95
+ h = Health.LIVE
96
+ cooldown_until = 0.0
97
+ self._mem[key_id] = KeyState(
98
+ health=h,
99
+ cooldown_until=cooldown_until,
100
+ dead_reason=dead_reason or "",
101
+ rpm_observed=rpm_obs,
102
+ tpm_observed=tpm_obs,
103
+ )
104
+
105
+ def _persist(self, key_id: int) -> None:
106
+ st = self._mem[key_id]
107
+ self._conn.execute(
108
+ _UPSERT,
109
+ (
110
+ key_id,
111
+ int(st.health),
112
+ st.cooldown_until,
113
+ st.dead_reason,
114
+ st.rpm_observed,
115
+ st.tpm_observed,
116
+ time.time(),
117
+ ),
118
+ )
119
+ self._conn.commit()
120
+
121
+ def get(self, key_id: int) -> KeyState:
122
+ return self._mem.get(key_id, KeyState())
123
+
124
+ def set_cooling(self, key_id: int, until: float, rpm_observed: float | None = None) -> None:
125
+ st = self._mem.get(key_id, KeyState())
126
+ st.health = Health.COOLING
127
+ st.cooldown_until = until
128
+ if rpm_observed is not None:
129
+ st.rpm_observed = rpm_observed
130
+ self._mem[key_id] = st
131
+ self._persist(key_id)
132
+
133
+ def set_dead(self, key_id: int, reason: str) -> None:
134
+ st = self._mem.get(key_id, KeyState())
135
+ st.health = Health.DEAD
136
+ st.dead_reason = reason
137
+ self._mem[key_id] = st
138
+ self._persist(key_id)
139
+
140
+ def set_live(self, key_id: int) -> None:
141
+ st = self._mem.get(key_id, KeyState())
142
+ if st.health != Health.LIVE:
143
+ st.health = Health.LIVE
144
+ st.cooldown_until = 0.0
145
+ self._mem[key_id] = st
146
+ self._persist(key_id)
147
+
148
+ def all(self) -> dict[int, KeyState]:
149
+ return dict(self._mem)
150
+
151
+ def close(self) -> None:
152
+ self._conn.close()
synckey/tiers.py ADDED
@@ -0,0 +1,157 @@
1
+ """Model tier catalog, pricing, and tier-floored fallback logic.
2
+
3
+ Tiers:
4
+ FRONTIER (4) best quality, most expensive (Opus, GPT-5, o3)
5
+ HIGH (3) capable, premium (Sonnet, GPT-4o, o1)
6
+ MID (2) fast, affordable (Haiku, GPT-4o-mini, Flash, 70b)
7
+ LOW (1) budget, high-volume (8b, Gemma, Qwen)
8
+
9
+ A request for a frontier model will never fall back below FRONTIER unless the
10
+ caller explicitly sets X-Quality-Floor: high (or lower). This is the Opus ->
11
+ GPT-5 guarantee, never Opus -> oss-llama.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ FRONTIER = 4
19
+ HIGH = 3
20
+ MID = 2
21
+ LOW = 1
22
+
23
+ TIER_NAMES = {FRONTIER: "frontier", HIGH: "high", MID: "mid", LOW: "low"}
24
+ TIER_BY_NAME = {v: k for k, v in TIER_NAMES.items()}
25
+
26
+ # (pattern, tier) -- checked in order; more-specific patterns first.
27
+ # gpt-4o-mini must come before gpt-4o to avoid premature match.
28
+ _PATTERNS: list[tuple[re.Pattern, int]] = [
29
+ (re.compile(r"claude-opus", re.I), FRONTIER),
30
+ (re.compile(r"gpt-5", re.I), FRONTIER),
31
+ (re.compile(r"^o3", re.I), FRONTIER),
32
+ (re.compile(r"gemini-2\.5-pro", re.I), FRONTIER),
33
+ (re.compile(r"grok-3", re.I), FRONTIER),
34
+ (re.compile(r"magistral", re.I), FRONTIER),
35
+
36
+ (re.compile(r"claude-sonnet", re.I), HIGH),
37
+ (re.compile(r"gpt-4\.1(?!-mini)", re.I), HIGH),
38
+ (re.compile(r"gpt-4o(?!-mini)", re.I), HIGH),
39
+ (re.compile(r"^o1", re.I), HIGH),
40
+ (re.compile(r"gemini-2\.0-pro", re.I), HIGH),
41
+ (re.compile(r"gemini-1\.5-pro", re.I), HIGH),
42
+ (re.compile(r"mistral-large", re.I), HIGH),
43
+ (re.compile(r"grok-2", re.I), HIGH),
44
+ (re.compile(r"deepseek-r1", re.I), HIGH),
45
+
46
+ (re.compile(r"claude-haiku", re.I), MID),
47
+ (re.compile(r"gpt-4o-mini", re.I), MID),
48
+ (re.compile(r"gpt-4\.1-mini", re.I), MID),
49
+ (re.compile(r"gemini-2\.0-flash", re.I), MID),
50
+ (re.compile(r"gemini-flash", re.I), MID),
51
+ (re.compile(r"llama-3\.3-70b", re.I), MID),
52
+ (re.compile(r"llama-3\.1-70b", re.I), MID),
53
+ (re.compile(r"llama-3-70b", re.I), MID),
54
+ (re.compile(r"mistral-medium", re.I), MID),
55
+ (re.compile(r"codestral", re.I), MID),
56
+ (re.compile(r"deepseek-chat", re.I), MID),
57
+ (re.compile(r"command-r-plus", re.I), MID),
58
+ (re.compile(r"command-r(?!-plus)", re.I), MID),
59
+ (re.compile(r"qwen-72b", re.I), MID),
60
+ (re.compile(r"mixtral", re.I), MID),
61
+ (re.compile(r"llama3\.1-70b", re.I), MID),
62
+
63
+ (re.compile(r"gemma", re.I), LOW),
64
+ (re.compile(r"llama-3\.1-8b", re.I), LOW),
65
+ (re.compile(r"llama-3-8b", re.I), LOW),
66
+ (re.compile(r"llama3\.1-8b", re.I), LOW),
67
+ (re.compile(r"qwen(?!-72b)", re.I), LOW),
68
+ (re.compile(r"mistral-7b", re.I), LOW),
69
+ (re.compile(r"mistral-0", re.I), LOW),
70
+ (re.compile(r"command-light", re.I), LOW),
71
+ (re.compile(r"gemini-1\.5-flash-8", re.I), LOW),
72
+ (re.compile(r"phi-3", re.I), LOW),
73
+ ]
74
+
75
+ # (input $/M tokens, output $/M tokens) -- best-effort, users can override
76
+ _PRICES: list[tuple[re.Pattern, tuple[float, float]]] = [
77
+ (re.compile(r"claude-opus-4-8", re.I), (15.00, 75.00)),
78
+ (re.compile(r"claude-opus", re.I), (15.00, 75.00)),
79
+ (re.compile(r"claude-sonnet-4-6", re.I), ( 3.00, 15.00)),
80
+ (re.compile(r"claude-sonnet", re.I), ( 3.00, 15.00)),
81
+ (re.compile(r"claude-haiku-4-5", re.I), ( 0.80, 4.00)),
82
+ (re.compile(r"claude-haiku", re.I), ( 0.80, 4.00)),
83
+ (re.compile(r"gpt-5", re.I), (10.00, 40.00)),
84
+ (re.compile(r"gpt-4\.1-mini", re.I), ( 0.40, 1.60)),
85
+ (re.compile(r"gpt-4\.1", re.I), ( 2.00, 8.00)),
86
+ (re.compile(r"gpt-4o-mini", re.I), ( 0.15, 0.60)),
87
+ (re.compile(r"gpt-4o", re.I), ( 5.00, 15.00)),
88
+ (re.compile(r"^o3", re.I), (10.00, 40.00)),
89
+ (re.compile(r"^o1-mini", re.I), ( 3.00, 12.00)),
90
+ (re.compile(r"^o1", re.I), (15.00, 60.00)),
91
+ (re.compile(r"gemini-2\.5-pro", re.I), ( 3.50, 10.50)),
92
+ (re.compile(r"gemini-2\.0-flash", re.I), ( 0.10, 0.40)),
93
+ (re.compile(r"gemini-flash", re.I), ( 0.10, 0.40)),
94
+ (re.compile(r"gemini-1\.5-pro", re.I), ( 3.50, 10.50)),
95
+ (re.compile(r"gemini-1\.5-flash", re.I), ( 0.075, 0.30)),
96
+ (re.compile(r"llama-3\.3-70b", re.I), ( 0.59, 0.79)),
97
+ (re.compile(r"llama-3\.1-70b", re.I), ( 0.52, 0.75)),
98
+ (re.compile(r"llama-3\.1-8b", re.I), ( 0.05, 0.08)),
99
+ (re.compile(r"llama3\.1-70b", re.I), ( 0.52, 0.75)),
100
+ (re.compile(r"llama3\.1-8b", re.I), ( 0.05, 0.08)),
101
+ (re.compile(r"mistral-large", re.I), ( 3.00, 9.00)),
102
+ (re.compile(r"mistral-medium", re.I), ( 2.70, 8.10)),
103
+ (re.compile(r"codestral", re.I), ( 0.30, 0.90)),
104
+ (re.compile(r"deepseek-r1", re.I), ( 0.55, 2.19)),
105
+ (re.compile(r"deepseek-chat", re.I), ( 0.27, 1.10)),
106
+ (re.compile(r"command-r-plus", re.I), ( 2.50, 10.00)),
107
+ (re.compile(r"command-r", re.I), ( 0.15, 0.60)),
108
+ (re.compile(r"grok-3", re.I), ( 3.00, 15.00)),
109
+ (re.compile(r"grok-2", re.I), ( 2.00, 10.00)),
110
+ (re.compile(r"mixtral", re.I), ( 0.24, 0.24)),
111
+ (re.compile(r"gemma", re.I), ( 0.10, 0.10)),
112
+ ]
113
+
114
+ # User-configurable overrides; set by load_overrides().
115
+ _tier_overrides: dict[str, int] = {}
116
+ _price_overrides: dict[str, tuple[float, float]] = {}
117
+
118
+
119
+ def load_overrides(tiers: dict[str, str], prices: dict[str, list]) -> None:
120
+ """Load user-defined tier and price overrides from config."""
121
+ for model, tier_name in tiers.items():
122
+ t = TIER_BY_NAME.get(tier_name.lower())
123
+ if t is not None:
124
+ _tier_overrides[model.lower()] = t
125
+ for model, pair in prices.items():
126
+ if len(pair) == 2:
127
+ _price_overrides[model.lower()] = (float(pair[0]), float(pair[1]))
128
+
129
+
130
+ def model_tier(model: str) -> int | None:
131
+ """Return the capability tier for a model name, or None if unknown."""
132
+ key = model.lower()
133
+ if key in _tier_overrides:
134
+ return _tier_overrides[key]
135
+ for rx, tier in _PATTERNS:
136
+ if rx.search(model):
137
+ return tier
138
+ return None
139
+
140
+
141
+ def model_price(model: str) -> tuple[float, float] | None:
142
+ """Return (input, output) price in $/M tokens, or None if unknown."""
143
+ key = model.lower()
144
+ if key in _price_overrides:
145
+ return _price_overrides[key]
146
+ for rx, price in _PRICES:
147
+ if rx.search(model):
148
+ return price
149
+ return None
150
+
151
+
152
+ def cost_usd(model: str, prompt_tokens: int, completion_tokens: int) -> float | None:
153
+ """Compute cost in USD for a completed request, or None if price unknown."""
154
+ price = model_price(model)
155
+ if price is None:
156
+ return None
157
+ return (prompt_tokens * price[0] + completion_tokens * price[1]) / 1_000_000
synckey/usage.py ADDED
@@ -0,0 +1,99 @@
1
+ """Batched off-thread usage writer.
2
+
3
+ The gateway calls record() once per request. Non-blocking: the row queues and
4
+ a background thread flushes in batches. One fsync per batch instead of per request.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import queue
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from .db import USAGE_INSERT, tune
16
+
17
+ Row = tuple
18
+
19
+
20
+ class UsageWriter:
21
+ def __init__(self, path: Path, batch_size: int = 200, flush_interval: float = 0.5):
22
+ self.path = Path(path)
23
+ self.batch_size = batch_size
24
+ self.flush_interval = flush_interval
25
+ self.queue: queue.Queue[Row] = queue.Queue(maxsize=20000)
26
+ self._stop = threading.Event()
27
+ self._thread: threading.Thread | None = None
28
+
29
+ def start(self) -> None:
30
+ self._thread = threading.Thread(target=self._run, name="synckey-usage", daemon=True)
31
+ self._thread.start()
32
+
33
+ def record(
34
+ self,
35
+ *,
36
+ provider: str,
37
+ model: str,
38
+ key_id: int | None,
39
+ prompt_tokens: int = 0,
40
+ completion_tokens: int = 0,
41
+ total_tokens: int = 0,
42
+ cost_usd: float | None = None,
43
+ status_code: int = 0,
44
+ latency_ms: int = 0,
45
+ stream: bool = False,
46
+ tier: int | None = None,
47
+ error: str | None = None,
48
+ ) -> None:
49
+ row = (
50
+ time.time(),
51
+ provider,
52
+ model,
53
+ key_id,
54
+ prompt_tokens,
55
+ completion_tokens,
56
+ total_tokens,
57
+ cost_usd,
58
+ status_code,
59
+ latency_ms,
60
+ 1 if stream else 0,
61
+ tier,
62
+ error,
63
+ )
64
+ try:
65
+ self.queue.put_nowait(row)
66
+ except queue.Full:
67
+ pass # drop metrics under extreme backpressure
68
+
69
+ def stop(self, timeout: float = 5.0) -> None:
70
+ self._stop.set()
71
+ if self._thread:
72
+ self._thread.join(timeout=timeout)
73
+
74
+ def _run(self) -> None:
75
+ conn = sqlite3.connect(str(self.path), check_same_thread=False)
76
+ tune(conn)
77
+ try:
78
+ while True:
79
+ batch = self._collect()
80
+ if batch:
81
+ conn.executemany(USAGE_INSERT, batch)
82
+ conn.commit()
83
+ elif self._stop.is_set() and self.queue.empty():
84
+ return
85
+ finally:
86
+ conn.close()
87
+
88
+ def _collect(self) -> list[Row]:
89
+ batch: list[Row] = []
90
+ try:
91
+ batch.append(self.queue.get(timeout=self.flush_interval))
92
+ except queue.Empty:
93
+ return batch
94
+ while len(batch) < self.batch_size:
95
+ try:
96
+ batch.append(self.queue.get_nowait())
97
+ except queue.Empty:
98
+ break
99
+ return batch