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
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
+ ]
@@ -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"]
@@ -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
+ ]