stcode 0.0.1a0__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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Providers — LLM adapters, unified request/event types, the registry, and the
|
|
3
|
+
difficulty-routing gateway built on them.
|
|
4
|
+
|
|
5
|
+
A thin re-export hub; each concern is its own leaf module (`base.py`, `<provider>.py`,
|
|
6
|
+
`registry.py`, `gateway.py`) so nothing outside needs the internal layout.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from stcode.core.providers.anthropic_claude import AnthropicProvider
|
|
10
|
+
from stcode.core.providers.base import BaseModelProvider
|
|
11
|
+
from stcode.core.providers.gateway import LLMGateway
|
|
12
|
+
from stcode.core.providers.google_gemini import GoogleGenAIProvider
|
|
13
|
+
from stcode.core.providers.openai_gpt import OpenAIProvider
|
|
14
|
+
from stcode.core.providers.registry import (
|
|
15
|
+
DEFAULT_MODELS,
|
|
16
|
+
PROVIDERS,
|
|
17
|
+
default_model_for,
|
|
18
|
+
get_provider,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_MODELS",
|
|
23
|
+
"PROVIDERS",
|
|
24
|
+
"AnthropicProvider",
|
|
25
|
+
"BaseModelProvider",
|
|
26
|
+
"GoogleGenAIProvider",
|
|
27
|
+
"LLMGateway",
|
|
28
|
+
"OpenAIProvider",
|
|
29
|
+
"default_model_for",
|
|
30
|
+
"get_provider",
|
|
31
|
+
]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Anthropic LLM provider.
|
|
3
|
+
|
|
4
|
+
**Prompt caching lives here and nowhere else.** It is the single biggest cost lever, and
|
|
5
|
+
the one optimisation that has to be applied at the provider boundary: only Anthropic
|
|
6
|
+
asks for it explicitly. OpenAI caches long prefixes on its own, and Gemini needs a
|
|
7
|
+
separate cached-content resource, which is a different feature. See the `prompt caching`
|
|
8
|
+
section at the bottom for where the three markers go and why.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, AsyncGenerator
|
|
14
|
+
|
|
15
|
+
import anthropic
|
|
16
|
+
|
|
17
|
+
from stcode.core.providers.base import BaseModelProvider
|
|
18
|
+
from stcode.core.providers.types import (
|
|
19
|
+
Message,
|
|
20
|
+
MessageStop,
|
|
21
|
+
ReasoningDelta,
|
|
22
|
+
ReasoningEffort,
|
|
23
|
+
StopReason,
|
|
24
|
+
StreamEvent,
|
|
25
|
+
TextBlock,
|
|
26
|
+
TextDelta,
|
|
27
|
+
ToolCallDelta,
|
|
28
|
+
ToolCallEnd,
|
|
29
|
+
ToolCallStart,
|
|
30
|
+
ToolDefinition,
|
|
31
|
+
ToolResultBlock,
|
|
32
|
+
ToolUseBlock,
|
|
33
|
+
Usage,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_MODEL = "claude-opus-5"
|
|
37
|
+
|
|
38
|
+
# Anthropic's output_config.effort has no "none"/"minimal" rung — "none" instead
|
|
39
|
+
# turns thinking off outright (handled separately in stream()), and "minimal"
|
|
40
|
+
# has no closer match than "low".
|
|
41
|
+
_EFFORT_TO_ANTHROPIC: dict[str, str] = {
|
|
42
|
+
"minimal": "low",
|
|
43
|
+
"low": "low",
|
|
44
|
+
"medium": "medium",
|
|
45
|
+
"high": "high",
|
|
46
|
+
"xhigh": "xhigh",
|
|
47
|
+
"max": "max",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AnthropicProvider(BaseModelProvider):
|
|
52
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
|
53
|
+
super().__init__(api_key, base_url)
|
|
54
|
+
self._client = anthropic.AsyncAnthropic(api_key=api_key, base_url=base_url)
|
|
55
|
+
|
|
56
|
+
async def list_models(self) -> list[str]:
|
|
57
|
+
return [model.id async for model in self._client.models.list()]
|
|
58
|
+
|
|
59
|
+
async def aclose(self) -> None:
|
|
60
|
+
await self._client.close()
|
|
61
|
+
|
|
62
|
+
async def stream(
|
|
63
|
+
self,
|
|
64
|
+
messages: list[Message],
|
|
65
|
+
*,
|
|
66
|
+
model: str = DEFAULT_MODEL,
|
|
67
|
+
system: str | None = None,
|
|
68
|
+
tools: list[ToolDefinition] | None = None,
|
|
69
|
+
max_tokens: int = 8192,
|
|
70
|
+
reasoning_effort: ReasoningEffort | None = None,
|
|
71
|
+
temperature: float | None = None,
|
|
72
|
+
top_p: float | None = None,
|
|
73
|
+
stop: list[str] | None = None,
|
|
74
|
+
parallel_tool_calls: bool | None = None,
|
|
75
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
76
|
+
"""`reasoning_effort="none"` sends `thinking: {"type": "disabled"}`; any
|
|
77
|
+
other value turns on adaptive thinking and sets `output_config.effort`
|
|
78
|
+
(Anthropic's `budget_tokens` token-ceiling style is a 400 on current
|
|
79
|
+
models, so this provider never uses it). Leaving `reasoning_effort`
|
|
80
|
+
unset omits `thinking` entirely, matching current models' own default
|
|
81
|
+
(adaptive thinking already on).
|
|
82
|
+
|
|
83
|
+
`temperature`/`top_p` are passed straight through — on Claude Opus 5,
|
|
84
|
+
Sonnet 5, Fable 5, and Opus 4.7/4.8, the API itself returns 400 if
|
|
85
|
+
either is combined with adaptive thinking, i.e. whenever
|
|
86
|
+
`reasoning_effort` is anything but `"none"`.
|
|
87
|
+
"""
|
|
88
|
+
kwargs: dict[str, Any] = {}
|
|
89
|
+
if system:
|
|
90
|
+
kwargs["system"] = _mark_last([{"type": "text", "text": system}])
|
|
91
|
+
if tools:
|
|
92
|
+
kwargs["tools"] = _mark_last([self._to_tool(tool) for tool in tools])
|
|
93
|
+
if parallel_tool_calls is not None:
|
|
94
|
+
kwargs["tool_choice"] = {
|
|
95
|
+
"type": "auto",
|
|
96
|
+
"disable_parallel_tool_use": not parallel_tool_calls,
|
|
97
|
+
}
|
|
98
|
+
if reasoning_effort == "none":
|
|
99
|
+
kwargs["thinking"] = {"type": "disabled"}
|
|
100
|
+
elif reasoning_effort is not None:
|
|
101
|
+
kwargs["thinking"] = {"type": "adaptive"}
|
|
102
|
+
kwargs["output_config"] = {"effort": _EFFORT_TO_ANTHROPIC[reasoning_effort]}
|
|
103
|
+
if temperature is not None:
|
|
104
|
+
kwargs["temperature"] = temperature
|
|
105
|
+
if top_p is not None:
|
|
106
|
+
kwargs["top_p"] = top_p
|
|
107
|
+
if stop is not None:
|
|
108
|
+
kwargs["stop_sequences"] = stop
|
|
109
|
+
|
|
110
|
+
# index -> tool_use block id, for pairing content_block_delta/_stop back to a call
|
|
111
|
+
pending_calls: dict[int, str] = {}
|
|
112
|
+
usage = Usage()
|
|
113
|
+
stop_reason: StopReason = "end_turn"
|
|
114
|
+
|
|
115
|
+
async with self._client.messages.stream(
|
|
116
|
+
model=model,
|
|
117
|
+
max_tokens=max_tokens,
|
|
118
|
+
messages=_mark_last_block([self._to_message(message) for message in messages]),
|
|
119
|
+
**kwargs,
|
|
120
|
+
) as stream:
|
|
121
|
+
async for event in stream:
|
|
122
|
+
if event.type == "message_start":
|
|
123
|
+
started = event.message.usage
|
|
124
|
+
usage.input_tokens = started.input_tokens
|
|
125
|
+
# Present only when caching is in play, and the whole point of it:
|
|
126
|
+
# a non-zero read on turn 2 is the evidence the prefix held.
|
|
127
|
+
usage.cache_creation_input_tokens = (
|
|
128
|
+
getattr(started, "cache_creation_input_tokens", None) or 0
|
|
129
|
+
)
|
|
130
|
+
usage.cache_read_input_tokens = (
|
|
131
|
+
getattr(started, "cache_read_input_tokens", None) or 0
|
|
132
|
+
)
|
|
133
|
+
elif event.type == "content_block_start":
|
|
134
|
+
if event.content_block.type == "tool_use":
|
|
135
|
+
pending_calls[event.index] = event.content_block.id
|
|
136
|
+
yield ToolCallStart(id=event.content_block.id, name=event.content_block.name)
|
|
137
|
+
elif event.type == "content_block_delta":
|
|
138
|
+
if event.delta.type == "text_delta":
|
|
139
|
+
yield TextDelta(text=event.delta.text)
|
|
140
|
+
elif event.delta.type == "thinking_delta":
|
|
141
|
+
yield ReasoningDelta(text=event.delta.thinking)
|
|
142
|
+
elif event.delta.type == "input_json_delta":
|
|
143
|
+
call_id = pending_calls.get(event.index)
|
|
144
|
+
if call_id is not None:
|
|
145
|
+
yield ToolCallDelta(id=call_id, partial_json=event.delta.partial_json)
|
|
146
|
+
elif event.type == "content_block_stop":
|
|
147
|
+
call_id = pending_calls.pop(event.index, None)
|
|
148
|
+
if call_id is not None:
|
|
149
|
+
block = event.content_block
|
|
150
|
+
args = block.input if isinstance(getattr(block, "input", None), dict) else {}
|
|
151
|
+
yield ToolCallEnd(id=call_id, name=block.name, input=args)
|
|
152
|
+
elif event.type == "message_delta":
|
|
153
|
+
usage.output_tokens = event.usage.output_tokens
|
|
154
|
+
if event.delta.stop_reason is not None:
|
|
155
|
+
stop_reason = self._map_stop_reason(event.delta.stop_reason)
|
|
156
|
+
|
|
157
|
+
yield MessageStop(stop_reason=stop_reason, usage=usage)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _to_tool(tool: ToolDefinition) -> dict[str, Any]:
|
|
161
|
+
return {
|
|
162
|
+
"name": tool.name,
|
|
163
|
+
"description": tool.description,
|
|
164
|
+
"input_schema": tool.input_schema,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _to_message(message: Message) -> dict[str, Any]:
|
|
169
|
+
if isinstance(message.content, str):
|
|
170
|
+
return {"role": message.role, "content": message.content}
|
|
171
|
+
|
|
172
|
+
content: list[dict[str, Any]] = []
|
|
173
|
+
for block in message.content:
|
|
174
|
+
if isinstance(block, TextBlock):
|
|
175
|
+
content.append({"type": "text", "text": block.text})
|
|
176
|
+
elif isinstance(block, ToolUseBlock):
|
|
177
|
+
content.append(
|
|
178
|
+
{"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
|
|
179
|
+
)
|
|
180
|
+
elif isinstance(block, ToolResultBlock):
|
|
181
|
+
content.append(
|
|
182
|
+
{
|
|
183
|
+
"type": "tool_result",
|
|
184
|
+
"tool_use_id": block.tool_use_id,
|
|
185
|
+
"content": block.content,
|
|
186
|
+
"is_error": block.is_error,
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
return {"role": message.role, "content": content}
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _map_stop_reason(stop_reason: str) -> StopReason:
|
|
193
|
+
if stop_reason in ("end_turn", "tool_use", "max_tokens", "stop_sequence", "pause_turn", "refusal"):
|
|
194
|
+
return stop_reason # type: ignore[return-value]
|
|
195
|
+
return "end_turn"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---- prompt caching --------------------------------------------------------------
|
|
199
|
+
#
|
|
200
|
+
# Anthropic caches the request *prefix*, in the order tools -> system -> messages, up
|
|
201
|
+
# to each breakpoint. Three breakpoints, and each earns its place:
|
|
202
|
+
#
|
|
203
|
+
# last tool `registry.definitions()` sorts by name, so the tool block is
|
|
204
|
+
# byte-stable for a session and survives a turn where the system
|
|
205
|
+
# prompt moved underneath it.
|
|
206
|
+
# last system Stable too, except on turns where the todo list changed —
|
|
207
|
+
# `environment_section` is deliberately last for exactly this reason.
|
|
208
|
+
# last message The one that actually matters in an agent loop. After ten tool calls
|
|
209
|
+
# the history dwarfs both of the above, so without a breakpoint here
|
|
210
|
+
# "turn 2 is ~10x cheaper" would be a rounding error rather than a
|
|
211
|
+
# fact. Each turn's breakpoint is the next turn's cache hit.
|
|
212
|
+
#
|
|
213
|
+
# A write costs 1.25x and a read 0.1x, so this pays for itself on the second request
|
|
214
|
+
# and every one after. A single-shot call pays the premium for nothing — the deliberate
|
|
215
|
+
# trade, because this project is an agent loop.
|
|
216
|
+
|
|
217
|
+
CACHE_CONTROL = {"type": "ephemeral"}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _mark_last(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
221
|
+
"""Put a cache breakpoint on the final entry of `blocks`."""
|
|
222
|
+
if blocks:
|
|
223
|
+
blocks[-1] = {**blocks[-1], "cache_control": CACHE_CONTROL}
|
|
224
|
+
return blocks
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _mark_last_block(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
228
|
+
"""Put a cache breakpoint on the last content block of the last message.
|
|
229
|
+
|
|
230
|
+
A string `content` is promoted to a one-block list first: `cache_control` is a
|
|
231
|
+
property of a block, and there is nowhere to hang it on a bare string.
|
|
232
|
+
"""
|
|
233
|
+
if not messages:
|
|
234
|
+
return messages
|
|
235
|
+
last = dict(messages[-1])
|
|
236
|
+
content = last["content"]
|
|
237
|
+
if isinstance(content, str):
|
|
238
|
+
content = [{"type": "text", "text": content}]
|
|
239
|
+
if not content:
|
|
240
|
+
return messages
|
|
241
|
+
last["content"] = [*content[:-1], {**content[-1], "cache_control": CACHE_CONTROL}]
|
|
242
|
+
messages[-1] = last
|
|
243
|
+
return messages
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base LLM Provider Class
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import AsyncGenerator
|
|
7
|
+
|
|
8
|
+
from stcode.core.providers.types import Message, ReasoningEffort, StreamEvent, ToolDefinition
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseModelProvider(ABC):
|
|
12
|
+
"""Base LLM Provider Class — external LLM provider communication async methods."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
|
15
|
+
"""`base_url` lets a subclass talk to any endpoint that speaks its provider's
|
|
16
|
+
wire protocol — an OpenAI-compatible router, a self-hosted Anthropic-compatible
|
|
17
|
+
gateway, a proxy — not just the vendor's own API.
|
|
18
|
+
"""
|
|
19
|
+
self.api_key = api_key
|
|
20
|
+
self.base_url = base_url
|
|
21
|
+
|
|
22
|
+
async def aclose(self) -> None:
|
|
23
|
+
"""Release the underlying SDK client's network resources (connection pool,
|
|
24
|
+
open sockets). Subclasses whose client holds one should override this;
|
|
25
|
+
callers should always await it — via `async with provider:` or a
|
|
26
|
+
try/finally — once done streaming, or the client's transport can outlive
|
|
27
|
+
the event loop and raise on interpreter shutdown.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
async def __aenter__(self) -> "BaseModelProvider":
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
34
|
+
await self.aclose()
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def list_models(self) -> list[str]:
|
|
38
|
+
"""List all possible models in provider."""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def stream(
|
|
42
|
+
self,
|
|
43
|
+
messages: list[Message],
|
|
44
|
+
*,
|
|
45
|
+
model: str,
|
|
46
|
+
system: str | None = None,
|
|
47
|
+
tools: list[ToolDefinition] | None = None,
|
|
48
|
+
max_tokens: int = 8192,
|
|
49
|
+
reasoning_effort: ReasoningEffort | None = None,
|
|
50
|
+
temperature: float | None = None,
|
|
51
|
+
top_p: float | None = None,
|
|
52
|
+
stop: list[str] | None = None,
|
|
53
|
+
parallel_tool_calls: bool | None = None,
|
|
54
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
55
|
+
"""Stream a completion as a sequence of unified StreamEvents.
|
|
56
|
+
|
|
57
|
+
`messages` holds only user/assistant turns — the system prompt is
|
|
58
|
+
passed separately since every provider treats it as a distinct field.
|
|
59
|
+
|
|
60
|
+
`reasoning_effort` is the superset of every provider's effort scale
|
|
61
|
+
(`"none"` through `"max"`); each provider maps it onto its own control
|
|
62
|
+
(OpenAI's `reasoning_effort`, Anthropic's adaptive `thinking` +
|
|
63
|
+
`output_config.effort`, Gemini's `thinking_config.thinking_level`) and
|
|
64
|
+
clamps or drops values its API doesn't support. Leave it `None` to let
|
|
65
|
+
the provider apply its own default rather than forcing a level.
|
|
66
|
+
|
|
67
|
+
`temperature`, `top_p`, `stop`, and `parallel_tool_calls` are passed
|
|
68
|
+
through to each provider's equivalent field as-is — this base class
|
|
69
|
+
does not resolve cross-field conflicts a provider's own API rejects
|
|
70
|
+
(e.g. Anthropic returns 400 for `temperature`/`top_p` alongside
|
|
71
|
+
adaptive thinking on current-generation models); see each provider's
|
|
72
|
+
`stream()` docstring for its specific caveats.
|
|
73
|
+
"""
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Gateway — model orchestration: difficulty-based routing and retry.
|
|
3
|
+
|
|
4
|
+
This is a thin dispatch layer: given a difficulty tier (or an explicit provider/model
|
|
5
|
+
override), it resolves which provider+model+credentials to use, retries transient
|
|
6
|
+
failures, and streams back unified StreamEvents. It deliberately does not run an agent
|
|
7
|
+
loop (no tool execution, no dynamic tool sets, no human-in-the-loop gating) — see
|
|
8
|
+
`LLMGateway`'s docstring for why.
|
|
9
|
+
|
|
10
|
+
It takes a `ModelConfig` and asks it for routes and credentials; it never touches paths,
|
|
11
|
+
TOML or the environment itself — `core/configs.py` owns all three.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextlib
|
|
18
|
+
import logging
|
|
19
|
+
import random
|
|
20
|
+
from typing import Any, AsyncGenerator
|
|
21
|
+
|
|
22
|
+
import anthropic
|
|
23
|
+
import openai
|
|
24
|
+
from google.genai import errors as genai_errors
|
|
25
|
+
|
|
26
|
+
from stcode.core.common import trace
|
|
27
|
+
from stcode.core.configs import Difficulty, ModelConfig
|
|
28
|
+
from stcode.core.providers.base import BaseModelProvider
|
|
29
|
+
from stcode.core.providers.registry import default_model_for, get_provider
|
|
30
|
+
from stcode.core.providers.types import (
|
|
31
|
+
Message,
|
|
32
|
+
MessageStop,
|
|
33
|
+
ReasoningEffort,
|
|
34
|
+
StreamEvent,
|
|
35
|
+
TextDelta,
|
|
36
|
+
ToolCallEnd,
|
|
37
|
+
ToolDefinition,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("stcode.providers.gateway")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Transient/retryable errors per SDK — network hiccups, rate limits, and 5xx. Anything
|
|
44
|
+
# else (bad request, auth, not found, content policy) is a caller/config bug and should
|
|
45
|
+
# surface immediately rather than being retried.
|
|
46
|
+
_RETRYABLE_ANTHROPIC: tuple[type[Exception], ...] = (
|
|
47
|
+
anthropic.APIConnectionError, # also covers APITimeoutError, a subclass
|
|
48
|
+
anthropic.RateLimitError,
|
|
49
|
+
anthropic.InternalServerError,
|
|
50
|
+
anthropic.OverloadedError,
|
|
51
|
+
)
|
|
52
|
+
_RETRYABLE_OPENAI: tuple[type[Exception], ...] = (
|
|
53
|
+
openai.APIConnectionError, # also covers APITimeoutError, a subclass
|
|
54
|
+
openai.RateLimitError,
|
|
55
|
+
openai.InternalServerError,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_retryable(exc: Exception) -> bool:
|
|
60
|
+
if isinstance(exc, _RETRYABLE_ANTHROPIC + _RETRYABLE_OPENAI):
|
|
61
|
+
return True
|
|
62
|
+
if isinstance(exc, genai_errors.ServerError):
|
|
63
|
+
return True
|
|
64
|
+
# google-genai has no dedicated RateLimitError — 429 surfaces as a generic ClientError.
|
|
65
|
+
if isinstance(exc, genai_errors.ClientError) and exc.code == 429:
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LLMGateway:
|
|
71
|
+
"""Routes completions to a provider+model by difficulty tier, retrying transient
|
|
72
|
+
failures.
|
|
73
|
+
|
|
74
|
+
Deliberately excludes the agent loop. Tool execution, turn-by-turn tool sets and
|
|
75
|
+
human-in-the-loop approval all vary per call and live *above* this layer; baking a
|
|
76
|
+
loop in would mean either re-exposing every provider knob through the gateway or
|
|
77
|
+
hard-coding one harness's shape into a thin "send this, stream that back" API.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, config: ModelConfig) -> None:
|
|
81
|
+
self._config = config
|
|
82
|
+
self._provider_instances: dict[tuple[str, str | None, str | None], BaseModelProvider] = {}
|
|
83
|
+
self._limits: dict[tuple[str, int], asyncio.Semaphore] = {}
|
|
84
|
+
"""One semaphore per `(provider, cap)`, shared by everything holding this
|
|
85
|
+
gateway — which is the point. Keyed by the cap as well as the name so lowering
|
|
86
|
+
it in `reconfigure` makes a new, smaller one rather than a stale wide one."""
|
|
87
|
+
|
|
88
|
+
async def reconfigure(self, config: ModelConfig) -> None:
|
|
89
|
+
"""Replace this gateway's configuration **in place**, keeping its identity.
|
|
90
|
+
|
|
91
|
+
In place because every agent in the daemon holds a reference to this object: a
|
|
92
|
+
new key typed into `/model` has to reach the session already running. The
|
|
93
|
+
cached clients are closed, since each was built around the key it replaces.
|
|
94
|
+
"""
|
|
95
|
+
await self._close_clients()
|
|
96
|
+
self._config = config
|
|
97
|
+
|
|
98
|
+
async def aclose(self) -> None:
|
|
99
|
+
"""Close every cached provider client. They are reused across `stream()` calls
|
|
100
|
+
and retries, so call this only when tearing the gateway down."""
|
|
101
|
+
await self._close_clients()
|
|
102
|
+
|
|
103
|
+
async def _close_clients(self) -> None:
|
|
104
|
+
for instance in self._provider_instances.values():
|
|
105
|
+
await instance.aclose()
|
|
106
|
+
self._provider_instances.clear()
|
|
107
|
+
|
|
108
|
+
async def __aenter__(self) -> "LLMGateway":
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
112
|
+
await self.aclose()
|
|
113
|
+
|
|
114
|
+
def _get_provider(self, name: str) -> BaseModelProvider:
|
|
115
|
+
"""A client for one `[model.providers]` entry, cached by its credentials."""
|
|
116
|
+
entry = self._config.providers[name]
|
|
117
|
+
key, base_url = entry.key(), entry.url()
|
|
118
|
+
cache_key = (name, key, base_url)
|
|
119
|
+
if cache_key not in self._provider_instances:
|
|
120
|
+
self._provider_instances[cache_key] = get_provider(
|
|
121
|
+
entry.provider, api_key=key, base_url=base_url
|
|
122
|
+
)
|
|
123
|
+
return self._provider_instances[cache_key]
|
|
124
|
+
|
|
125
|
+
def resolve(
|
|
126
|
+
self, difficulty: Difficulty, provider: str | None = None, model: str | None = None
|
|
127
|
+
) -> tuple[str, str]:
|
|
128
|
+
"""`(provider entry, model)` for one call.
|
|
129
|
+
|
|
130
|
+
A `provider` override (a session's `/model`) replaces the tier's route; a
|
|
131
|
+
`model` override alone keeps the route's provider. An empty model means the
|
|
132
|
+
library's own default.
|
|
133
|
+
"""
|
|
134
|
+
if provider:
|
|
135
|
+
name, routed = provider, ""
|
|
136
|
+
else:
|
|
137
|
+
name, routed = self._config.route(difficulty)
|
|
138
|
+
entry = self._config.providers.get(name)
|
|
139
|
+
if entry is None:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Provider {name!r} not configured. Configured: {sorted(self._config.providers)}"
|
|
142
|
+
)
|
|
143
|
+
return name, model or routed or entry.model or default_model_for(entry.provider)
|
|
144
|
+
|
|
145
|
+
def _limiter(self, provider: str) -> "asyncio.Semaphore | contextlib.AbstractAsyncContextManager[Any]":
|
|
146
|
+
"""The in-flight cap for one provider, or a no-op when it has none.
|
|
147
|
+
|
|
148
|
+
Built lazily rather than in `__init__`: a gateway is routinely constructed
|
|
149
|
+
outside a running loop (the daemon builds one before it binds), and a semaphore
|
|
150
|
+
is only ever awaited from inside one.
|
|
151
|
+
"""
|
|
152
|
+
entry = self._config.providers.get(provider)
|
|
153
|
+
cap = entry.max_concurrent if entry is not None else 0
|
|
154
|
+
if cap <= 0:
|
|
155
|
+
return contextlib.nullcontext()
|
|
156
|
+
key = (provider, cap)
|
|
157
|
+
limiter = self._limits.get(key)
|
|
158
|
+
if limiter is None:
|
|
159
|
+
limiter = asyncio.Semaphore(cap)
|
|
160
|
+
self._limits[key] = limiter
|
|
161
|
+
return limiter
|
|
162
|
+
|
|
163
|
+
async def list_models(self, provider: str) -> list[str]:
|
|
164
|
+
return await self._get_provider(self.resolve("medium", provider)[0]).list_models()
|
|
165
|
+
|
|
166
|
+
async def stream(
|
|
167
|
+
self,
|
|
168
|
+
messages: list[Message],
|
|
169
|
+
*,
|
|
170
|
+
difficulty: Difficulty = "medium",
|
|
171
|
+
provider: str | None = None,
|
|
172
|
+
model: str | None = None,
|
|
173
|
+
system: str | None = None,
|
|
174
|
+
tools: list[ToolDefinition] | None = None,
|
|
175
|
+
max_tokens: int = 8192,
|
|
176
|
+
reasoning_effort: ReasoningEffort | None = None,
|
|
177
|
+
temperature: float | None = None,
|
|
178
|
+
top_p: float | None = None,
|
|
179
|
+
stop: list[str] | None = None,
|
|
180
|
+
parallel_tool_calls: bool | None = None,
|
|
181
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
182
|
+
"""Stream one completion, routed by `difficulty` unless overridden. → `resolve`
|
|
183
|
+
|
|
184
|
+
`reasoning_effort`, `temperature`, `top_p`, `stop` and `parallel_tool_calls`
|
|
185
|
+
pass straight through; see `BaseModelProvider.stream` for the unified semantics.
|
|
186
|
+
"""
|
|
187
|
+
provider_name, model_name = self.resolve(difficulty, provider, model)
|
|
188
|
+
provider_instance = self._get_provider(provider_name)
|
|
189
|
+
|
|
190
|
+
# `chat <model>`, the GenAI convention's name for an inference span. Platforms
|
|
191
|
+
# read the `gen_ai.*` attributes off it and show a generation with its tokens;
|
|
192
|
+
# anything named our own way shows as an anonymous box.
|
|
193
|
+
attributes = {
|
|
194
|
+
"gen_ai.operation.name": "chat",
|
|
195
|
+
"gen_ai.provider.name": provider_name,
|
|
196
|
+
"gen_ai.request.model": model_name,
|
|
197
|
+
"gen_ai.request.max_tokens": max_tokens,
|
|
198
|
+
"stcode.difficulty": difficulty,
|
|
199
|
+
}
|
|
200
|
+
# Held for the whole completion, not just the request: the point is how many
|
|
201
|
+
# streams the endpoint is serving at once. Every caller wraps its iteration in
|
|
202
|
+
# `aclosing`, so abandoning a stream still releases this.
|
|
203
|
+
async with self._limiter(provider_name):
|
|
204
|
+
with trace.span(f"chat {model_name}", kind="client", attributes=attributes) as recorder:
|
|
205
|
+
capture = trace.records_content()
|
|
206
|
+
output: list[dict[str, Any]] = []
|
|
207
|
+
if capture:
|
|
208
|
+
inputs = [message.model_dump(mode="json") for message in messages]
|
|
209
|
+
if system:
|
|
210
|
+
inputs.insert(0, {"role": "system", "content": system})
|
|
211
|
+
recorder.set(**{"gen_ai.input.messages": inputs})
|
|
212
|
+
async for event in self._stream_with_retry(
|
|
213
|
+
provider_instance,
|
|
214
|
+
messages,
|
|
215
|
+
recorder,
|
|
216
|
+
model=model_name,
|
|
217
|
+
system=system,
|
|
218
|
+
tools=tools,
|
|
219
|
+
max_tokens=max_tokens,
|
|
220
|
+
reasoning_effort=reasoning_effort,
|
|
221
|
+
temperature=temperature,
|
|
222
|
+
top_p=top_p,
|
|
223
|
+
stop=stop,
|
|
224
|
+
parallel_tool_calls=parallel_tool_calls,
|
|
225
|
+
):
|
|
226
|
+
if capture and isinstance(event, (TextDelta, ToolCallEnd)):
|
|
227
|
+
output.append(event.model_dump(mode="json"))
|
|
228
|
+
yield event
|
|
229
|
+
if capture:
|
|
230
|
+
recorder.set(**{"gen_ai.output.messages": output})
|
|
231
|
+
|
|
232
|
+
async def _stream_with_retry(
|
|
233
|
+
self,
|
|
234
|
+
provider_instance: BaseModelProvider,
|
|
235
|
+
messages: list[Message],
|
|
236
|
+
recorder: "trace.Recorder",
|
|
237
|
+
**kwargs: Any,
|
|
238
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
239
|
+
"""The retry loop. Split out only so the span above can wrap it whole."""
|
|
240
|
+
retry = self._config.retry
|
|
241
|
+
for attempt in range(1, retry.max_attempts + 1):
|
|
242
|
+
started = False
|
|
243
|
+
try:
|
|
244
|
+
async for event in provider_instance.stream(messages, **kwargs):
|
|
245
|
+
started = True
|
|
246
|
+
if isinstance(event, MessageStop):
|
|
247
|
+
recorder.set(
|
|
248
|
+
**{
|
|
249
|
+
"gen_ai.response.finish_reasons": [event.stop_reason],
|
|
250
|
+
"gen_ai.usage.input_tokens": event.usage.input_tokens,
|
|
251
|
+
"gen_ai.usage.output_tokens": event.usage.output_tokens,
|
|
252
|
+
"gen_ai.usage.cache_read_input_tokens": (
|
|
253
|
+
event.usage.cache_read_input_tokens
|
|
254
|
+
),
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
yield event
|
|
258
|
+
return
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
# Once anything is yielded the caller has partial content, and retrying
|
|
261
|
+
# would duplicate it. Only pre-first-token failures are retryable.
|
|
262
|
+
if started or attempt == retry.max_attempts or not _is_retryable(exc):
|
|
263
|
+
raise
|
|
264
|
+
delay = min(retry.base_delay * (2 ** (attempt - 1)), retry.max_delay)
|
|
265
|
+
if retry.jitter:
|
|
266
|
+
delay += random.uniform(0, delay * 0.1)
|
|
267
|
+
await asyncio.sleep(delay)
|