golem-agent-sdk 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.
- golem_agent_sdk-0.1.0/PKG-INFO +56 -0
- golem_agent_sdk-0.1.0/README.md +48 -0
- golem_agent_sdk-0.1.0/golem/__init__.py +28 -0
- golem_agent_sdk-0.1.0/golem/client.py +175 -0
- golem_agent_sdk-0.1.0/golem/extension.py +237 -0
- golem_agent_sdk-0.1.0/golem/provider.py +115 -0
- golem_agent_sdk-0.1.0/pyproject.toml +25 -0
- golem_agent_sdk-0.1.0/pyproject.toml.orig +27 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: golem-agent-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Golem extensions
|
|
5
|
+
Author: Nawaz Gafar
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# golem-sdk
|
|
10
|
+
|
|
11
|
+
Python SDK for [Golem](https://github.com/terracotta4u/golem) extensions (channels, providers, and other long-running processes).
|
|
12
|
+
|
|
13
|
+
Install from PyPI:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
uv add golem-agent-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or from this directory:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
uv sync --dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Import as `golem`. The wire protocol lives in Golem: [docs/extensions.md](https://github.com/terracotta4u/golem/blob/main/docs/extensions.md).
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from golem import Client, Extension, Provider
|
|
29
|
+
|
|
30
|
+
client = Client.from_env()
|
|
31
|
+
client.wait_ready()
|
|
32
|
+
text = client.send(conversation_id, "telegram", "hello")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`post_turn` and `stream_turn` expose the same flow as SSE events (`log`, `done`, `error`).
|
|
36
|
+
|
|
37
|
+
A provider extension binds a loopback callback, registers, and heartbeats:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from golem import Extension, Message, Provider
|
|
41
|
+
|
|
42
|
+
class Echo(Provider):
|
|
43
|
+
def chat(self, model, messages, tools=None) -> Message:
|
|
44
|
+
return Message(role="assistant", content=messages[-1].content)
|
|
45
|
+
|
|
46
|
+
Extension.from_env("golem-echo").provider("echo", Echo()).run()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`task()` runs your own loop (a channel). `capability()` registers kinds Golem does not call.
|
|
50
|
+
|
|
51
|
+
## Develop
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv sync --dev
|
|
55
|
+
uv run pytest
|
|
56
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# golem-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for [Golem](https://github.com/terracotta4u/golem) extensions (channels, providers, and other long-running processes).
|
|
4
|
+
|
|
5
|
+
Install from PyPI:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uv add golem-agent-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or from this directory:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
uv sync --dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Import as `golem`. The wire protocol lives in Golem: [docs/extensions.md](https://github.com/terracotta4u/golem/blob/main/docs/extensions.md).
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from golem import Client, Extension, Provider
|
|
21
|
+
|
|
22
|
+
client = Client.from_env()
|
|
23
|
+
client.wait_ready()
|
|
24
|
+
text = client.send(conversation_id, "telegram", "hello")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`post_turn` and `stream_turn` expose the same flow as SSE events (`log`, `done`, `error`).
|
|
28
|
+
|
|
29
|
+
A provider extension binds a loopback callback, registers, and heartbeats:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from golem import Extension, Message, Provider
|
|
33
|
+
|
|
34
|
+
class Echo(Provider):
|
|
35
|
+
def chat(self, model, messages, tools=None) -> Message:
|
|
36
|
+
return Message(role="assistant", content=messages[-1].content)
|
|
37
|
+
|
|
38
|
+
Extension.from_env("golem-echo").provider("echo", Echo()).run()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`task()` runs your own loop (a channel). `capability()` registers kinds Golem does not call.
|
|
42
|
+
|
|
43
|
+
## Develop
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
uv sync --dev
|
|
47
|
+
uv run pytest
|
|
48
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from golem.client import Client, GolemError, TurnEvent
|
|
2
|
+
from golem.extension import Extension
|
|
3
|
+
from golem.provider import (
|
|
4
|
+
FunctionCall,
|
|
5
|
+
JSONSchema,
|
|
6
|
+
Message,
|
|
7
|
+
Provider,
|
|
8
|
+
ToolCall,
|
|
9
|
+
ToolDef,
|
|
10
|
+
UnsupportedFormat,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Client",
|
|
15
|
+
"Extension",
|
|
16
|
+
"FunctionCall",
|
|
17
|
+
"GolemError",
|
|
18
|
+
"JSONSchema",
|
|
19
|
+
"Message",
|
|
20
|
+
"Provider",
|
|
21
|
+
"ToolCall",
|
|
22
|
+
"ToolDef",
|
|
23
|
+
"TurnEvent",
|
|
24
|
+
"UnsupportedFormat",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GolemError(Exception):
|
|
13
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status = status
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class TurnEvent:
|
|
20
|
+
name: str
|
|
21
|
+
data: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
def __init__(self, url: str, token: str = "") -> None:
|
|
26
|
+
self.url = url.rstrip("/")
|
|
27
|
+
self.token = token
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_env(cls) -> Client:
|
|
31
|
+
url = os.environ.get("GOLEM_URL", "").strip() or "http://127.0.0.1:8743"
|
|
32
|
+
token = os.environ.get("GOLEM_TOKEN", "").strip()
|
|
33
|
+
return cls(url, token)
|
|
34
|
+
|
|
35
|
+
def wait_ready(self, timeout: float = 30.0) -> None:
|
|
36
|
+
deadline = time.monotonic() + timeout
|
|
37
|
+
last: Exception | None = None
|
|
38
|
+
while time.monotonic() < deadline:
|
|
39
|
+
try:
|
|
40
|
+
body = self._request("GET", "/v1/health")
|
|
41
|
+
if body.get("ok"):
|
|
42
|
+
return
|
|
43
|
+
last = GolemError("golem health not ok")
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
last = exc
|
|
46
|
+
time.sleep(0.4)
|
|
47
|
+
if last is None:
|
|
48
|
+
last = GolemError("golem not ready")
|
|
49
|
+
raise GolemError(f"golem not ready: {last}") from last
|
|
50
|
+
|
|
51
|
+
def post_turn(self, conversation_id: str, channel: str, text: str) -> str:
|
|
52
|
+
accepted = self._request(
|
|
53
|
+
"POST",
|
|
54
|
+
f"/v1/conversations/{conversation_id}/turns",
|
|
55
|
+
{"channel": channel, "text": text},
|
|
56
|
+
)
|
|
57
|
+
if accepted.get("error"):
|
|
58
|
+
raise GolemError(accepted["error"])
|
|
59
|
+
turn_id = accepted.get("id")
|
|
60
|
+
if not turn_id:
|
|
61
|
+
raise GolemError("missing turn id")
|
|
62
|
+
return str(turn_id)
|
|
63
|
+
|
|
64
|
+
def stream_turn(self, turn_id: str) -> Iterator[TurnEvent]:
|
|
65
|
+
req = urllib.request.Request(
|
|
66
|
+
self.url + f"/v1/turns/{turn_id}",
|
|
67
|
+
headers=self._headers(),
|
|
68
|
+
method="GET",
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
with urllib.request.urlopen(req, timeout=None) as resp:
|
|
72
|
+
for name, raw in _read_sse(resp):
|
|
73
|
+
data = json.loads(raw) if raw else {}
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
raise GolemError(f"invalid event data: {raw}")
|
|
76
|
+
yield TurnEvent(name=name, data=data)
|
|
77
|
+
if name in ("done", "error"):
|
|
78
|
+
return
|
|
79
|
+
except urllib.error.HTTPError as exc:
|
|
80
|
+
raw = exc.read()
|
|
81
|
+
raise GolemError(
|
|
82
|
+
f"unexpected status {exc.code}: {raw.decode(errors='replace')}",
|
|
83
|
+
status=exc.code,
|
|
84
|
+
) from exc
|
|
85
|
+
except urllib.error.URLError as exc:
|
|
86
|
+
raise GolemError(f"request failed: {exc.reason}") from exc
|
|
87
|
+
except json.JSONDecodeError as exc:
|
|
88
|
+
raise GolemError(f"invalid json: {exc}") from exc
|
|
89
|
+
|
|
90
|
+
def send(self, conversation_id: str, channel: str, text: str) -> str:
|
|
91
|
+
turn_id = self.post_turn(conversation_id, channel, text)
|
|
92
|
+
for ev in self.stream_turn(turn_id):
|
|
93
|
+
if ev.name == "done":
|
|
94
|
+
return str(ev.data.get("text") or "")
|
|
95
|
+
if ev.name == "error":
|
|
96
|
+
raise GolemError(str(ev.data.get("error") or "turn failed"))
|
|
97
|
+
raise GolemError("turn ended without done")
|
|
98
|
+
|
|
99
|
+
def register(self, name: str, callback_url: str, capabilities: list[dict[str, Any]]) -> None:
|
|
100
|
+
body = self._request(
|
|
101
|
+
"POST",
|
|
102
|
+
"/v1/extensions/register",
|
|
103
|
+
{"name": name, "callback_url": callback_url, "capabilities": capabilities},
|
|
104
|
+
)
|
|
105
|
+
if not isinstance(body, dict) or not body.get("ok"):
|
|
106
|
+
raise GolemError("register failed")
|
|
107
|
+
|
|
108
|
+
def heartbeat(self, name: str) -> None:
|
|
109
|
+
body = self._request("POST", "/v1/extensions/heartbeat", {"name": name})
|
|
110
|
+
if not isinstance(body, dict) or not body.get("ok"):
|
|
111
|
+
raise GolemError("heartbeat failed")
|
|
112
|
+
|
|
113
|
+
def _headers(self, body: dict[str, Any] | None = None) -> dict[str, str]:
|
|
114
|
+
headers: dict[str, str] = {}
|
|
115
|
+
if self.token:
|
|
116
|
+
headers["Authorization"] = "Bearer " + self.token
|
|
117
|
+
if body is not None:
|
|
118
|
+
headers["Content-Type"] = "application/json"
|
|
119
|
+
return headers
|
|
120
|
+
|
|
121
|
+
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
122
|
+
data = None
|
|
123
|
+
headers = self._headers(body)
|
|
124
|
+
if body is not None:
|
|
125
|
+
data = json.dumps(body).encode()
|
|
126
|
+
req = urllib.request.Request(
|
|
127
|
+
self.url + path,
|
|
128
|
+
data=data,
|
|
129
|
+
headers=headers,
|
|
130
|
+
method=method,
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
134
|
+
raw = resp.read()
|
|
135
|
+
except urllib.error.HTTPError as exc:
|
|
136
|
+
raw = exc.read()
|
|
137
|
+
raise GolemError(
|
|
138
|
+
f"unexpected status {exc.code}: {raw.decode(errors='replace')}",
|
|
139
|
+
status=exc.code,
|
|
140
|
+
) from exc
|
|
141
|
+
except urllib.error.URLError as exc:
|
|
142
|
+
raise GolemError(f"request failed: {exc.reason}") from exc
|
|
143
|
+
if not raw:
|
|
144
|
+
return {}
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(raw)
|
|
147
|
+
except json.JSONDecodeError as exc:
|
|
148
|
+
raise GolemError(f"invalid json: {raw.decode(errors='replace')}") from exc
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _read_sse(resp: Any) -> Iterator[tuple[str, str]]:
|
|
152
|
+
event = "message"
|
|
153
|
+
data: list[str] = []
|
|
154
|
+
while True:
|
|
155
|
+
line = resp.readline()
|
|
156
|
+
if not line:
|
|
157
|
+
break
|
|
158
|
+
text = line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
159
|
+
if text == "":
|
|
160
|
+
if data:
|
|
161
|
+
yield event, "\n".join(data)
|
|
162
|
+
event = "message"
|
|
163
|
+
data = []
|
|
164
|
+
continue
|
|
165
|
+
if text.startswith(":"):
|
|
166
|
+
continue
|
|
167
|
+
if text.startswith("event:"):
|
|
168
|
+
event = text[6:].lstrip()
|
|
169
|
+
elif text.startswith("data:"):
|
|
170
|
+
value = text[5:]
|
|
171
|
+
if value.startswith(" "):
|
|
172
|
+
value = value[1:]
|
|
173
|
+
data.append(value)
|
|
174
|
+
if data:
|
|
175
|
+
yield event, "\n".join(data)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from types import FrameType
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from golem.client import Client, GolemError
|
|
12
|
+
from golem.provider import JSONSchema, Message, Provider, ToolDef, UnsupportedFormat
|
|
13
|
+
|
|
14
|
+
Task = Callable[[Client, threading.Event], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Extension:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
name: str,
|
|
21
|
+
client: Client | str | None = None,
|
|
22
|
+
token: str = "",
|
|
23
|
+
*,
|
|
24
|
+
heartbeat_interval: float = 10.0,
|
|
25
|
+
) -> None:
|
|
26
|
+
name = name.strip()
|
|
27
|
+
if not name:
|
|
28
|
+
raise ValueError("name is required")
|
|
29
|
+
self.name = name
|
|
30
|
+
if isinstance(client, Client):
|
|
31
|
+
self.client = client
|
|
32
|
+
elif isinstance(client, str):
|
|
33
|
+
self.client = Client(client, token)
|
|
34
|
+
else:
|
|
35
|
+
self.client = Client.from_env()
|
|
36
|
+
self.heartbeat_interval = heartbeat_interval
|
|
37
|
+
self._provider_id = ""
|
|
38
|
+
self._provider: Provider | None = None
|
|
39
|
+
self._tasks: list[Task] = []
|
|
40
|
+
self._extra_caps: list[dict[str, Any]] = []
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_env(cls, name: str, **kwargs: Any) -> Extension:
|
|
44
|
+
return cls(name, Client.from_env(), **kwargs)
|
|
45
|
+
|
|
46
|
+
def provider(self, provider_id: str, impl: Provider) -> Extension:
|
|
47
|
+
provider_id = provider_id.strip()
|
|
48
|
+
if not provider_id:
|
|
49
|
+
raise ValueError("provider id is required")
|
|
50
|
+
if self._provider is not None:
|
|
51
|
+
raise ValueError("provider already set")
|
|
52
|
+
self._provider_id = provider_id
|
|
53
|
+
self._provider = impl
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def capability(self, cap: dict[str, Any]) -> Extension:
|
|
57
|
+
self._extra_caps.append(cap)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def task(self, fn: Task) -> Extension:
|
|
61
|
+
self._tasks.append(fn)
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def run(self, stop: threading.Event | None = None) -> None:
|
|
65
|
+
own_stop = stop is None
|
|
66
|
+
if stop is None:
|
|
67
|
+
stop = threading.Event()
|
|
68
|
+
if threading.current_thread() is threading.main_thread():
|
|
69
|
+
def handle(_signum: int, _frame: FrameType | None) -> None:
|
|
70
|
+
stop.set()
|
|
71
|
+
|
|
72
|
+
signal.signal(signal.SIGINT, handle)
|
|
73
|
+
signal.signal(signal.SIGTERM, handle)
|
|
74
|
+
|
|
75
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _handler(self))
|
|
76
|
+
callback = f"http://127.0.0.1:{server.server_address[1]}"
|
|
77
|
+
serving = threading.Thread(target=server.serve_forever, daemon=True)
|
|
78
|
+
serving.start()
|
|
79
|
+
try:
|
|
80
|
+
self._loop(callback, stop)
|
|
81
|
+
finally:
|
|
82
|
+
if own_stop:
|
|
83
|
+
stop.set()
|
|
84
|
+
server.shutdown()
|
|
85
|
+
serving.join(timeout=2)
|
|
86
|
+
server.server_close()
|
|
87
|
+
|
|
88
|
+
def _loop(self, callback: str, stop: threading.Event) -> None:
|
|
89
|
+
while not stop.is_set():
|
|
90
|
+
try:
|
|
91
|
+
self.client.wait_ready(timeout=5)
|
|
92
|
+
self._register(callback)
|
|
93
|
+
break
|
|
94
|
+
except GolemError as exc:
|
|
95
|
+
print(f"{self.name}: {exc}", file=sys.stderr, flush=True)
|
|
96
|
+
if stop.wait(2):
|
|
97
|
+
return
|
|
98
|
+
else:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
print(f"{self.name}: {callback} → {self.client.url}", file=sys.stderr, flush=True)
|
|
102
|
+
threading.Thread(target=self._heartbeat_loop, args=(callback, stop), daemon=True).start()
|
|
103
|
+
for fn in self._tasks:
|
|
104
|
+
threading.Thread(target=self._run_task, args=(fn, stop), daemon=True).start()
|
|
105
|
+
stop.wait()
|
|
106
|
+
|
|
107
|
+
def _register(self, callback: str) -> None:
|
|
108
|
+
self.client.register(self.name, callback, self._capabilities())
|
|
109
|
+
|
|
110
|
+
def _heartbeat_loop(self, callback: str, stop: threading.Event) -> None:
|
|
111
|
+
while not stop.wait(self.heartbeat_interval):
|
|
112
|
+
try:
|
|
113
|
+
self.client.heartbeat(self.name)
|
|
114
|
+
except GolemError as exc:
|
|
115
|
+
if exc.status == 404:
|
|
116
|
+
try:
|
|
117
|
+
self._register(callback)
|
|
118
|
+
except GolemError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
def _run_task(self, fn: Task, stop: threading.Event) -> None:
|
|
122
|
+
try:
|
|
123
|
+
fn(self.client, stop)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
print(f"{self.name} task: {exc}", file=sys.stderr, flush=True)
|
|
126
|
+
|
|
127
|
+
def _capabilities(self) -> list[dict[str, Any]]:
|
|
128
|
+
caps: list[dict[str, Any]] = []
|
|
129
|
+
if self._provider is not None:
|
|
130
|
+
cap: dict[str, Any] = {"kind": "provider", "id": self._provider_id, "chat": True}
|
|
131
|
+
if _overrides(self._provider, "chat_structured"):
|
|
132
|
+
cap["structured"] = True
|
|
133
|
+
if _overrides(self._provider, "embed"):
|
|
134
|
+
cap["embed"] = True
|
|
135
|
+
caps.append(cap)
|
|
136
|
+
caps.extend(self._extra_caps)
|
|
137
|
+
return caps
|
|
138
|
+
|
|
139
|
+
def _dispatch(self, path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
|
|
140
|
+
if self._provider is None:
|
|
141
|
+
return 404, _error("not found")
|
|
142
|
+
model = str(body.get("model") or "")
|
|
143
|
+
try:
|
|
144
|
+
if path == "/v1/chat":
|
|
145
|
+
msg = self._provider.chat(model, _messages(body), _tools(body))
|
|
146
|
+
return 200, msg.to_dict()
|
|
147
|
+
if path == "/v1/chat/structured":
|
|
148
|
+
raw = body.get("schema") if isinstance(body.get("schema"), dict) else {}
|
|
149
|
+
data = self._provider.chat_structured(model, _messages(body), JSONSchema.from_dict(raw))
|
|
150
|
+
return 200, {"data": data}
|
|
151
|
+
if path == "/v1/embed":
|
|
152
|
+
texts = body.get("texts") or []
|
|
153
|
+
if not isinstance(texts, list):
|
|
154
|
+
return 400, _error("texts must be a list")
|
|
155
|
+
vectors = self._provider.embed(model, [str(t) for t in texts])
|
|
156
|
+
return 200, {"vectors": vectors}
|
|
157
|
+
except UnsupportedFormat as exc:
|
|
158
|
+
return 400, {"error": {"code": "unsupported_format", "message": str(exc) or "no json schema"}}
|
|
159
|
+
except NotImplementedError:
|
|
160
|
+
return 404, _error("not found")
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
return 500, _error(str(exc))
|
|
163
|
+
return 404, _error("not found")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _overrides(provider: Provider, method: str) -> bool:
|
|
167
|
+
return getattr(type(provider), method) is not getattr(Provider, method)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _messages(body: dict[str, Any]) -> list[Message]:
|
|
171
|
+
out: list[Message] = []
|
|
172
|
+
for raw in body.get("messages") or []:
|
|
173
|
+
if isinstance(raw, dict):
|
|
174
|
+
out.append(Message.from_dict(raw))
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _tools(body: dict[str, Any]) -> list[ToolDef] | None:
|
|
179
|
+
raw = body.get("tools") or []
|
|
180
|
+
if not raw:
|
|
181
|
+
return None
|
|
182
|
+
return [ToolDef.from_dict(item) for item in raw if isinstance(item, dict)]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _error(message: str) -> dict[str, Any]:
|
|
186
|
+
return {"error": {"message": message}}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _handler(ext: Extension) -> type[BaseHTTPRequestHandler]:
|
|
190
|
+
class Handler(BaseHTTPRequestHandler):
|
|
191
|
+
protocol_version = "HTTP/1.1"
|
|
192
|
+
|
|
193
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
def do_POST(self) -> None:
|
|
197
|
+
if not self._authorized():
|
|
198
|
+
self._write(401, _error("unauthorized"))
|
|
199
|
+
return
|
|
200
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
201
|
+
raw = self.rfile.read(length) if length else b""
|
|
202
|
+
if not raw:
|
|
203
|
+
body: dict[str, Any] = {}
|
|
204
|
+
else:
|
|
205
|
+
try:
|
|
206
|
+
parsed = json.loads(raw)
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
self._write(400, _error("invalid json"))
|
|
209
|
+
return
|
|
210
|
+
if not isinstance(parsed, dict):
|
|
211
|
+
self._write(400, _error("invalid json"))
|
|
212
|
+
return
|
|
213
|
+
body = parsed
|
|
214
|
+
status, payload = ext._dispatch(self.path, body)
|
|
215
|
+
self._write(status, payload)
|
|
216
|
+
|
|
217
|
+
def do_GET(self) -> None:
|
|
218
|
+
if not self._authorized():
|
|
219
|
+
self._write(401, _error("unauthorized"))
|
|
220
|
+
return
|
|
221
|
+
self._write(404, _error("not found"))
|
|
222
|
+
|
|
223
|
+
def _authorized(self) -> bool:
|
|
224
|
+
token = ext.client.token
|
|
225
|
+
if not token:
|
|
226
|
+
return True
|
|
227
|
+
return self.headers.get("Authorization") == "Bearer " + token
|
|
228
|
+
|
|
229
|
+
def _write(self, status: int, payload: dict[str, Any]) -> None:
|
|
230
|
+
raw = json.dumps(payload).encode()
|
|
231
|
+
self.send_response(status)
|
|
232
|
+
self.send_header("Content-Type", "application/json")
|
|
233
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
234
|
+
self.end_headers()
|
|
235
|
+
self.wfile.write(raw)
|
|
236
|
+
|
|
237
|
+
return Handler
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsupportedFormat(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class FunctionCall:
|
|
14
|
+
name: str
|
|
15
|
+
arguments: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ToolCall:
|
|
20
|
+
id: str
|
|
21
|
+
type: str = "function"
|
|
22
|
+
function: FunctionCall = field(default_factory=lambda: FunctionCall(""))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Message:
|
|
27
|
+
role: str
|
|
28
|
+
content: str = ""
|
|
29
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
30
|
+
tool_call_id: str = ""
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, data: dict[str, Any]) -> Message:
|
|
34
|
+
calls: list[ToolCall] = []
|
|
35
|
+
for raw in data.get("tool_calls") or []:
|
|
36
|
+
if not isinstance(raw, dict):
|
|
37
|
+
continue
|
|
38
|
+
fn = raw.get("function") if isinstance(raw.get("function"), dict) else {}
|
|
39
|
+
calls.append(
|
|
40
|
+
ToolCall(
|
|
41
|
+
id=str(raw.get("id") or ""),
|
|
42
|
+
type=str(raw.get("type") or "function"),
|
|
43
|
+
function=FunctionCall(
|
|
44
|
+
name=str(fn.get("name") or ""),
|
|
45
|
+
arguments=str(fn.get("arguments") or ""),
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
return cls(
|
|
50
|
+
role=str(data.get("role") or ""),
|
|
51
|
+
content=str(data.get("content") or ""),
|
|
52
|
+
tool_calls=calls,
|
|
53
|
+
tool_call_id=str(data.get("tool_call_id") or ""),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
out: dict[str, Any] = {"role": self.role}
|
|
58
|
+
if self.content:
|
|
59
|
+
out["content"] = self.content
|
|
60
|
+
if self.tool_calls:
|
|
61
|
+
out["tool_calls"] = [
|
|
62
|
+
{
|
|
63
|
+
"id": call.id,
|
|
64
|
+
"type": call.type,
|
|
65
|
+
"function": {"name": call.function.name, "arguments": call.function.arguments},
|
|
66
|
+
}
|
|
67
|
+
for call in self.tool_calls
|
|
68
|
+
]
|
|
69
|
+
if self.tool_call_id:
|
|
70
|
+
out["tool_call_id"] = self.tool_call_id
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ToolDef:
|
|
76
|
+
name: str
|
|
77
|
+
description: str = ""
|
|
78
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, data: dict[str, Any]) -> ToolDef:
|
|
82
|
+
params = data.get("parameters")
|
|
83
|
+
return cls(
|
|
84
|
+
name=str(data.get("name") or ""),
|
|
85
|
+
description=str(data.get("description") or ""),
|
|
86
|
+
parameters=params if isinstance(params, dict) else {},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class JSONSchema:
|
|
92
|
+
name: str
|
|
93
|
+
strict: bool = False
|
|
94
|
+
schema: dict[str, Any] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_dict(cls, data: dict[str, Any]) -> JSONSchema:
|
|
98
|
+
inner = data.get("schema")
|
|
99
|
+
return cls(
|
|
100
|
+
name=str(data.get("name") or ""),
|
|
101
|
+
strict=bool(data.get("strict")),
|
|
102
|
+
schema=inner if isinstance(inner, dict) else {},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Provider(ABC):
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def chat(self, model: str, messages: list[Message], tools: list[ToolDef] | None = None) -> Message:
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
def chat_structured(self, model: str, messages: list[Message], schema: JSONSchema) -> Any:
|
|
112
|
+
raise UnsupportedFormat("no json schema")
|
|
113
|
+
|
|
114
|
+
def embed(self, model: str, texts: list[str]) -> list[list[float]]:
|
|
115
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "golem-agent-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for Golem extensions"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "Nawaz Gafar"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
14
|
+
build-backend = "uv_build"
|
|
15
|
+
|
|
16
|
+
[tool.uv.build-backend]
|
|
17
|
+
module-root = ""
|
|
18
|
+
module-name = "golem"
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
pythonpath = ["."]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = ["pytest>=9.1.1"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "golem-agent-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for Golem extensions"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Nawaz Gafar" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
14
|
+
build-backend = "uv_build"
|
|
15
|
+
|
|
16
|
+
[tool.uv.build-backend]
|
|
17
|
+
module-root = ""
|
|
18
|
+
module-name = "golem"
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
pythonpath = ["."]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=9.1.1",
|
|
27
|
+
]
|