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,37 @@
1
+ """Provider adapter factory (SPEC section 11)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ctxoptimizer.models import Endpoint, Provider
6
+ from ctxoptimizer.providers.anthropic import AnthropicAdapter
7
+ from ctxoptimizer.providers.base import PassthroughAdapter, ProviderAdapter
8
+ from ctxoptimizer.providers.gemini import GeminiAdapter
9
+ from ctxoptimizer.providers.ollama import OllamaAdapter
10
+ from ctxoptimizer.providers.openai_chat import OpenAIChatAdapter
11
+ from ctxoptimizer.providers.openai_responses import OpenAIResponsesAdapter
12
+
13
+
14
+ def adapter_for(provider: Provider, endpoint: Endpoint) -> ProviderAdapter:
15
+ if provider is Provider.ANTHROPIC:
16
+ return AnthropicAdapter()
17
+ if provider is Provider.OPENAI:
18
+ if endpoint is Endpoint.RESPONSES:
19
+ return OpenAIResponsesAdapter()
20
+ return OpenAIChatAdapter()
21
+ if provider is Provider.GEMINI:
22
+ return GeminiAdapter()
23
+ if provider is Provider.OLLAMA:
24
+ return OllamaAdapter()
25
+ return PassthroughAdapter(provider)
26
+
27
+
28
+ __all__ = [
29
+ "AnthropicAdapter",
30
+ "GeminiAdapter",
31
+ "OllamaAdapter",
32
+ "OpenAIChatAdapter",
33
+ "OpenAIResponsesAdapter",
34
+ "PassthroughAdapter",
35
+ "ProviderAdapter",
36
+ "adapter_for",
37
+ ]
@@ -0,0 +1,105 @@
1
+ """Anthropic Messages adapter (SPEC section 11.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ from ctxoptimizer.analysis.segments import Segment, make_segment
9
+ from ctxoptimizer.models import Provider, ToolResultRef
10
+ from ctxoptimizer.providers.base import (
11
+ DictToolResultRef,
12
+ blocks_of,
13
+ text_of,
14
+ )
15
+
16
+
17
+ class AnthropicAdapter:
18
+ provider = Provider.ANTHROPIC
19
+
20
+ def model_of(self, payload: dict[str, Any]) -> str | None:
21
+ model = payload.get("model")
22
+ return model if isinstance(model, str) else None
23
+
24
+ def is_stream(self, payload: dict[str, Any]) -> bool:
25
+ return bool(payload.get("stream"))
26
+
27
+ def is_transformable(self, payload: dict[str, Any]) -> bool:
28
+ return True
29
+
30
+ def iter_tool_results(self, payload: dict[str, Any]) -> Iterator[ToolResultRef]:
31
+ names = self._tool_use_names(payload)
32
+ for message_index, message in enumerate(payload.get("messages") or []):
33
+ if not isinstance(message, dict):
34
+ continue
35
+ for block_index, block in enumerate(blocks_of(message.get("content"))):
36
+ if block.get("type") != "tool_result":
37
+ continue
38
+ tool_use_id = str(block.get("tool_use_id") or "")
39
+ yield DictToolResultRef(
40
+ block,
41
+ message_index=message_index,
42
+ block_index=block_index,
43
+ tool_use_id=tool_use_id,
44
+ tool_name=names.get(tool_use_id),
45
+ is_error=bool(block.get("is_error")),
46
+ key="content",
47
+ )
48
+
49
+ def segments(self, payload: dict[str, Any]) -> list[Segment]:
50
+ result: list[Segment] = []
51
+ for index, tool in enumerate(payload.get("tools") or []):
52
+ if isinstance(tool, dict):
53
+ result.append(make_segment("tool_def", tool, block_index=index))
54
+ system = payload.get("system")
55
+ system_blocks = blocks_of(system)
56
+ if isinstance(system, str):
57
+ system_blocks = [{"type": "text", "text": system}]
58
+ for index, block in enumerate(system_blocks):
59
+ result.append(make_segment("system", block, block_index=index))
60
+ for message_index, message in enumerate(payload.get("messages") or []):
61
+ if not isinstance(message, dict):
62
+ continue
63
+ role = str(message.get("role") or "")
64
+ for block_index, block in enumerate(blocks_of(message.get("content"))):
65
+ result.append(
66
+ make_segment(
67
+ "message",
68
+ {**block, "role": role},
69
+ msg_index=message_index,
70
+ block_index=block_index,
71
+ )
72
+ )
73
+ return result
74
+
75
+ def system_text(self, payload: dict[str, Any]) -> str:
76
+ return text_of(payload.get("system"))
77
+
78
+ def first_user_text(self, payload: dict[str, Any]) -> str:
79
+ for message in payload.get("messages") or []:
80
+ if isinstance(message, dict) and message.get("role") == "user":
81
+ return text_of(message.get("content"))
82
+ return ""
83
+
84
+ def tool_names(self, payload: dict[str, Any]) -> list[str]:
85
+ return [
86
+ str(tool["name"])
87
+ for tool in payload.get("tools") or []
88
+ if isinstance(tool, dict) and isinstance(tool.get("name"), str)
89
+ ]
90
+
91
+ def used_tool_names(self, payload: dict[str, Any]) -> list[str]:
92
+ return list(self._tool_use_names(payload).values())
93
+
94
+ def _tool_use_names(self, payload: dict[str, Any]) -> dict[str, str]:
95
+ names: dict[str, str] = {}
96
+ for message in payload.get("messages") or []:
97
+ if not isinstance(message, dict):
98
+ continue
99
+ for block in blocks_of(message.get("content")):
100
+ if block.get("type") == "tool_use" and isinstance(block.get("id"), str):
101
+ names[block["id"]] = str(block.get("name") or "")
102
+ return names
103
+
104
+
105
+ __all__ = ["AnthropicAdapter"]
@@ -0,0 +1,191 @@
1
+ """Provider adapter protocol and shared payload helpers (SPEC section 11)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any, Protocol
7
+
8
+ from ctxoptimizer.analysis.segments import Segment
9
+ from ctxoptimizer.models import Provider, ToolResultRef
10
+
11
+
12
+ class ProviderAdapter(Protocol):
13
+ provider: Provider
14
+
15
+ def model_of(self, payload: dict[str, Any]) -> str | None: ...
16
+
17
+ def is_stream(self, payload: dict[str, Any]) -> bool: ...
18
+
19
+ def is_transformable(self, payload: dict[str, Any]) -> bool: ...
20
+
21
+ def iter_tool_results(self, payload: dict[str, Any]) -> Iterator[ToolResultRef]: ...
22
+
23
+ def segments(self, payload: dict[str, Any]) -> list[Segment]: ...
24
+
25
+ def system_text(self, payload: dict[str, Any]) -> str: ...
26
+
27
+ def first_user_text(self, payload: dict[str, Any]) -> str: ...
28
+
29
+ def tool_names(self, payload: dict[str, Any]) -> list[str]: ...
30
+
31
+ def used_tool_names(self, payload: dict[str, Any]) -> list[str]: ...
32
+
33
+
34
+ class DictToolResultRef(ToolResultRef):
35
+ """Edits a text-bearing ``content``/``output`` field inside a JSON block."""
36
+
37
+ __slots__ = ("_block", "_key")
38
+
39
+ def __init__(
40
+ self,
41
+ block: dict[str, Any],
42
+ *,
43
+ message_index: int,
44
+ block_index: int | None,
45
+ tool_use_id: str,
46
+ tool_name: str | None,
47
+ is_error: bool,
48
+ key: str,
49
+ ) -> None:
50
+ super().__init__(
51
+ message_index=message_index,
52
+ block_index=block_index,
53
+ tool_use_id=tool_use_id,
54
+ tool_name=tool_name,
55
+ is_error=is_error,
56
+ )
57
+ self._block = block
58
+ self._key = key
59
+
60
+ def get_text(self) -> str | None:
61
+ return content_text(self._block.get(self._key))
62
+
63
+ def set_text(self, new_text: str) -> None:
64
+ set_content_text(self._block, self._key, new_text)
65
+
66
+
67
+ class MappingToolResultRef(ToolResultRef):
68
+ """Edits a string value on a mapping (Gemini functionResponse)."""
69
+
70
+ __slots__ = ("_block", "_key")
71
+
72
+ def __init__(
73
+ self,
74
+ block: dict[str, Any],
75
+ *,
76
+ key: str,
77
+ message_index: int,
78
+ block_index: int | None,
79
+ tool_use_id: str,
80
+ tool_name: str | None,
81
+ is_error: bool,
82
+ ) -> None:
83
+ super().__init__(
84
+ message_index=message_index,
85
+ block_index=block_index,
86
+ tool_use_id=tool_use_id,
87
+ tool_name=tool_name,
88
+ is_error=is_error,
89
+ )
90
+ self._block = block
91
+ self._key = key
92
+
93
+ def get_text(self) -> str | None:
94
+ value = self._block.get(self._key)
95
+ return value if isinstance(value, str) else None
96
+
97
+ def set_text(self, new_text: str) -> None:
98
+ self._block[self._key] = new_text
99
+
100
+
101
+ def content_text(value: Any) -> str | None:
102
+ if isinstance(value, str):
103
+ return value
104
+ if isinstance(value, list):
105
+ parts = [
106
+ part.get("text", "")
107
+ for part in value
108
+ if isinstance(part, dict) and part.get("type") == "text"
109
+ ]
110
+ return "".join(parts)
111
+ return None
112
+
113
+
114
+ def set_content_text(block: dict[str, Any], key: str, text: str) -> None:
115
+ value = block.get(key)
116
+ if isinstance(value, list):
117
+ remaining = [
118
+ part for part in value if not (isinstance(part, dict) and part.get("type") == "text")
119
+ ]
120
+ block[key] = [{"type": "text", "text": text}, *remaining]
121
+ else:
122
+ block[key] = text
123
+
124
+
125
+ def blocks_of(content: Any) -> list[dict[str, Any]]:
126
+ if isinstance(content, str):
127
+ return [{"type": "text", "text": content}]
128
+ if isinstance(content, list):
129
+ return [block for block in content if isinstance(block, dict)]
130
+ return []
131
+
132
+
133
+ def text_of(value: Any) -> str:
134
+ if isinstance(value, str):
135
+ return value
136
+ if isinstance(value, list):
137
+ return "".join(
138
+ part.get("text", "")
139
+ for part in value
140
+ if isinstance(part, dict) and part.get("type") == "text"
141
+ )
142
+ return ""
143
+
144
+
145
+ class PassthroughAdapter:
146
+ """No structural access; used for native Gemini/Ollama until P6."""
147
+
148
+ provider: Provider
149
+
150
+ def __init__(self, provider: Provider) -> None:
151
+ self.provider = provider
152
+
153
+ def model_of(self, payload: dict[str, Any]) -> str | None:
154
+ model = payload.get("model")
155
+ return model if isinstance(model, str) else None
156
+
157
+ def is_stream(self, payload: dict[str, Any]) -> bool:
158
+ return bool(payload.get("stream"))
159
+
160
+ def is_transformable(self, payload: dict[str, Any]) -> bool:
161
+ return False
162
+
163
+ def iter_tool_results(self, payload: dict[str, Any]) -> Iterator[ToolResultRef]:
164
+ return iter(())
165
+
166
+ def segments(self, payload: dict[str, Any]) -> list[Segment]:
167
+ return []
168
+
169
+ def system_text(self, payload: dict[str, Any]) -> str:
170
+ return ""
171
+
172
+ def first_user_text(self, payload: dict[str, Any]) -> str:
173
+ return ""
174
+
175
+ def tool_names(self, payload: dict[str, Any]) -> list[str]:
176
+ return []
177
+
178
+ def used_tool_names(self, payload: dict[str, Any]) -> list[str]:
179
+ return []
180
+
181
+
182
+ __all__ = [
183
+ "DictToolResultRef",
184
+ "MappingToolResultRef",
185
+ "PassthroughAdapter",
186
+ "ProviderAdapter",
187
+ "blocks_of",
188
+ "content_text",
189
+ "set_content_text",
190
+ "text_of",
191
+ ]
@@ -0,0 +1,143 @@
1
+ """Native Gemini ``generateContent`` adapter (SPEC section 11.4, P6-1).
2
+
3
+ Telemetry first: segments and tool-result handles are exposed so the analysis
4
+ layer can measure usage. Transforms only touch string leaves inside
5
+ ``functionResponse`` parts, which is the byte-true subset.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from typing import Any
12
+
13
+ from ctxoptimizer.analysis.segments import Segment, make_segment
14
+ from ctxoptimizer.models import Provider, ToolResultRef
15
+ from ctxoptimizer.providers.base import MappingToolResultRef, text_of
16
+
17
+
18
+ class GeminiAdapter:
19
+ provider = Provider.GEMINI
20
+
21
+ def model_of(self, payload: dict[str, Any]) -> str | None:
22
+ model = payload.get("model")
23
+ return model if isinstance(model, str) else None
24
+
25
+ def is_stream(self, payload: dict[str, Any]) -> bool:
26
+ return bool(payload.get("stream"))
27
+
28
+ def is_transformable(self, payload: dict[str, Any]) -> bool:
29
+ return isinstance(payload.get("contents"), list)
30
+
31
+ def iter_tool_results(self, payload: dict[str, Any]) -> Iterator[ToolResultRef]:
32
+ for message_index, content in enumerate(payload.get("contents") or []):
33
+ if not isinstance(content, dict):
34
+ continue
35
+ for block_index, part in enumerate(content.get("parts") or []):
36
+ if not isinstance(part, dict):
37
+ continue
38
+ response = part.get("functionResponse")
39
+ if not isinstance(response, dict):
40
+ continue
41
+ name = str(response.get("name") or "")
42
+ handle = _response_handle(response)
43
+ if handle is None:
44
+ continue
45
+ parent, key, is_error = handle
46
+ yield MappingToolResultRef(
47
+ parent,
48
+ key=key,
49
+ message_index=message_index,
50
+ block_index=block_index,
51
+ tool_use_id=name,
52
+ tool_name=name or None,
53
+ is_error=is_error,
54
+ )
55
+
56
+ def segments(self, payload: dict[str, Any]) -> list[Segment]:
57
+ result: list[Segment] = []
58
+ for tool in payload.get("tools") or []:
59
+ if not isinstance(tool, dict):
60
+ continue
61
+ result.extend(
62
+ make_segment("tool_def", declaration)
63
+ for declaration in tool.get("functionDeclarations") or []
64
+ if isinstance(declaration, dict)
65
+ )
66
+ system = _parts(payload.get("systemInstruction"))
67
+ for index, part in enumerate(system):
68
+ result.append(make_segment("system", part, block_index=index))
69
+ for message_index, content in enumerate(payload.get("contents") or []):
70
+ if not isinstance(content, dict):
71
+ continue
72
+ role = str(content.get("role") or "")
73
+ for block_index, part in enumerate(content.get("parts") or []):
74
+ if isinstance(part, dict):
75
+ result.append(
76
+ make_segment(
77
+ "message",
78
+ {"role": role, **part},
79
+ msg_index=message_index,
80
+ block_index=block_index,
81
+ )
82
+ )
83
+ return result
84
+
85
+ def system_text(self, payload: dict[str, Any]) -> str:
86
+ return text_of(_parts(payload.get("systemInstruction")))
87
+
88
+ def first_user_text(self, payload: dict[str, Any]) -> str:
89
+ for content in payload.get("contents") or []:
90
+ if isinstance(content, dict) and content.get("role") == "user":
91
+ return text_of(_parts(content))
92
+ return ""
93
+
94
+ def tool_names(self, payload: dict[str, Any]) -> list[str]:
95
+ names: list[str] = []
96
+ for tool in payload.get("tools") or []:
97
+ if not isinstance(tool, dict):
98
+ continue
99
+ names.extend(
100
+ declaration["name"]
101
+ for declaration in tool.get("functionDeclarations") or []
102
+ if isinstance(declaration, dict) and isinstance(declaration.get("name"), str)
103
+ )
104
+ return names
105
+
106
+ def used_tool_names(self, payload: dict[str, Any]) -> list[str]:
107
+ names: list[str] = []
108
+ for content in payload.get("contents") or []:
109
+ if not isinstance(content, dict):
110
+ continue
111
+ for part in content.get("parts") or []:
112
+ if not isinstance(part, dict):
113
+ continue
114
+ call = part.get("functionCall")
115
+ if isinstance(call, dict) and isinstance(call.get("name"), str):
116
+ names.append(call["name"])
117
+ return names
118
+
119
+
120
+ def _parts(value: Any) -> list[dict[str, Any]]:
121
+ if isinstance(value, dict):
122
+ parts = value.get("parts")
123
+ if isinstance(parts, list):
124
+ return [part for part in parts if isinstance(part, dict)]
125
+ return []
126
+
127
+
128
+ def _response_handle(
129
+ response: dict[str, Any],
130
+ ) -> tuple[dict[str, Any], str, bool] | None:
131
+ inner = response.get("response")
132
+ if isinstance(inner, str):
133
+ return response, "response", False
134
+ if not isinstance(inner, dict):
135
+ return None
136
+ is_error = "error" in inner
137
+ for key in ("content", "result", "output", "text"):
138
+ if isinstance(inner.get(key), str):
139
+ return inner, key, is_error
140
+ return None
141
+
142
+
143
+ __all__ = ["GeminiAdapter"]
@@ -0,0 +1,133 @@
1
+ """Native Ollama ``/api/chat`` and ``/api/generate`` adapter (P6-2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ from ctxoptimizer.analysis.segments import Segment, make_segment
9
+ from ctxoptimizer.models import Provider, ToolResultRef
10
+ from ctxoptimizer.providers.base import DictToolResultRef, text_of
11
+
12
+
13
+ class OllamaAdapter:
14
+ provider = Provider.OLLAMA
15
+
16
+ def model_of(self, payload: dict[str, Any]) -> str | None:
17
+ model = payload.get("model")
18
+ return model if isinstance(model, str) else None
19
+
20
+ def is_stream(self, payload: dict[str, Any]) -> bool:
21
+ # Ollama streams by default when ``stream`` is omitted.
22
+ return bool(payload.get("stream", True))
23
+
24
+ def is_transformable(self, payload: dict[str, Any]) -> bool:
25
+ return isinstance(payload.get("messages"), list) or isinstance(payload.get("prompt"), str)
26
+
27
+ def iter_tool_results(self, payload: dict[str, Any]) -> Iterator[ToolResultRef]:
28
+ for message_index, message in enumerate(payload.get("messages") or []):
29
+ if not isinstance(message, dict) or message.get("role") != "tool":
30
+ continue
31
+ yield DictToolResultRef(
32
+ message,
33
+ message_index=message_index,
34
+ block_index=None,
35
+ tool_use_id=str(message.get("tool_name") or ""),
36
+ tool_name=(
37
+ message.get("tool_name") if isinstance(message.get("tool_name"), str) else None
38
+ ),
39
+ is_error=False,
40
+ key="content",
41
+ )
42
+
43
+ def segments(self, payload: dict[str, Any]) -> list[Segment]:
44
+ result: list[Segment] = []
45
+ for tool in payload.get("tools") or []:
46
+ if not isinstance(tool, dict):
47
+ continue
48
+ function = tool.get("function")
49
+ if isinstance(function, dict):
50
+ result.append(make_segment("tool_def", function))
51
+ system = payload.get("system")
52
+ if isinstance(system, str) and system:
53
+ result.append(make_segment("system", {"type": "text", "text": system}))
54
+ messages = payload.get("messages")
55
+ if isinstance(messages, list):
56
+ for message_index, message in enumerate(messages):
57
+ if not isinstance(message, dict):
58
+ continue
59
+ role = str(message.get("role") or "")
60
+ content = message.get("content")
61
+ if isinstance(content, str):
62
+ result.append(
63
+ make_segment(
64
+ "message",
65
+ {"role": role, "content": content},
66
+ msg_index=message_index,
67
+ block_index=0,
68
+ )
69
+ )
70
+ elif content is None and isinstance(message.get("tool_calls"), list):
71
+ result.append(
72
+ make_segment(
73
+ "message",
74
+ {"role": role, "tool_calls": message["tool_calls"]},
75
+ msg_index=message_index,
76
+ )
77
+ )
78
+ elif isinstance(payload.get("prompt"), str):
79
+ result.append(
80
+ make_segment(
81
+ "message",
82
+ {"role": "user", "content": payload["prompt"]},
83
+ msg_index=0,
84
+ block_index=0,
85
+ )
86
+ )
87
+ return result
88
+
89
+ def system_text(self, payload: dict[str, Any]) -> str:
90
+ system = payload.get("system")
91
+ if isinstance(system, str):
92
+ return system
93
+ parts = [
94
+ text_of(message.get("content"))
95
+ for message in payload.get("messages") or []
96
+ if isinstance(message, dict) and message.get("role") == "system"
97
+ ]
98
+ return "\n".join(part for part in parts if part)
99
+
100
+ def first_user_text(self, payload: dict[str, Any]) -> str:
101
+ prompt = payload.get("prompt")
102
+ if isinstance(prompt, str):
103
+ return prompt
104
+ for message in payload.get("messages") or []:
105
+ if isinstance(message, dict) and message.get("role") == "user":
106
+ return text_of(message.get("content"))
107
+ return ""
108
+
109
+ def tool_names(self, payload: dict[str, Any]) -> list[str]:
110
+ names: list[str] = []
111
+ for tool in payload.get("tools") or []:
112
+ if not isinstance(tool, dict):
113
+ continue
114
+ function = tool.get("function")
115
+ if isinstance(function, dict) and isinstance(function.get("name"), str):
116
+ names.append(function["name"])
117
+ return names
118
+
119
+ def used_tool_names(self, payload: dict[str, Any]) -> list[str]:
120
+ names: list[str] = []
121
+ for message in payload.get("messages") or []:
122
+ if not isinstance(message, dict):
123
+ continue
124
+ for call in message.get("tool_calls") or []:
125
+ if not isinstance(call, dict):
126
+ continue
127
+ function = call.get("function")
128
+ if isinstance(function, dict) and isinstance(function.get("name"), str):
129
+ names.append(function["name"])
130
+ return names
131
+
132
+
133
+ __all__ = ["OllamaAdapter"]