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.
Files changed (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. 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)