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/config.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Configuration loading and validation (SPEC section 7).
|
|
2
|
+
|
|
3
|
+
Precedence is CLI overrides > ``CTXOPTIMIZER_*`` environment > config file >
|
|
4
|
+
defaults. The models are frozen so a request always sees one immutable snapshot;
|
|
5
|
+
hot reload builds a new :class:`Config` instead of mutating the old one.
|
|
6
|
+
|
|
7
|
+
``config_hash`` covers only the settings that change forwarded bytes
|
|
8
|
+
(transforms, tool eligibility and pipeline policy), per SPEC section 7.1.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import tomllib
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from hashlib import sha256
|
|
19
|
+
from importlib import resources
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
import platformdirs
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
25
|
+
|
|
26
|
+
Mode = Literal["passthrough", "shadow", "active"]
|
|
27
|
+
CaptureMode = Literal["off", "changed_blocks", "full"]
|
|
28
|
+
UpstreamKind = Literal["anthropic", "openai", "gemini", "ollama"]
|
|
29
|
+
UsageStyle = Literal[
|
|
30
|
+
"anthropic",
|
|
31
|
+
"openai",
|
|
32
|
+
"deepseek",
|
|
33
|
+
"openrouter",
|
|
34
|
+
"gemini",
|
|
35
|
+
"gemini-openai",
|
|
36
|
+
"ollama",
|
|
37
|
+
"ollama-openai",
|
|
38
|
+
]
|
|
39
|
+
CachePolicy = Literal[
|
|
40
|
+
"explicit-anthropic",
|
|
41
|
+
"implicit-openai",
|
|
42
|
+
"automatic-deepseek",
|
|
43
|
+
"gemini-implicit",
|
|
44
|
+
"none",
|
|
45
|
+
]
|
|
46
|
+
CostSource = Literal["pricing", "provider", "none"]
|
|
47
|
+
ShellKind = Literal["bash", "zsh", "fish", "powershell"]
|
|
48
|
+
|
|
49
|
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
50
|
+
ENV_PREFIX = "CTXOPTIMIZER_"
|
|
51
|
+
CONFIG_RESOURCE = "default_config.toml"
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
_ENV_FIELDS = {
|
|
55
|
+
"CTXOPTIMIZER_HOST": "server.host",
|
|
56
|
+
"CTXOPTIMIZER_PORT": "server.port",
|
|
57
|
+
"CTXOPTIMIZER_MODE": "mode.default",
|
|
58
|
+
"CTXOPTIMIZER_LOG_LEVEL": "server.log_level",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Section(BaseModel):
|
|
63
|
+
"""Base for every config section: immutable and tolerant of new keys."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ServerConfig(_Section):
|
|
69
|
+
host: str = "127.0.0.1"
|
|
70
|
+
port: int = Field(default=8787, ge=1, le=65535)
|
|
71
|
+
allow_remote: bool = False
|
|
72
|
+
admin_token: str = ""
|
|
73
|
+
max_body_bytes: int = Field(default=33554432, gt=0)
|
|
74
|
+
log_level: str = "info"
|
|
75
|
+
|
|
76
|
+
@model_validator(mode="after")
|
|
77
|
+
def _check_binding(self) -> ServerConfig:
|
|
78
|
+
if self.allow_remote:
|
|
79
|
+
if not self.admin_token:
|
|
80
|
+
raise ValueError("server.admin_token is required when allow_remote = true")
|
|
81
|
+
elif self.host not in LOOPBACK_HOSTS:
|
|
82
|
+
raise ValueError("server.host must be loopback unless allow_remote = true")
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ModeSettings(_Section):
|
|
87
|
+
default: Mode = "shadow"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class UpstreamEntry(_Section):
|
|
91
|
+
url: str
|
|
92
|
+
kind: UpstreamKind
|
|
93
|
+
usage: UsageStyle | None = None
|
|
94
|
+
cache_policy: CachePolicy | None = None
|
|
95
|
+
cost_source: CostSource | None = None
|
|
96
|
+
cache_hints: bool | None = None
|
|
97
|
+
inject_stream_usage: bool | None = None
|
|
98
|
+
pricing_provider: str | None = None
|
|
99
|
+
|
|
100
|
+
@field_validator("url")
|
|
101
|
+
@classmethod
|
|
102
|
+
def _check_url(cls, value: str) -> str:
|
|
103
|
+
if not value.startswith(("http://", "https://")):
|
|
104
|
+
raise ValueError("upstream url must start with http:// or https://")
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class HttpConfig(_Section):
|
|
109
|
+
connect_timeout_s: float = Field(default=10, gt=0)
|
|
110
|
+
write_timeout_s: float = Field(default=60, gt=0)
|
|
111
|
+
pool_timeout_s: float = Field(default=30, gt=0)
|
|
112
|
+
read_timeout_s: float = Field(default=0, ge=0)
|
|
113
|
+
http2: bool = True
|
|
114
|
+
max_connections: int = Field(default=100, gt=0)
|
|
115
|
+
force_identity_encoding: bool = True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PipelineConfig(_Section):
|
|
119
|
+
time_budget_ms: int = Field(default=250, gt=0)
|
|
120
|
+
offload_threshold_bytes: int = Field(default=262144, gt=0)
|
|
121
|
+
min_saving_tokens: int = Field(default=20, ge=0)
|
|
122
|
+
retry_original_on_status: list[int] = Field(default_factory=lambda: [400, 422])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AnsiStripConfig(_Section):
|
|
126
|
+
enabled: bool = True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ProgressCollapseConfig(_Section):
|
|
130
|
+
enabled: bool = True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class LineDedupeConfig(_Section):
|
|
134
|
+
enabled: bool = True
|
|
135
|
+
min_repeat: int = Field(default=3, ge=2)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class WhitespaceTrimConfig(_Section):
|
|
139
|
+
enabled: bool = True
|
|
140
|
+
max_blank_run: int = Field(default=2, ge=1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SmartTruncateConfig(_Section):
|
|
144
|
+
enabled: bool = True
|
|
145
|
+
trigger_bytes: int = Field(default=16384, gt=0)
|
|
146
|
+
target_bytes: int = Field(default=10240, gt=0)
|
|
147
|
+
head_lines: int = Field(default=40, ge=0)
|
|
148
|
+
tail_lines: int = Field(default=60, ge=0)
|
|
149
|
+
context_lines: int = Field(default=3, ge=0)
|
|
150
|
+
max_line_chars: int = Field(default=2000, gt=0)
|
|
151
|
+
max_signal_lines: int = Field(default=150, ge=1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DuplicateBackrefConfig(_Section):
|
|
155
|
+
enabled: bool = True
|
|
156
|
+
min_bytes: int = Field(default=2048, ge=0)
|
|
157
|
+
include_never_touch: bool = True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class StaleResultElisionConfig(_Section):
|
|
161
|
+
enabled: bool = False
|
|
162
|
+
keep_recent_turns: int = Field(default=12, ge=0)
|
|
163
|
+
batch_turns: int = Field(default=8, ge=1)
|
|
164
|
+
min_bytes: int = Field(default=4096, ge=0)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class AnthropicCacheConfig(_Section):
|
|
168
|
+
enabled: bool = True
|
|
169
|
+
ttl: Literal["5m", "1h"] = "5m"
|
|
170
|
+
respect_existing: bool = True
|
|
171
|
+
max_breakpoints: int = Field(default=4, ge=1, le=4)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class OpenaiHintsConfig(_Section):
|
|
175
|
+
enabled: bool = False
|
|
176
|
+
send_prompt_cache_key: bool = True
|
|
177
|
+
inject_stream_usage: bool = False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class CacheServeConfig(_Section):
|
|
181
|
+
enabled: bool = False
|
|
182
|
+
include_never_touch: bool = True
|
|
183
|
+
min_bytes: int = Field(default=2048, ge=0)
|
|
184
|
+
max_stubs_per_request: int = Field(default=6, ge=0)
|
|
185
|
+
batch_turns: int = Field(default=8, ge=1)
|
|
186
|
+
keep_recent_turns: int = Field(default=12, ge=0)
|
|
187
|
+
max_state_sessions: int = Field(default=256, ge=1)
|
|
188
|
+
max_entries_per_session: int = Field(default=512, ge=1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class VolatileNormalizerConfig(_Section):
|
|
192
|
+
enabled: bool = False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TransformsConfig(_Section):
|
|
196
|
+
ansi_strip: AnsiStripConfig = Field(default_factory=AnsiStripConfig)
|
|
197
|
+
progress_collapse: ProgressCollapseConfig = Field(default_factory=ProgressCollapseConfig)
|
|
198
|
+
line_dedupe: LineDedupeConfig = Field(default_factory=LineDedupeConfig)
|
|
199
|
+
whitespace_trim: WhitespaceTrimConfig = Field(default_factory=WhitespaceTrimConfig)
|
|
200
|
+
smart_truncate: SmartTruncateConfig = Field(default_factory=SmartTruncateConfig)
|
|
201
|
+
duplicate_backref: DuplicateBackrefConfig = Field(default_factory=DuplicateBackrefConfig)
|
|
202
|
+
stale_result_elision: StaleResultElisionConfig = Field(default_factory=StaleResultElisionConfig)
|
|
203
|
+
anthropic_cache: AnthropicCacheConfig = Field(default_factory=AnthropicCacheConfig)
|
|
204
|
+
openai_hints: OpenaiHintsConfig = Field(default_factory=OpenaiHintsConfig)
|
|
205
|
+
cache_serve: CacheServeConfig = Field(default_factory=CacheServeConfig)
|
|
206
|
+
volatile_normalizer: VolatileNormalizerConfig = Field(default_factory=VolatileNormalizerConfig)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ToolsConfig(_Section):
|
|
210
|
+
compactable: list[str] = Field(
|
|
211
|
+
default_factory=lambda: [
|
|
212
|
+
"*bash*",
|
|
213
|
+
"*shell*",
|
|
214
|
+
"*terminal*",
|
|
215
|
+
"*exec*",
|
|
216
|
+
"*run*",
|
|
217
|
+
"*command*",
|
|
218
|
+
"*test*",
|
|
219
|
+
"*build*",
|
|
220
|
+
"*npm*",
|
|
221
|
+
"*pytest*",
|
|
222
|
+
"*cargo*",
|
|
223
|
+
]
|
|
224
|
+
)
|
|
225
|
+
never_touch: list[str] = Field(
|
|
226
|
+
default_factory=lambda: [
|
|
227
|
+
"*read*",
|
|
228
|
+
"*view*",
|
|
229
|
+
"*cat*",
|
|
230
|
+
"*edit*",
|
|
231
|
+
"*write*",
|
|
232
|
+
"*patch*",
|
|
233
|
+
"*apply*",
|
|
234
|
+
"*notebook*",
|
|
235
|
+
]
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TelemetryConfig(_Section):
|
|
240
|
+
db_path: str = ""
|
|
241
|
+
capture_payloads: CaptureMode = "changed_blocks"
|
|
242
|
+
retention_days: int = Field(default=14, ge=1)
|
|
243
|
+
redact_secrets: bool = True
|
|
244
|
+
writer_queue_max: int = Field(default=10000, ge=1)
|
|
245
|
+
max_blob_bytes: int = Field(default=524288000, ge=0)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class RedundantRereadConfig(_Section):
|
|
249
|
+
enabled: bool = True
|
|
250
|
+
min_bytes: int = Field(default=512, ge=0)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class IdenticalOutputConfig(_Section):
|
|
254
|
+
enabled: bool = True
|
|
255
|
+
min_bytes: int = Field(default=1024, ge=0)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class UnbatchedFanoutConfig(_Section):
|
|
259
|
+
enabled: bool = True
|
|
260
|
+
min_calls: int = Field(default=4, ge=2)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class RunawaySessionConfig(_Section):
|
|
264
|
+
enabled: bool = True
|
|
265
|
+
idle_gap_s: int = Field(default=900, ge=0)
|
|
266
|
+
min_turns: int = Field(default=6, ge=1)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class RecurringDriftConfig(_Section):
|
|
270
|
+
enabled: bool = True
|
|
271
|
+
min_occurrences: int = Field(default=2, ge=1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class ChurnDetectorConfig(_Section):
|
|
275
|
+
enabled: bool = False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class SchemaBloatConfig(_Section):
|
|
279
|
+
enabled: bool = True
|
|
280
|
+
min_session_requests: int = Field(default=10, ge=1)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class WasteConfig(_Section):
|
|
284
|
+
enabled: bool = True
|
|
285
|
+
dedupe_window_min: int = Field(default=30, ge=0)
|
|
286
|
+
capture_assistant_hashes: bool = True
|
|
287
|
+
redundant_reread: RedundantRereadConfig = Field(default_factory=RedundantRereadConfig)
|
|
288
|
+
identical_output: IdenticalOutputConfig = Field(default_factory=IdenticalOutputConfig)
|
|
289
|
+
unbatched_fanout: UnbatchedFanoutConfig = Field(default_factory=UnbatchedFanoutConfig)
|
|
290
|
+
runaway_session: RunawaySessionConfig = Field(default_factory=RunawaySessionConfig)
|
|
291
|
+
recurring_drift: RecurringDriftConfig = Field(default_factory=RecurringDriftConfig)
|
|
292
|
+
churn_detector: ChurnDetectorConfig = Field(default_factory=ChurnDetectorConfig)
|
|
293
|
+
schema_bloat: SchemaBloatConfig = Field(default_factory=SchemaBloatConfig)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class GuardConfig(_Section):
|
|
297
|
+
enabled: bool = True
|
|
298
|
+
fallback_window: int = Field(default=50, ge=1)
|
|
299
|
+
fallback_limit: int = Field(default=3, ge=0)
|
|
300
|
+
our_fault_drift_limit: int = Field(default=1, ge=0)
|
|
301
|
+
min_savings_to_stay: float = Field(default=0.01, ge=0)
|
|
302
|
+
notify: Literal["dashboard", "dashboard+banner"] = "dashboard"
|
|
303
|
+
auto_reenable: int = Field(default=0, ge=0)
|
|
304
|
+
expand_error_rate: float = Field(default=0.20, ge=0, le=1)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class PricingConfig(_Section):
|
|
308
|
+
file: str = ""
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class DashboardConfig(_Section):
|
|
312
|
+
enabled: bool = True
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _default_upstreams() -> dict[str, str | UpstreamEntry]:
|
|
316
|
+
return {
|
|
317
|
+
"anthropic": "https://api.anthropic.com",
|
|
318
|
+
"openai": "https://api.openai.com",
|
|
319
|
+
"gemini": "https://generativelanguage.googleapis.com",
|
|
320
|
+
"ollama": "http://127.0.0.1:11434",
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class Config(_Section):
|
|
325
|
+
"""Effective configuration for one process."""
|
|
326
|
+
|
|
327
|
+
schema_version: int = 1
|
|
328
|
+
server: ServerConfig = Field(default_factory=ServerConfig)
|
|
329
|
+
mode: ModeSettings = Field(default_factory=ModeSettings)
|
|
330
|
+
upstreams: dict[str, str | UpstreamEntry] = Field(default_factory=_default_upstreams)
|
|
331
|
+
http: HttpConfig = Field(default_factory=HttpConfig)
|
|
332
|
+
pipeline: PipelineConfig = Field(default_factory=PipelineConfig)
|
|
333
|
+
transforms: TransformsConfig = Field(default_factory=TransformsConfig)
|
|
334
|
+
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
|
335
|
+
telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig)
|
|
336
|
+
waste: WasteConfig = Field(default_factory=WasteConfig)
|
|
337
|
+
guard: GuardConfig = Field(default_factory=GuardConfig)
|
|
338
|
+
pricing: PricingConfig = Field(default_factory=PricingConfig)
|
|
339
|
+
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
|
|
340
|
+
|
|
341
|
+
@field_validator("upstreams")
|
|
342
|
+
@classmethod
|
|
343
|
+
def _check_upstreams(
|
|
344
|
+
cls, value: dict[str, str | UpstreamEntry]
|
|
345
|
+
) -> dict[str, str | UpstreamEntry]:
|
|
346
|
+
for name, entry in value.items():
|
|
347
|
+
url = entry if isinstance(entry, str) else entry.url
|
|
348
|
+
if not url.startswith(("http://", "https://")):
|
|
349
|
+
raise ValueError(f"upstream {name!r} url must start with http:// or https://")
|
|
350
|
+
return value
|
|
351
|
+
|
|
352
|
+
@model_validator(mode="after")
|
|
353
|
+
def _check_consistency(self) -> Config:
|
|
354
|
+
truncate = self.transforms.smart_truncate
|
|
355
|
+
if truncate.target_bytes * 1.5 > truncate.trigger_bytes:
|
|
356
|
+
raise ValueError(
|
|
357
|
+
"transforms.smart_truncate.target_bytes * 1.5 must be <= trigger_bytes"
|
|
358
|
+
)
|
|
359
|
+
if self.transforms.cache_serve.enabled and self.telemetry.capture_payloads == "off":
|
|
360
|
+
raise ValueError(
|
|
361
|
+
"transforms.cache_serve.enabled requires telemetry.capture_payloads != 'off'"
|
|
362
|
+
)
|
|
363
|
+
return self
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def config_hash(self) -> str:
|
|
367
|
+
"""Hash of the settings that change forwarded bytes (SPEC section 7.1)."""
|
|
368
|
+
return compute_config_hash(self)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def compute_config_hash(config: Config) -> str:
|
|
372
|
+
"""Return a stable sha256 over transform-effective configuration."""
|
|
373
|
+
payload = {
|
|
374
|
+
"transforms": config.transforms.model_dump(mode="json"),
|
|
375
|
+
"tools": config.tools.model_dump(mode="json"),
|
|
376
|
+
"pipeline": config.pipeline.model_dump(mode="json"),
|
|
377
|
+
}
|
|
378
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
379
|
+
return sha256(canonical.encode("utf-8")).hexdigest()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def default_config_path() -> Path:
|
|
383
|
+
"""Return the per-user config file path."""
|
|
384
|
+
return Path(platformdirs.user_config_dir("ctxoptimizer")) / "config.toml"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def data_dir(env: Mapping[str, str] | None = None) -> Path:
|
|
388
|
+
"""Return the data directory, honouring ``CTXOPTIMIZER_DATA_DIR``."""
|
|
389
|
+
source = os.environ if env is None else env
|
|
390
|
+
override = source.get("CTXOPTIMIZER_DATA_DIR")
|
|
391
|
+
if override:
|
|
392
|
+
return Path(override).expanduser()
|
|
393
|
+
return Path(platformdirs.user_data_dir("ctxoptimizer"))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def resolve_config_path(path: Path | None = None, env: Mapping[str, str] | None = None) -> Path:
|
|
397
|
+
"""Resolve the config path from an explicit value, env or platform default."""
|
|
398
|
+
source = os.environ if env is None else env
|
|
399
|
+
if path is not None:
|
|
400
|
+
return path.expanduser()
|
|
401
|
+
from_env = source.get("CTXOPTIMIZER_CONFIG")
|
|
402
|
+
if from_env:
|
|
403
|
+
return Path(from_env).expanduser()
|
|
404
|
+
return default_config_path()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def default_config_text() -> str:
|
|
408
|
+
"""Return the commented default config shipped with the package."""
|
|
409
|
+
resource = resources.files("ctxoptimizer").joinpath("data", CONFIG_RESOURCE)
|
|
410
|
+
return resource.read_text(encoding="utf-8")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def ensure_config_file(path: Path) -> bool:
|
|
414
|
+
"""Create *path* from the shipped template if missing. Return True if created."""
|
|
415
|
+
if path.exists():
|
|
416
|
+
return False
|
|
417
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
path.write_text(default_config_text(), encoding="utf-8")
|
|
419
|
+
return True
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def load_config(
|
|
423
|
+
*,
|
|
424
|
+
path: Path | None = None,
|
|
425
|
+
overrides: Mapping[str, object] | None = None,
|
|
426
|
+
env: Mapping[str, str] | None = None,
|
|
427
|
+
) -> Config:
|
|
428
|
+
"""Load and validate configuration (CLI > env > file > defaults)."""
|
|
429
|
+
source = dict(os.environ if env is None else env)
|
|
430
|
+
explicit = path is not None or "CTXOPTIMIZER_CONFIG" in source
|
|
431
|
+
config_path = resolve_config_path(path, source)
|
|
432
|
+
raw: dict[str, Any] = {}
|
|
433
|
+
if config_path.exists():
|
|
434
|
+
with config_path.open("rb") as handle:
|
|
435
|
+
raw = tomllib.load(handle)
|
|
436
|
+
elif explicit:
|
|
437
|
+
raise FileNotFoundError(f"config file not found: {config_path}")
|
|
438
|
+
_apply_env(raw, source)
|
|
439
|
+
if overrides:
|
|
440
|
+
for dotted, value in overrides.items():
|
|
441
|
+
_set_dotted(raw, dotted, value)
|
|
442
|
+
unknown = unknown_keys(raw)
|
|
443
|
+
if unknown:
|
|
444
|
+
logger.warning("unknown config keys ignored: %s", ", ".join(unknown))
|
|
445
|
+
return Config.model_validate(raw)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def unknown_keys(raw: Mapping[str, Any]) -> list[str]:
|
|
449
|
+
"""Return dotted paths present in *raw* that the config models do not define."""
|
|
450
|
+
return _unknown_in(raw, Config, "")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _unknown_in(raw: Mapping[str, Any], model: type[BaseModel], prefix: str) -> list[str]:
|
|
454
|
+
found: list[str] = []
|
|
455
|
+
fields = model.model_fields
|
|
456
|
+
for key, value in raw.items():
|
|
457
|
+
path = f"{prefix}{key}"
|
|
458
|
+
field = fields.get(key)
|
|
459
|
+
if field is None:
|
|
460
|
+
found.append(path)
|
|
461
|
+
continue
|
|
462
|
+
annotation = field.annotation
|
|
463
|
+
if (
|
|
464
|
+
isinstance(value, Mapping)
|
|
465
|
+
and isinstance(annotation, type)
|
|
466
|
+
and issubclass(annotation, BaseModel)
|
|
467
|
+
):
|
|
468
|
+
found.extend(_unknown_in(value, annotation, f"{path}."))
|
|
469
|
+
return found
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _apply_env(raw: dict[str, Any], env: Mapping[str, str]) -> None:
|
|
473
|
+
for name, dotted in _ENV_FIELDS.items():
|
|
474
|
+
if name in env:
|
|
475
|
+
_set_dotted(raw, dotted, env[name])
|
|
476
|
+
for name, value in env.items():
|
|
477
|
+
if not name.startswith(f"{ENV_PREFIX}UPSTREAM_"):
|
|
478
|
+
continue
|
|
479
|
+
upstream = name.removeprefix(f"{ENV_PREFIX}UPSTREAM_").lower()
|
|
480
|
+
if upstream:
|
|
481
|
+
_set_dotted(raw, f"upstreams.{upstream}", value)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _set_dotted(raw: dict[str, Any], dotted: str, value: object) -> None:
|
|
485
|
+
parts = dotted.split(".")
|
|
486
|
+
cursor = raw
|
|
487
|
+
for part in parts[:-1]:
|
|
488
|
+
existing = cursor.get(part)
|
|
489
|
+
if not isinstance(existing, dict):
|
|
490
|
+
existing = {}
|
|
491
|
+
cursor[part] = existing
|
|
492
|
+
cursor = existing
|
|
493
|
+
cursor[parts[-1]] = value
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
__all__ = [
|
|
497
|
+
"CachePolicy",
|
|
498
|
+
"Config",
|
|
499
|
+
"CostSource",
|
|
500
|
+
"Mode",
|
|
501
|
+
"ShellKind",
|
|
502
|
+
"UpstreamKind",
|
|
503
|
+
"UsageStyle",
|
|
504
|
+
"compute_config_hash",
|
|
505
|
+
"data_dir",
|
|
506
|
+
"default_config_path",
|
|
507
|
+
"default_config_text",
|
|
508
|
+
"ensure_config_file",
|
|
509
|
+
"load_config",
|
|
510
|
+
"resolve_config_path",
|
|
511
|
+
"unknown_keys",
|
|
512
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Admin API and server-rendered dashboard (SPEC section 16)."""
|