nextloop 0.1.0rc1__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.
- nextloop/__init__.py +96 -0
- nextloop/adapters/__init__.py +6 -0
- nextloop/adapters/_sse.py +25 -0
- nextloop/adapters/anthropic.py +211 -0
- nextloop/adapters/memory.py +97 -0
- nextloop/adapters/openai.py +188 -0
- nextloop/adapters/sqlite.py +250 -0
- nextloop/contracts.py +350 -0
- nextloop/driver.py +446 -0
- nextloop/events/__init__.py +17 -0
- nextloop/events/outcome.py +183 -0
- nextloop/events/project.py +186 -0
- nextloop/events/repair.py +143 -0
- nextloop/events/response.py +215 -0
- nextloop/execute/__init__.py +15 -0
- nextloop/execute/batch.py +165 -0
- nextloop/execute/model.py +134 -0
- nextloop/execute/tools.py +151 -0
- nextloop/loop.py +169 -0
- nextloop/py.typed +0 -0
- nextloop-0.1.0rc1.dist-info/METADATA +148 -0
- nextloop-0.1.0rc1.dist-info/RECORD +25 -0
- nextloop-0.1.0rc1.dist-info/WHEEL +5 -0
- nextloop-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
- nextloop-0.1.0rc1.dist-info/top_level.txt +1 -0
nextloop/__init__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from .adapters.anthropic import AnthropicProvider
|
|
2
|
+
from .adapters.memory import InMemoryEventStore
|
|
3
|
+
from .adapters.openai import OpenAIProvider
|
|
4
|
+
from .adapters.sqlite import SQLiteEventStore
|
|
5
|
+
from .contracts import (
|
|
6
|
+
API_VERSION,
|
|
7
|
+
EVENT_SCHEMA_VERSION,
|
|
8
|
+
Cancelled,
|
|
9
|
+
CandidateRequest,
|
|
10
|
+
CapacityError,
|
|
11
|
+
Completed,
|
|
12
|
+
DeliveryOwner,
|
|
13
|
+
Event,
|
|
14
|
+
EventStore,
|
|
15
|
+
EventType,
|
|
16
|
+
FinalMessage,
|
|
17
|
+
FinishReason,
|
|
18
|
+
InvariantViolation,
|
|
19
|
+
LoopConfig,
|
|
20
|
+
Message,
|
|
21
|
+
ModelProvider,
|
|
22
|
+
ModelResponse,
|
|
23
|
+
ModelUsage,
|
|
24
|
+
NewEvent,
|
|
25
|
+
NextloopError,
|
|
26
|
+
ProtocolExhausted,
|
|
27
|
+
ProviderError,
|
|
28
|
+
RecoveryOutcome,
|
|
29
|
+
Skipped,
|
|
30
|
+
SurfaceOp,
|
|
31
|
+
ToolCall,
|
|
32
|
+
ToolExecutionMode,
|
|
33
|
+
ToolOutcome,
|
|
34
|
+
ToolRuntime,
|
|
35
|
+
ToolSpec,
|
|
36
|
+
TurnContext,
|
|
37
|
+
TurnOutcome,
|
|
38
|
+
TurnStatus,
|
|
39
|
+
UncertainHanging,
|
|
40
|
+
UncertainSettled,
|
|
41
|
+
ValidatedCall,
|
|
42
|
+
ValidationIssue,
|
|
43
|
+
new_id,
|
|
44
|
+
utc_now,
|
|
45
|
+
)
|
|
46
|
+
from .execute.tools import ToolContext, ToolRegistry
|
|
47
|
+
from .loop import AgentLoop
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"API_VERSION",
|
|
51
|
+
"EVENT_SCHEMA_VERSION",
|
|
52
|
+
"AgentLoop",
|
|
53
|
+
"AnthropicProvider",
|
|
54
|
+
"CandidateRequest",
|
|
55
|
+
"Cancelled",
|
|
56
|
+
"CapacityError",
|
|
57
|
+
"Completed",
|
|
58
|
+
"DeliveryOwner",
|
|
59
|
+
"Event",
|
|
60
|
+
"EventStore",
|
|
61
|
+
"EventType",
|
|
62
|
+
"FinalMessage",
|
|
63
|
+
"FinishReason",
|
|
64
|
+
"InMemoryEventStore",
|
|
65
|
+
"InvariantViolation",
|
|
66
|
+
"LoopConfig",
|
|
67
|
+
"Message",
|
|
68
|
+
"ModelProvider",
|
|
69
|
+
"ModelResponse",
|
|
70
|
+
"ModelUsage",
|
|
71
|
+
"NewEvent",
|
|
72
|
+
"NextloopError",
|
|
73
|
+
"OpenAIProvider",
|
|
74
|
+
"ProtocolExhausted",
|
|
75
|
+
"ProviderError",
|
|
76
|
+
"RecoveryOutcome",
|
|
77
|
+
"SQLiteEventStore",
|
|
78
|
+
"Skipped",
|
|
79
|
+
"SurfaceOp",
|
|
80
|
+
"ToolCall",
|
|
81
|
+
"ToolContext",
|
|
82
|
+
"ToolExecutionMode",
|
|
83
|
+
"ToolOutcome",
|
|
84
|
+
"ToolRegistry",
|
|
85
|
+
"ToolRuntime",
|
|
86
|
+
"ToolSpec",
|
|
87
|
+
"TurnContext",
|
|
88
|
+
"TurnOutcome",
|
|
89
|
+
"TurnStatus",
|
|
90
|
+
"UncertainHanging",
|
|
91
|
+
"UncertainSettled",
|
|
92
|
+
"ValidatedCall",
|
|
93
|
+
"ValidationIssue",
|
|
94
|
+
"new_id",
|
|
95
|
+
"utc_now",
|
|
96
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def data_events(response: httpx.Response) -> AsyncIterator[tuple[str, str]]:
|
|
9
|
+
event = "message"
|
|
10
|
+
data: list[str] = []
|
|
11
|
+
async for line in response.aiter_lines():
|
|
12
|
+
if not line:
|
|
13
|
+
if data:
|
|
14
|
+
yield event, "\n".join(data)
|
|
15
|
+
event = "message"
|
|
16
|
+
data = []
|
|
17
|
+
continue
|
|
18
|
+
if line.startswith(":"):
|
|
19
|
+
continue
|
|
20
|
+
if line.startswith("event:"):
|
|
21
|
+
event = line[6:].strip()
|
|
22
|
+
elif line.startswith("data:"):
|
|
23
|
+
data.append(line[5:].lstrip())
|
|
24
|
+
if data:
|
|
25
|
+
yield event, "\n".join(data)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..contracts import (
|
|
11
|
+
CandidateRequest,
|
|
12
|
+
FinishReason,
|
|
13
|
+
Message,
|
|
14
|
+
ModelResponse,
|
|
15
|
+
ModelUsage,
|
|
16
|
+
ProviderError,
|
|
17
|
+
ToolCall,
|
|
18
|
+
)
|
|
19
|
+
from ._sse import data_events
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AnthropicProvider:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
model_id: str,
|
|
27
|
+
base_url: str,
|
|
28
|
+
api_key: str,
|
|
29
|
+
default_parameters: Mapping[str, Any] | None = None,
|
|
30
|
+
timeout_seconds: float = 60.0,
|
|
31
|
+
http_client: httpx.AsyncClient | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.model_id = model_id
|
|
34
|
+
self.base_url = base_url.rstrip("/")
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.default_parameters = {"max_tokens": 4096, **dict(default_parameters or {})}
|
|
37
|
+
self.timeout_seconds = timeout_seconds
|
|
38
|
+
self.http_client = http_client
|
|
39
|
+
|
|
40
|
+
async def complete(
|
|
41
|
+
self,
|
|
42
|
+
request: CandidateRequest,
|
|
43
|
+
*,
|
|
44
|
+
attempt: int,
|
|
45
|
+
cancel: asyncio.Event,
|
|
46
|
+
) -> ModelResponse:
|
|
47
|
+
system = "\n\n".join(
|
|
48
|
+
message.content for message in request.messages if message.role == "system"
|
|
49
|
+
)
|
|
50
|
+
body = {
|
|
51
|
+
**self.default_parameters,
|
|
52
|
+
"model": self.model_id,
|
|
53
|
+
"messages": self._messages(request.messages),
|
|
54
|
+
"stream": True,
|
|
55
|
+
}
|
|
56
|
+
if system:
|
|
57
|
+
body["system"] = system
|
|
58
|
+
if request.tools:
|
|
59
|
+
body["tools"] = [
|
|
60
|
+
{
|
|
61
|
+
"name": tool.name,
|
|
62
|
+
"description": tool.description,
|
|
63
|
+
"input_schema": dict(tool.parameters),
|
|
64
|
+
}
|
|
65
|
+
for tool in request.tools
|
|
66
|
+
]
|
|
67
|
+
headers = {
|
|
68
|
+
"x-api-key": self.api_key,
|
|
69
|
+
"anthropic-version": "2023-06-01",
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
}
|
|
72
|
+
client = self.http_client or httpx.AsyncClient(timeout=self.timeout_seconds)
|
|
73
|
+
text_blocks: dict[int, list[str]] = {}
|
|
74
|
+
tool_blocks: dict[int, dict[str, Any]] = {}
|
|
75
|
+
finish_reason = FinishReason.STOP
|
|
76
|
+
input_tokens = None
|
|
77
|
+
output_tokens = None
|
|
78
|
+
try:
|
|
79
|
+
async with client.stream(
|
|
80
|
+
"POST", self._endpoint(), json=body, headers=headers
|
|
81
|
+
) as response:
|
|
82
|
+
if response.status_code >= 400:
|
|
83
|
+
error_body = await response.aread()
|
|
84
|
+
error_text = error_body.decode("utf-8", errors="replace")
|
|
85
|
+
raise ProviderError(
|
|
86
|
+
f"Anthropic HTTP {response.status_code}: {error_text}",
|
|
87
|
+
retryable=response.status_code in (408, 409, 429)
|
|
88
|
+
or response.status_code >= 500,
|
|
89
|
+
code=str(response.status_code),
|
|
90
|
+
)
|
|
91
|
+
async for event, data in data_events(response):
|
|
92
|
+
if cancel.is_set():
|
|
93
|
+
return ModelResponse(
|
|
94
|
+
content=self._text(text_blocks),
|
|
95
|
+
finish_reason=FinishReason.CANCELLED,
|
|
96
|
+
)
|
|
97
|
+
payload = json.loads(data)
|
|
98
|
+
if event == "message_start":
|
|
99
|
+
usage = payload.get("message", {}).get("usage", {})
|
|
100
|
+
input_tokens = usage.get("input_tokens", input_tokens)
|
|
101
|
+
elif event == "content_block_start":
|
|
102
|
+
index = int(payload["index"])
|
|
103
|
+
block = payload["content_block"]
|
|
104
|
+
if block["type"] == "text":
|
|
105
|
+
text_blocks[index] = [block.get("text", "")]
|
|
106
|
+
elif block["type"] == "tool_use":
|
|
107
|
+
tool_blocks[index] = {
|
|
108
|
+
"id": block["id"],
|
|
109
|
+
"name": block["name"],
|
|
110
|
+
"arguments": (
|
|
111
|
+
json.dumps(block["input"], separators=(",", ":"))
|
|
112
|
+
if block.get("input")
|
|
113
|
+
else ""
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
elif event == "content_block_delta":
|
|
117
|
+
index = int(payload["index"])
|
|
118
|
+
delta = payload["delta"]
|
|
119
|
+
if delta["type"] == "text_delta":
|
|
120
|
+
text_blocks.setdefault(index, []).append(delta["text"])
|
|
121
|
+
elif delta["type"] == "input_json_delta":
|
|
122
|
+
tool_blocks[index]["arguments"] += delta["partial_json"]
|
|
123
|
+
elif event == "message_delta":
|
|
124
|
+
stop_reason = payload.get("delta", {}).get("stop_reason")
|
|
125
|
+
finish_reason = self._finish_reason(stop_reason) or finish_reason
|
|
126
|
+
output_tokens = payload.get("usage", {}).get("output_tokens", output_tokens)
|
|
127
|
+
elif event == "error":
|
|
128
|
+
error = payload.get("error", {})
|
|
129
|
+
raise ProviderError(
|
|
130
|
+
f"Anthropic stream error: {error}",
|
|
131
|
+
retryable=error.get("type") in ("overloaded_error", "rate_limit_error"),
|
|
132
|
+
partial_output=bool(text_blocks or tool_blocks),
|
|
133
|
+
code=error.get("type"),
|
|
134
|
+
)
|
|
135
|
+
return ModelResponse(
|
|
136
|
+
content=self._text(text_blocks),
|
|
137
|
+
tool_calls=tuple(
|
|
138
|
+
ToolCall(block["id"], block["name"], block["arguments"])
|
|
139
|
+
for _, block in sorted(tool_blocks.items())
|
|
140
|
+
),
|
|
141
|
+
finish_reason=finish_reason,
|
|
142
|
+
usage=ModelUsage(input_tokens=input_tokens, output_tokens=output_tokens),
|
|
143
|
+
)
|
|
144
|
+
except ProviderError:
|
|
145
|
+
raise
|
|
146
|
+
except (TimeoutError, httpx.HTTPError, json.JSONDecodeError, KeyError) as exc:
|
|
147
|
+
raise ProviderError(
|
|
148
|
+
f"Anthropic stream failed: {exc}",
|
|
149
|
+
retryable=True,
|
|
150
|
+
partial_output=bool(text_blocks or tool_blocks),
|
|
151
|
+
) from exc
|
|
152
|
+
finally:
|
|
153
|
+
if self.http_client is None:
|
|
154
|
+
await client.aclose()
|
|
155
|
+
|
|
156
|
+
def _endpoint(self) -> str:
|
|
157
|
+
if self.base_url.endswith("/v1/messages"):
|
|
158
|
+
return self.base_url
|
|
159
|
+
return self.base_url + "/v1/messages"
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _text(blocks: Mapping[int, list[str]]) -> str:
|
|
163
|
+
return "".join("".join(parts) for _, parts in sorted(blocks.items()))
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _messages(messages: tuple[Message, ...]) -> list[dict[str, Any]]:
|
|
167
|
+
result = []
|
|
168
|
+
for message in messages:
|
|
169
|
+
if message.role == "system":
|
|
170
|
+
continue
|
|
171
|
+
if message.role == "assistant":
|
|
172
|
+
content: list[dict[str, Any]] = []
|
|
173
|
+
if message.content:
|
|
174
|
+
content.append({"type": "text", "text": message.content})
|
|
175
|
+
content.extend(
|
|
176
|
+
{
|
|
177
|
+
"type": "tool_use",
|
|
178
|
+
"id": call.call_id,
|
|
179
|
+
"name": call.name,
|
|
180
|
+
"input": json.loads(call.arguments),
|
|
181
|
+
}
|
|
182
|
+
for call in message.tool_calls
|
|
183
|
+
)
|
|
184
|
+
result.append({"role": "assistant", "content": content})
|
|
185
|
+
elif message.role == "tool":
|
|
186
|
+
result.append(
|
|
187
|
+
{
|
|
188
|
+
"role": "user",
|
|
189
|
+
"content": [
|
|
190
|
+
{
|
|
191
|
+
"type": "tool_result",
|
|
192
|
+
"tool_use_id": message.tool_call_id,
|
|
193
|
+
"content": message.content,
|
|
194
|
+
"is_error": message.content.startswith("[tool error]"),
|
|
195
|
+
}
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
result.append({"role": "user", "content": message.content})
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _finish_reason(value: Any) -> FinishReason | None:
|
|
205
|
+
return {
|
|
206
|
+
"end_turn": FinishReason.STOP,
|
|
207
|
+
"stop_sequence": FinishReason.STOP,
|
|
208
|
+
"tool_use": FinishReason.TOOL_CALLS,
|
|
209
|
+
"max_tokens": FinishReason.LENGTH,
|
|
210
|
+
"refusal": FinishReason.CONTENT_FILTER,
|
|
211
|
+
}.get(value)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from ..contracts import (
|
|
9
|
+
Event,
|
|
10
|
+
InvariantViolation,
|
|
11
|
+
NewEvent,
|
|
12
|
+
SurfaceOp,
|
|
13
|
+
utc_now,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InMemoryEventStore:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._events: dict[str, list[Event]] = defaultdict(list)
|
|
20
|
+
self._epochs: dict[str, int] = defaultdict(int)
|
|
21
|
+
self._lock = asyncio.Lock()
|
|
22
|
+
|
|
23
|
+
async def attach_writer(self, session_id: str) -> tuple[int, int, int]:
|
|
24
|
+
async with self._lock:
|
|
25
|
+
self._epochs[session_id] += 1
|
|
26
|
+
events = self._events[session_id]
|
|
27
|
+
last_turn = max((event.turn for event in events), default=0)
|
|
28
|
+
return self._epochs[session_id], len(events), last_turn
|
|
29
|
+
|
|
30
|
+
async def append(
|
|
31
|
+
self,
|
|
32
|
+
session_id: str,
|
|
33
|
+
writer_epoch: int,
|
|
34
|
+
expected_seq: int,
|
|
35
|
+
events: Sequence[NewEvent],
|
|
36
|
+
) -> Sequence[Event]:
|
|
37
|
+
async with self._lock:
|
|
38
|
+
if self._epochs[session_id] != writer_epoch:
|
|
39
|
+
raise InvariantViolation("Stale writer_epoch")
|
|
40
|
+
existing = self._events[session_id]
|
|
41
|
+
if expected_seq != len(existing) + 1:
|
|
42
|
+
return self._idempotent(existing, events)
|
|
43
|
+
committed = []
|
|
44
|
+
for offset, draft in enumerate(events):
|
|
45
|
+
event = Event(
|
|
46
|
+
session_id=session_id,
|
|
47
|
+
seq=expected_seq + offset,
|
|
48
|
+
turn=draft.turn,
|
|
49
|
+
step=draft.step,
|
|
50
|
+
attempt=draft.attempt,
|
|
51
|
+
writer_epoch=writer_epoch,
|
|
52
|
+
event_id=draft.event_id,
|
|
53
|
+
event_type=draft.event_type,
|
|
54
|
+
surface_op=draft.surface_op,
|
|
55
|
+
payload=copy.deepcopy(dict(draft.payload)),
|
|
56
|
+
occurred_at=utc_now(),
|
|
57
|
+
)
|
|
58
|
+
existing.append(event)
|
|
59
|
+
committed.append(event)
|
|
60
|
+
return committed
|
|
61
|
+
|
|
62
|
+
async def load_surface(self, session_id: str) -> Sequence[Event]:
|
|
63
|
+
async with self._lock:
|
|
64
|
+
events = self._events[session_id]
|
|
65
|
+
epoch_seq = max(
|
|
66
|
+
(event.seq for event in events if event.surface_op == SurfaceOp.EPOCH),
|
|
67
|
+
default=1,
|
|
68
|
+
)
|
|
69
|
+
return tuple(
|
|
70
|
+
copy.deepcopy(event)
|
|
71
|
+
for event in events
|
|
72
|
+
if event.seq >= epoch_seq and event.surface_op != SurfaceOp.NONE
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def load_recovery(self, session_id: str) -> Sequence[Event]:
|
|
76
|
+
async with self._lock:
|
|
77
|
+
return tuple(copy.deepcopy(self._events[session_id]))
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _idempotent(existing: list[Event], drafts: Sequence[NewEvent]) -> Sequence[Event]:
|
|
81
|
+
by_id = {event.event_id: event for event in existing}
|
|
82
|
+
committed = []
|
|
83
|
+
for draft in drafts:
|
|
84
|
+
event = by_id.get(draft.event_id)
|
|
85
|
+
if event is None:
|
|
86
|
+
raise InvariantViolation("Expected sequence does not match Session tail")
|
|
87
|
+
if (
|
|
88
|
+
event.event_type != draft.event_type
|
|
89
|
+
or event.turn != draft.turn
|
|
90
|
+
or event.step != draft.step
|
|
91
|
+
or event.attempt != draft.attempt
|
|
92
|
+
or event.surface_op != draft.surface_op
|
|
93
|
+
or event.payload != dict(draft.payload)
|
|
94
|
+
):
|
|
95
|
+
raise InvariantViolation("Event ID reused with different content")
|
|
96
|
+
committed.append(event)
|
|
97
|
+
return committed
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..contracts import (
|
|
11
|
+
CandidateRequest,
|
|
12
|
+
FinishReason,
|
|
13
|
+
Message,
|
|
14
|
+
ModelResponse,
|
|
15
|
+
ModelUsage,
|
|
16
|
+
ProviderError,
|
|
17
|
+
ToolCall,
|
|
18
|
+
)
|
|
19
|
+
from ._sse import data_events
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenAIProvider:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
model_id: str,
|
|
27
|
+
base_url: str,
|
|
28
|
+
api_key: str,
|
|
29
|
+
default_parameters: Mapping[str, Any] | None = None,
|
|
30
|
+
timeout_seconds: float = 60.0,
|
|
31
|
+
http_client: httpx.AsyncClient | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.model_id = model_id
|
|
34
|
+
self.base_url = base_url.rstrip("/")
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.default_parameters = dict(default_parameters or {})
|
|
37
|
+
self.timeout_seconds = timeout_seconds
|
|
38
|
+
self.http_client = http_client
|
|
39
|
+
|
|
40
|
+
async def complete(
|
|
41
|
+
self,
|
|
42
|
+
request: CandidateRequest,
|
|
43
|
+
*,
|
|
44
|
+
attempt: int,
|
|
45
|
+
cancel: asyncio.Event,
|
|
46
|
+
) -> ModelResponse:
|
|
47
|
+
body = {
|
|
48
|
+
**self.default_parameters,
|
|
49
|
+
"model": self.model_id,
|
|
50
|
+
"messages": [self._message(message) for message in request.messages],
|
|
51
|
+
"stream": True,
|
|
52
|
+
}
|
|
53
|
+
if request.tools:
|
|
54
|
+
body["tools"] = [
|
|
55
|
+
{
|
|
56
|
+
"type": "function",
|
|
57
|
+
"function": {
|
|
58
|
+
"name": tool.name,
|
|
59
|
+
"description": tool.description,
|
|
60
|
+
"parameters": dict(tool.parameters),
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
for tool in request.tools
|
|
64
|
+
]
|
|
65
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
66
|
+
client = self.http_client or httpx.AsyncClient(timeout=self.timeout_seconds)
|
|
67
|
+
content_parts: list[str] = []
|
|
68
|
+
refusal_parts: list[str] = []
|
|
69
|
+
calls: dict[int, dict[str, str]] = {}
|
|
70
|
+
finish_reason = FinishReason.STOP
|
|
71
|
+
usage: ModelUsage | None = None
|
|
72
|
+
try:
|
|
73
|
+
async with client.stream(
|
|
74
|
+
"POST",
|
|
75
|
+
self._endpoint(),
|
|
76
|
+
json=body,
|
|
77
|
+
headers=headers,
|
|
78
|
+
) as response:
|
|
79
|
+
if response.status_code >= 400:
|
|
80
|
+
error_body = await response.aread()
|
|
81
|
+
error_text = error_body.decode("utf-8", errors="replace")
|
|
82
|
+
raise ProviderError(
|
|
83
|
+
f"OpenAI HTTP {response.status_code}: {error_text}",
|
|
84
|
+
retryable=response.status_code in (408, 409, 429)
|
|
85
|
+
or response.status_code >= 500,
|
|
86
|
+
code=str(response.status_code),
|
|
87
|
+
)
|
|
88
|
+
async for _event, data in data_events(response):
|
|
89
|
+
if cancel.is_set():
|
|
90
|
+
return ModelResponse(
|
|
91
|
+
content="".join(content_parts),
|
|
92
|
+
finish_reason=FinishReason.CANCELLED,
|
|
93
|
+
)
|
|
94
|
+
if data == "[DONE]":
|
|
95
|
+
break
|
|
96
|
+
chunk = json.loads(data)
|
|
97
|
+
usage = self._usage(chunk.get("usage")) or usage
|
|
98
|
+
choices = chunk.get("choices") or []
|
|
99
|
+
if not choices:
|
|
100
|
+
continue
|
|
101
|
+
choice = choices[0]
|
|
102
|
+
delta = choice.get("delta") or {}
|
|
103
|
+
if delta.get("content"):
|
|
104
|
+
content_parts.append(str(delta["content"]))
|
|
105
|
+
if delta.get("refusal"):
|
|
106
|
+
refusal_parts.append(str(delta["refusal"]))
|
|
107
|
+
for fragment in delta.get("tool_calls") or []:
|
|
108
|
+
index = int(fragment["index"])
|
|
109
|
+
current = calls.setdefault(
|
|
110
|
+
index, {"id": f"call_{index}", "name": "", "arguments": ""}
|
|
111
|
+
)
|
|
112
|
+
if fragment.get("id"):
|
|
113
|
+
current["id"] = str(fragment["id"])
|
|
114
|
+
function = fragment.get("function") or {}
|
|
115
|
+
if function.get("name"):
|
|
116
|
+
current["name"] += str(function["name"])
|
|
117
|
+
if function.get("arguments"):
|
|
118
|
+
current["arguments"] += str(function["arguments"])
|
|
119
|
+
finish_reason = (
|
|
120
|
+
self._finish_reason(choice.get("finish_reason")) or finish_reason
|
|
121
|
+
)
|
|
122
|
+
return ModelResponse(
|
|
123
|
+
content="".join(content_parts),
|
|
124
|
+
refusal="".join(refusal_parts) or None,
|
|
125
|
+
tool_calls=tuple(
|
|
126
|
+
ToolCall(call["id"], call["name"], call["arguments"])
|
|
127
|
+
for _, call in sorted(calls.items())
|
|
128
|
+
),
|
|
129
|
+
finish_reason=finish_reason,
|
|
130
|
+
usage=usage,
|
|
131
|
+
)
|
|
132
|
+
except ProviderError:
|
|
133
|
+
raise
|
|
134
|
+
except (TimeoutError, httpx.HTTPError, json.JSONDecodeError) as exc:
|
|
135
|
+
partial = bool(content_parts or refusal_parts or calls)
|
|
136
|
+
raise ProviderError(
|
|
137
|
+
f"OpenAI stream failed: {exc}",
|
|
138
|
+
retryable=True,
|
|
139
|
+
partial_output=partial,
|
|
140
|
+
) from exc
|
|
141
|
+
finally:
|
|
142
|
+
if self.http_client is None:
|
|
143
|
+
await client.aclose()
|
|
144
|
+
|
|
145
|
+
def _endpoint(self) -> str:
|
|
146
|
+
if self.base_url.endswith("/chat/completions"):
|
|
147
|
+
return self.base_url
|
|
148
|
+
return self.base_url + "/chat/completions"
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _message(message: Message) -> dict[str, Any]:
|
|
152
|
+
if message.role == "tool":
|
|
153
|
+
return {
|
|
154
|
+
"role": "tool",
|
|
155
|
+
"tool_call_id": message.tool_call_id,
|
|
156
|
+
"content": message.content,
|
|
157
|
+
}
|
|
158
|
+
value: dict[str, Any] = {"role": message.role, "content": message.content or None}
|
|
159
|
+
if message.tool_calls:
|
|
160
|
+
value["tool_calls"] = [
|
|
161
|
+
{
|
|
162
|
+
"id": call.call_id,
|
|
163
|
+
"type": "function",
|
|
164
|
+
"function": {"name": call.name, "arguments": call.arguments},
|
|
165
|
+
}
|
|
166
|
+
for call in message.tool_calls
|
|
167
|
+
]
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _finish_reason(value: Any) -> FinishReason | None:
|
|
172
|
+
return {
|
|
173
|
+
"stop": FinishReason.STOP,
|
|
174
|
+
"tool_calls": FinishReason.TOOL_CALLS,
|
|
175
|
+
"length": FinishReason.LENGTH,
|
|
176
|
+
"content_filter": FinishReason.CONTENT_FILTER,
|
|
177
|
+
}.get(value)
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _usage(value: Any) -> ModelUsage | None:
|
|
181
|
+
if not isinstance(value, Mapping):
|
|
182
|
+
return None
|
|
183
|
+
details = value.get("prompt_tokens_details") or {}
|
|
184
|
+
return ModelUsage(
|
|
185
|
+
input_tokens=value.get("prompt_tokens"),
|
|
186
|
+
output_tokens=value.get("completion_tokens"),
|
|
187
|
+
cached_input_tokens=details.get("cached_tokens"),
|
|
188
|
+
)
|