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.
Files changed (83) hide show
  1. ctxoptimizer/__init__.py +5 -0
  2. ctxoptimizer/__main__.py +14 -0
  3. ctxoptimizer/admin_security.py +130 -0
  4. ctxoptimizer/analysis/__init__.py +1 -0
  5. ctxoptimizer/analysis/baseline.py +69 -0
  6. ctxoptimizer/analysis/break_even.py +49 -0
  7. ctxoptimizer/analysis/drift.py +151 -0
  8. ctxoptimizer/analysis/edits.py +148 -0
  9. ctxoptimizer/analysis/markers.py +34 -0
  10. ctxoptimizer/analysis/recommend.py +114 -0
  11. ctxoptimizer/analysis/segments.py +57 -0
  12. ctxoptimizer/analysis/session.py +62 -0
  13. ctxoptimizer/analysis/state.py +239 -0
  14. ctxoptimizer/analysis/tokens.py +62 -0
  15. ctxoptimizer/analysis/waste.py +322 -0
  16. ctxoptimizer/cli.py +873 -0
  17. ctxoptimizer/config.py +512 -0
  18. ctxoptimizer/dashboard/__init__.py +1 -0
  19. ctxoptimizer/dashboard/api.py +537 -0
  20. ctxoptimizer/dashboard/service.py +157 -0
  21. ctxoptimizer/dashboard/static/.gitkeep +1 -0
  22. ctxoptimizer/dashboard/templates/.gitkeep +1 -0
  23. ctxoptimizer/dashboard/templates/doctor.html +46 -0
  24. ctxoptimizer/dashboard/templates/live.html +91 -0
  25. ctxoptimizer/dashboard/templates/overview.html +172 -0
  26. ctxoptimizer/dashboard/templates/pricing.html +115 -0
  27. ctxoptimizer/dashboard/templates/request.html +111 -0
  28. ctxoptimizer/dashboard/templates/session.html +67 -0
  29. ctxoptimizer/dashboard/templates/settings.html +79 -0
  30. ctxoptimizer/dashboard/templates/table.html +56 -0
  31. ctxoptimizer/dashboard/templates/transforms.html +118 -0
  32. ctxoptimizer/dashboard/templates/waste.html +120 -0
  33. ctxoptimizer/dashboard/views.py +429 -0
  34. ctxoptimizer/data/cache_limits.json +23 -0
  35. ctxoptimizer/data/default_config.toml +185 -0
  36. ctxoptimizer/data/pricing.json +421 -0
  37. ctxoptimizer/doctor.py +129 -0
  38. ctxoptimizer/guard.py +139 -0
  39. ctxoptimizer/ids.py +73 -0
  40. ctxoptimizer/log.py +73 -0
  41. ctxoptimizer/mcp.py +124 -0
  42. ctxoptimizer/models.py +145 -0
  43. ctxoptimizer/pipeline.py +126 -0
  44. ctxoptimizer/profiles.py +152 -0
  45. ctxoptimizer/providers/__init__.py +37 -0
  46. ctxoptimizer/providers/anthropic.py +105 -0
  47. ctxoptimizer/providers/base.py +191 -0
  48. ctxoptimizer/providers/gemini.py +143 -0
  49. ctxoptimizer/providers/ollama.py +133 -0
  50. ctxoptimizer/providers/openai_chat.py +135 -0
  51. ctxoptimizer/providers/openai_responses.py +121 -0
  52. ctxoptimizer/proxy.py +665 -0
  53. ctxoptimizer/routing.py +259 -0
  54. ctxoptimizer/server.py +319 -0
  55. ctxoptimizer/service.py +150 -0
  56. ctxoptimizer/telemetry/__init__.py +1 -0
  57. ctxoptimizer/telemetry/blobs.py +57 -0
  58. ctxoptimizer/telemetry/expand.py +62 -0
  59. ctxoptimizer/telemetry/pricing.py +168 -0
  60. ctxoptimizer/telemetry/redact.py +53 -0
  61. ctxoptimizer/telemetry/sse.py +145 -0
  62. ctxoptimizer/telemetry/store.py +829 -0
  63. ctxoptimizer/telemetry/usage.py +435 -0
  64. ctxoptimizer/transforms/__init__.py +87 -0
  65. ctxoptimizer/transforms/ansi.py +21 -0
  66. ctxoptimizer/transforms/anthropic_cache.py +100 -0
  67. ctxoptimizer/transforms/backref.py +74 -0
  68. ctxoptimizer/transforms/base.py +93 -0
  69. ctxoptimizer/transforms/cache_serve.py +146 -0
  70. ctxoptimizer/transforms/dedupe_lines.py +69 -0
  71. ctxoptimizer/transforms/eligibility.py +24 -0
  72. ctxoptimizer/transforms/openai_hints.py +84 -0
  73. ctxoptimizer/transforms/progress.py +52 -0
  74. ctxoptimizer/transforms/stale_result_elision.py +109 -0
  75. ctxoptimizer/transforms/truncate.py +100 -0
  76. ctxoptimizer/transforms/volatile.py +177 -0
  77. ctxoptimizer/transforms/whitespace.py +25 -0
  78. ctxoptimizer-0.4.1.dist-info/METADATA +196 -0
  79. ctxoptimizer-0.4.1.dist-info/RECORD +83 -0
  80. ctxoptimizer-0.4.1.dist-info/WHEEL +4 -0
  81. ctxoptimizer-0.4.1.dist-info/entry_points.txt +3 -0
  82. ctxoptimizer-0.4.1.dist-info/licenses/LICENSE +21 -0
  83. ctxoptimizer-0.4.1.dist-info/licenses/NOTICE +13 -0
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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
+ ]