semora-llm 0.1.0__tar.gz

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.
@@ -0,0 +1,28 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ build/
9
+ dist/
10
+ .coverage
11
+ htmlcov/
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+
17
+ # Local tool/editor state — machine-specific, never pushed.
18
+ .claude/
19
+ .codecanvas/
20
+ .vscode/
21
+
22
+
23
+ # Superpowers design/spec scratch — working notes, not project documentation.
24
+ docs/superpowers/
25
+
26
+ # 로컬 자격증명 — 절대 커밋 금지.
27
+ a.txt
28
+ *.token
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: semora-llm
3
+ Version: 0.1.0
4
+ Summary: OpenAI-compatible chat model for Semora: official openai SDK, LangChain chunks.
5
+ Project-URL: Homepage, https://github.com/donggyun112/semora
6
+ Project-URL: Source, https://github.com/donggyun112/semora
7
+ Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
8
+ Author: donggyun112
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: langchain-core<2,>=1
17
+ Requires-Dist: openai<4,>=3
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "semora-llm"
7
+ version = "0.1.0"
8
+ description = "OpenAI-compatible chat model for Semora: official openai SDK, LangChain chunks."
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ authors = [{ name = "donggyun112" }]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Typing :: Typed",
18
+ ]
19
+ urls = { Homepage = "https://github.com/donggyun112/semora", Source = "https://github.com/donggyun112/semora", Changelog = "https://github.com/donggyun112/semora/blob/main/CHANGELOG.md" }
20
+ dependencies = [
21
+ "langchain-core>=1,<2",
22
+ "openai>=3,<4",
23
+ ]
24
+ # Own footprint: the OpenAI Chat Completions wire and the chunk adapter the loop
25
+ # already speaks. Not a provider catalog. Anthropic/Google native APIs stay extras.
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/semora_llm"]
@@ -0,0 +1,14 @@
1
+ """OpenAI-compatible chat model used by Semora's planner."""
2
+
3
+ from .chat import ChatModel, openrouter, xai
4
+ from .dsml import DsmlFilter, parse_dsml_tool_calls, recover_dsml_chunks, strip_dsml
5
+
6
+ __all__ = [
7
+ "ChatModel",
8
+ "DsmlFilter",
9
+ "openrouter",
10
+ "parse_dsml_tool_calls",
11
+ "recover_dsml_chunks",
12
+ "strip_dsml",
13
+ "xai",
14
+ ]
@@ -0,0 +1,303 @@
1
+ """OpenAI Chat Completions client that streams LangChain chunks.
2
+
3
+ The loop binds tools and consumes ``astream``. This class is that surface over the
4
+ official ``openai`` SDK. HTTP, SSE, retries-off, and error types stay the SDK's.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from langchain_core.messages import (
16
+ AIMessage,
17
+ AIMessageChunk,
18
+ BaseMessage,
19
+ HumanMessage,
20
+ SystemMessage,
21
+ ToolMessage,
22
+ )
23
+ from openai import AsyncOpenAI
24
+
25
+ from .dsml import recover_dsml_chunks
26
+
27
+ __all__ = ["ChatModel"]
28
+
29
+ OPENROUTER_URL = "https://openrouter.ai/api/v1"
30
+ XAI_URL = "https://api.x.ai/v1"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ChatModel:
35
+ """Stream chat completions from any OpenAI-compatible ``/v1`` endpoint.
36
+
37
+ ``bind_tools`` / ``astream`` / ``_identifying_params`` are the planner contract.
38
+ Construct with ``base_url`` for OpenRouter, xAI, Groq, vLLM, Ollama, and the rest
39
+ of the OpenAI-compatible wire. Native Anthropic/Google APIs are not this class.
40
+ """
41
+
42
+ model: str
43
+ api_key: str | None = None
44
+ base_url: str | None = None
45
+ default_headers: Mapping[str, str] | None = None
46
+ extra_body: Mapping[str, Any] | None = None
47
+ tools: tuple[dict[str, Any], ...] = ()
48
+ client: Any = None
49
+ timeout: float | None = None
50
+ """Seconds before a request is abandoned. None leaves the SDK's own default, which
51
+ is generous: a hung provider holds a worker for ten minutes without one."""
52
+ recover_dsml: bool = False
53
+ """Repair tool markup a gateway left in assistant content. A no-op for a provider
54
+ that does not leak it, so presets turn it on per gateway rather than per model."""
55
+
56
+ def bind_tools(self, tools: Sequence[Any], **_kwargs: Any) -> ChatModel:
57
+ """Return a copy that sends these OpenAI-format tool definitions."""
58
+ encoded = tuple(_as_openai_tool(tool) for tool in tools)
59
+ return ChatModel(
60
+ model=self.model,
61
+ api_key=self.api_key,
62
+ base_url=self.base_url,
63
+ default_headers=self.default_headers,
64
+ extra_body=self.extra_body,
65
+ tools=encoded,
66
+ client=self.client,
67
+ timeout=self.timeout,
68
+ recover_dsml=self.recover_dsml,
69
+ )
70
+
71
+ @property
72
+ def _identifying_params(self) -> dict[str, Any]:
73
+ """Stable identity used by the durable model request key."""
74
+ return {
75
+ "model": self.model,
76
+ "base_url": self.base_url or "",
77
+ "tools": list(self.tools),
78
+ }
79
+
80
+ async def astream(
81
+ self, messages: Iterable[BaseMessage], **_kwargs: Any
82
+ ) -> AsyncIterator[AIMessageChunk]:
83
+ """Yield one LangChain chunk per provider delta, repaired where a gateway leaks."""
84
+ deltas = self._deltas(messages)
85
+ repaired = recover_dsml_chunks(deltas) if self.recover_dsml else deltas
86
+ async for chunk in repaired:
87
+ yield chunk
88
+
89
+ async def _deltas(
90
+ self, messages: Iterable[BaseMessage]
91
+ ) -> AsyncIterator[AIMessageChunk]:
92
+ """The provider's own stream, one LangChain chunk per delta."""
93
+ stream = await self._openai().chat.completions.create(
94
+ model=self.model,
95
+ messages=encode_messages(list(messages)),
96
+ stream=True,
97
+ stream_options={"include_usage": True},
98
+ **self._create_options(),
99
+ )
100
+ async for chunk in stream:
101
+ yield decode_chunk(chunk)
102
+
103
+ def _openai(self) -> Any:
104
+ """Return the injected client or construct the official async SDK client."""
105
+ if self.client is not None:
106
+ return self.client
107
+ options: dict[str, Any] = {}
108
+ if self.timeout is not None:
109
+ options["timeout"] = self.timeout
110
+ return AsyncOpenAI(
111
+ api_key=self.api_key,
112
+ base_url=self.base_url,
113
+ default_headers=dict(self.default_headers) if self.default_headers else None,
114
+ # Retries stay off here. A retried model call is a second turn the ledger
115
+ # never saw, and deciding whether that is safe is the caller's, not ours.
116
+ max_retries=0,
117
+ **options,
118
+ )
119
+
120
+ def _create_options(self) -> dict[str, Any]:
121
+ """Keyword arguments that only exist when the caller set them."""
122
+ options: dict[str, Any] = {}
123
+ if self.tools:
124
+ options["tools"] = list(self.tools)
125
+ if self.extra_body:
126
+ options["extra_body"] = dict(self.extra_body)
127
+ return options
128
+
129
+
130
+ def encode_messages(messages: list[BaseMessage]) -> list[dict[str, Any]]:
131
+ """Turn LangChain messages into Chat Completions message dicts."""
132
+ return [_encode_one(message) for message in messages]
133
+
134
+
135
+ def decode_chunk(chunk: Any) -> AIMessageChunk:
136
+ """Turn one SDK stream chunk into the chunk type the loop already adds."""
137
+ choice = (getattr(chunk, "choices", None) or [None])[0]
138
+ delta = getattr(choice, "delta", None) if choice is not None else None
139
+ text = getattr(delta, "content", None) if delta is not None else None
140
+ content: str | list[dict[str, Any]] = text or ""
141
+ reasoning = _extra(delta, "reasoning")
142
+ if reasoning:
143
+ blocks: list[dict[str, Any]] = [
144
+ {"type": "reasoning", "reasoning": str(reasoning), "index": 0},
145
+ ]
146
+ if text:
147
+ blocks.append({"type": "text", "text": str(text)})
148
+ content = blocks
149
+ kwargs: dict[str, Any] = {}
150
+ details = _extra(delta, "reasoning_details")
151
+ if details:
152
+ kwargs["reasoning_details"] = details
153
+ usage = getattr(chunk, "usage", None)
154
+ usage_metadata = _usage(usage)
155
+ model_name = getattr(chunk, "model", None)
156
+ return AIMessageChunk(
157
+ content=content,
158
+ additional_kwargs=kwargs,
159
+ tool_call_chunks=_tool_call_chunks(delta),
160
+ usage_metadata=usage_metadata or None,
161
+ response_metadata={"model_name": model_name} if model_name else {},
162
+ )
163
+
164
+
165
+ def _encode_one(message: BaseMessage) -> dict[str, Any]:
166
+ """Encode one LangChain message to the Chat Completions shape."""
167
+ if isinstance(message, SystemMessage):
168
+ return {"role": "system", "content": _text(message)}
169
+ if isinstance(message, HumanMessage):
170
+ return {"role": "user", "content": _text(message)}
171
+ if isinstance(message, ToolMessage):
172
+ return {
173
+ "role": "tool",
174
+ "tool_call_id": message.tool_call_id,
175
+ "content": _text(message),
176
+ }
177
+ if isinstance(message, AIMessage):
178
+ body: dict[str, Any] = {"role": "assistant", "content": _text(message) or None}
179
+ if message.tool_calls:
180
+ body["tool_calls"] = [_encode_tool_call(call) for call in message.tool_calls]
181
+ if details := message.additional_kwargs.get("reasoning_details"):
182
+ body["reasoning_details"] = details
183
+ return body
184
+ return {"role": "user", "content": _text(message)}
185
+
186
+
187
+ def _encode_tool_call(call: Mapping[str, Any]) -> dict[str, Any]:
188
+ """Encode one LangChain tool call as an OpenAI function tool call."""
189
+ arguments = call.get("args", {})
190
+ if not isinstance(arguments, str):
191
+ arguments = json.dumps(arguments, ensure_ascii=False)
192
+ return {
193
+ "id": call.get("id") or "",
194
+ "type": "function",
195
+ "function": {"name": call.get("name") or "", "arguments": arguments},
196
+ }
197
+
198
+
199
+ def _as_openai_tool(tool: Any) -> dict[str, Any]:
200
+ """Accept already-normalized OpenAI tool dicts from ``as_model_tools``."""
201
+ if isinstance(tool, Mapping) and tool.get("type") == "function":
202
+ return dict(tool)
203
+ if isinstance(tool, Mapping) and "name" in tool:
204
+ return {
205
+ "type": "function",
206
+ "function": {
207
+ "name": tool["name"],
208
+ "description": tool.get("description", ""),
209
+ "parameters": tool.get("parameters") or tool.get("schema") or {},
210
+ },
211
+ }
212
+ raise TypeError(f"unsupported tool definition: {type(tool)!r}")
213
+
214
+
215
+ def _tool_call_chunks(delta: Any) -> list[dict[str, Any]]:
216
+ """Extract streaming tool-call fragments from a delta."""
217
+ calls = getattr(delta, "tool_calls", None) if delta is not None else None
218
+ if not calls:
219
+ return []
220
+ chunks: list[dict[str, Any]] = []
221
+ for call in calls:
222
+ function = getattr(call, "function", None)
223
+ chunks.append(
224
+ {
225
+ "name": getattr(function, "name", None) or "",
226
+ "args": getattr(function, "arguments", None) or "",
227
+ "id": getattr(call, "id", None) or "",
228
+ "index": getattr(call, "index", None) or 0,
229
+ }
230
+ )
231
+ return chunks
232
+
233
+
234
+ def _usage(usage: Any) -> dict[str, int]:
235
+ """Map SDK usage onto the names the loop already reads."""
236
+ if usage is None:
237
+ return {}
238
+ prompt = getattr(usage, "prompt_tokens", None) or getattr(usage, "input_tokens", 0) or 0
239
+ completion = (
240
+ getattr(usage, "completion_tokens", None) or getattr(usage, "output_tokens", 0) or 0
241
+ )
242
+ total = getattr(usage, "total_tokens", None) or (prompt + completion)
243
+ return {
244
+ "input_tokens": int(prompt),
245
+ "output_tokens": int(completion),
246
+ "total_tokens": int(total),
247
+ }
248
+
249
+
250
+ def _extra(delta: Any, key: str) -> Any:
251
+ """Read an undocumented delta field without depending on one SDK attribute layout."""
252
+ if delta is None:
253
+ return None
254
+ value = getattr(delta, key, None)
255
+ if value:
256
+ return value
257
+ extra = getattr(delta, "model_extra", None)
258
+ if isinstance(extra, Mapping):
259
+ return extra.get(key)
260
+ return None
261
+
262
+
263
+ def _text(message: BaseMessage) -> str:
264
+ """Flatten message content to the string Chat Completions still accepts."""
265
+ content = message.content
266
+ if isinstance(content, str):
267
+ return content
268
+ if isinstance(content, list):
269
+ parts = [
270
+ str(block.get("text") or block.get("reasoning") or "")
271
+ for block in content
272
+ if isinstance(block, dict)
273
+ ]
274
+ return "".join(parts)
275
+ return str(content or "")
276
+
277
+
278
+ def openrouter(model: str, *, api_key: str | None = None, **options: Any) -> ChatModel:
279
+ """OpenRouter preset: OpenAI wire plus the attribution headers their docs ask for."""
280
+ return ChatModel(
281
+ model,
282
+ api_key=api_key or os.environ.get("OPENROUTER_API_KEY"),
283
+ base_url=OPENROUTER_URL,
284
+ default_headers={
285
+ "HTTP-Referer": options.pop("referer", "https://semora.dev"),
286
+ "X-Title": options.pop("title", "Semora"),
287
+ },
288
+ extra_body=options.pop("extra_body", None),
289
+ # OpenRouter is the gateway observed dropping DeepSeek's markup into content.
290
+ # The repair costs a suffix scan per delta and does nothing to a clean stream.
291
+ recover_dsml=options.pop("recover_dsml", True),
292
+ **options,
293
+ )
294
+
295
+
296
+ def xai(model: str, *, api_key: str | None = None, **options: Any) -> ChatModel:
297
+ """XAI preset: Grok over the OpenAI-compatible ``api.x.ai`` endpoint."""
298
+ return ChatModel(
299
+ model,
300
+ api_key=api_key or os.environ.get("XAI_API_KEY"),
301
+ base_url=XAI_URL,
302
+ **options,
303
+ )
@@ -0,0 +1,206 @@
1
+ """Recover DeepSeek DSML tool markup that a provider left as assistant text.
2
+
3
+ DeepSeek V4 writes its tool calls as a markup block using fullwidth bars, and some
4
+ gateways (OpenRouter among them) fail to lift that into OpenAI ``tool_calls``,
5
+ streaming it as assistant content instead. Repairing it belongs to the provider
6
+ client: the planner speaks LangChain tool calls and should never learn a vendor's
7
+ markup dialect.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import uuid
15
+ from collections.abc import AsyncIterator
16
+ from typing import Any
17
+
18
+ from langchain_core.messages import AIMessageChunk
19
+
20
+ _BAR = "\uff5c" # FULLWIDTH VERTICAL LINE, what DeepSeek actually emits
21
+ _DSML = rf"(?:\|DSML\||{_BAR}DSML{_BAR})"
22
+ _INVOKE = re.compile(
23
+ rf"<{_DSML}invoke\s+name=\"([^\"]+)\"\s*>(.*?)</{_DSML}invoke>",
24
+ re.DOTALL,
25
+ )
26
+ _PARAM = re.compile(
27
+ rf"<{_DSML}parameter\s+name=\"([^\"]+)\"(?:\s+string=\"(true|false)\")?\s*>"
28
+ rf"(.*?)</{_DSML}parameter>",
29
+ re.DOTALL,
30
+ )
31
+ _OPENS = (
32
+ "<|DSML|tool_calls>",
33
+ "<|DSML|function_calls>",
34
+ "<|DSML|invoke ",
35
+ "<|DSML|invoke>",
36
+ )
37
+
38
+
39
+ def _ascii(text: str) -> str:
40
+ """Normalise the fullwidth bars so one set of tags matches both spellings."""
41
+ return text.replace(_BAR, "|")
42
+
43
+
44
+ def parse_dsml_tool_calls(text: str) -> list[dict[str, Any]]:
45
+ """Turn DSML markup into LangChain tool_call dicts. Empty if none."""
46
+ calls: list[dict[str, Any]] = []
47
+ for name, body in _INVOKE.findall(text or ""):
48
+ args: dict[str, Any] = {}
49
+ for key, is_str, raw in _PARAM.findall(body):
50
+ value: Any = raw
51
+ if is_str == "false":
52
+ try:
53
+ value = json.loads(raw)
54
+ except json.JSONDecodeError:
55
+ value = raw
56
+ args[key] = value
57
+ calls.append(
58
+ {
59
+ "name": name,
60
+ "args": args,
61
+ "id": f"call_{uuid.uuid4().hex[:12]}",
62
+ "type": "tool_call",
63
+ }
64
+ )
65
+ return calls
66
+
67
+
68
+ def strip_dsml(text: str) -> str:
69
+ """Drop DSML markup, including an unfinished open tag at the end."""
70
+ if not text:
71
+ return ""
72
+ start = _open_index(text)
73
+ if start is not None:
74
+ return text[:start].rstrip()
75
+ held = _prefix_len(text)
76
+ return text[:-held].rstrip() if held else text
77
+
78
+
79
+ def _open_index(text: str) -> int | None:
80
+ hits = [_ascii(text).find(tag) for tag in _OPENS]
81
+ found = [index for index in hits if index >= 0]
82
+ return min(found) if found else None
83
+
84
+
85
+ def _prefix_len(text: str) -> int:
86
+ """Longest suffix of ``text`` that is a prefix of a DSML open tag."""
87
+ norm = _ascii(text)
88
+ best = 0
89
+ for tag in _OPENS:
90
+ limit = min(len(tag), len(norm))
91
+ for size in range(1, limit + 1):
92
+ if norm.endswith(tag[:size]):
93
+ best = max(best, size)
94
+ return best
95
+
96
+
97
+ class DsmlFilter:
98
+ """Hold streamed deltas until they are either ordinary text or DSML markup."""
99
+
100
+ def __init__(self) -> None:
101
+ """Start with nothing held and nothing swallowed."""
102
+ self._held = ""
103
+ self._swallow = False
104
+
105
+ @property
106
+ def swallowed(self) -> bool:
107
+ """True once a real open tag arrived and the rest is markup."""
108
+ return self._swallow
109
+
110
+ @property
111
+ def markup(self) -> str:
112
+ """The swallowed block, for parsing. Empty while nothing has been swallowed."""
113
+ return self._held if self._swallow else ""
114
+
115
+ def push(self, delta: str) -> str:
116
+ """Return the visible slice of ``delta``; empty when it belongs to DSML."""
117
+ if not delta:
118
+ return ""
119
+ if self._swallow:
120
+ self._held += delta
121
+ return ""
122
+ self._held += delta
123
+ start = _open_index(self._held)
124
+ if start is not None:
125
+ visible = self._held[:start]
126
+ self._held = self._held[start:]
127
+ self._swallow = True
128
+ return visible
129
+ held = _prefix_len(self._held)
130
+ if not held:
131
+ visible, self._held = self._held, ""
132
+ return visible
133
+ visible, self._held = self._held[:-held], self._held[-held:]
134
+ return visible
135
+
136
+ def finish(self) -> str:
137
+ """Flush a prefix that never became markup. Swallowed DSML stays hidden."""
138
+ if self._swallow or "DSML" in _ascii(self._held):
139
+ self._swallow = True
140
+ return ""
141
+ visible, self._held = self._held, ""
142
+ return visible
143
+
144
+
145
+ def _chunk_text(chunk: Any) -> str:
146
+ """The plain text of a chunk, or empty when its content is not a string."""
147
+ text = getattr(chunk, "text", None)
148
+ if isinstance(text, str) and text:
149
+ return text
150
+ content = getattr(chunk, "content", "")
151
+ return content if isinstance(content, str) else ""
152
+
153
+
154
+ def _has_native_calls(chunk: Any) -> bool:
155
+ """True when the gateway did lift the call, leaving nothing to recover."""
156
+ return bool(getattr(chunk, "tool_calls", None) or getattr(chunk, "tool_call_chunks", None))
157
+
158
+
159
+ async def recover_dsml_chunks(
160
+ chunks: AsyncIterator[AIMessageChunk],
161
+ ) -> AsyncIterator[AIMessageChunk]:
162
+ """Hide leaked markup mid-stream and close with the calls parsed out of it.
163
+
164
+ A delta that is entirely the start of an open tag is held back, because the next
165
+ one decides whether it was markup or a stray character. Held chunks are dropped
166
+ once later output carries them, and released unchanged when the block never
167
+ completed — a sentence ending in ``<`` is a sentence, not a tool call.
168
+ """
169
+ pending: list[AIMessageChunk] = []
170
+ filt = DsmlFilter()
171
+ last: AIMessageChunk | None = None
172
+ native = False
173
+ async for chunk in chunks:
174
+ last = chunk
175
+ if _has_native_calls(chunk):
176
+ held = filt.finish()
177
+ if held:
178
+ yield AIMessageChunk(content=held)
179
+ pending.clear()
180
+ native = True
181
+ yield chunk
182
+ continue
183
+ piece = _chunk_text(chunk)
184
+ visible = filt.push(piece)
185
+ if visible:
186
+ pending.clear()
187
+ yield chunk.model_copy(update={"content": visible}) if piece != visible else chunk
188
+ elif piece:
189
+ pending.append(chunk)
190
+ leftover = filt.finish()
191
+ if leftover:
192
+ yield AIMessageChunk(content=leftover)
193
+ return
194
+ if native:
195
+ return
196
+ calls = parse_dsml_tool_calls(filt.markup)
197
+ if not calls:
198
+ for held_chunk in pending:
199
+ yield held_chunk
200
+ return
201
+ yield AIMessageChunk(
202
+ content="",
203
+ tool_calls=calls,
204
+ id=getattr(last, "id", None),
205
+ response_metadata=getattr(last, "response_metadata", {}) or {},
206
+ )
File without changes