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/ids.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Time-sortable ULID identifiers, using only the standard library.
|
|
2
|
+
|
|
3
|
+
A ULID is 26 characters of Crockford base32: a 48-bit millisecond timestamp
|
|
4
|
+
followed by 80 bits of randomness. ULIDs sort lexicographically by creation
|
|
5
|
+
time, which keeps request ids cheap to order in SQLite without a separate
|
|
6
|
+
counter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
16
|
+
ULID_LENGTH = 26
|
|
17
|
+
_TIMESTAMP_CHARS = 10
|
|
18
|
+
_RANDOM_CHARS = 16
|
|
19
|
+
_RANDOM_BITS = 80
|
|
20
|
+
|
|
21
|
+
_lock = threading.Lock()
|
|
22
|
+
_last_ms = 0
|
|
23
|
+
_last_random = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def new_ulid(*, now_ms: int | None = None) -> str:
|
|
27
|
+
"""Return a new ULID, monotonic within a single millisecond.
|
|
28
|
+
|
|
29
|
+
Passing ``now_ms`` is intended for tests; production callers omit it.
|
|
30
|
+
"""
|
|
31
|
+
global _last_ms, _last_random
|
|
32
|
+
with _lock:
|
|
33
|
+
current = int(time.time() * 1000) if now_ms is None else now_ms
|
|
34
|
+
if current > _last_ms:
|
|
35
|
+
_last_ms = current
|
|
36
|
+
_last_random = int.from_bytes(os.urandom(10), "big")
|
|
37
|
+
else:
|
|
38
|
+
_last_random += 1
|
|
39
|
+
if _last_random >= 1 << _RANDOM_BITS:
|
|
40
|
+
_last_ms += 1
|
|
41
|
+
_last_random = 0
|
|
42
|
+
timestamp = _last_ms
|
|
43
|
+
randomness = _last_random
|
|
44
|
+
return _encode(timestamp, _TIMESTAMP_CHARS) + _encode(randomness, _RANDOM_CHARS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ulid_timestamp_ms(value: str) -> int:
|
|
48
|
+
"""Return the millisecond timestamp encoded in *value*."""
|
|
49
|
+
if len(value) != ULID_LENGTH:
|
|
50
|
+
raise ValueError(f"ULID must be {ULID_LENGTH} characters, got {len(value)}")
|
|
51
|
+
return _decode(value[:_TIMESTAMP_CHARS])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _encode(value: int, length: int) -> str:
|
|
55
|
+
digits: list[str] = []
|
|
56
|
+
for _ in range(length):
|
|
57
|
+
digits.append(CROCKFORD[value & 0x1F])
|
|
58
|
+
value >>= 5
|
|
59
|
+
return "".join(reversed(digits))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _decode(value: str) -> int:
|
|
63
|
+
result = 0
|
|
64
|
+
for char in value:
|
|
65
|
+
try:
|
|
66
|
+
digit = CROCKFORD.index(char)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise ValueError(f"invalid Crockford base32 character: {char!r}") from exc
|
|
69
|
+
result = (result << 5) | digit
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["CROCKFORD", "ULID_LENGTH", "new_ulid", "ulid_timestamp_ms"]
|
ctxoptimizer/log.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Logging setup and secret sanitization (SPEC section 17, SEC-2).
|
|
2
|
+
|
|
3
|
+
Nothing in this proxy may write credentials to a log. Call
|
|
4
|
+
:func:`sanitize_headers` and :func:`sanitize_url` at every log site that can
|
|
5
|
+
see client traffic; the values stay untouched on the forwarding path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
13
|
+
|
|
14
|
+
REDACTED = "REDACTED"
|
|
15
|
+
|
|
16
|
+
SENSITIVE_HEADERS = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"authorization",
|
|
19
|
+
"proxy-authorization",
|
|
20
|
+
"x-api-key",
|
|
21
|
+
"x-goog-api-key",
|
|
22
|
+
"api-key",
|
|
23
|
+
"cookie",
|
|
24
|
+
"set-cookie",
|
|
25
|
+
"x-ctxoptimizer-admin-token",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
SENSITIVE_QUERY_KEYS = frozenset({"key", "api_key", "apikey", "access_token", "token", "auth"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sanitize_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
33
|
+
"""Return a copy of *headers* with credential values redacted."""
|
|
34
|
+
return {
|
|
35
|
+
name: REDACTED if name.lower() in SENSITIVE_HEADERS else value
|
|
36
|
+
for name, value in headers.items()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sanitize_url(url: str) -> str:
|
|
41
|
+
"""Return a log-safe form of *url* with credentials and keys redacted."""
|
|
42
|
+
parts = urlsplit(url)
|
|
43
|
+
netloc = parts.netloc
|
|
44
|
+
if "@" in netloc:
|
|
45
|
+
netloc = f"{REDACTED}@{netloc.rsplit('@', 1)[1]}"
|
|
46
|
+
query = [
|
|
47
|
+
(name, REDACTED if name.lower() in SENSITIVE_QUERY_KEYS else value)
|
|
48
|
+
for name, value in parse_qsl(parts.query, keep_blank_values=True)
|
|
49
|
+
]
|
|
50
|
+
return urlunsplit((parts.scheme, netloc, parts.path, urlencode(query), parts.fragment))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def configure_logging(level: str = "info") -> None:
|
|
54
|
+
"""Configure the root logger once, at the requested level."""
|
|
55
|
+
numeric = logging.getLevelNamesMapping().get(level.upper())
|
|
56
|
+
if numeric is None:
|
|
57
|
+
raise ValueError(f"unknown log level: {level!r}")
|
|
58
|
+
root = logging.getLogger()
|
|
59
|
+
if not root.handlers:
|
|
60
|
+
handler = logging.StreamHandler()
|
|
61
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
|
62
|
+
root.addHandler(handler)
|
|
63
|
+
root.setLevel(numeric)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"REDACTED",
|
|
68
|
+
"SENSITIVE_HEADERS",
|
|
69
|
+
"SENSITIVE_QUERY_KEYS",
|
|
70
|
+
"configure_logging",
|
|
71
|
+
"sanitize_headers",
|
|
72
|
+
"sanitize_url",
|
|
73
|
+
]
|
ctxoptimizer/mcp.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Minimal MCP stdio server exposing ``ctxoptimizer_expand`` (SPEC 12.13)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ctxoptimizer import __version__
|
|
11
|
+
from ctxoptimizer.analysis.tokens import estimate_tokens
|
|
12
|
+
from ctxoptimizer.config import data_dir
|
|
13
|
+
from ctxoptimizer.telemetry.blobs import BlobStore
|
|
14
|
+
from ctxoptimizer.telemetry.expand import record_expand
|
|
15
|
+
|
|
16
|
+
_PROTOCOL_VERSION = "2024-11-05"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def handle_message(
|
|
20
|
+
message: dict[str, Any],
|
|
21
|
+
blobs: BlobStore,
|
|
22
|
+
on_expand: Callable[[str, bool], None] | None = None,
|
|
23
|
+
) -> dict[str, Any] | None:
|
|
24
|
+
"""Handle one JSON-RPC message. Notifications return None."""
|
|
25
|
+
request_id = message.get("id")
|
|
26
|
+
method = message.get("method")
|
|
27
|
+
if request_id is None:
|
|
28
|
+
return None
|
|
29
|
+
if method == "initialize":
|
|
30
|
+
result: dict[str, Any] = {
|
|
31
|
+
"protocolVersion": _PROTOCOL_VERSION,
|
|
32
|
+
"capabilities": {"tools": {}},
|
|
33
|
+
"serverInfo": {"name": "ctxoptimizer", "version": __version__},
|
|
34
|
+
}
|
|
35
|
+
return _result(request_id, result)
|
|
36
|
+
if method == "tools/list":
|
|
37
|
+
return _result(
|
|
38
|
+
request_id,
|
|
39
|
+
{
|
|
40
|
+
"tools": [
|
|
41
|
+
{
|
|
42
|
+
"name": "ctxoptimizer_expand",
|
|
43
|
+
"description": ("Retrieve the original bytes behind a ctxoptimizer ref."),
|
|
44
|
+
"inputSchema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"ref": {"type": "string"},
|
|
48
|
+
"max_tokens": {"type": "integer"},
|
|
49
|
+
},
|
|
50
|
+
"required": ["ref"],
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
if method == "tools/call":
|
|
57
|
+
params = message.get("params") or {}
|
|
58
|
+
if params.get("name") != "ctxoptimizer_expand":
|
|
59
|
+
return _error(request_id, -32602, "unknown tool")
|
|
60
|
+
arguments = params.get("arguments") or {}
|
|
61
|
+
ref = str(arguments.get("ref") or "")
|
|
62
|
+
data = blobs.get(ref)
|
|
63
|
+
if data is None:
|
|
64
|
+
if on_expand is not None:
|
|
65
|
+
on_expand(ref, False)
|
|
66
|
+
return _result(
|
|
67
|
+
request_id,
|
|
68
|
+
{
|
|
69
|
+
"content": [
|
|
70
|
+
{
|
|
71
|
+
"type": "text",
|
|
72
|
+
"text": (
|
|
73
|
+
f"error: no stored blob for ref {ref!r}; "
|
|
74
|
+
"re-run the tool that produced it"
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"isError": True,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
text = data.decode("utf-8", "replace")
|
|
82
|
+
max_tokens = arguments.get("max_tokens")
|
|
83
|
+
if isinstance(max_tokens, int) and max_tokens > 0 and estimate_tokens(text) > max_tokens:
|
|
84
|
+
head = text[: max_tokens * 2]
|
|
85
|
+
tail = text[-max_tokens * 2 :]
|
|
86
|
+
text = f"{head}\n[ctxoptimizer: middle omitted; ref={ref}]\n{tail}"
|
|
87
|
+
if on_expand is not None:
|
|
88
|
+
on_expand(ref, True)
|
|
89
|
+
return _result(request_id, {"content": [{"type": "text", "text": text}]})
|
|
90
|
+
return _error(request_id, -32601, f"unknown method: {method}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_stdio() -> None:
|
|
94
|
+
"""Serve JSON-RPC over stdin/stdout until EOF."""
|
|
95
|
+
blobs = BlobStore(data_dir() / "blobs")
|
|
96
|
+
for line in sys.stdin:
|
|
97
|
+
line = line.strip()
|
|
98
|
+
if not line:
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
message = json.loads(line)
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
continue
|
|
104
|
+
if not isinstance(message, dict):
|
|
105
|
+
continue
|
|
106
|
+
response = handle_message(message, blobs, on_expand=record_expand)
|
|
107
|
+
if response is not None:
|
|
108
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
117
|
+
return {
|
|
118
|
+
"jsonrpc": "2.0",
|
|
119
|
+
"id": request_id,
|
|
120
|
+
"error": {"code": code, "message": message},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["handle_message", "run_stdio"]
|
ctxoptimizer/models.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Core dataclasses and enums (SPEC section 10).
|
|
2
|
+
|
|
3
|
+
These are the provider-agnostic shapes every layer passes around. Payload
|
|
4
|
+
mutation stays behind :class:`ToolResultRef`, so transforms never need to know
|
|
5
|
+
a provider's JSON layout (INV-7).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from ctxoptimizer.config import Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Provider(StrEnum):
|
|
18
|
+
ANTHROPIC = "anthropic"
|
|
19
|
+
OPENAI = "openai"
|
|
20
|
+
GEMINI = "gemini"
|
|
21
|
+
OLLAMA = "ollama"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Endpoint(StrEnum):
|
|
25
|
+
MESSAGES = "messages"
|
|
26
|
+
CHAT = "chat"
|
|
27
|
+
RESPONSES = "responses"
|
|
28
|
+
GENERATE = "generate"
|
|
29
|
+
PASSTHROUGH = "passthrough"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Mode(StrEnum):
|
|
33
|
+
PASSTHROUGH = "passthrough"
|
|
34
|
+
SHADOW = "shadow"
|
|
35
|
+
ACTIVE = "active"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class RequestContext:
|
|
40
|
+
"""Everything the pipeline needs about one request, frozen at ingress."""
|
|
41
|
+
|
|
42
|
+
request_id: str
|
|
43
|
+
provider: Provider
|
|
44
|
+
endpoint: Endpoint
|
|
45
|
+
upstream_name: str
|
|
46
|
+
model: str | None
|
|
47
|
+
stream: bool
|
|
48
|
+
mode: Mode
|
|
49
|
+
session_id: str
|
|
50
|
+
project: str | None
|
|
51
|
+
client_name: str | None
|
|
52
|
+
config: Config
|
|
53
|
+
config_hash: str
|
|
54
|
+
started_monotonic: float
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class ToolResultRef:
|
|
59
|
+
"""Mutable handle to one tool-result text block (INV-7: text only).
|
|
60
|
+
|
|
61
|
+
Adapters implement :meth:`get_text` and :meth:`set_text` for their payload
|
|
62
|
+
shape. Everything downstream treats the block as an opaque string.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
message_index: int
|
|
66
|
+
block_index: int | None
|
|
67
|
+
tool_use_id: str
|
|
68
|
+
tool_name: str | None
|
|
69
|
+
is_error: bool
|
|
70
|
+
|
|
71
|
+
def get_text(self) -> str | None:
|
|
72
|
+
"""Return the text, or None when the block is not text."""
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
def set_text(self, new_text: str) -> None:
|
|
76
|
+
"""Replace the block text in place."""
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(slots=True)
|
|
81
|
+
class TransformReport:
|
|
82
|
+
"""Outcome of one transform run, persisted per request."""
|
|
83
|
+
|
|
84
|
+
transform_id: str
|
|
85
|
+
ran: bool = False
|
|
86
|
+
changed: bool = False
|
|
87
|
+
items_touched: int = 0
|
|
88
|
+
bytes_before: int = 0
|
|
89
|
+
bytes_after: int = 0
|
|
90
|
+
est_tokens_before: int = 0
|
|
91
|
+
est_tokens_after: int = 0
|
|
92
|
+
duration_ms: float = 0.0
|
|
93
|
+
discarded_reason: str | None = None
|
|
94
|
+
error: str | None = None
|
|
95
|
+
notes: dict[str, object] = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(slots=True)
|
|
99
|
+
class Usage:
|
|
100
|
+
"""Normalized provider usage (SPEC section 14.1)."""
|
|
101
|
+
|
|
102
|
+
fresh_input: int | None = None
|
|
103
|
+
cache_read: int | None = None
|
|
104
|
+
cache_write_5m: int | None = None
|
|
105
|
+
cache_write_1h: int | None = None
|
|
106
|
+
cache_write_30m: int | None = None
|
|
107
|
+
cache_write_12h: int | None = None
|
|
108
|
+
output: int | None = None
|
|
109
|
+
reasoning: int | None = None
|
|
110
|
+
provider_cost_usd: float | None = None
|
|
111
|
+
source: Literal["provider", "estimated", "none"] = "none"
|
|
112
|
+
raw: dict[str, object] | None = None
|
|
113
|
+
assistant_text_hashes: list[bytes] | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(slots=True)
|
|
117
|
+
class WasteFinding:
|
|
118
|
+
"""An advisory, evidence-linked waste finding (INV-16, INV-18)."""
|
|
119
|
+
|
|
120
|
+
finding_id: str
|
|
121
|
+
ts: float
|
|
122
|
+
session_id: str
|
|
123
|
+
project: str | None
|
|
124
|
+
detector: str
|
|
125
|
+
key_hash: bytes
|
|
126
|
+
request_id: str | None
|
|
127
|
+
occurrences: int
|
|
128
|
+
first_seen_ts: float
|
|
129
|
+
last_seen_ts: float
|
|
130
|
+
evidence: dict[str, object]
|
|
131
|
+
est_tokens_wasted: int | None
|
|
132
|
+
est_cost_usd: float | None
|
|
133
|
+
status: Literal["new", "ack", "fixed", "skipped"] = "new"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = [
|
|
137
|
+
"Endpoint",
|
|
138
|
+
"Mode",
|
|
139
|
+
"Provider",
|
|
140
|
+
"RequestContext",
|
|
141
|
+
"ToolResultRef",
|
|
142
|
+
"TransformReport",
|
|
143
|
+
"Usage",
|
|
144
|
+
"WasteFinding",
|
|
145
|
+
]
|
ctxoptimizer/pipeline.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Transform pipeline: ordering, journal, budget and no-regress (SPEC 12.7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ctxoptimizer.analysis.segments import Segment
|
|
11
|
+
from ctxoptimizer.config import Config
|
|
12
|
+
from ctxoptimizer.models import Mode, RequestContext, ToolResultRef, TransformReport
|
|
13
|
+
from ctxoptimizer.providers.base import ProviderAdapter
|
|
14
|
+
from ctxoptimizer.transforms.base import Transform
|
|
15
|
+
|
|
16
|
+
_CACHE_MARKER_TRANSFORMS = frozenset({"anthropic_cache", "openai_hints"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class PipelineResult:
|
|
21
|
+
reports: list[TransformReport]
|
|
22
|
+
original_segments: list[Segment]
|
|
23
|
+
final_segments: list[Segment]
|
|
24
|
+
orig_est_tokens: int
|
|
25
|
+
final_est_tokens: int
|
|
26
|
+
changed: bool
|
|
27
|
+
shadow: bool
|
|
28
|
+
changed_blocks: list[tuple[str, str]]
|
|
29
|
+
original_texts: list[tuple[str | None, str | None, str]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_pipeline(
|
|
33
|
+
ctx: RequestContext,
|
|
34
|
+
payload: dict[str, Any],
|
|
35
|
+
adapter: ProviderAdapter,
|
|
36
|
+
transforms: Sequence[Transform],
|
|
37
|
+
config: Config,
|
|
38
|
+
) -> PipelineResult:
|
|
39
|
+
"""Run transforms in order, reverting failures and non-saving changes."""
|
|
40
|
+
original_segments = adapter.segments(payload)
|
|
41
|
+
original_texts: list[tuple[str | None, str | None, str]] = [
|
|
42
|
+
(ref.tool_use_id, ref.tool_name, text)
|
|
43
|
+
for ref in adapter.iter_tool_results(payload)
|
|
44
|
+
if (text := ref.get_text()) is not None
|
|
45
|
+
]
|
|
46
|
+
orig_tokens = _token_total(original_segments)
|
|
47
|
+
reports: list[TransformReport] = []
|
|
48
|
+
changed_blocks: list[tuple[str, str]] = []
|
|
49
|
+
started = time.perf_counter()
|
|
50
|
+
budget = config.pipeline.time_budget_ms
|
|
51
|
+
|
|
52
|
+
for transform in transforms:
|
|
53
|
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
54
|
+
if elapsed_ms > budget:
|
|
55
|
+
reports.append(
|
|
56
|
+
TransformReport(transform_id=transform.id, ran=False, discarded_reason="budget")
|
|
57
|
+
)
|
|
58
|
+
continue
|
|
59
|
+
if not transform.applies(ctx, payload, adapter):
|
|
60
|
+
continue
|
|
61
|
+
before_tokens = _token_total(adapter.segments(payload))
|
|
62
|
+
snapshot = _snapshot(adapter, payload)
|
|
63
|
+
try:
|
|
64
|
+
report = transform.apply(ctx, payload, adapter, config)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
_restore(snapshot)
|
|
67
|
+
reports.append(
|
|
68
|
+
TransformReport(
|
|
69
|
+
transform_id=transform.id,
|
|
70
|
+
ran=True,
|
|
71
|
+
discarded_reason="error",
|
|
72
|
+
error=str(exc),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
after_tokens = _token_total(adapter.segments(payload))
|
|
77
|
+
if (
|
|
78
|
+
report.changed
|
|
79
|
+
and transform.id not in _CACHE_MARKER_TRANSFORMS
|
|
80
|
+
and before_tokens - after_tokens < config.pipeline.min_saving_tokens
|
|
81
|
+
):
|
|
82
|
+
_restore(snapshot)
|
|
83
|
+
report.changed = False
|
|
84
|
+
report.discarded_reason = "no_regress"
|
|
85
|
+
report.est_tokens_after = report.est_tokens_before
|
|
86
|
+
report.bytes_after = report.bytes_before
|
|
87
|
+
if report.changed and report.discarded_reason is None:
|
|
88
|
+
for ref, previous in snapshot:
|
|
89
|
+
current = ref.get_text()
|
|
90
|
+
if current is not None and current != previous:
|
|
91
|
+
changed_blocks.append((previous, current))
|
|
92
|
+
reports.append(report)
|
|
93
|
+
|
|
94
|
+
final_segments = adapter.segments(payload)
|
|
95
|
+
return PipelineResult(
|
|
96
|
+
reports=reports,
|
|
97
|
+
original_segments=original_segments,
|
|
98
|
+
final_segments=final_segments,
|
|
99
|
+
orig_est_tokens=orig_tokens,
|
|
100
|
+
final_est_tokens=_token_total(final_segments),
|
|
101
|
+
changed=any(report.changed and report.discarded_reason is None for report in reports),
|
|
102
|
+
shadow=ctx.mode is Mode.SHADOW,
|
|
103
|
+
changed_blocks=changed_blocks,
|
|
104
|
+
original_texts=original_texts,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _snapshot(adapter: ProviderAdapter, payload: dict[str, Any]) -> list[tuple[ToolResultRef, str]]:
|
|
109
|
+
snapshot: list[tuple[ToolResultRef, str]] = []
|
|
110
|
+
for ref in adapter.iter_tool_results(payload):
|
|
111
|
+
text = ref.get_text()
|
|
112
|
+
if text is not None:
|
|
113
|
+
snapshot.append((ref, text))
|
|
114
|
+
return snapshot
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _restore(snapshot: list[tuple[ToolResultRef, str]]) -> None:
|
|
118
|
+
for ref, text in snapshot:
|
|
119
|
+
ref.set_text(text)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _token_total(segments: list[Segment]) -> int:
|
|
123
|
+
return sum(segment.est_tokens for segment in segments)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["PipelineResult", "run_pipeline"]
|
ctxoptimizer/profiles.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Per-upstream profiles (ADR 0006).
|
|
2
|
+
|
|
3
|
+
A profile answers the questions the proxy needs in order to treat a host
|
|
4
|
+
correctly: which usage shape it speaks, how its cache behaves, whether cost comes
|
|
5
|
+
from our pricing table or the provider, and whether cache hints may be injected.
|
|
6
|
+
|
|
7
|
+
Protocol detection stays in :mod:`ctxoptimizer.routing`; this module only resolves
|
|
8
|
+
names to URLs and profiles.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from ctxoptimizer.config import (
|
|
17
|
+
CachePolicy,
|
|
18
|
+
Config,
|
|
19
|
+
CostSource,
|
|
20
|
+
UpstreamEntry,
|
|
21
|
+
UpstreamKind,
|
|
22
|
+
UsageStyle,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnknownUpstream(LookupError):
|
|
27
|
+
"""Raised when a named upstream is not configured."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class UpstreamProfile:
|
|
32
|
+
kind: UpstreamKind | None
|
|
33
|
+
usage: UsageStyle
|
|
34
|
+
cache_policy: CachePolicy
|
|
35
|
+
cost_source: CostSource
|
|
36
|
+
cache_hints: bool
|
|
37
|
+
inject_stream_usage: bool
|
|
38
|
+
pricing_provider: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ResolvedUpstream:
|
|
43
|
+
name: str
|
|
44
|
+
url: str
|
|
45
|
+
profile: UpstreamProfile
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
BUILTIN_PROFILES: dict[str, UpstreamProfile] = {
|
|
49
|
+
"anthropic": UpstreamProfile(
|
|
50
|
+
"anthropic",
|
|
51
|
+
"anthropic",
|
|
52
|
+
"explicit-anthropic",
|
|
53
|
+
"pricing",
|
|
54
|
+
True,
|
|
55
|
+
False,
|
|
56
|
+
"anthropic",
|
|
57
|
+
),
|
|
58
|
+
"openai": UpstreamProfile(
|
|
59
|
+
"openai", "openai", "implicit-openai", "pricing", True, False, "openai"
|
|
60
|
+
),
|
|
61
|
+
"gemini": UpstreamProfile(
|
|
62
|
+
"gemini", "gemini", "gemini-implicit", "pricing", False, False, "gemini"
|
|
63
|
+
),
|
|
64
|
+
"ollama": UpstreamProfile("ollama", "ollama", "none", "none", False, False, "ollama"),
|
|
65
|
+
"deepseek": UpstreamProfile(
|
|
66
|
+
"openai", "deepseek", "automatic-deepseek", "pricing", False, False, "deepseek"
|
|
67
|
+
),
|
|
68
|
+
"deepseek-anthropic": UpstreamProfile(
|
|
69
|
+
"anthropic",
|
|
70
|
+
"anthropic",
|
|
71
|
+
"automatic-deepseek",
|
|
72
|
+
"pricing",
|
|
73
|
+
False,
|
|
74
|
+
False,
|
|
75
|
+
"deepseek",
|
|
76
|
+
),
|
|
77
|
+
"openrouter": UpstreamProfile(
|
|
78
|
+
"openai", "openrouter", "implicit-openai", "provider", False, False, "openrouter"
|
|
79
|
+
),
|
|
80
|
+
"gemini-openai": UpstreamProfile(
|
|
81
|
+
"openai", "gemini-openai", "gemini-implicit", "pricing", False, False, "gemini"
|
|
82
|
+
),
|
|
83
|
+
"ollama-openai": UpstreamProfile(
|
|
84
|
+
"openai", "ollama-openai", "none", "none", False, False, "ollama"
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_upstream(name: str, config: Config) -> ResolvedUpstream:
|
|
90
|
+
"""Resolve a configured upstream name to its URL and effective profile."""
|
|
91
|
+
entry = config.upstreams.get(name)
|
|
92
|
+
if entry is None:
|
|
93
|
+
raise UnknownUpstream(f"unknown upstream: {name}")
|
|
94
|
+
if isinstance(entry, str):
|
|
95
|
+
profile = BUILTIN_PROFILES.get(name) or _default_profile(None, entry, name)
|
|
96
|
+
return ResolvedUpstream(name=name, url=entry, profile=profile)
|
|
97
|
+
profile = _profile_for_entry(name, entry)
|
|
98
|
+
return ResolvedUpstream(name=name, url=entry.url, profile=profile)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _profile_for_entry(name: str, entry: UpstreamEntry) -> UpstreamProfile:
|
|
102
|
+
builtin = BUILTIN_PROFILES.get(name)
|
|
103
|
+
base = builtin or _default_profile(entry.kind, entry.url, name)
|
|
104
|
+
return UpstreamProfile(
|
|
105
|
+
kind=entry.kind,
|
|
106
|
+
usage=entry.usage or base.usage,
|
|
107
|
+
cache_policy=entry.cache_policy or base.cache_policy,
|
|
108
|
+
cost_source=entry.cost_source or base.cost_source,
|
|
109
|
+
cache_hints=base.cache_hints if entry.cache_hints is None else entry.cache_hints,
|
|
110
|
+
inject_stream_usage=(
|
|
111
|
+
base.inject_stream_usage
|
|
112
|
+
if entry.inject_stream_usage is None
|
|
113
|
+
else entry.inject_stream_usage
|
|
114
|
+
),
|
|
115
|
+
pricing_provider=entry.pricing_provider or base.pricing_provider,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _default_profile(kind: UpstreamKind | None, url: str, name: str) -> UpstreamProfile:
|
|
120
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
121
|
+
if kind == "anthropic":
|
|
122
|
+
return UpstreamProfile(
|
|
123
|
+
kind,
|
|
124
|
+
"anthropic",
|
|
125
|
+
"explicit-anthropic",
|
|
126
|
+
"pricing",
|
|
127
|
+
host == "api.anthropic.com",
|
|
128
|
+
False,
|
|
129
|
+
name,
|
|
130
|
+
)
|
|
131
|
+
if kind == "gemini":
|
|
132
|
+
return UpstreamProfile(kind, "gemini", "gemini-implicit", "pricing", False, False, name)
|
|
133
|
+
if kind == "ollama":
|
|
134
|
+
return UpstreamProfile(kind, "ollama", "none", "none", False, False, name)
|
|
135
|
+
return UpstreamProfile(
|
|
136
|
+
kind,
|
|
137
|
+
"openai",
|
|
138
|
+
"implicit-openai",
|
|
139
|
+
"pricing",
|
|
140
|
+
host == "api.openai.com",
|
|
141
|
+
False,
|
|
142
|
+
name,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"BUILTIN_PROFILES",
|
|
148
|
+
"ResolvedUpstream",
|
|
149
|
+
"UnknownUpstream",
|
|
150
|
+
"UpstreamProfile",
|
|
151
|
+
"resolve_upstream",
|
|
152
|
+
]
|