ctxoptimizer 0.4.1__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.
- ctxoptimizer/__init__.py +5 -0
- ctxoptimizer/__main__.py +14 -0
- ctxoptimizer/admin_security.py +130 -0
- ctxoptimizer/analysis/__init__.py +1 -0
- ctxoptimizer/analysis/baseline.py +69 -0
- ctxoptimizer/analysis/break_even.py +49 -0
- ctxoptimizer/analysis/drift.py +151 -0
- ctxoptimizer/analysis/edits.py +148 -0
- ctxoptimizer/analysis/markers.py +34 -0
- ctxoptimizer/analysis/recommend.py +114 -0
- ctxoptimizer/analysis/segments.py +57 -0
- ctxoptimizer/analysis/session.py +62 -0
- ctxoptimizer/analysis/state.py +239 -0
- ctxoptimizer/analysis/tokens.py +62 -0
- ctxoptimizer/analysis/waste.py +322 -0
- ctxoptimizer/cli.py +873 -0
- ctxoptimizer/config.py +512 -0
- ctxoptimizer/dashboard/__init__.py +1 -0
- ctxoptimizer/dashboard/api.py +537 -0
- ctxoptimizer/dashboard/service.py +157 -0
- ctxoptimizer/dashboard/static/.gitkeep +1 -0
- ctxoptimizer/dashboard/templates/.gitkeep +1 -0
- ctxoptimizer/dashboard/templates/doctor.html +46 -0
- ctxoptimizer/dashboard/templates/live.html +91 -0
- ctxoptimizer/dashboard/templates/overview.html +172 -0
- ctxoptimizer/dashboard/templates/pricing.html +115 -0
- ctxoptimizer/dashboard/templates/request.html +111 -0
- ctxoptimizer/dashboard/templates/session.html +67 -0
- ctxoptimizer/dashboard/templates/settings.html +79 -0
- ctxoptimizer/dashboard/templates/table.html +56 -0
- ctxoptimizer/dashboard/templates/transforms.html +118 -0
- ctxoptimizer/dashboard/templates/waste.html +120 -0
- ctxoptimizer/dashboard/views.py +429 -0
- ctxoptimizer/data/cache_limits.json +23 -0
- ctxoptimizer/data/default_config.toml +185 -0
- ctxoptimizer/data/pricing.json +421 -0
- ctxoptimizer/doctor.py +129 -0
- ctxoptimizer/guard.py +139 -0
- ctxoptimizer/ids.py +73 -0
- ctxoptimizer/log.py +73 -0
- ctxoptimizer/mcp.py +124 -0
- ctxoptimizer/models.py +145 -0
- ctxoptimizer/pipeline.py +126 -0
- ctxoptimizer/profiles.py +152 -0
- ctxoptimizer/providers/__init__.py +37 -0
- ctxoptimizer/providers/anthropic.py +105 -0
- ctxoptimizer/providers/base.py +191 -0
- ctxoptimizer/providers/gemini.py +143 -0
- ctxoptimizer/providers/ollama.py +133 -0
- ctxoptimizer/providers/openai_chat.py +135 -0
- ctxoptimizer/providers/openai_responses.py +121 -0
- ctxoptimizer/proxy.py +665 -0
- ctxoptimizer/routing.py +259 -0
- ctxoptimizer/server.py +319 -0
- ctxoptimizer/service.py +150 -0
- ctxoptimizer/telemetry/__init__.py +1 -0
- ctxoptimizer/telemetry/blobs.py +57 -0
- ctxoptimizer/telemetry/expand.py +62 -0
- ctxoptimizer/telemetry/pricing.py +168 -0
- ctxoptimizer/telemetry/redact.py +53 -0
- ctxoptimizer/telemetry/sse.py +145 -0
- ctxoptimizer/telemetry/store.py +829 -0
- ctxoptimizer/telemetry/usage.py +435 -0
- ctxoptimizer/transforms/__init__.py +87 -0
- ctxoptimizer/transforms/ansi.py +21 -0
- ctxoptimizer/transforms/anthropic_cache.py +100 -0
- ctxoptimizer/transforms/backref.py +74 -0
- ctxoptimizer/transforms/base.py +93 -0
- ctxoptimizer/transforms/cache_serve.py +146 -0
- ctxoptimizer/transforms/dedupe_lines.py +69 -0
- ctxoptimizer/transforms/eligibility.py +24 -0
- ctxoptimizer/transforms/openai_hints.py +84 -0
- ctxoptimizer/transforms/progress.py +52 -0
- ctxoptimizer/transforms/stale_result_elision.py +109 -0
- ctxoptimizer/transforms/truncate.py +100 -0
- ctxoptimizer/transforms/volatile.py +177 -0
- ctxoptimizer/transforms/whitespace.py +25 -0
- ctxoptimizer-0.4.1.dist-info/METADATA +196 -0
- ctxoptimizer-0.4.1.dist-info/RECORD +83 -0
- ctxoptimizer-0.4.1.dist-info/WHEEL +4 -0
- ctxoptimizer-0.4.1.dist-info/entry_points.txt +3 -0
- ctxoptimizer-0.4.1.dist-info/licenses/LICENSE +21 -0
- ctxoptimizer-0.4.1.dist-info/licenses/NOTICE +13 -0
ctxoptimizer/service.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Autostart service generation (SPEC section 21, P7-1).
|
|
2
|
+
|
|
3
|
+
Generates a per-user launchd/systemd/scheduled-task registration. The pure
|
|
4
|
+
``service_plan`` helper is testable without touching the host.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ServicePlan:
|
|
18
|
+
platform: str
|
|
19
|
+
path: Path
|
|
20
|
+
content: str
|
|
21
|
+
commands: tuple[tuple[str, ...], ...]
|
|
22
|
+
remove_commands: tuple[tuple[str, ...], ...]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def service_plan(
|
|
26
|
+
*, platform: str | None = None, home: Path | None = None, config_path: str | None = None
|
|
27
|
+
) -> ServicePlan:
|
|
28
|
+
name = platform or _platform()
|
|
29
|
+
home = home or Path.home()
|
|
30
|
+
args = [sys.executable, "-m", "ctxoptimizer", "run"]
|
|
31
|
+
if config_path:
|
|
32
|
+
args.extend(["--config", config_path])
|
|
33
|
+
command = " ".join(_quote(part) for part in args)
|
|
34
|
+
|
|
35
|
+
if name == "windows":
|
|
36
|
+
task = "ctxoptimizer"
|
|
37
|
+
return ServicePlan(
|
|
38
|
+
platform=name,
|
|
39
|
+
path=home / "AppData" / "Local" / "ctxoptimizer" / "ctxoptimizer-service.txt",
|
|
40
|
+
content=f"Scheduled task {task}: {command}\n",
|
|
41
|
+
commands=(
|
|
42
|
+
(
|
|
43
|
+
"schtasks",
|
|
44
|
+
"/Create",
|
|
45
|
+
"/SC",
|
|
46
|
+
"ONLOGON",
|
|
47
|
+
"/TN",
|
|
48
|
+
task,
|
|
49
|
+
"/TR",
|
|
50
|
+
command,
|
|
51
|
+
"/F",
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
remove_commands=(("schtasks", "/Delete", "/TN", task, "/F"),),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if name == "darwin":
|
|
58
|
+
plist = home / "Library" / "LaunchAgents" / "com.ctxoptimizer.plist"
|
|
59
|
+
content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
60
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
61
|
+
<plist version="1.0"><dict>
|
|
62
|
+
<key>Label</key><string>com.ctxoptimizer</string>
|
|
63
|
+
<key>ProgramArguments</key><array>
|
|
64
|
+
{"".join(f"<string>{_xml(part)}</string>" for part in args)}
|
|
65
|
+
</array>
|
|
66
|
+
<key>RunAtLoad</key><true/>
|
|
67
|
+
<key>KeepAlive</key><true/>
|
|
68
|
+
</dict></plist>
|
|
69
|
+
"""
|
|
70
|
+
return ServicePlan(
|
|
71
|
+
platform=name,
|
|
72
|
+
path=plist,
|
|
73
|
+
content=content,
|
|
74
|
+
commands=(("launchctl", "load", str(plist)),),
|
|
75
|
+
remove_commands=(("launchctl", "unload", str(plist)),),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
unit = home / ".config" / "systemd" / "user" / "ctxoptimizer.service"
|
|
79
|
+
content = (
|
|
80
|
+
"[Unit]\n"
|
|
81
|
+
"Description=ctxoptimizer local LLM proxy\n"
|
|
82
|
+
"After=network-online.target\n\n"
|
|
83
|
+
"[Service]\n"
|
|
84
|
+
"Type=simple\n"
|
|
85
|
+
f"ExecStart={command}\n"
|
|
86
|
+
"Restart=on-failure\n\n"
|
|
87
|
+
"[Install]\n"
|
|
88
|
+
"WantedBy=default.target\n"
|
|
89
|
+
)
|
|
90
|
+
return ServicePlan(
|
|
91
|
+
platform="linux",
|
|
92
|
+
path=unit,
|
|
93
|
+
content=content,
|
|
94
|
+
commands=(
|
|
95
|
+
("systemctl", "--user", "daemon-reload"),
|
|
96
|
+
("systemctl", "--user", "enable", "--now", "ctxoptimizer.service"),
|
|
97
|
+
),
|
|
98
|
+
remove_commands=(("systemctl", "--user", "disable", "--now", "ctxoptimizer.service"),),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def install_service(
|
|
103
|
+
*,
|
|
104
|
+
platform: str | None = None,
|
|
105
|
+
home: Path | None = None,
|
|
106
|
+
config_path: str | None = None,
|
|
107
|
+
dry_run: bool = False,
|
|
108
|
+
) -> ServicePlan:
|
|
109
|
+
plan = service_plan(platform=platform, home=home, config_path=config_path)
|
|
110
|
+
if dry_run:
|
|
111
|
+
return plan
|
|
112
|
+
plan.path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
plan.path.write_text(plan.content, encoding="utf-8")
|
|
114
|
+
for command in plan.commands:
|
|
115
|
+
subprocess.run(command, check=False) # noqa: S603
|
|
116
|
+
return plan
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def uninstall_service(
|
|
120
|
+
*,
|
|
121
|
+
platform: str | None = None,
|
|
122
|
+
home: Path | None = None,
|
|
123
|
+
dry_run: bool = False,
|
|
124
|
+
) -> ServicePlan:
|
|
125
|
+
plan = service_plan(platform=platform, home=home)
|
|
126
|
+
if dry_run:
|
|
127
|
+
return plan
|
|
128
|
+
for command in plan.remove_commands:
|
|
129
|
+
subprocess.run(command, check=False) # noqa: S603
|
|
130
|
+
plan.path.unlink(missing_ok=True)
|
|
131
|
+
return plan
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _platform() -> str:
|
|
135
|
+
if os.name == "nt":
|
|
136
|
+
return "windows"
|
|
137
|
+
if sys.platform == "darwin":
|
|
138
|
+
return "darwin"
|
|
139
|
+
return "linux"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _quote(value: str) -> str:
|
|
143
|
+
return f'"{value}"' if " " in value else value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _xml(value: str) -> str:
|
|
147
|
+
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = ["ServicePlan", "install_service", "service_plan", "uninstall_service"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Telemetry: usage parsing, pricing, storage and redaction (SPEC section 14)."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Content-addressed blob storage for omitted/duplicated originals (SPEC 12.13)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import zlib
|
|
8
|
+
from hashlib import sha256
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BlobStore:
|
|
13
|
+
"""zlib-compressed files named by sha256; supports short-ref lookup."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, root: Path) -> None:
|
|
16
|
+
self.root = Path(root)
|
|
17
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
def put(self, data: bytes) -> str:
|
|
20
|
+
digest = sha256(data).hexdigest()
|
|
21
|
+
path = self._path(digest)
|
|
22
|
+
if not path.exists():
|
|
23
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
path.write_bytes(zlib.compress(data, 6))
|
|
25
|
+
if os.name == "posix":
|
|
26
|
+
os.chmod(path, 0o600)
|
|
27
|
+
return digest
|
|
28
|
+
|
|
29
|
+
def get(self, ref: str) -> bytes | None:
|
|
30
|
+
if not ref:
|
|
31
|
+
return None
|
|
32
|
+
if len(ref) >= 64:
|
|
33
|
+
path = self._path(ref)
|
|
34
|
+
else:
|
|
35
|
+
matches = sorted(self.root.glob(f"*/{ref}*"))
|
|
36
|
+
if len(matches) != 1:
|
|
37
|
+
return None
|
|
38
|
+
path = matches[0]
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
return zlib.decompress(path.read_bytes())
|
|
43
|
+
except zlib.error:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def purge(self) -> None:
|
|
47
|
+
shutil.rmtree(self.root, ignore_errors=True)
|
|
48
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
def count(self) -> int:
|
|
51
|
+
return sum(1 for _ in self.root.glob("*/*"))
|
|
52
|
+
|
|
53
|
+
def _path(self, digest: str) -> Path:
|
|
54
|
+
return self.root / digest[:2] / digest
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["BlobStore"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Expand-usage log (SPEC 12.13).
|
|
2
|
+
|
|
3
|
+
The MCP server runs in a separate process, so it writes to the same SQLite file
|
|
4
|
+
with a tiny direct connection. Content is never logged, only the ref and result.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ctxoptimizer.telemetry.store import default_db_path
|
|
15
|
+
|
|
16
|
+
_SCHEMA = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS expand_events(
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
ts REAL NOT NULL,
|
|
20
|
+
ref TEXT NOT NULL,
|
|
21
|
+
ok INTEGER NOT NULL
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_expand_ts ON expand_events(ts);
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def record_expand(ref: str, ok: bool, *, db_path: Path | None = None) -> None:
|
|
28
|
+
path = db_path or default_db_path()
|
|
29
|
+
try:
|
|
30
|
+
with sqlite3.connect(path) as conn:
|
|
31
|
+
conn.executescript(_SCHEMA)
|
|
32
|
+
conn.execute(
|
|
33
|
+
"INSERT INTO expand_events(ts, ref, ok) VALUES(?, ?, ?)",
|
|
34
|
+
(time.time(), ref, 1 if ok else 0),
|
|
35
|
+
)
|
|
36
|
+
conn.commit()
|
|
37
|
+
except sqlite3.Error:
|
|
38
|
+
# Expand must still return its result if telemetry is unavailable.
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def expand_stats(*, db_path: Path | None = None) -> dict[str, Any]:
|
|
43
|
+
path = db_path or default_db_path()
|
|
44
|
+
try:
|
|
45
|
+
with sqlite3.connect(path) as conn:
|
|
46
|
+
conn.executescript(_SCHEMA)
|
|
47
|
+
row = conn.execute(
|
|
48
|
+
"SELECT COUNT(*), COALESCE(SUM(ok), 0), MAX(ts) FROM expand_events"
|
|
49
|
+
).fetchone()
|
|
50
|
+
except sqlite3.Error:
|
|
51
|
+
return {"total": 0, "ok": 0, "errors": 0, "last_ts": None}
|
|
52
|
+
total = int(row[0])
|
|
53
|
+
ok = int(row[1])
|
|
54
|
+
return {
|
|
55
|
+
"total": total,
|
|
56
|
+
"ok": ok,
|
|
57
|
+
"errors": total - ok,
|
|
58
|
+
"last_ts": row[2],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["expand_stats", "record_expand"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Model pricing lookup and cost math (SPEC section 14.2).
|
|
2
|
+
|
|
3
|
+
Provider-reported cost (OpenRouter) wins when present. Otherwise the bundled
|
|
4
|
+
table is used; an unknown model or a missing rate yields ``priced=False`` and a
|
|
5
|
+
NULL cost rather than an invented number (INV-13).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from fnmatch import fnmatchcase
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from ctxoptimizer.models import Usage
|
|
18
|
+
|
|
19
|
+
PriceSource = Literal["provider", "pricing", "none"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class ModelPrice:
|
|
24
|
+
provider: str
|
|
25
|
+
match: str
|
|
26
|
+
input: float
|
|
27
|
+
output: float
|
|
28
|
+
cache_read: float = 0.0
|
|
29
|
+
cache_write_5m: float | None = None
|
|
30
|
+
cache_write_1h: float | None = None
|
|
31
|
+
cache_write_30m: float | None = None
|
|
32
|
+
cache_write_12h: float | None = None
|
|
33
|
+
verified: bool = False
|
|
34
|
+
source: str = ""
|
|
35
|
+
notes: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class PriceResult:
|
|
40
|
+
cost_usd: float | None
|
|
41
|
+
priced: bool
|
|
42
|
+
source: PriceSource
|
|
43
|
+
price: ModelPrice | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PricingTable:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
models: list[ModelPrice],
|
|
50
|
+
*,
|
|
51
|
+
per: int = 1_000_000,
|
|
52
|
+
currency: str = "USD",
|
|
53
|
+
version: str = "",
|
|
54
|
+
) -> None:
|
|
55
|
+
self.models = tuple(models)
|
|
56
|
+
self.per = per
|
|
57
|
+
self.currency = currency
|
|
58
|
+
self.version = version
|
|
59
|
+
|
|
60
|
+
def lookup(self, provider: str, model: str) -> ModelPrice | None:
|
|
61
|
+
candidates = [
|
|
62
|
+
price
|
|
63
|
+
for price in self.models
|
|
64
|
+
if price.provider == provider and fnmatchcase(model, price.match)
|
|
65
|
+
]
|
|
66
|
+
if not candidates:
|
|
67
|
+
return None
|
|
68
|
+
return max(candidates, key=lambda price: (_literal_prefix(price.match), len(price.match)))
|
|
69
|
+
|
|
70
|
+
def cost(
|
|
71
|
+
self,
|
|
72
|
+
usage: Usage,
|
|
73
|
+
price: ModelPrice | None,
|
|
74
|
+
*,
|
|
75
|
+
prefer_provider_cost: bool = True,
|
|
76
|
+
) -> PriceResult:
|
|
77
|
+
if prefer_provider_cost and usage.provider_cost_usd is not None:
|
|
78
|
+
return PriceResult(usage.provider_cost_usd, True, "provider", price)
|
|
79
|
+
if price is None:
|
|
80
|
+
return PriceResult(None, False, "none", None)
|
|
81
|
+
total = 0.0
|
|
82
|
+
buckets = (
|
|
83
|
+
(usage.fresh_input, price.input),
|
|
84
|
+
(usage.cache_read, price.cache_read),
|
|
85
|
+
(usage.cache_write_5m, price.cache_write_5m),
|
|
86
|
+
(usage.cache_write_1h, price.cache_write_1h),
|
|
87
|
+
(usage.cache_write_30m, price.cache_write_30m),
|
|
88
|
+
(usage.cache_write_12h, price.cache_write_12h),
|
|
89
|
+
(usage.output, price.output),
|
|
90
|
+
)
|
|
91
|
+
for tokens, rate in buckets:
|
|
92
|
+
if not tokens:
|
|
93
|
+
continue
|
|
94
|
+
if rate is None:
|
|
95
|
+
return PriceResult(None, False, "none", price)
|
|
96
|
+
total += tokens * rate
|
|
97
|
+
return PriceResult(total / self.per, True, "pricing", price)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_pricing(user_file: Path | None = None) -> PricingTable:
|
|
101
|
+
"""Load the bundled table and merge an optional user override."""
|
|
102
|
+
bundled = _read_json(
|
|
103
|
+
resources.files("ctxoptimizer").joinpath("data", "pricing.json").read_text("utf-8")
|
|
104
|
+
)
|
|
105
|
+
models = [_parse_price(raw) for raw in bundled.get("models", [])]
|
|
106
|
+
if user_file is not None:
|
|
107
|
+
user = _read_json(user_file.read_text(encoding="utf-8"))
|
|
108
|
+
overrides = {(entry.provider, entry.match): entry for entry in models}
|
|
109
|
+
for raw in user.get("models", []):
|
|
110
|
+
parsed = _parse_price(raw)
|
|
111
|
+
overrides[(parsed.provider, parsed.match)] = parsed
|
|
112
|
+
models = list(overrides.values())
|
|
113
|
+
return PricingTable(
|
|
114
|
+
models,
|
|
115
|
+
per=int(bundled.get("per", 1_000_000)),
|
|
116
|
+
currency=str(bundled.get("currency", "USD")),
|
|
117
|
+
version=str(bundled.get("version", "")),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def price_usage(usage: Usage, table: PricingTable, provider: str, model: str | None) -> PriceResult:
|
|
122
|
+
"""Price one request. Unknown models stay unpriced, never guessed."""
|
|
123
|
+
price = table.lookup(provider, model) if model else None
|
|
124
|
+
return table.cost(usage, price)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_price(raw: dict[str, Any]) -> ModelPrice:
|
|
128
|
+
return ModelPrice(
|
|
129
|
+
provider=str(raw["provider"]),
|
|
130
|
+
match=str(raw["match"]),
|
|
131
|
+
input=float(raw["input"]),
|
|
132
|
+
output=float(raw["output"]),
|
|
133
|
+
cache_read=float(raw.get("cache_read", 0.0)),
|
|
134
|
+
cache_write_5m=_optional_float(raw.get("cache_write_5m")),
|
|
135
|
+
cache_write_1h=_optional_float(raw.get("cache_write_1h")),
|
|
136
|
+
cache_write_30m=_optional_float(raw.get("cache_write_30m")),
|
|
137
|
+
cache_write_12h=_optional_float(raw.get("cache_write_12h")),
|
|
138
|
+
verified=bool(raw.get("verified", False)),
|
|
139
|
+
source=str(raw.get("source", "")),
|
|
140
|
+
notes=str(raw.get("notes", "")),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _read_json(text: str) -> dict[str, Any]:
|
|
145
|
+
value = json.loads(text)
|
|
146
|
+
if not isinstance(value, dict):
|
|
147
|
+
raise ValueError("pricing file must contain a JSON object")
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _optional_float(value: Any) -> float | None:
|
|
152
|
+
return None if value is None else float(value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _literal_prefix(pattern: str) -> int:
|
|
156
|
+
for index, char in enumerate(pattern):
|
|
157
|
+
if char in "*?[":
|
|
158
|
+
return index
|
|
159
|
+
return len(pattern)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = [
|
|
163
|
+
"ModelPrice",
|
|
164
|
+
"PriceResult",
|
|
165
|
+
"PricingTable",
|
|
166
|
+
"load_pricing",
|
|
167
|
+
"price_usage",
|
|
168
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Secret redaction for stored copies (SPEC section 14.4).
|
|
2
|
+
|
|
3
|
+
Redaction applies only to blobs/exports written to disk, never to forwarded
|
|
4
|
+
traffic. Patterns are linear-time and conservative: when in doubt, redact.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
12
|
+
re.compile(r"sk-ant-[A-Za-z0-9_\-]{8,}"),
|
|
13
|
+
re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
|
|
14
|
+
re.compile(r"AIza[0-9A-Za-z_\-]{20,}"),
|
|
15
|
+
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"),
|
|
16
|
+
re.compile(r"github_pat_[A-Za-z0-9_\-]{20,}"),
|
|
17
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
18
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}"),
|
|
19
|
+
re.compile(
|
|
20
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
21
|
+
re.DOTALL,
|
|
22
|
+
),
|
|
23
|
+
re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]{8,}"),
|
|
24
|
+
re.compile(
|
|
25
|
+
r"(?im)^([ \t]*(?:export[ \t]+)?[A-Za-z0-9_.-]*"
|
|
26
|
+
r"(?:secret|token|password|passwd|api[_-]?key)[A-Za-z0-9_.-]*[ \t]*=[ \t]*)"
|
|
27
|
+
r"(\"[^\"]*\"|'[^']*'|[^\s#]+)"
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_REDACTED = "[REDACTED]"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def redact_text(text: str) -> str:
|
|
35
|
+
"""Return *text* with recognized credentials replaced."""
|
|
36
|
+
if not text:
|
|
37
|
+
return text
|
|
38
|
+
for pattern in _PATTERNS[:-1]:
|
|
39
|
+
text = pattern.sub(_REDACTED, text)
|
|
40
|
+
text = _PATTERNS[-1].sub(lambda match: match.group(1) + _REDACTED, text)
|
|
41
|
+
return text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def redact_bytes(data: bytes) -> bytes:
|
|
45
|
+
"""Best-effort UTF-8 decoding so binary blobs are never damaged."""
|
|
46
|
+
try:
|
|
47
|
+
text = data.decode("utf-8")
|
|
48
|
+
except UnicodeDecodeError:
|
|
49
|
+
return data
|
|
50
|
+
return redact_text(text).encode("utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["redact_bytes", "redact_text"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Incremental Server-Sent Events decoder (WHATWG event-stream grammar).
|
|
2
|
+
|
|
3
|
+
Byte-buffered so a chunk can split a line or a multi-byte UTF-8 character, and
|
|
4
|
+
bounded so a malicious stream cannot grow memory without limit. The decoder only
|
|
5
|
+
frames events; provider parsers interpret the JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
DEFAULT_MAX_LINE_BYTES = 4 * 1024 * 1024
|
|
13
|
+
DEFAULT_MAX_EVENT_BYTES = 8 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SSEFrameError(ValueError):
|
|
17
|
+
"""Raised when a frame exceeds the configured memory bound."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class SSEEvent:
|
|
22
|
+
event: str | None
|
|
23
|
+
data: str
|
|
24
|
+
id: str | None = None
|
|
25
|
+
retry: int | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SSEDecoder:
|
|
29
|
+
"""Feed it bytes, receive complete events. Tolerates CR, LF and CRLF."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
max_line_bytes: int = DEFAULT_MAX_LINE_BYTES,
|
|
35
|
+
max_event_bytes: int = DEFAULT_MAX_EVENT_BYTES,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._buffer = bytearray()
|
|
38
|
+
self._event: str | None = None
|
|
39
|
+
self._data: list[str] = []
|
|
40
|
+
self._data_bytes = 0
|
|
41
|
+
self._last_id: str | None = None
|
|
42
|
+
self._retry: int | None = None
|
|
43
|
+
self._first_line = True
|
|
44
|
+
self._max_line = max_line_bytes
|
|
45
|
+
self._max_event = max_event_bytes
|
|
46
|
+
|
|
47
|
+
def feed(self, chunk: bytes) -> list[SSEEvent]:
|
|
48
|
+
"""Append bytes and return every event completed by this chunk."""
|
|
49
|
+
self._buffer.extend(chunk)
|
|
50
|
+
events: list[SSEEvent] = []
|
|
51
|
+
while True:
|
|
52
|
+
line = self._pop_line()
|
|
53
|
+
if line is None:
|
|
54
|
+
break
|
|
55
|
+
event = self._process_line(line)
|
|
56
|
+
if event is not None:
|
|
57
|
+
events.append(event)
|
|
58
|
+
if len(self._buffer) > self._max_line:
|
|
59
|
+
raise SSEFrameError("SSE line exceeds the configured limit")
|
|
60
|
+
return events
|
|
61
|
+
|
|
62
|
+
def finish(self) -> list[SSEEvent]:
|
|
63
|
+
"""Flush a trailing partial line and any pending data at EOF."""
|
|
64
|
+
events: list[SSEEvent] = []
|
|
65
|
+
if self._buffer:
|
|
66
|
+
raw = bytes(self._buffer).rstrip(b"\r")
|
|
67
|
+
self._buffer.clear()
|
|
68
|
+
event = self._process_line(raw)
|
|
69
|
+
if event is not None:
|
|
70
|
+
events.append(event)
|
|
71
|
+
event = self._dispatch()
|
|
72
|
+
if event is not None:
|
|
73
|
+
events.append(event)
|
|
74
|
+
return events
|
|
75
|
+
|
|
76
|
+
def _pop_line(self) -> bytes | None:
|
|
77
|
+
cr = self._buffer.find(b"\r")
|
|
78
|
+
lf = self._buffer.find(b"\n")
|
|
79
|
+
candidates = [index for index in (cr, lf) if index >= 0]
|
|
80
|
+
if not candidates:
|
|
81
|
+
return None
|
|
82
|
+
index = min(candidates)
|
|
83
|
+
if self._buffer[index] == 13:
|
|
84
|
+
if index + 1 == len(self._buffer):
|
|
85
|
+
return None
|
|
86
|
+
end = index + 2 if self._buffer[index + 1] == 10 else index + 1
|
|
87
|
+
else:
|
|
88
|
+
end = index + 1
|
|
89
|
+
line = bytes(self._buffer[:index])
|
|
90
|
+
del self._buffer[:end]
|
|
91
|
+
return line
|
|
92
|
+
|
|
93
|
+
def _process_line(self, raw: bytes) -> SSEEvent | None:
|
|
94
|
+
line = raw.decode("utf-8", errors="replace")
|
|
95
|
+
if self._first_line:
|
|
96
|
+
line = line.removeprefix("\ufeff")
|
|
97
|
+
self._first_line = False
|
|
98
|
+
if line == "":
|
|
99
|
+
return self._dispatch()
|
|
100
|
+
if line.startswith(":"):
|
|
101
|
+
return None
|
|
102
|
+
field, separator, value = line.partition(":")
|
|
103
|
+
if separator and value.startswith(" "):
|
|
104
|
+
value = value[1:]
|
|
105
|
+
if field == "event":
|
|
106
|
+
self._event = value
|
|
107
|
+
elif field == "data":
|
|
108
|
+
self._data.append(value)
|
|
109
|
+
self._data_bytes += len(value.encode("utf-8"))
|
|
110
|
+
if self._data_bytes > self._max_event:
|
|
111
|
+
raise SSEFrameError("SSE event exceeds the configured limit")
|
|
112
|
+
elif field == "id":
|
|
113
|
+
if "\0" not in value:
|
|
114
|
+
self._last_id = value
|
|
115
|
+
elif field == "retry":
|
|
116
|
+
try:
|
|
117
|
+
self._retry = int(value)
|
|
118
|
+
except ValueError:
|
|
119
|
+
pass
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def _dispatch(self) -> SSEEvent | None:
|
|
123
|
+
if not self._data and self._event is None:
|
|
124
|
+
self._retry = None
|
|
125
|
+
return None
|
|
126
|
+
event = SSEEvent(
|
|
127
|
+
event=self._event,
|
|
128
|
+
data="\n".join(self._data),
|
|
129
|
+
id=self._last_id,
|
|
130
|
+
retry=self._retry,
|
|
131
|
+
)
|
|
132
|
+
self._event = None
|
|
133
|
+
self._data = []
|
|
134
|
+
self._data_bytes = 0
|
|
135
|
+
self._retry = None
|
|
136
|
+
return event
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = [
|
|
140
|
+
"DEFAULT_MAX_EVENT_BYTES",
|
|
141
|
+
"DEFAULT_MAX_LINE_BYTES",
|
|
142
|
+
"SSEDecoder",
|
|
143
|
+
"SSEEvent",
|
|
144
|
+
"SSEFrameError",
|
|
145
|
+
]
|