omega-code 0.4.0__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.
- omega/__init__.py +0 -0
- omega/__main__.py +589 -0
- omega/artifacts.py +151 -0
- omega/checkpoint.py +246 -0
- omega/compact.py +106 -0
- omega/config.py +285 -0
- omega/eval/__init__.py +3 -0
- omega/eval/cli.py +127 -0
- omega/eval/examples/plan-version-flag.yaml +11 -0
- omega/eval/examples/relative-age-negative-delta.yaml +14 -0
- omega/eval/examples/version-flag.yaml +10 -0
- omega/eval/manifest.py +129 -0
- omega/eval/prices.py +29 -0
- omega/eval/report.py +135 -0
- omega/eval/runner.py +199 -0
- omega/eval/tasks.py +97 -0
- omega/events.py +145 -0
- omega/export.py +80 -0
- omega/gitlog.py +229 -0
- omega/hooks.py +63 -0
- omega/instructions.py +103 -0
- omega/integrations.py +284 -0
- omega/keys.py +173 -0
- omega/llm.py +442 -0
- omega/loop.py +510 -0
- omega/mcp.py +490 -0
- omega/memory/__init__.py +5 -0
- omega/memory/consolidate.py +103 -0
- omega/memory/curate.py +69 -0
- omega/memory/store.py +321 -0
- omega/memory/tools.py +175 -0
- omega/migrate.py +40 -0
- omega/onboarding.py +242 -0
- omega/permissions.py +137 -0
- omega/secrets.py +173 -0
- omega/server/__init__.py +7 -0
- omega/server/__main__.py +18 -0
- omega/server/app.py +71 -0
- omega/server/auth.py +73 -0
- omega/server/manager.py +287 -0
- omega/server/models.py +123 -0
- omega/server/tasks_api.py +311 -0
- omega/server/terminals.py +245 -0
- omega/server/worker.py +186 -0
- omega/session.py +209 -0
- omega/setup.html +281 -0
- omega/setup_server.py +452 -0
- omega/skills.py +158 -0
- omega/subagent.py +98 -0
- omega/tasks.py +195 -0
- omega/tools.py +590 -0
- omega/trace.py +156 -0
- omega/trajectory.py +146 -0
- omega/ui/__init__.py +0 -0
- omega/ui/composer.py +140 -0
- omega/ui/format.py +708 -0
- omega/ui/plain.py +141 -0
- omega/ui/tui/__init__.py +9 -0
- omega/ui/tui/app.py +958 -0
- omega/ui/tui/history.py +50 -0
- omega/ui/tui/modals.py +292 -0
- omega/ui/tui/onboarding.py +367 -0
- omega/ui/tui/prefs.py +25 -0
- omega/ui/tui/sidebar.py +510 -0
- omega/ui/tui/status.py +115 -0
- omega/ui/tui/theme.py +91 -0
- omega/ui/tui/transcript.py +783 -0
- omega/verify.py +133 -0
- omega_code-0.4.0.dist-info/METADATA +479 -0
- omega_code-0.4.0.dist-info/RECORD +73 -0
- omega_code-0.4.0.dist-info/WHEEL +4 -0
- omega_code-0.4.0.dist-info/entry_points.txt +2 -0
- omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/llm.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import random
|
|
4
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import anthropic
|
|
9
|
+
import openai
|
|
10
|
+
from anthropic import AsyncAnthropic
|
|
11
|
+
from openai import AsyncOpenAI
|
|
12
|
+
|
|
13
|
+
from .config import Role
|
|
14
|
+
from .session import Message
|
|
15
|
+
|
|
16
|
+
PhaseState = Literal["waiting", "thinking", "streaming"]
|
|
17
|
+
FallbackInfo = tuple[str, str, str]
|
|
18
|
+
StreamEvent = (tuple[Literal["text"], str] | tuple[Literal["tool"], "ToolCall"]
|
|
19
|
+
| tuple[Literal["done"], "Turn"] | tuple[Literal["phase"], PhaseState]
|
|
20
|
+
| tuple[Literal["fallback"], FallbackInfo])
|
|
21
|
+
|
|
22
|
+
# The array-form fallback header would also work, but the scalar "default" mode
|
|
23
|
+
# picks Anthropic's recommended fallback per refusal category instead of
|
|
24
|
+
# pinning one model -- see shared/model-migration.md -> New API features.
|
|
25
|
+
_ANTHROPIC_BETAS = ["server-side-fallback-2026-07-01"]
|
|
26
|
+
_ANTHROPIC_MAX_TOKENS = 64000
|
|
27
|
+
_VOLATILE_MARKER = "\n<!-- volatile -->\n"
|
|
28
|
+
|
|
29
|
+
# Retry ladder shared by both backends: retried only while no output has
|
|
30
|
+
# reached the caller yet -- a mid-stream failure must surface as an error,
|
|
31
|
+
# never silently restart and duplicate what was already emitted.
|
|
32
|
+
LLM_MAX_ATTEMPTS = 3
|
|
33
|
+
LLM_RETRY_BASE_DELAY = 1.0
|
|
34
|
+
_RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504, 529}
|
|
35
|
+
_RETRYABLE_EXC = (openai.APIStatusError, openai.APIConnectionError,
|
|
36
|
+
anthropic.APIStatusError, anthropic.APIConnectionError)
|
|
37
|
+
# Swapped out by tests to avoid real waits; production code must not call
|
|
38
|
+
# asyncio.sleep directly so a monkeypatch here covers both backends.
|
|
39
|
+
_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_retryable(exc: BaseException) -> bool:
|
|
43
|
+
if isinstance(exc, (openai.APIStatusError, anthropic.APIStatusError)):
|
|
44
|
+
return exc.status_code in _RETRYABLE_STATUS
|
|
45
|
+
# APIConnectionError (incl. APITimeoutError, its subclass in both SDKs)
|
|
46
|
+
# never carries a status code -- network/timeout failures are always
|
|
47
|
+
# worth a retry.
|
|
48
|
+
return isinstance(exc, (openai.APIConnectionError, anthropic.APIConnectionError))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _retry_delay(attempt: int, exc: BaseException) -> float:
|
|
52
|
+
if isinstance(exc, (openai.APIStatusError, anthropic.APIStatusError)):
|
|
53
|
+
retry_after = exc.response.headers.get("retry-after")
|
|
54
|
+
if retry_after:
|
|
55
|
+
try:
|
|
56
|
+
return float(retry_after)
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
base = LLM_RETRY_BASE_DELAY * (2.0 ** (attempt - 1))
|
|
60
|
+
return base + random.uniform(0, base * 0.5)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fallback_reason(exc: BaseException) -> str:
|
|
64
|
+
if isinstance(exc, (openai.APIStatusError, anthropic.APIStatusError)):
|
|
65
|
+
return f"HTTP {exc.status_code}"
|
|
66
|
+
return type(exc).__name__
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ToolCall:
|
|
71
|
+
id: str
|
|
72
|
+
name: str
|
|
73
|
+
arguments: str = ""
|
|
74
|
+
|
|
75
|
+
def args(self) -> dict[str, Any]:
|
|
76
|
+
try:
|
|
77
|
+
return dict(json.loads(self.arguments or "{}"))
|
|
78
|
+
except json.JSONDecodeError as e:
|
|
79
|
+
raise ValueError(f"bad arguments for {self.name}: {e}") from None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Turn:
|
|
84
|
+
text: str = ""
|
|
85
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
86
|
+
finish_reason: str = ""
|
|
87
|
+
prompt_tokens: int = 0
|
|
88
|
+
completion_tokens: int = 0
|
|
89
|
+
cached_tokens: int = 0
|
|
90
|
+
cache_creation_tokens: int = 0
|
|
91
|
+
thinking: list[dict[str, Any]] = field(default_factory=list)
|
|
92
|
+
model: str = ""
|
|
93
|
+
|
|
94
|
+
def as_message(self) -> Message:
|
|
95
|
+
msg: Message = {"role": "assistant", "content": self.text or None}
|
|
96
|
+
if self.tool_calls:
|
|
97
|
+
msg["tool_calls"] = [
|
|
98
|
+
{"id": c.id, "type": "function",
|
|
99
|
+
"function": {"name": c.name, "arguments": c.arguments or "{}"}}
|
|
100
|
+
for c in self.tool_calls
|
|
101
|
+
]
|
|
102
|
+
if self.thinking:
|
|
103
|
+
msg["thinking"] = self.thinking
|
|
104
|
+
if self.model:
|
|
105
|
+
msg["model"] = self.model
|
|
106
|
+
return msg
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_clients: dict[str, AsyncOpenAI] = {}
|
|
110
|
+
_anthropic_clients: dict[str, AsyncAnthropic] = {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def client_for(role: Role) -> AsyncOpenAI:
|
|
114
|
+
p = role.provider
|
|
115
|
+
if p.name not in _clients:
|
|
116
|
+
_clients[p.name] = AsyncOpenAI(base_url=p.base_url, api_key=p.api_key)
|
|
117
|
+
return _clients[p.name]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _anthropic_client_for(role: Role) -> AsyncAnthropic:
|
|
121
|
+
p = role.provider
|
|
122
|
+
if p.name not in _anthropic_clients:
|
|
123
|
+
kwargs: dict[str, Any] = {"api_key": p.api_key}
|
|
124
|
+
if p.base_url:
|
|
125
|
+
kwargs["base_url"] = p.base_url
|
|
126
|
+
_anthropic_clients[p.name] = AsyncAnthropic(**kwargs)
|
|
127
|
+
return _anthropic_clients[p.name]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _backend_for(role: Role) -> Callable[[Role, list[Message], list[dict[str, Any]] | None],
|
|
131
|
+
AsyncIterator[StreamEvent]]:
|
|
132
|
+
return _stream_anthropic if getattr(role.provider, "type", "openai") == "anthropic" else _stream_openai
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _stream_with_retries(role: Role, messages: list[Message],
|
|
136
|
+
tools: list[dict[str, Any]] | None) -> AsyncIterator[StreamEvent]:
|
|
137
|
+
"""The retry ladder for one role/backend: up to `LLM_MAX_ATTEMPTS` tries,
|
|
138
|
+
exponential backoff with jitter (honoring `retry-after` when present), on
|
|
139
|
+
HTTP 408/409/429/500/502/503/504/529 and connection/timeout errors.
|
|
140
|
+
|
|
141
|
+
Retried only while this attempt produced no `text`/`tool` output yet --
|
|
142
|
+
once the caller has seen output, a failure must propagate as an error
|
|
143
|
+
rather than silently restart and duplicate it.
|
|
144
|
+
"""
|
|
145
|
+
backend = _backend_for(role)
|
|
146
|
+
last_exc: BaseException | None = None
|
|
147
|
+
for attempt in range(1, LLM_MAX_ATTEMPTS + 1):
|
|
148
|
+
yielded = False
|
|
149
|
+
try:
|
|
150
|
+
async for ev in backend(role, messages, tools):
|
|
151
|
+
if ev[0] in ("text", "tool"):
|
|
152
|
+
yielded = True
|
|
153
|
+
yield ev
|
|
154
|
+
return
|
|
155
|
+
except _RETRYABLE_EXC as exc:
|
|
156
|
+
if yielded or attempt == LLM_MAX_ATTEMPTS or not _is_retryable(exc):
|
|
157
|
+
raise
|
|
158
|
+
last_exc = exc
|
|
159
|
+
yield "phase", "waiting"
|
|
160
|
+
await _sleep(_retry_delay(attempt, exc))
|
|
161
|
+
if last_exc is not None:
|
|
162
|
+
raise last_exc
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def stream(role: Role, messages: list[Message],
|
|
166
|
+
tools: list[dict[str, Any]] | None = None,
|
|
167
|
+
fallback: Role | None = None) -> AsyncIterator[StreamEvent]:
|
|
168
|
+
"""Yield ('text', delta) and ('tool', ToolCall) events.
|
|
169
|
+
|
|
170
|
+
A tool call is emitted the moment its arguments are known to be complete --
|
|
171
|
+
when a higher-indexed call begins, or when the stream ends -- so the caller
|
|
172
|
+
can start executing it while the model is still generating the next one.
|
|
173
|
+
|
|
174
|
+
Dispatches to the OpenAI-compatible or native Anthropic backend based on
|
|
175
|
+
`role.provider.type` (defaults to "openai" for callers -- tests included --
|
|
176
|
+
that build a bare provider stand-in without a `type` attribute).
|
|
177
|
+
|
|
178
|
+
Wraps the call in a retry ladder (see `_stream_with_retries`); if it is
|
|
179
|
+
exhausted -- or the failure isn't retryable at all -- and `fallback` is
|
|
180
|
+
given, retries the whole thing against the fallback role (with its own
|
|
181
|
+
retry ladder), announced via a `("fallback", (from, to, reason))` event.
|
|
182
|
+
Falls back only when nothing has reached the caller yet, for the same
|
|
183
|
+
no-duplicate-output reason the retry ladder itself stops retrying.
|
|
184
|
+
"""
|
|
185
|
+
any_output = False
|
|
186
|
+
try:
|
|
187
|
+
async for ev in _stream_with_retries(role, messages, tools):
|
|
188
|
+
if ev[0] in ("text", "tool"):
|
|
189
|
+
any_output = True
|
|
190
|
+
yield ev
|
|
191
|
+
except _RETRYABLE_EXC as exc:
|
|
192
|
+
if any_output or fallback is None:
|
|
193
|
+
raise
|
|
194
|
+
yield "phase", "waiting"
|
|
195
|
+
yield "fallback", (role.model, fallback.model, _fallback_reason(exc))
|
|
196
|
+
async for ev in _stream_with_retries(fallback, messages, tools):
|
|
197
|
+
yield ev
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def _stream_openai(role: Role, messages: list[Message],
|
|
201
|
+
tools: list[dict[str, Any]] | None) -> AsyncIterator[StreamEvent]:
|
|
202
|
+
# `thinking`/`model` are Anthropic-only bookkeeping stamped by as_message();
|
|
203
|
+
# some OpenAI-compatible providers hard-reject unrecognized message keys.
|
|
204
|
+
sent = [{k: v for k, v in m.items() if k not in ("thinking", "model")} for m in messages]
|
|
205
|
+
kwargs: dict[str, Any] = {"model": role.model, "messages": sent, "stream": True,
|
|
206
|
+
"stream_options": {"include_usage": True}}
|
|
207
|
+
if tools:
|
|
208
|
+
kwargs["tools"] = tools
|
|
209
|
+
kwargs["tool_choice"] = "auto"
|
|
210
|
+
|
|
211
|
+
pending: dict[int, ToolCall] = {}
|
|
212
|
+
turn = Turn(model=role.model)
|
|
213
|
+
emitted: set[int] = set()
|
|
214
|
+
|
|
215
|
+
async for chunk in await client_for(role).chat.completions.create(**kwargs):
|
|
216
|
+
# The usage-bearing chunk arrives last with an EMPTY choices array, so
|
|
217
|
+
# it must be read before the choices guard or it is silently discarded.
|
|
218
|
+
if getattr(chunk, "usage", None):
|
|
219
|
+
u = chunk.usage
|
|
220
|
+
turn.prompt_tokens = getattr(u, "prompt_tokens", 0) or 0
|
|
221
|
+
turn.completion_tokens = getattr(u, "completion_tokens", 0) or 0
|
|
222
|
+
details = getattr(u, "prompt_tokens_details", None)
|
|
223
|
+
turn.cached_tokens = (getattr(details, "cached_tokens", 0) or 0) if details else 0
|
|
224
|
+
if not chunk.choices:
|
|
225
|
+
continue
|
|
226
|
+
choice = chunk.choices[0]
|
|
227
|
+
delta = choice.delta
|
|
228
|
+
|
|
229
|
+
if delta and delta.content:
|
|
230
|
+
turn.text += delta.content
|
|
231
|
+
yield "text", delta.content
|
|
232
|
+
|
|
233
|
+
for tc in (delta.tool_calls or []) if delta else []:
|
|
234
|
+
# A provider that reuses an index for a new call would otherwise
|
|
235
|
+
# concatenate two names into one ("greplread").
|
|
236
|
+
existing = pending.get(tc.index)
|
|
237
|
+
if (existing is not None and tc.id and existing.id
|
|
238
|
+
and tc.id != existing.id and not existing.id.startswith("call_")):
|
|
239
|
+
if tc.index not in emitted:
|
|
240
|
+
emitted.add(tc.index)
|
|
241
|
+
turn.tool_calls.append(existing)
|
|
242
|
+
yield "tool", existing
|
|
243
|
+
del pending[tc.index]
|
|
244
|
+
if tc.index not in pending:
|
|
245
|
+
for done in sorted(i for i in pending if i < tc.index and i not in emitted):
|
|
246
|
+
emitted.add(done)
|
|
247
|
+
turn.tool_calls.append(pending[done])
|
|
248
|
+
yield "tool", pending[done]
|
|
249
|
+
pending[tc.index] = ToolCall(id=tc.id or f"call_{tc.index}", name="")
|
|
250
|
+
call = pending[tc.index]
|
|
251
|
+
if tc.id:
|
|
252
|
+
call.id = tc.id
|
|
253
|
+
if tc.function and tc.function.name:
|
|
254
|
+
call.name += tc.function.name
|
|
255
|
+
if tc.function and tc.function.arguments:
|
|
256
|
+
call.arguments += tc.function.arguments
|
|
257
|
+
|
|
258
|
+
if choice.finish_reason:
|
|
259
|
+
turn.finish_reason = choice.finish_reason
|
|
260
|
+
|
|
261
|
+
incomplete: list[str] = []
|
|
262
|
+
for i in sorted(i for i in pending if i not in emitted):
|
|
263
|
+
call = pending[i]
|
|
264
|
+
# finish_reason "length" means generation was cut off mid-call: its
|
|
265
|
+
# arguments are truncated JSON and must never be dispatched or stored.
|
|
266
|
+
if turn.finish_reason == "length" and not _parseable(call.arguments):
|
|
267
|
+
incomplete.append(call.name or f"index {i}")
|
|
268
|
+
continue
|
|
269
|
+
turn.tool_calls.append(call)
|
|
270
|
+
yield "tool", call
|
|
271
|
+
|
|
272
|
+
if incomplete:
|
|
273
|
+
turn.text += (f"\n[turn truncated by token limit; dropped incomplete "
|
|
274
|
+
f"tool call(s): {', '.join(incomplete)}]")
|
|
275
|
+
|
|
276
|
+
yield "done", turn
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parseable(args: str) -> bool:
|
|
280
|
+
try:
|
|
281
|
+
json.loads(args or "{}")
|
|
282
|
+
return True
|
|
283
|
+
except json.JSONDecodeError:
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _anthropic_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
288
|
+
out = []
|
|
289
|
+
for t in tools:
|
|
290
|
+
fn = t.get("function", t)
|
|
291
|
+
out.append({"name": fn["name"], "description": fn.get("description", ""),
|
|
292
|
+
"input_schema": fn.get("parameters") or {"type": "object", "properties": {}}})
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _anthropic_history(history: list[Message], role: Role) -> list[dict[str, Any]]:
|
|
297
|
+
"""Convert omega's OpenAI-shaped history (after the leading system message)
|
|
298
|
+
into Messages API turns. Consecutive `tool` messages collapse into one
|
|
299
|
+
user message holding all their `tool_result` blocks -- the API rejects a
|
|
300
|
+
`tool_result` that isn't inside a user message immediately following the
|
|
301
|
+
`tool_use` turn. Thinking blocks are only replayed on the model that
|
|
302
|
+
produced them (see the module docstring in compact.py for why)."""
|
|
303
|
+
out: list[dict[str, Any]] = []
|
|
304
|
+
i = 0
|
|
305
|
+
while i < len(history):
|
|
306
|
+
m = history[i]
|
|
307
|
+
role_name = m.get("role")
|
|
308
|
+
if role_name == "user":
|
|
309
|
+
out.append({"role": "user", "content": [{"type": "text", "text": str(m.get("content") or "")}]})
|
|
310
|
+
i += 1
|
|
311
|
+
elif role_name == "assistant":
|
|
312
|
+
content: list[dict[str, Any]] = []
|
|
313
|
+
if m.get("model") == role.model:
|
|
314
|
+
content.extend(m.get("thinking") or [])
|
|
315
|
+
if m.get("content"):
|
|
316
|
+
content.append({"type": "text", "text": str(m["content"])})
|
|
317
|
+
for tc in m.get("tool_calls") or []:
|
|
318
|
+
fn = tc["function"]
|
|
319
|
+
try:
|
|
320
|
+
call_input = json.loads(fn.get("arguments") or "{}")
|
|
321
|
+
except json.JSONDecodeError:
|
|
322
|
+
call_input = {}
|
|
323
|
+
content.append({"type": "tool_use", "id": tc["id"], "name": fn["name"], "input": call_input})
|
|
324
|
+
out.append({"role": "assistant", "content": content})
|
|
325
|
+
i += 1
|
|
326
|
+
elif role_name == "tool":
|
|
327
|
+
blocks = []
|
|
328
|
+
while i < len(history) and history[i].get("role") == "tool":
|
|
329
|
+
t = history[i]
|
|
330
|
+
blocks.append({"type": "tool_result", "tool_use_id": t["tool_call_id"],
|
|
331
|
+
"content": str(t.get("content") or "")})
|
|
332
|
+
i += 1
|
|
333
|
+
out.append({"role": "user", "content": blocks})
|
|
334
|
+
else:
|
|
335
|
+
i += 1
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _anthropic_system_blocks(system_text: str) -> list[dict[str, Any]]:
|
|
340
|
+
"""Split on the stable/volatile marker `loop.py` renders the system prompt
|
|
341
|
+
with. The stable prefix (persona, tool-use conventions -- identical every
|
|
342
|
+
turn) gets the long-lived breakpoint; the volatile tail (memory snapshot,
|
|
343
|
+
per-session notes) is a separate, uncached block so editing it never
|
|
344
|
+
invalidates the expensive stable prefix. Render order is tools -> system
|
|
345
|
+
-> messages, so tool schemas sit ahead of this and are cached for free.
|
|
346
|
+
"""
|
|
347
|
+
stable, _, volatile = system_text.partition(_VOLATILE_MARKER)
|
|
348
|
+
blocks = [{"type": "text", "text": stable,
|
|
349
|
+
"cache_control": {"type": "ephemeral", "ttl": "1h"}}]
|
|
350
|
+
if volatile:
|
|
351
|
+
blocks.append({"type": "text", "text": volatile})
|
|
352
|
+
return blocks
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def _stream_anthropic(role: Role, messages: list[Message],
|
|
356
|
+
tools: list[dict[str, Any]] | None) -> AsyncIterator[StreamEvent]:
|
|
357
|
+
system_text = str(messages[0].get("content") or "") if messages and messages[0].get("role") == "system" else ""
|
|
358
|
+
body = messages[1:] if messages and messages[0].get("role") == "system" else messages
|
|
359
|
+
anth_messages = _anthropic_history(body, role)
|
|
360
|
+
if anth_messages and anth_messages[-1]["content"]:
|
|
361
|
+
# Breakpoint on the last block of the last turn: every earlier
|
|
362
|
+
# breakpoint stays a valid read, so hits accrue as history grows.
|
|
363
|
+
anth_messages[-1]["content"][-1] = {
|
|
364
|
+
**anth_messages[-1]["content"][-1], "cache_control": {"type": "ephemeral"}}
|
|
365
|
+
|
|
366
|
+
kwargs: dict[str, Any] = {
|
|
367
|
+
"model": role.model,
|
|
368
|
+
"max_tokens": _ANTHROPIC_MAX_TOKENS,
|
|
369
|
+
"system": _anthropic_system_blocks(system_text),
|
|
370
|
+
"messages": anth_messages,
|
|
371
|
+
"betas": _ANTHROPIC_BETAS,
|
|
372
|
+
"fallbacks": "default",
|
|
373
|
+
}
|
|
374
|
+
if tools:
|
|
375
|
+
kwargs["tools"] = _anthropic_tools(tools)
|
|
376
|
+
# Fable 5.1 thinking is always on and rejects any explicit `thinking` config
|
|
377
|
+
# (including "adaptive"); every other claude-* model needs it spelled out.
|
|
378
|
+
if not role.model.startswith("claude-fable") and role.model.startswith("claude"):
|
|
379
|
+
kwargs["thinking"] = {"type": "adaptive"}
|
|
380
|
+
if role.effort:
|
|
381
|
+
kwargs["output_config"] = {"effort": role.effort}
|
|
382
|
+
|
|
383
|
+
turn = Turn(model=role.model)
|
|
384
|
+
pending_tools: dict[int, ToolCall] = {}
|
|
385
|
+
in_thinking = False
|
|
386
|
+
|
|
387
|
+
client = _anthropic_client_for(role)
|
|
388
|
+
async with client.beta.messages.stream(**kwargs) as anthropic_stream:
|
|
389
|
+
async for event in anthropic_stream:
|
|
390
|
+
if event.type == "content_block_start":
|
|
391
|
+
if event.content_block.type in ("thinking", "redacted_thinking"):
|
|
392
|
+
if not in_thinking:
|
|
393
|
+
in_thinking = True
|
|
394
|
+
yield "phase", "thinking"
|
|
395
|
+
else:
|
|
396
|
+
if in_thinking:
|
|
397
|
+
in_thinking = False
|
|
398
|
+
yield "phase", "streaming"
|
|
399
|
+
if event.content_block.type == "tool_use":
|
|
400
|
+
pending_tools[event.index] = ToolCall(
|
|
401
|
+
id=event.content_block.id, name=event.content_block.name)
|
|
402
|
+
elif event.type == "content_block_delta":
|
|
403
|
+
if event.delta.type == "thinking_delta" and not in_thinking:
|
|
404
|
+
in_thinking = True
|
|
405
|
+
yield "phase", "thinking"
|
|
406
|
+
elif event.delta.type == "text_delta":
|
|
407
|
+
if in_thinking:
|
|
408
|
+
in_thinking = False
|
|
409
|
+
yield "phase", "streaming"
|
|
410
|
+
turn.text += event.delta.text
|
|
411
|
+
yield "text", event.delta.text
|
|
412
|
+
elif event.delta.type == "input_json_delta":
|
|
413
|
+
call = pending_tools.get(event.index)
|
|
414
|
+
if call is not None:
|
|
415
|
+
call.arguments += event.delta.partial_json
|
|
416
|
+
elif event.type == "content_block_stop" and event.index in pending_tools:
|
|
417
|
+
call = pending_tools.pop(event.index)
|
|
418
|
+
turn.tool_calls.append(call)
|
|
419
|
+
yield "tool", call
|
|
420
|
+
final = await anthropic_stream.get_final_message()
|
|
421
|
+
|
|
422
|
+
turn.prompt_tokens = final.usage.input_tokens
|
|
423
|
+
turn.completion_tokens = final.usage.output_tokens
|
|
424
|
+
turn.cached_tokens = final.usage.cache_read_input_tokens or 0
|
|
425
|
+
turn.cache_creation_tokens = getattr(final.usage, "cache_creation_input_tokens", 0) or 0
|
|
426
|
+
turn.thinking = [b.model_dump() for b in final.content if b.type in ("thinking", "redacted_thinking")]
|
|
427
|
+
|
|
428
|
+
if final.stop_reason == "max_tokens":
|
|
429
|
+
turn.finish_reason = "length"
|
|
430
|
+
elif final.stop_reason == "tool_use":
|
|
431
|
+
turn.finish_reason = "tool_calls"
|
|
432
|
+
elif final.stop_reason == "refusal":
|
|
433
|
+
turn.finish_reason = "refusal"
|
|
434
|
+
note = "\n[request declined by the model's safety classifiers"
|
|
435
|
+
served_by = getattr(final, "model", "") or ""
|
|
436
|
+
if served_by and served_by != role.model:
|
|
437
|
+
note += f"; fallback model {served_by} was tried but also declined"
|
|
438
|
+
turn.text += note + "]"
|
|
439
|
+
else:
|
|
440
|
+
turn.finish_reason = final.stop_reason or ""
|
|
441
|
+
|
|
442
|
+
yield "done", turn
|