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,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"]