relay-code 0.1.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.
- agent/__init__.py +6 -0
- agent/agent.py +162 -0
- agent/events.py +133 -0
- agent/session.py +71 -0
- client/__init__.py +23 -0
- client/llm_client.py +236 -0
- client/response.py +83 -0
- config/__init__.py +6 -0
- config/config.py +76 -0
- config/credentials.py +54 -0
- config/loader.py +108 -0
- config/oauth.py +137 -0
- context/__init__.py +5 -0
- context/manager.py +92 -0
- main.py +231 -0
- prompts/__init__.py +0 -0
- prompts/system.py +350 -0
- relay_code-0.1.0.dist-info/METADATA +64 -0
- relay_code-0.1.0.dist-info/RECORD +50 -0
- relay_code-0.1.0.dist-info/WHEEL +5 -0
- relay_code-0.1.0.dist-info/entry_points.txt +2 -0
- relay_code-0.1.0.dist-info/licenses/LICENSE +674 -0
- relay_code-0.1.0.dist-info/top_level.txt +9 -0
- tools/__init__.py +14 -0
- tools/base.py +188 -0
- tools/core/__init__.py +44 -0
- tools/core/directories.py +65 -0
- tools/core/edit.py +162 -0
- tools/core/glob.py +92 -0
- tools/core/grep.py +128 -0
- tools/core/read.py +129 -0
- tools/core/shell.py +162 -0
- tools/core/todo.py +135 -0
- tools/core/write.py +76 -0
- tools/memory/__init__.py +0 -0
- tools/memory/memory.py +191 -0
- tools/network/__init__.py +0 -0
- tools/network/fetch.py +72 -0
- tools/network/search.py +72 -0
- tools/registry.py +96 -0
- ui/__init__.py +5 -0
- ui/app.py +949 -0
- ui/format.py +145 -0
- ui/logo.py +46 -0
- ui/renderer.py +580 -0
- ui/theme.py +60 -0
- utils/__init__.py +17 -0
- utils/errors.py +47 -0
- utils/paths.py +44 -0
- utils/text.py +74 -0
agent/__init__.py
ADDED
agent/agent.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from typing import AsyncGenerator, Awaitable, Callable
|
|
4
|
+
from agent.session import Session
|
|
5
|
+
from config.config import Config
|
|
6
|
+
from agent.events import AgentEvent, AgentEventType
|
|
7
|
+
from client.response import StreamEventType, ToolCall, ToolResultMessage
|
|
8
|
+
from tools.base import ToolResult
|
|
9
|
+
|
|
10
|
+
class Agent:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
config: Config,
|
|
14
|
+
confirmation_handler: Callable[[str, dict], Awaitable[bool]] | None = None,
|
|
15
|
+
):
|
|
16
|
+
self.config = config
|
|
17
|
+
self.confirmation_handler = confirmation_handler
|
|
18
|
+
self.session: Session | None = Session(self.config)
|
|
19
|
+
|
|
20
|
+
async def run(self, message: str):
|
|
21
|
+
yield AgentEvent.agent_start(message)
|
|
22
|
+
self.session.reset_turn_usage()
|
|
23
|
+
self.session.context_manager.add_user_message(message)
|
|
24
|
+
|
|
25
|
+
final_response: str | None = None
|
|
26
|
+
async for event in self._agentic_loop():
|
|
27
|
+
yield event
|
|
28
|
+
|
|
29
|
+
if event.type == AgentEventType.TEXT_COMPLETE:
|
|
30
|
+
final_response = event.data.get("content")
|
|
31
|
+
|
|
32
|
+
yield AgentEvent.agent_end(final_response, self.session.last_usage)
|
|
33
|
+
|
|
34
|
+
async def _agentic_loop(self) -> AsyncGenerator[AgentEvent, None]:
|
|
35
|
+
|
|
36
|
+
max_turns = self.config.max_turns
|
|
37
|
+
|
|
38
|
+
for i in range(max_turns):
|
|
39
|
+
|
|
40
|
+
self.session.inc_turn()
|
|
41
|
+
|
|
42
|
+
response_text = ""
|
|
43
|
+
|
|
44
|
+
tool_schemas = self.session.tool_registery.get_schemas()
|
|
45
|
+
tool_calls: list[ToolCall] = []
|
|
46
|
+
|
|
47
|
+
async for event in self.session.client.chat_completion(
|
|
48
|
+
self.session.context_manager.get_messages(),
|
|
49
|
+
tools=tool_schemas if tool_schemas else None,
|
|
50
|
+
stream=True
|
|
51
|
+
):
|
|
52
|
+
if event.type == StreamEventType.TEXT_DELTA:
|
|
53
|
+
if not event.text_delta:
|
|
54
|
+
continue
|
|
55
|
+
content = event.text_delta.content
|
|
56
|
+
response_text += content
|
|
57
|
+
yield AgentEvent.text_delta(content)
|
|
58
|
+
elif event.type == StreamEventType.TOOL_CALL_COMPLETE:
|
|
59
|
+
if event.tool_call:
|
|
60
|
+
tool_calls.append(event.tool_call)
|
|
61
|
+
elif event.type == StreamEventType.MESSAGE_COMPLETE:
|
|
62
|
+
if event.usage:
|
|
63
|
+
self.session.last_usage = event.usage
|
|
64
|
+
self.session.turn_usage += event.usage
|
|
65
|
+
yield AgentEvent.usage(self.session.turn_usage)
|
|
66
|
+
if event.text_delta:
|
|
67
|
+
response_text += event.text_delta.content
|
|
68
|
+
yield AgentEvent.text_delta(event.text_delta.content)
|
|
69
|
+
if event.tool_calls:
|
|
70
|
+
tool_calls.extend(event.tool_calls)
|
|
71
|
+
elif event.type == StreamEventType.ERROR:
|
|
72
|
+
yield AgentEvent.agent_error(event.error or "Unknown error occurred.")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
self.session.context_manager.add_assistant_message(
|
|
76
|
+
response_text or None,
|
|
77
|
+
[
|
|
78
|
+
{
|
|
79
|
+
'id': tc.call_id,
|
|
80
|
+
'type': 'function',
|
|
81
|
+
'function': {
|
|
82
|
+
'name': tc.name,
|
|
83
|
+
'arguments': json.dumps(tc.arguments),
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
for tc in tool_calls
|
|
87
|
+
]
|
|
88
|
+
if tool_calls
|
|
89
|
+
else None
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if response_text:
|
|
93
|
+
yield AgentEvent.text_complete(response_text)
|
|
94
|
+
|
|
95
|
+
if not tool_calls:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
tool_call_results: list[ToolResultMessage] = []
|
|
99
|
+
|
|
100
|
+
for tool_call in tool_calls:
|
|
101
|
+
yield AgentEvent.tool_call_start(
|
|
102
|
+
tool_call.call_id,
|
|
103
|
+
tool_call.name,
|
|
104
|
+
tool_call.arguments
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
tool = self.session.tool_registery.get(tool_call.name)
|
|
108
|
+
approved = True
|
|
109
|
+
if tool and tool.is_mutating(tool_call.arguments):
|
|
110
|
+
if self.confirmation_handler is None:
|
|
111
|
+
approved = False
|
|
112
|
+
else:
|
|
113
|
+
approved = await self.confirmation_handler(
|
|
114
|
+
tool_call.name,
|
|
115
|
+
tool_call.arguments,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if approved:
|
|
119
|
+
result = await self.session.tool_registery.invoke(
|
|
120
|
+
tool_call.name,
|
|
121
|
+
tool_call.arguments,
|
|
122
|
+
self.config.cwd,
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
result = ToolResult.error_result(
|
|
126
|
+
f"Tool execution denied: {tool_call.name}",
|
|
127
|
+
metadata={"tool_name": tool_call.name, "denied": True},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
yield AgentEvent.tool_call_complete(
|
|
131
|
+
tool_call.call_id,
|
|
132
|
+
tool_call.name,
|
|
133
|
+
result,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
tool_call_results.append(
|
|
137
|
+
ToolResultMessage(
|
|
138
|
+
tool_call_id=tool_call.call_id,
|
|
139
|
+
content=result.to_model_output(),
|
|
140
|
+
is_error=not result.success
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
for tool_result in tool_call_results:
|
|
145
|
+
self.session.context_manager.add_tool_result(
|
|
146
|
+
tool_result.tool_call_id,
|
|
147
|
+
tool_result.content,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def __aenter__(self) -> Agent:
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
async def __aexit__(
|
|
154
|
+
self,
|
|
155
|
+
exc_type,
|
|
156
|
+
exc_val,
|
|
157
|
+
exc_tb
|
|
158
|
+
) -> None:
|
|
159
|
+
|
|
160
|
+
if self.session and self.session.client:
|
|
161
|
+
await self.session.client.close()
|
|
162
|
+
self.session = None # Close session, everything else will be garbage collected
|
agent/events.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from client.response import TokenUsage
|
|
6
|
+
from tools.base import ToolResult
|
|
7
|
+
|
|
8
|
+
class AgentEventType(str, Enum):
|
|
9
|
+
|
|
10
|
+
# Agentic loops, and err
|
|
11
|
+
AGENT_START="agent_start"
|
|
12
|
+
AGENT_END="agent_end"
|
|
13
|
+
AGENT_ERROR="agent_error"
|
|
14
|
+
|
|
15
|
+
# Token accounting, emitted once per completion
|
|
16
|
+
USAGE = "usage"
|
|
17
|
+
|
|
18
|
+
# Text streaming
|
|
19
|
+
TEXT_DELTA = "text_delta"
|
|
20
|
+
TEXT_COMPLETE = "text_complete"
|
|
21
|
+
|
|
22
|
+
# Tool Calls
|
|
23
|
+
TOOL_CALL_START = 'tool_call_start'
|
|
24
|
+
TOOL_CALL_COMPLETE = 'tool_call_complete'
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AgentEvent:
|
|
28
|
+
type: AgentEventType
|
|
29
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def agent_start(
|
|
33
|
+
cls,
|
|
34
|
+
message: str
|
|
35
|
+
) -> AgentEvent:
|
|
36
|
+
return cls(
|
|
37
|
+
type=AgentEventType.AGENT_START,
|
|
38
|
+
data={
|
|
39
|
+
"message": message
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def agent_end(
|
|
45
|
+
cls,
|
|
46
|
+
response: str | None = None,
|
|
47
|
+
usage: TokenUsage | None = None
|
|
48
|
+
) -> AgentEvent:
|
|
49
|
+
return cls(
|
|
50
|
+
type=AgentEventType.AGENT_END,
|
|
51
|
+
data={
|
|
52
|
+
"response": response,
|
|
53
|
+
'usage': usage.__dict__ if usage else None
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def usage(
|
|
59
|
+
cls,
|
|
60
|
+
turn_usage: TokenUsage
|
|
61
|
+
) -> AgentEvent:
|
|
62
|
+
return cls(
|
|
63
|
+
type=AgentEventType.USAGE,
|
|
64
|
+
data={
|
|
65
|
+
'usage': turn_usage.__dict__,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def agent_error(
|
|
71
|
+
cls,
|
|
72
|
+
error: str,
|
|
73
|
+
details: dict[str, Any] | None = None
|
|
74
|
+
) -> AgentEvent:
|
|
75
|
+
return cls(
|
|
76
|
+
type=AgentEventType.AGENT_ERROR,
|
|
77
|
+
data={
|
|
78
|
+
"error": error,
|
|
79
|
+
'details': details or {}
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def text_delta(
|
|
85
|
+
cls,
|
|
86
|
+
content: str
|
|
87
|
+
) -> AgentEvent:
|
|
88
|
+
return cls(
|
|
89
|
+
type=AgentEventType.TEXT_DELTA,
|
|
90
|
+
data={
|
|
91
|
+
"content": content
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def text_complete(
|
|
97
|
+
cls,
|
|
98
|
+
content: str
|
|
99
|
+
) -> AgentEvent:
|
|
100
|
+
return cls(
|
|
101
|
+
type=AgentEventType.TEXT_COMPLETE,
|
|
102
|
+
data={
|
|
103
|
+
"content": content
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def tool_call_start(cls, call_id: str, name: str, arguments: dict[str, Any]):
|
|
109
|
+
return cls(
|
|
110
|
+
type=AgentEventType.TOOL_CALL_START,
|
|
111
|
+
data={
|
|
112
|
+
'call_id': call_id,
|
|
113
|
+
'name': name,
|
|
114
|
+
'arguments': arguments
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def tool_call_complete(cls, call_id: str, name: str, result: ToolResult):
|
|
120
|
+
return cls(
|
|
121
|
+
type=AgentEventType.TOOL_CALL_COMPLETE,
|
|
122
|
+
data={
|
|
123
|
+
'call_id': call_id,
|
|
124
|
+
'name': name,
|
|
125
|
+
'success': result.success,
|
|
126
|
+
'output': result.output,
|
|
127
|
+
'error': result.error,
|
|
128
|
+
'metadata': result.metadata,
|
|
129
|
+
'diff': result.diff.create_diff() if result.diff else None,
|
|
130
|
+
'truncated': result.truncated,
|
|
131
|
+
'exit_code': result.exit_code,
|
|
132
|
+
}
|
|
133
|
+
)
|
agent/session.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from client.llm_client import LLMClient
|
|
5
|
+
from client.response import TokenUsage
|
|
6
|
+
from config.config import Config
|
|
7
|
+
from config.loader import get_data_dir
|
|
8
|
+
from context.manager import ContextManager
|
|
9
|
+
from tools.registry import create_default_registery
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
class Session:
|
|
13
|
+
def __init__(self, config: Config):
|
|
14
|
+
self.config = config
|
|
15
|
+
self.client = LLMClient(
|
|
16
|
+
config=config,
|
|
17
|
+
)
|
|
18
|
+
self.context_manager = ContextManager(
|
|
19
|
+
config=config,
|
|
20
|
+
user_memory=self._load_memory()
|
|
21
|
+
)
|
|
22
|
+
self.tool_registery = create_default_registery(config)
|
|
23
|
+
self.session_id = str(uuid.uuid4()) # Unique identifiers to resume sessions
|
|
24
|
+
self.created_at = datetime.now()
|
|
25
|
+
self.updated_at = datetime.now()
|
|
26
|
+
|
|
27
|
+
self.last_usage: TokenUsage | None = None
|
|
28
|
+
|
|
29
|
+
self.turn_usage = TokenUsage()
|
|
30
|
+
|
|
31
|
+
self._turn_count = 0
|
|
32
|
+
|
|
33
|
+
def _load_memory(self) -> str | None:
|
|
34
|
+
|
|
35
|
+
data_dir = get_data_dir()
|
|
36
|
+
|
|
37
|
+
data_dir.mkdir(
|
|
38
|
+
parents=True,
|
|
39
|
+
exist_ok=True
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
path = data_dir / 'user_memory.json'
|
|
43
|
+
|
|
44
|
+
if not path.exists():
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
content = path.read_text(
|
|
49
|
+
encoding='utf-8'
|
|
50
|
+
)
|
|
51
|
+
data = json.loads(content)
|
|
52
|
+
entries = data.get('entries')
|
|
53
|
+
if not entries:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
lines = ["User prefrences and notes:"]
|
|
57
|
+
for key, value in entries.items():
|
|
58
|
+
lines.append(f"- {key}: {value}")
|
|
59
|
+
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def reset_turn_usage(self) -> None:
|
|
65
|
+
self.turn_usage = TokenUsage()
|
|
66
|
+
|
|
67
|
+
def inc_turn(self) -> int:
|
|
68
|
+
self._turn_count += 1
|
|
69
|
+
self.updated_at = datetime.now()
|
|
70
|
+
|
|
71
|
+
return self._turn_count
|
client/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Client module for LLM interactions."""
|
|
2
|
+
|
|
3
|
+
from client.llm_client import LLMClient
|
|
4
|
+
from client.response import (
|
|
5
|
+
StreamEvent,
|
|
6
|
+
StreamEventType,
|
|
7
|
+
TextDelta,
|
|
8
|
+
TokenUsage,
|
|
9
|
+
ToolCall,
|
|
10
|
+
ToolCallDelta,
|
|
11
|
+
ToolResultMessage,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'LLMClient',
|
|
16
|
+
'StreamEvent',
|
|
17
|
+
'StreamEventType',
|
|
18
|
+
'TextDelta',
|
|
19
|
+
'TokenUsage',
|
|
20
|
+
'ToolCall',
|
|
21
|
+
'ToolCallDelta',
|
|
22
|
+
'ToolResultMessage',
|
|
23
|
+
]
|
client/llm_client.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import asyncio
|
|
3
|
+
from openai import AsyncOpenAI, RateLimitError, APIConnectionError, APIError
|
|
4
|
+
from typing import Any, AsyncGenerator
|
|
5
|
+
from client.response import TextDelta, TokenUsage, StreamEvent, StreamEventType, ToolCallDelta, ToolCall, parse_tool_call_arguments
|
|
6
|
+
from config.config import Config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LLMClient:
|
|
10
|
+
def __init__(self, config: Config) -> None:
|
|
11
|
+
self._client: AsyncOpenAI | None = None
|
|
12
|
+
self._max_retries: int = 3
|
|
13
|
+
self.config = config
|
|
14
|
+
|
|
15
|
+
def get_client(self) -> AsyncOpenAI:
|
|
16
|
+
if self._client is None:
|
|
17
|
+
# TODO: wire up to your own config system instead of hardcoding
|
|
18
|
+
# DONE !
|
|
19
|
+
self._client = AsyncOpenAI(
|
|
20
|
+
api_key=self.config.api_key,
|
|
21
|
+
base_url=self.config.base_url # "https://openrouter.ai/api/v1",
|
|
22
|
+
)
|
|
23
|
+
return self._client
|
|
24
|
+
|
|
25
|
+
async def close(self) -> None:
|
|
26
|
+
if self._client:
|
|
27
|
+
await self._client.close()
|
|
28
|
+
self._client = None
|
|
29
|
+
|
|
30
|
+
def _build_tools(
|
|
31
|
+
self,
|
|
32
|
+
tools: list[dict[str, Any]]
|
|
33
|
+
):
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
'type': 'function',
|
|
37
|
+
'function': {
|
|
38
|
+
'name': tool['name'],
|
|
39
|
+
'description': tool.get('description', ""),
|
|
40
|
+
'parameters': tool.get('parameters', {
|
|
41
|
+
'type': 'object',
|
|
42
|
+
'properties': {}
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
for tool in tools
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
async def chat_completion(
|
|
50
|
+
self,
|
|
51
|
+
messages: list[dict[str, Any]],
|
|
52
|
+
tools: list[dict[str, Any]] | None = None,
|
|
53
|
+
stream: bool = True
|
|
54
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
55
|
+
|
|
56
|
+
client = self.get_client()
|
|
57
|
+
|
|
58
|
+
kwargs = {
|
|
59
|
+
"model": self.config.model_name,
|
|
60
|
+
"messages": messages,
|
|
61
|
+
"stream": stream,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Without this the stream carries no usage chunk at all, and the
|
|
65
|
+
# context gauge on the prompt rule has nothing to show.
|
|
66
|
+
if stream:
|
|
67
|
+
kwargs["stream_options"] = {"include_usage": True}
|
|
68
|
+
|
|
69
|
+
if tools:
|
|
70
|
+
kwargs['tools'] = self._build_tools(tools)
|
|
71
|
+
kwargs['tool_choice'] = 'auto'
|
|
72
|
+
|
|
73
|
+
for attempt in range(self._max_retries + 1):
|
|
74
|
+
emitted = False
|
|
75
|
+
try:
|
|
76
|
+
if stream:
|
|
77
|
+
async for event in self._stream_response(client, kwargs):
|
|
78
|
+
emitted = True
|
|
79
|
+
yield event
|
|
80
|
+
else:
|
|
81
|
+
event = await self._non_stream_response(client, kwargs)
|
|
82
|
+
yield event
|
|
83
|
+
return
|
|
84
|
+
except RateLimitError as e:
|
|
85
|
+
if not emitted and attempt < self._max_retries:
|
|
86
|
+
wait_time = math.pow(2, attempt)
|
|
87
|
+
await asyncio.sleep(wait_time)
|
|
88
|
+
else:
|
|
89
|
+
yield StreamEvent(
|
|
90
|
+
type=StreamEventType.ERROR,
|
|
91
|
+
error=f"Rate limit exceeded: {e}",
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
except APIConnectionError as e:
|
|
95
|
+
if not emitted and attempt < self._max_retries:
|
|
96
|
+
wait_time = math.pow(2, attempt)
|
|
97
|
+
await asyncio.sleep(wait_time)
|
|
98
|
+
else:
|
|
99
|
+
yield StreamEvent(
|
|
100
|
+
type=StreamEventType.ERROR,
|
|
101
|
+
error=f"Connection Error exceeded: {e}",
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
# No retries here, solely because there's no point in retrying if there is a hardstuck API Error
|
|
105
|
+
except APIError as e:
|
|
106
|
+
yield StreamEvent(
|
|
107
|
+
type=StreamEventType.ERROR,
|
|
108
|
+
error=f"API Error: {e}",
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
async def _stream_response(self, client: AsyncOpenAI, kwargs: dict[str, Any]) -> AsyncGenerator[StreamEvent, None]:
|
|
113
|
+
response = await client.chat.completions.create(**kwargs)
|
|
114
|
+
|
|
115
|
+
finish_reason: str | None = None
|
|
116
|
+
usage: TokenUsage | None = None
|
|
117
|
+
tool_calls: dict[int, dict[str, Any]] = {}
|
|
118
|
+
|
|
119
|
+
async for chunk in response:
|
|
120
|
+
if hasattr(chunk, "usage") and chunk.usage:
|
|
121
|
+
usage = TokenUsage(
|
|
122
|
+
prompt_tokens=chunk.usage.prompt_tokens,
|
|
123
|
+
completion_tokens=chunk.usage.completion_tokens,
|
|
124
|
+
total_tokens=chunk.usage.total_tokens,
|
|
125
|
+
cached_tokens=(
|
|
126
|
+
getattr(chunk.usage.prompt_tokens_details, "cached_tokens", 0)
|
|
127
|
+
if chunk.usage.prompt_tokens_details else 0
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not chunk.choices:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
choice = chunk.choices[0]
|
|
135
|
+
delta = choice.delta
|
|
136
|
+
|
|
137
|
+
if choice.finish_reason:
|
|
138
|
+
finish_reason = choice.finish_reason
|
|
139
|
+
|
|
140
|
+
if delta.content:
|
|
141
|
+
yield StreamEvent(
|
|
142
|
+
type=StreamEventType.TEXT_DELTA,
|
|
143
|
+
text_delta=TextDelta(delta.content),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if delta.tool_calls:
|
|
147
|
+
for tool_call_delta in delta.tool_calls:
|
|
148
|
+
index = tool_call_delta.index
|
|
149
|
+
|
|
150
|
+
if index not in tool_calls:
|
|
151
|
+
tool_calls[index] = {
|
|
152
|
+
'id': tool_call_delta.id or "",
|
|
153
|
+
'name': '',
|
|
154
|
+
'arguments': '',
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# NOTE: this used to be nested inside "if index not in tool_calls",
|
|
158
|
+
# which meant argument fragments after the first chunk for a given
|
|
159
|
+
# index were silently dropped. Now runs on every chunk.
|
|
160
|
+
if tool_call_delta.function:
|
|
161
|
+
if tool_call_delta.function.name:
|
|
162
|
+
tool_calls[index]['name'] = tool_call_delta.function.name
|
|
163
|
+
yield StreamEvent(
|
|
164
|
+
type=StreamEventType.TOOL_CALL_START,
|
|
165
|
+
tool_call_delta=ToolCallDelta(
|
|
166
|
+
call_id=tool_calls[index]['id'],
|
|
167
|
+
name=tool_call_delta.function.name,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if tool_call_delta.function.arguments:
|
|
172
|
+
tool_calls[index]['arguments'] += tool_call_delta.function.arguments
|
|
173
|
+
yield StreamEvent(
|
|
174
|
+
type=StreamEventType.TOOL_CALL_DELTA,
|
|
175
|
+
tool_call_delta=ToolCallDelta(
|
|
176
|
+
call_id=tool_calls[index]['id'],
|
|
177
|
+
name=tool_calls[index]['name'],
|
|
178
|
+
arguments_delta=tool_call_delta.function.arguments,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
for index, toolcall in tool_calls.items():
|
|
183
|
+
yield StreamEvent(
|
|
184
|
+
type=StreamEventType.TOOL_CALL_COMPLETE,
|
|
185
|
+
tool_call=ToolCall(
|
|
186
|
+
call_id=toolcall['id'],
|
|
187
|
+
name=toolcall['name'],
|
|
188
|
+
arguments=parse_tool_call_arguments(toolcall['arguments']),
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
yield StreamEvent(
|
|
193
|
+
type=StreamEventType.MESSAGE_COMPLETE,
|
|
194
|
+
finish_reason=finish_reason,
|
|
195
|
+
usage=usage,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def _non_stream_response(self, client: AsyncOpenAI, kwargs: dict[str, Any]) -> StreamEvent:
|
|
199
|
+
response = await client.chat.completions.create(**kwargs)
|
|
200
|
+
choice = response.choices[0] # only interested in first index, first message
|
|
201
|
+
message = choice.message
|
|
202
|
+
|
|
203
|
+
text_delta = None
|
|
204
|
+
if message.content:
|
|
205
|
+
text_delta = TextDelta(content=message.content)
|
|
206
|
+
|
|
207
|
+
tool_calls: list[ToolCall] = []
|
|
208
|
+
if message.tool_calls:
|
|
209
|
+
for toolcall in message.tool_calls:
|
|
210
|
+
tool_calls.append(ToolCall(
|
|
211
|
+
call_id=toolcall.id,
|
|
212
|
+
name=toolcall.function.name,
|
|
213
|
+
arguments=parse_tool_call_arguments(
|
|
214
|
+
toolcall.function.arguments
|
|
215
|
+
)
|
|
216
|
+
))
|
|
217
|
+
|
|
218
|
+
usage = None
|
|
219
|
+
if response.usage:
|
|
220
|
+
usage = TokenUsage(
|
|
221
|
+
prompt_tokens=response.usage.prompt_tokens,
|
|
222
|
+
completion_tokens=response.usage.completion_tokens,
|
|
223
|
+
total_tokens=response.usage.total_tokens,
|
|
224
|
+
cached_tokens=(
|
|
225
|
+
getattr(response.usage.prompt_tokens_details, "cached_tokens", 0)
|
|
226
|
+
if response.usage.prompt_tokens_details else 0
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return StreamEvent(
|
|
231
|
+
type=StreamEventType.MESSAGE_COMPLETE,
|
|
232
|
+
text_delta=text_delta,
|
|
233
|
+
tool_calls=tool_calls,
|
|
234
|
+
finish_reason=choice.finish_reason,
|
|
235
|
+
usage=usage,
|
|
236
|
+
)
|