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,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google GenAI (Gemini) LLM Provider
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import AsyncGenerator
|
|
9
|
+
|
|
10
|
+
from google import genai
|
|
11
|
+
from google.genai import types
|
|
12
|
+
|
|
13
|
+
from stcode.core.providers.base import BaseModelProvider
|
|
14
|
+
from stcode.core.providers.types import (
|
|
15
|
+
Message,
|
|
16
|
+
MessageStop,
|
|
17
|
+
ReasoningDelta,
|
|
18
|
+
ReasoningEffort,
|
|
19
|
+
StopReason,
|
|
20
|
+
StreamEvent,
|
|
21
|
+
TextBlock,
|
|
22
|
+
TextDelta,
|
|
23
|
+
ToolCallDelta,
|
|
24
|
+
ToolCallEnd,
|
|
25
|
+
ToolCallStart,
|
|
26
|
+
ToolDefinition,
|
|
27
|
+
ToolResultBlock,
|
|
28
|
+
ToolUseBlock,
|
|
29
|
+
Usage,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DEFAULT_MODEL = "gemini-3.7-flash"
|
|
33
|
+
|
|
34
|
+
# ThinkingConfig.thinking_level tops out at HIGH — there's no xhigh/max rung,
|
|
35
|
+
# so both clamp down to it. "none" is handled separately via thinking_budget=0.
|
|
36
|
+
_EFFORT_TO_GEMINI_THINKING_LEVEL: dict[str, str] = {
|
|
37
|
+
"minimal": "MINIMAL",
|
|
38
|
+
"low": "LOW",
|
|
39
|
+
"medium": "MEDIUM",
|
|
40
|
+
"high": "HIGH",
|
|
41
|
+
"xhigh": "HIGH",
|
|
42
|
+
"max": "HIGH",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_ROLE_MAP = {"user": "user", "assistant": "model"}
|
|
46
|
+
|
|
47
|
+
_REFUSAL_FINISH_REASONS = {
|
|
48
|
+
types.FinishReason.SAFETY,
|
|
49
|
+
types.FinishReason.PROHIBITED_CONTENT,
|
|
50
|
+
types.FinishReason.BLOCKLIST,
|
|
51
|
+
types.FinishReason.SPII,
|
|
52
|
+
types.FinishReason.RECITATION,
|
|
53
|
+
types.FinishReason.IMAGE_SAFETY,
|
|
54
|
+
types.FinishReason.IMAGE_PROHIBITED_CONTENT,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GoogleGenAIProvider(BaseModelProvider):
|
|
59
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
|
60
|
+
super().__init__(api_key, base_url)
|
|
61
|
+
http_options = types.HttpOptions(base_url=base_url) if base_url else None
|
|
62
|
+
self._client = genai.Client(api_key=api_key, http_options=http_options)
|
|
63
|
+
|
|
64
|
+
async def list_models(self) -> list[str]:
|
|
65
|
+
return [model.name.removeprefix("models/") async for model in self._client.aio.models.list()]
|
|
66
|
+
|
|
67
|
+
async def aclose(self) -> None:
|
|
68
|
+
await self._client.aio.aclose()
|
|
69
|
+
|
|
70
|
+
async def stream(
|
|
71
|
+
self,
|
|
72
|
+
messages: list[Message],
|
|
73
|
+
*,
|
|
74
|
+
model: str = DEFAULT_MODEL,
|
|
75
|
+
system: str | None = None,
|
|
76
|
+
tools: list[ToolDefinition] | None = None,
|
|
77
|
+
max_tokens: int = 8192,
|
|
78
|
+
reasoning_effort: ReasoningEffort | None = None,
|
|
79
|
+
temperature: float | None = None,
|
|
80
|
+
top_p: float | None = None,
|
|
81
|
+
stop: list[str] | None = None,
|
|
82
|
+
parallel_tool_calls: bool | None = None,
|
|
83
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
84
|
+
"""`reasoning_effort="none"` sets `thinking_budget=0` (thinking off);
|
|
85
|
+
any other value maps onto `thinking_config.thinking_level`, clamped to
|
|
86
|
+
Gemini's four-level scale (`xhigh`/`max` both become `HIGH`). Thoughts
|
|
87
|
+
are always requested (`include_thoughts=True`) so a thinking-capable
|
|
88
|
+
model's reasoning surfaces as `ReasoningDelta` regardless of level —
|
|
89
|
+
it's a no-op on models that don't support thinking.
|
|
90
|
+
|
|
91
|
+
`parallel_tool_calls` has no Gemini equivalent exposed by this SDK and
|
|
92
|
+
is accepted but ignored.
|
|
93
|
+
"""
|
|
94
|
+
thinking_config = (
|
|
95
|
+
types.ThinkingConfig(thinking_budget=0)
|
|
96
|
+
if reasoning_effort == "none"
|
|
97
|
+
else types.ThinkingConfig(
|
|
98
|
+
include_thoughts=True,
|
|
99
|
+
thinking_level=(
|
|
100
|
+
_EFFORT_TO_GEMINI_THINKING_LEVEL[reasoning_effort] if reasoning_effort else None
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
config = types.GenerateContentConfig(
|
|
105
|
+
system_instruction=system,
|
|
106
|
+
max_output_tokens=max_tokens,
|
|
107
|
+
tools=[self._to_tool(tools)] if tools else None,
|
|
108
|
+
thinking_config=thinking_config,
|
|
109
|
+
temperature=temperature,
|
|
110
|
+
top_p=top_p,
|
|
111
|
+
stop_sequences=stop,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
has_tool_call = False
|
|
115
|
+
finish_reason: types.FinishReason | None = None
|
|
116
|
+
usage = Usage()
|
|
117
|
+
|
|
118
|
+
response_stream = await self._client.aio.models.generate_content_stream(
|
|
119
|
+
model=model,
|
|
120
|
+
contents=self._to_contents(messages),
|
|
121
|
+
config=config,
|
|
122
|
+
)
|
|
123
|
+
async for chunk in response_stream:
|
|
124
|
+
for candidate in chunk.candidates or []:
|
|
125
|
+
if candidate.finish_reason is not None:
|
|
126
|
+
finish_reason = candidate.finish_reason
|
|
127
|
+
if candidate.content is None or not candidate.content.parts:
|
|
128
|
+
continue
|
|
129
|
+
for part in candidate.content.parts:
|
|
130
|
+
if part.text:
|
|
131
|
+
if part.thought:
|
|
132
|
+
yield ReasoningDelta(text=part.text)
|
|
133
|
+
else:
|
|
134
|
+
yield TextDelta(text=part.text)
|
|
135
|
+
elif part.function_call is not None:
|
|
136
|
+
has_tool_call = True
|
|
137
|
+
call = part.function_call
|
|
138
|
+
call_id = call.id or call.name or ""
|
|
139
|
+
args = dict(call.args) if call.args else {}
|
|
140
|
+
yield ToolCallStart(id=call_id, name=call.name)
|
|
141
|
+
yield ToolCallDelta(id=call_id, partial_json=json.dumps(args, default=str))
|
|
142
|
+
yield ToolCallEnd(id=call_id, name=call.name, input=args)
|
|
143
|
+
if chunk.usage_metadata is not None:
|
|
144
|
+
usage = Usage(
|
|
145
|
+
input_tokens=chunk.usage_metadata.prompt_token_count or 0,
|
|
146
|
+
output_tokens=chunk.usage_metadata.candidates_token_count or 0,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
yield MessageStop(
|
|
150
|
+
stop_reason=self._map_stop_reason(has_tool_call, finish_reason),
|
|
151
|
+
usage=usage,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _to_tool(tools: list[ToolDefinition]) -> types.Tool:
|
|
156
|
+
return types.Tool(
|
|
157
|
+
function_declarations=[
|
|
158
|
+
types.FunctionDeclaration(
|
|
159
|
+
name=tool.name,
|
|
160
|
+
description=tool.description,
|
|
161
|
+
parameters_json_schema=tool.input_schema,
|
|
162
|
+
)
|
|
163
|
+
for tool in tools
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _to_contents(messages: list[Message]) -> list[types.Content]:
|
|
169
|
+
# Gemini matches tool results by name, not id — recover each id's tool name
|
|
170
|
+
# from the tool_use blocks that precede it in the conversation.
|
|
171
|
+
tool_names: dict[str, str] = {}
|
|
172
|
+
for message in messages:
|
|
173
|
+
if isinstance(message.content, list):
|
|
174
|
+
for block in message.content:
|
|
175
|
+
if isinstance(block, ToolUseBlock):
|
|
176
|
+
tool_names[block.id] = block.name
|
|
177
|
+
|
|
178
|
+
contents: list[types.Content] = []
|
|
179
|
+
for message in messages:
|
|
180
|
+
blocks = (
|
|
181
|
+
[TextBlock(text=message.content)]
|
|
182
|
+
if isinstance(message.content, str)
|
|
183
|
+
else message.content
|
|
184
|
+
)
|
|
185
|
+
buffer: list[types.Part] = []
|
|
186
|
+
buffer_role: str | None = None
|
|
187
|
+
for block in blocks:
|
|
188
|
+
if isinstance(block, TextBlock):
|
|
189
|
+
role = _ROLE_MAP[message.role]
|
|
190
|
+
part = types.Part.from_text(text=block.text)
|
|
191
|
+
elif isinstance(block, ToolUseBlock):
|
|
192
|
+
role = "model"
|
|
193
|
+
part = types.Part(
|
|
194
|
+
function_call=types.FunctionCall(id=block.id, name=block.name, args=block.input)
|
|
195
|
+
)
|
|
196
|
+
elif isinstance(block, ToolResultBlock):
|
|
197
|
+
role = "tool"
|
|
198
|
+
name = tool_names.get(block.tool_use_id, block.tool_use_id)
|
|
199
|
+
response = {"error": block.content} if block.is_error else {"result": block.content}
|
|
200
|
+
part = types.Part(
|
|
201
|
+
function_response=types.FunctionResponse(
|
|
202
|
+
id=block.tool_use_id, name=name, response=response
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
if buffer_role is not None and buffer_role != role:
|
|
209
|
+
contents.append(types.Content(role=buffer_role, parts=buffer))
|
|
210
|
+
buffer = []
|
|
211
|
+
buffer_role = role
|
|
212
|
+
buffer.append(part)
|
|
213
|
+
if buffer:
|
|
214
|
+
contents.append(types.Content(role=buffer_role, parts=buffer))
|
|
215
|
+
return contents
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _map_stop_reason(has_tool_call: bool, finish_reason: types.FinishReason | None) -> StopReason:
|
|
219
|
+
if has_tool_call:
|
|
220
|
+
return "tool_use"
|
|
221
|
+
if finish_reason == types.FinishReason.MAX_TOKENS:
|
|
222
|
+
return "max_tokens"
|
|
223
|
+
if finish_reason in _REFUSAL_FINISH_REASONS:
|
|
224
|
+
return "refusal"
|
|
225
|
+
return "end_turn"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI LLM Provider
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, AsyncGenerator
|
|
10
|
+
|
|
11
|
+
from openai import AsyncOpenAI
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("stcode.providers.openai")
|
|
14
|
+
|
|
15
|
+
from stcode.core.providers.base import BaseModelProvider
|
|
16
|
+
from stcode.core.providers.types import (
|
|
17
|
+
Message,
|
|
18
|
+
MessageStop,
|
|
19
|
+
ReasoningDelta,
|
|
20
|
+
ReasoningEffort,
|
|
21
|
+
StopReason,
|
|
22
|
+
StreamEvent,
|
|
23
|
+
TextBlock,
|
|
24
|
+
TextDelta,
|
|
25
|
+
ToolCallDelta,
|
|
26
|
+
ToolCallEnd,
|
|
27
|
+
ToolCallStart,
|
|
28
|
+
ToolDefinition,
|
|
29
|
+
ToolResultBlock,
|
|
30
|
+
ToolUseBlock,
|
|
31
|
+
Usage,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
DEFAULT_MODEL = "gpt-5.6"
|
|
35
|
+
|
|
36
|
+
_FINISH_REASON_MAP: dict[str, StopReason] = {
|
|
37
|
+
"stop": "end_turn",
|
|
38
|
+
"tool_calls": "tool_use",
|
|
39
|
+
"length": "max_tokens",
|
|
40
|
+
"content_filter": "refusal",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_tool_arguments(raw_json: str) -> dict[str, Any]:
|
|
45
|
+
"""Parse tool call arguments from streamed chunks with lenient repair.
|
|
46
|
+
|
|
47
|
+
A stream cut off by `finish_reason="length"` or an open-source proxy often drops the
|
|
48
|
+
closing quote or bracket. Attempting simple repairs prevents a truncated generation
|
|
49
|
+
from crashing the turn with JSONDecodeError.
|
|
50
|
+
"""
|
|
51
|
+
if not raw_json or not raw_json.strip():
|
|
52
|
+
return {}
|
|
53
|
+
cleaned = raw_json.strip()
|
|
54
|
+
try:
|
|
55
|
+
val = json.loads(cleaned)
|
|
56
|
+
return val if isinstance(val, dict) else {"_value": val}
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Try common truncation closures (unclosed string or unclosed objects)
|
|
61
|
+
for suffix in ['"}', '"}]', "}", "]}", '"]}', '"]}}']:
|
|
62
|
+
try:
|
|
63
|
+
val = json.loads(cleaned + suffix)
|
|
64
|
+
if isinstance(val, dict):
|
|
65
|
+
log.debug("Repaired truncated tool arguments using suffix %r", suffix)
|
|
66
|
+
return val
|
|
67
|
+
except json.JSONDecodeError:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
log.warning("Malformed tool call arguments received from model: %s", raw_json)
|
|
71
|
+
return {"_raw": raw_json, "_error": "JSONDecodeError"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OpenAIProvider(BaseModelProvider):
|
|
75
|
+
"""Talks to the OpenAI Chat Completions protocol — the de facto standard for
|
|
76
|
+
third-party OpenAI-compatible routers/proxies (unlike the newer Responses API,
|
|
77
|
+
which is rarely implemented outside api.openai.com itself).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
|
81
|
+
super().__init__(api_key, base_url)
|
|
82
|
+
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
83
|
+
|
|
84
|
+
async def list_models(self) -> list[str]:
|
|
85
|
+
return [model.id async for model in self._client.models.list()]
|
|
86
|
+
|
|
87
|
+
async def aclose(self) -> None:
|
|
88
|
+
await self._client.close()
|
|
89
|
+
|
|
90
|
+
async def stream(
|
|
91
|
+
self,
|
|
92
|
+
messages: list[Message],
|
|
93
|
+
*,
|
|
94
|
+
model: str = DEFAULT_MODEL,
|
|
95
|
+
system: str | None = None,
|
|
96
|
+
tools: list[ToolDefinition] | None = None,
|
|
97
|
+
max_tokens: int = 8192,
|
|
98
|
+
reasoning_effort: ReasoningEffort | None = None,
|
|
99
|
+
temperature: float | None = None,
|
|
100
|
+
top_p: float | None = None,
|
|
101
|
+
stop: list[str] | None = None,
|
|
102
|
+
parallel_tool_calls: bool | None = None,
|
|
103
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
104
|
+
"""`reasoning_effort` maps 1:1 onto Chat Completions' own `reasoning_effort`
|
|
105
|
+
field — OpenAI already uses this exact seven-value scale. Support for
|
|
106
|
+
each value is model-dependent; the API rejects a value the target model
|
|
107
|
+
doesn't recognize rather than us pre-validating it here.
|
|
108
|
+
"""
|
|
109
|
+
kwargs: dict[str, Any] = {}
|
|
110
|
+
if tools:
|
|
111
|
+
kwargs["tools"] = [self._to_tool(tool) for tool in tools]
|
|
112
|
+
if parallel_tool_calls is not None:
|
|
113
|
+
kwargs["parallel_tool_calls"] = parallel_tool_calls
|
|
114
|
+
if reasoning_effort is not None:
|
|
115
|
+
kwargs["reasoning_effort"] = reasoning_effort
|
|
116
|
+
if temperature is not None:
|
|
117
|
+
kwargs["temperature"] = temperature
|
|
118
|
+
if top_p is not None:
|
|
119
|
+
kwargs["top_p"] = top_p
|
|
120
|
+
if stop is not None:
|
|
121
|
+
kwargs["stop"] = stop
|
|
122
|
+
|
|
123
|
+
# index -> accumulated call state, for pairing deltas back to a call
|
|
124
|
+
pending_calls: dict[int, dict[str, Any]] = {}
|
|
125
|
+
usage = Usage()
|
|
126
|
+
stop_reason: StopReason = "end_turn"
|
|
127
|
+
|
|
128
|
+
response_stream = await self._client.chat.completions.create(
|
|
129
|
+
model=model,
|
|
130
|
+
messages=self._to_messages(messages, system),
|
|
131
|
+
max_tokens=max_tokens,
|
|
132
|
+
stream=True,
|
|
133
|
+
stream_options={"include_usage": True},
|
|
134
|
+
**kwargs,
|
|
135
|
+
)
|
|
136
|
+
# `async with`, not a bare `async for`. The SDK closes the response only "if the
|
|
137
|
+
# response body is read to completion", and an SSE stream never is: iteration
|
|
138
|
+
# stops at the `[DONE]` sentinel with httpcore's byte generator still suspended
|
|
139
|
+
# mid-body. Left to the garbage collector it is closed after the event loop has
|
|
140
|
+
# gone, which surfaces as `RuntimeError: generator didn't stop after athrow()`
|
|
141
|
+
# from inside httpcore — a traceback with nothing in it pointing here.
|
|
142
|
+
async with response_stream:
|
|
143
|
+
async for chunk in response_stream:
|
|
144
|
+
if chunk.usage is not None:
|
|
145
|
+
usage = Usage(
|
|
146
|
+
input_tokens=chunk.usage.prompt_tokens,
|
|
147
|
+
output_tokens=chunk.usage.completion_tokens,
|
|
148
|
+
)
|
|
149
|
+
if not chunk.choices:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
choice = chunk.choices[0]
|
|
153
|
+
delta = choice.delta
|
|
154
|
+
if delta.content:
|
|
155
|
+
yield TextDelta(text=delta.content)
|
|
156
|
+
|
|
157
|
+
# Not a Chat Completions field — some OpenAI-compatible routers (e.g.
|
|
158
|
+
# reasoning-model proxies) bolt it onto the delta as an extra attribute.
|
|
159
|
+
reasoning = getattr(delta, "reasoning", None)
|
|
160
|
+
if reasoning:
|
|
161
|
+
yield ReasoningDelta(text=reasoning)
|
|
162
|
+
|
|
163
|
+
for tool_call_delta in delta.tool_calls or []:
|
|
164
|
+
index = tool_call_delta.index
|
|
165
|
+
if tool_call_delta.id is not None:
|
|
166
|
+
# First chunk for this call: id + name arrive together.
|
|
167
|
+
name = tool_call_delta.function.name if tool_call_delta.function else ""
|
|
168
|
+
pending_calls[index] = {"id": tool_call_delta.id, "name": name, "json": ""}
|
|
169
|
+
yield ToolCallStart(id=tool_call_delta.id, name=name)
|
|
170
|
+
call = pending_calls.get(index)
|
|
171
|
+
if call is None:
|
|
172
|
+
continue
|
|
173
|
+
arguments = tool_call_delta.function.arguments if tool_call_delta.function else None
|
|
174
|
+
if arguments:
|
|
175
|
+
call["json"] += arguments
|
|
176
|
+
yield ToolCallDelta(id=call["id"], partial_json=arguments)
|
|
177
|
+
|
|
178
|
+
if choice.finish_reason is not None:
|
|
179
|
+
stop_reason = _FINISH_REASON_MAP.get(choice.finish_reason, "end_turn")
|
|
180
|
+
# Some OpenAI-compatible routers repeat finish_reason on a trailing
|
|
181
|
+
# usage-only chunk — clear so that chunk doesn't re-flush stale calls.
|
|
182
|
+
for call in pending_calls.values():
|
|
183
|
+
yield ToolCallEnd(
|
|
184
|
+
id=call["id"],
|
|
185
|
+
name=call["name"],
|
|
186
|
+
input=_parse_tool_arguments(call["json"]),
|
|
187
|
+
)
|
|
188
|
+
pending_calls.clear()
|
|
189
|
+
|
|
190
|
+
# Flush any remaining calls if finish_reason was omitted or stream ended early
|
|
191
|
+
for call in pending_calls.values():
|
|
192
|
+
yield ToolCallEnd(
|
|
193
|
+
id=call["id"],
|
|
194
|
+
name=call["name"],
|
|
195
|
+
input=_parse_tool_arguments(call["json"]),
|
|
196
|
+
)
|
|
197
|
+
pending_calls.clear()
|
|
198
|
+
|
|
199
|
+
yield MessageStop(stop_reason=stop_reason, usage=usage)
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _to_tool(tool: ToolDefinition) -> dict[str, Any]:
|
|
203
|
+
return {
|
|
204
|
+
"type": "function",
|
|
205
|
+
"function": {
|
|
206
|
+
"name": tool.name,
|
|
207
|
+
"description": tool.description,
|
|
208
|
+
"parameters": tool.input_schema,
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _to_messages(messages: list[Message], system: str | None) -> list[dict[str, Any]]:
|
|
214
|
+
chat_messages: list[dict[str, Any]] = []
|
|
215
|
+
if system:
|
|
216
|
+
chat_messages.append({"role": "system", "content": system})
|
|
217
|
+
|
|
218
|
+
for message in messages:
|
|
219
|
+
if isinstance(message.content, str):
|
|
220
|
+
chat_messages.append({"role": message.role, "content": message.content})
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
text_parts: list[str] = []
|
|
224
|
+
tool_calls: list[dict[str, Any]] = []
|
|
225
|
+
for block in message.content:
|
|
226
|
+
if isinstance(block, TextBlock):
|
|
227
|
+
text_parts.append(block.text)
|
|
228
|
+
elif isinstance(block, ToolUseBlock):
|
|
229
|
+
tool_calls.append(
|
|
230
|
+
{
|
|
231
|
+
"id": block.id,
|
|
232
|
+
"type": "function",
|
|
233
|
+
"function": {"name": block.name, "arguments": json.dumps(block.input)},
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
elif isinstance(block, ToolResultBlock):
|
|
237
|
+
# Chat Completions has no first-class error flag on tool messages —
|
|
238
|
+
# fold it into the content so the model still sees the failure.
|
|
239
|
+
content = f"Error: {block.content}" if block.is_error else block.content
|
|
240
|
+
chat_messages.append(
|
|
241
|
+
{"role": "tool", "tool_call_id": block.tool_use_id, "content": content}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if text_parts or tool_calls:
|
|
245
|
+
turn: dict[str, Any] = {"role": message.role, "content": "".join(text_parts) or None}
|
|
246
|
+
if tool_calls:
|
|
247
|
+
turn["tool_calls"] = tool_calls
|
|
248
|
+
chat_messages.append(turn)
|
|
249
|
+
|
|
250
|
+
return chat_messages
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider registry — the LLM libraries stcode can talk to, and each one's default model.
|
|
3
|
+
|
|
4
|
+
The default model lives in each provider module and is read from there, so it cannot
|
|
5
|
+
drift. Which environment variable holds a library's key is configuration, and lives in
|
|
6
|
+
`core/configs.py` (`PROVIDER_KEY_ENV`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from stcode.core.providers.anthropic_claude import (
|
|
10
|
+
DEFAULT_MODEL as ANTHROPIC_DEFAULT_MODEL,
|
|
11
|
+
)
|
|
12
|
+
from stcode.core.providers.anthropic_claude import AnthropicProvider
|
|
13
|
+
from stcode.core.providers.base import BaseModelProvider
|
|
14
|
+
from stcode.core.providers.google_gemini import DEFAULT_MODEL as GOOGLE_DEFAULT_MODEL
|
|
15
|
+
from stcode.core.providers.google_gemini import GoogleGenAIProvider
|
|
16
|
+
from stcode.core.providers.openai_gpt import DEFAULT_MODEL as OPENAI_DEFAULT_MODEL
|
|
17
|
+
from stcode.core.providers.openai_gpt import OpenAIProvider
|
|
18
|
+
|
|
19
|
+
PROVIDERS: dict[str, type[BaseModelProvider]] = {
|
|
20
|
+
"anthropic": AnthropicProvider,
|
|
21
|
+
"openai": OpenAIProvider,
|
|
22
|
+
"google": GoogleGenAIProvider,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_MODELS: dict[str, str] = {
|
|
27
|
+
"anthropic": ANTHROPIC_DEFAULT_MODEL,
|
|
28
|
+
"openai": OPENAI_DEFAULT_MODEL,
|
|
29
|
+
"google": GOOGLE_DEFAULT_MODEL,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_model_for(provider: str) -> str:
|
|
34
|
+
"""The model a library is pointed at when the config names none."""
|
|
35
|
+
return DEFAULT_MODELS.get(provider, "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_provider(
|
|
39
|
+
name: str, api_key: str | None = None, base_url: str | None = None
|
|
40
|
+
) -> BaseModelProvider:
|
|
41
|
+
"""Instantiate a registered provider by name (e.g. "anthropic", "openai", "google")."""
|
|
42
|
+
try:
|
|
43
|
+
provider_cls = PROVIDERS[name]
|
|
44
|
+
except KeyError:
|
|
45
|
+
raise ValueError(f"Unknown provider {name!r}. Available: {sorted(PROVIDERS)}") from None
|
|
46
|
+
return provider_cls(api_key=api_key, base_url=base_url)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified message, tool and streaming-event shapes, shared by every provider.
|
|
3
|
+
|
|
4
|
+
Providers translate to and from these at their boundary, so the agent loop, the tool
|
|
5
|
+
executor and the TUI never touch a provider SDK type directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Literal, Union
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from stcode.core.common.tools import ToolDefinition
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"REASONING_EFFORTS",
|
|
18
|
+
"ContentBlock",
|
|
19
|
+
"Message",
|
|
20
|
+
"MessageStop",
|
|
21
|
+
"ReasoningDelta",
|
|
22
|
+
"ReasoningEffort",
|
|
23
|
+
"Role",
|
|
24
|
+
"StopReason",
|
|
25
|
+
"StreamEvent",
|
|
26
|
+
"TextBlock",
|
|
27
|
+
"TextDelta",
|
|
28
|
+
"ToolCallDelta",
|
|
29
|
+
"ToolCallEnd",
|
|
30
|
+
"ToolCallStart",
|
|
31
|
+
"ToolDefinition",
|
|
32
|
+
"ToolResultBlock",
|
|
33
|
+
"ToolUseBlock",
|
|
34
|
+
"Usage",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
Role = Literal["user", "assistant"]
|
|
38
|
+
|
|
39
|
+
# The effort scale is the union of every provider's; a provider with a narrower one
|
|
40
|
+
# (Anthropic has no `none`/`minimal`, Gemini tops out at `high`) clamps at its boundary.
|
|
41
|
+
from stcode.core.configs import REASONING_EFFORTS, ReasoningEffort # noqa: E402
|
|
42
|
+
|
|
43
|
+
StopReason = Literal[
|
|
44
|
+
"end_turn",
|
|
45
|
+
"tool_use",
|
|
46
|
+
"max_tokens",
|
|
47
|
+
"stop_sequence",
|
|
48
|
+
"pause_turn",
|
|
49
|
+
"refusal",
|
|
50
|
+
"error",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TextBlock(BaseModel):
|
|
55
|
+
type: Literal["text"] = "text"
|
|
56
|
+
text: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ToolUseBlock(BaseModel):
|
|
60
|
+
type: Literal["tool_use"] = "tool_use"
|
|
61
|
+
id: str
|
|
62
|
+
name: str
|
|
63
|
+
input: dict[str, Any]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ToolResultBlock(BaseModel):
|
|
67
|
+
type: Literal["tool_result"] = "tool_result"
|
|
68
|
+
tool_use_id: str
|
|
69
|
+
content: str
|
|
70
|
+
is_error: bool = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
ContentBlock = Union[TextBlock, ToolUseBlock, ToolResultBlock]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Message(BaseModel):
|
|
77
|
+
role: Role
|
|
78
|
+
content: str | list[ContentBlock]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# `ToolDefinition` is re-exported, not defined here: three packages need it now, so it
|
|
82
|
+
# lives in `core/common/tools.py`. Importing it from this module still works — every
|
|
83
|
+
# provider adapter already does — but new code should reach for `core.common`.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Usage(BaseModel):
|
|
87
|
+
"""Token counts for one completion.
|
|
88
|
+
|
|
89
|
+
The two cache fields stay 0 for providers that cache implicitly (OpenAI) or not at
|
|
90
|
+
all. They are here because "turn 2 is ~10x cheaper" is otherwise an unverifiable
|
|
91
|
+
claim: `cache_read_input_tokens` on turn 2 is the evidence, and the agent writes it
|
|
92
|
+
into the session where anyone can look."""
|
|
93
|
+
|
|
94
|
+
input_tokens: int = 0
|
|
95
|
+
output_tokens: int = 0
|
|
96
|
+
cache_creation_input_tokens: int = 0
|
|
97
|
+
cache_read_input_tokens: int = 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TextDelta(BaseModel):
|
|
101
|
+
"""Incremental assistant text."""
|
|
102
|
+
|
|
103
|
+
type: Literal["text_delta"] = "text_delta"
|
|
104
|
+
text: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ReasoningDelta(BaseModel):
|
|
108
|
+
"""Incremental reasoning/thinking text — model's internal deliberation, not the
|
|
109
|
+
final answer. Never sent back to the provider as conversation history."""
|
|
110
|
+
|
|
111
|
+
type: Literal["reasoning_delta"] = "reasoning_delta"
|
|
112
|
+
text: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ToolCallStart(BaseModel):
|
|
116
|
+
"""A new tool call began; its input will arrive via ToolCallDelta events."""
|
|
117
|
+
|
|
118
|
+
type: Literal["tool_call_start"] = "tool_call_start"
|
|
119
|
+
id: str
|
|
120
|
+
name: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ToolCallDelta(BaseModel):
|
|
124
|
+
"""Incremental (partial-JSON) tool call input."""
|
|
125
|
+
|
|
126
|
+
type: Literal["tool_call_delta"] = "tool_call_delta"
|
|
127
|
+
id: str
|
|
128
|
+
partial_json: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ToolCallEnd(BaseModel):
|
|
132
|
+
"""A tool call finished streaming; `input` is the fully parsed argument dict."""
|
|
133
|
+
|
|
134
|
+
type: Literal["tool_call_end"] = "tool_call_end"
|
|
135
|
+
id: str
|
|
136
|
+
name: str
|
|
137
|
+
input: dict[str, Any]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class MessageStop(BaseModel):
|
|
141
|
+
"""Terminal event for a single completion — always the last event yielded."""
|
|
142
|
+
|
|
143
|
+
type: Literal["message_stop"] = "message_stop"
|
|
144
|
+
stop_reason: StopReason
|
|
145
|
+
usage: Usage
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
StreamEvent = Union[TextDelta, ReasoningDelta, ToolCallStart, ToolCallDelta, ToolCallEnd, MessageStop]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`core/repl/` — a persistent Python namespace, in a subprocess.
|
|
3
|
+
|
|
4
|
+
Two files: `_worker.py` is the child, `client.py` is the parent. They speak JSONL over
|
|
5
|
+
stdin/stdout — the same framing as the session file and the daemon protocol.
|
|
6
|
+
|
|
7
|
+
It exists for MCP-as-code: somewhere to `import` a generated stub and hold the result
|
|
8
|
+
across turns, so the bulk never touches the context window.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from stcode.core.repl.client import (
|
|
12
|
+
DEFAULT_TIMEOUT,
|
|
13
|
+
ExecResult,
|
|
14
|
+
PyREPL,
|
|
15
|
+
ReplError,
|
|
16
|
+
StreamFn,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = ["DEFAULT_TIMEOUT", "ExecResult", "PyREPL", "ReplError", "StreamFn"]
|