opencode-agent-sdk 0.2.0__tar.gz → 0.4.4__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.
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/PKG-INFO +2 -1
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/pyproject.toml +3 -1
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/__init__.py +3 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/acp.py +48 -10
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/transport.py +46 -1
- opencode_agent_sdk-0.4.4/src/opencode_agent_sdk/_mcp_bridge.py +188 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/client.py +86 -25
- opencode_agent_sdk-0.4.4/src/opencode_agent_sdk/model_registry.py +81 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/PKG-INFO +2 -1
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/SOURCES.txt +3 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/requires.txt +1 -0
- opencode_agent_sdk-0.4.4/tests/test_model_registry.py +64 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/README.md +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/setup.cfg +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_errors.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/__init__.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/http_transport.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/tools.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/types.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/dependency_links.txt +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/top_level.txt +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/tests/test_common.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/tests/test_opencode_agent.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/tests/test_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-agent-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Open-source Agent SDK backed by OpenCode ACP (drop-in replacement for claude_agent_sdk)
|
|
5
5
|
Author: OpenCode
|
|
6
6
|
License: MIT
|
|
@@ -16,6 +16,7 @@ Requires-Python: >=3.10
|
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
Requires-Dist: anyio
|
|
18
18
|
Requires-Dist: httpx
|
|
19
|
+
Requires-Dist: fastmcp>=2.0.0
|
|
19
20
|
Provides-Extra: opencode-ai
|
|
20
21
|
Requires-Dist: opencode-ai>=0.1.0a36; extra == "opencode-ai"
|
|
21
22
|
Provides-Extra: dev
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opencode-agent-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.4"
|
|
8
8
|
description = "Open-source Agent SDK backed by OpenCode ACP (drop-in replacement for claude_agent_sdk)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -25,6 +25,7 @@ classifiers = [
|
|
|
25
25
|
dependencies = [
|
|
26
26
|
"anyio",
|
|
27
27
|
"httpx",
|
|
28
|
+
"fastmcp>=2.0.0",
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.optional-dependencies]
|
|
@@ -45,4 +46,5 @@ addopts = "-q"
|
|
|
45
46
|
[dependency-groups]
|
|
46
47
|
dev = [
|
|
47
48
|
"build>=1.4.0",
|
|
49
|
+
"twine>=6.2.0",
|
|
48
50
|
]
|
|
@@ -11,6 +11,7 @@ from .types import (
|
|
|
11
11
|
)
|
|
12
12
|
from .client import AgentOptions, SDKClient
|
|
13
13
|
from .tools import create_sdk_mcp_server, tool
|
|
14
|
+
from .model_registry import ModelConfig, ModelRegistry
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
16
17
|
"AgentOptions",
|
|
@@ -19,6 +20,8 @@ __all__ = [
|
|
|
19
20
|
"HookInput",
|
|
20
21
|
"HookJSONOutput",
|
|
21
22
|
"HookMatcher",
|
|
23
|
+
"ModelConfig",
|
|
24
|
+
"ModelRegistry",
|
|
22
25
|
"ResultMessage",
|
|
23
26
|
"SDKClient",
|
|
24
27
|
"SystemMessage",
|
{opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/acp.py
RENAMED
|
@@ -23,7 +23,7 @@ from .transport import SubprocessTransport
|
|
|
23
23
|
|
|
24
24
|
logger = logging.getLogger(__name__)
|
|
25
25
|
|
|
26
|
-
_PROTOCOL_VERSION =
|
|
26
|
+
_PROTOCOL_VERSION = 1
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class ACPSession:
|
|
@@ -128,8 +128,9 @@ class ACPSession:
|
|
|
128
128
|
await self._handle_permission_request(msg)
|
|
129
129
|
return
|
|
130
130
|
|
|
131
|
-
# Notification:
|
|
132
|
-
|
|
131
|
+
# Notification: session/update (opencode ACP v1.2.15+)
|
|
132
|
+
# Also accept legacy "sessionUpdate" for backwards compatibility
|
|
133
|
+
if method in ("session/update", "sessionUpdate"):
|
|
133
134
|
params = msg.get("params", {})
|
|
134
135
|
await self._update_queue.put(params)
|
|
135
136
|
return
|
|
@@ -213,25 +214,62 @@ class ACPSession:
|
|
|
213
214
|
cwd: str,
|
|
214
215
|
mcp_servers: list[dict[str, Any]] | None = None,
|
|
215
216
|
model: str | None = None,
|
|
217
|
+
provider_id: str | None = None,
|
|
218
|
+
permission_mode: str = "",
|
|
219
|
+
system_prompt: str = "",
|
|
216
220
|
) -> str:
|
|
217
|
-
"""Create a new ACP session
|
|
221
|
+
"""Create a new ACP session and optionally set the model.
|
|
222
|
+
|
|
223
|
+
OpenCode's ``session/new`` does not accept model/provider params.
|
|
224
|
+
The model must be set separately via ``session/set_model`` after
|
|
225
|
+
the session is created. The ``modelId`` format is
|
|
226
|
+
``"{providerID}/{modelID}"``.
|
|
227
|
+
"""
|
|
218
228
|
params: dict[str, Any] = {
|
|
219
229
|
"cwd": cwd,
|
|
220
230
|
"mcpServers": mcp_servers or [],
|
|
221
231
|
}
|
|
222
|
-
|
|
232
|
+
if permission_mode:
|
|
233
|
+
params["permissionMode"] = permission_mode
|
|
234
|
+
if system_prompt:
|
|
235
|
+
params["systemPrompt"] = system_prompt
|
|
236
|
+
result = await self._send_request("session/new", params)
|
|
223
237
|
self._session_id = result.get("sessionId", "")
|
|
224
238
|
logger.debug("New session: %s", self._session_id)
|
|
239
|
+
|
|
240
|
+
# Set the model via session/set_model (session/new ignores model params)
|
|
241
|
+
if model and provider_id:
|
|
242
|
+
model_id = f"{provider_id}/{model}"
|
|
243
|
+
elif model:
|
|
244
|
+
model_id = model
|
|
245
|
+
else:
|
|
246
|
+
model_id = None
|
|
247
|
+
|
|
248
|
+
if model_id:
|
|
249
|
+
try:
|
|
250
|
+
await self._send_request("session/set_model", {
|
|
251
|
+
"sessionId": self._session_id,
|
|
252
|
+
"modelId": model_id,
|
|
253
|
+
})
|
|
254
|
+
logger.debug("Set model to %s", model_id)
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
logger.warning("Failed to set model %s: %s", model_id, exc)
|
|
257
|
+
|
|
225
258
|
return self._session_id
|
|
226
259
|
|
|
227
|
-
async def load_session(
|
|
260
|
+
async def load_session(
|
|
261
|
+
self,
|
|
262
|
+
session_id: str,
|
|
263
|
+
cwd: str,
|
|
264
|
+
mcp_servers: list[dict[str, Any]] | None = None,
|
|
265
|
+
) -> str:
|
|
228
266
|
"""Resume an existing ACP session."""
|
|
229
267
|
params: dict[str, Any] = {
|
|
230
268
|
"sessionId": session_id,
|
|
231
269
|
"cwd": cwd,
|
|
232
|
-
"mcpServers": [],
|
|
270
|
+
"mcpServers": mcp_servers or [],
|
|
233
271
|
}
|
|
234
|
-
result = await self._send_request("
|
|
272
|
+
result = await self._send_request("session/load", params)
|
|
235
273
|
self._session_id = result.get("sessionId", session_id)
|
|
236
274
|
return self._session_id
|
|
237
275
|
|
|
@@ -243,7 +281,7 @@ class ACPSession:
|
|
|
243
281
|
self._usage = {}
|
|
244
282
|
self._cost = {}
|
|
245
283
|
|
|
246
|
-
result = await self._send_request("prompt", {
|
|
284
|
+
result = await self._send_request("session/prompt", {
|
|
247
285
|
"sessionId": self._session_id,
|
|
248
286
|
"prompt": parts,
|
|
249
287
|
})
|
|
@@ -260,7 +298,7 @@ class ACPSession:
|
|
|
260
298
|
|
|
261
299
|
async def cancel(self) -> None:
|
|
262
300
|
"""Cancel the current operation."""
|
|
263
|
-
await self._send_notification("cancel", {
|
|
301
|
+
await self._send_notification("session/cancel", {
|
|
264
302
|
"sessionId": self._session_id,
|
|
265
303
|
})
|
|
266
304
|
|
{opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/transport.py
RENAMED
|
@@ -46,6 +46,7 @@ class SubprocessTransport:
|
|
|
46
46
|
self._cwd = os.path.abspath(cwd)
|
|
47
47
|
self._process: anyio.abc.Process | None = None
|
|
48
48
|
self._read_lock = anyio.Lock()
|
|
49
|
+
self._stderr_task: anyio.abc.TaskGroup | None = None
|
|
49
50
|
|
|
50
51
|
async def connect(self) -> None:
|
|
51
52
|
"""Spawn the opencode acp subprocess."""
|
|
@@ -53,12 +54,47 @@ class SubprocessTransport:
|
|
|
53
54
|
logger.debug("Spawning: %s acp --cwd %s", binary, self._cwd)
|
|
54
55
|
|
|
55
56
|
self._process = await anyio.open_process(
|
|
56
|
-
[binary, "acp", "--cwd", self._cwd],
|
|
57
|
+
[binary, "acp", "--print-logs", "--log-level", "INFO", "--cwd", self._cwd],
|
|
57
58
|
stdin=subprocess.PIPE,
|
|
58
59
|
stdout=subprocess.PIPE,
|
|
59
60
|
stderr=subprocess.PIPE,
|
|
60
61
|
)
|
|
61
62
|
|
|
63
|
+
# Drain stderr in background to prevent pipe buffer deadlock and
|
|
64
|
+
# surface opencode's internal logs (model routing, provider init, etc.)
|
|
65
|
+
self._stderr_scope = await anyio.create_task_group().__aenter__()
|
|
66
|
+
self._stderr_scope.start_soon(self._drain_stderr)
|
|
67
|
+
|
|
68
|
+
async def _drain_stderr(self) -> None:
|
|
69
|
+
"""Read stderr lines and log them.
|
|
70
|
+
|
|
71
|
+
Surfaces opencode's internal logs — especially ``service=llm``
|
|
72
|
+
lines that show which provider/model is actually used.
|
|
73
|
+
"""
|
|
74
|
+
if self._process is None or self._process.stderr is None:
|
|
75
|
+
return
|
|
76
|
+
buffer = b""
|
|
77
|
+
try:
|
|
78
|
+
async for chunk in self._process.stderr:
|
|
79
|
+
buffer += chunk
|
|
80
|
+
while b"\n" in buffer:
|
|
81
|
+
line_bytes, buffer = buffer.split(b"\n", 1)
|
|
82
|
+
line = line_bytes.decode("utf-8", errors="replace").strip()
|
|
83
|
+
if not line:
|
|
84
|
+
continue
|
|
85
|
+
if "service=llm" in line:
|
|
86
|
+
logger.info("opencode LLM: %s", line)
|
|
87
|
+
elif "service=provider" in line and "found" in line:
|
|
88
|
+
logger.debug("opencode provider: %s", line)
|
|
89
|
+
elif "ERR" in line or "error" in line.lower():
|
|
90
|
+
logger.warning("opencode stderr: %s", line)
|
|
91
|
+
else:
|
|
92
|
+
logger.debug("opencode: %s", line)
|
|
93
|
+
except anyio.ClosedResourceError:
|
|
94
|
+
pass
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
logger.debug("stderr drain ended: %s", exc)
|
|
97
|
+
|
|
62
98
|
async def write(self, data: dict[str, Any]) -> None:
|
|
63
99
|
"""Write a JSON-RPC message (NDJSON line) to the subprocess stdin."""
|
|
64
100
|
if self._process is None or self._process.stdin is None:
|
|
@@ -109,4 +145,13 @@ class SubprocessTransport:
|
|
|
109
145
|
except Exception:
|
|
110
146
|
pass
|
|
111
147
|
|
|
148
|
+
# Clean up the stderr drain task group
|
|
149
|
+
if self._stderr_scope is not None:
|
|
150
|
+
try:
|
|
151
|
+
self._stderr_scope.cancel_scope.cancel()
|
|
152
|
+
await self._stderr_scope.__aexit__(None, None, None)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
self._stderr_scope = None
|
|
156
|
+
|
|
112
157
|
self._process = None
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Bridge for running SDK MCP tools as HTTP servers.
|
|
2
|
+
|
|
3
|
+
Opencode expects MCP servers to be either subprocesses (stdio) or remote
|
|
4
|
+
HTTP/SSE servers. SDK-defined tools (created via create_sdk_mcp_server)
|
|
5
|
+
have Python function handlers that must run in the same process.
|
|
6
|
+
|
|
7
|
+
This module bridges the gap by starting FastMCP HTTP servers in the SDK
|
|
8
|
+
process and returning remote URLs for opencode to connect to.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import inspect
|
|
15
|
+
import logging
|
|
16
|
+
import socket
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_free_port() -> int:
|
|
23
|
+
"""Find an available TCP port on localhost."""
|
|
24
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
25
|
+
s.bind(("127.0.0.1", 0))
|
|
26
|
+
return s.getsockname()[1]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class McpHttpBridge:
|
|
30
|
+
"""Hosts SDK MCP tools as HTTP servers for opencode consumption.
|
|
31
|
+
|
|
32
|
+
For each SDK-defined MCP server (those with ``_tools`` in their config),
|
|
33
|
+
starts a FastMCP HTTP server on a random port. The config is then
|
|
34
|
+
converted to a remote server pointing to ``http://127.0.0.1:<port>/mcp``
|
|
35
|
+
so opencode can connect to it.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._servers: list[Any] = [] # uvicorn.Server instances
|
|
40
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
41
|
+
|
|
42
|
+
async def start_server(self, name: str) -> int:
|
|
43
|
+
"""Start an HTTP MCP server for the given tools.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
name: MCP server name (must match a name registered via
|
|
47
|
+
create_sdk_mcp_server).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The port the server is listening on.
|
|
51
|
+
"""
|
|
52
|
+
from .tools import _TOOL_REGISTRY
|
|
53
|
+
|
|
54
|
+
tool_handlers = _TOOL_REGISTRY.get(name, [])
|
|
55
|
+
if not tool_handlers:
|
|
56
|
+
raise ValueError(f"No tool handlers registered for MCP server '{name}'")
|
|
57
|
+
|
|
58
|
+
from fastmcp import FastMCP
|
|
59
|
+
from fastmcp.tools import Tool as FastMCPTool
|
|
60
|
+
|
|
61
|
+
mcp_server = FastMCP(name)
|
|
62
|
+
|
|
63
|
+
for sdk_tool in tool_handlers:
|
|
64
|
+
wrapper = _make_wrapper(sdk_tool)
|
|
65
|
+
mcp_server.add_tool(
|
|
66
|
+
FastMCPTool.from_function(
|
|
67
|
+
wrapper,
|
|
68
|
+
name=sdk_tool.name,
|
|
69
|
+
description=sdk_tool.description,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
app = mcp_server.http_app(transport="streamable-http")
|
|
74
|
+
port = _find_free_port()
|
|
75
|
+
|
|
76
|
+
import uvicorn
|
|
77
|
+
|
|
78
|
+
config = uvicorn.Config(
|
|
79
|
+
app,
|
|
80
|
+
host="127.0.0.1",
|
|
81
|
+
port=port,
|
|
82
|
+
log_level="warning",
|
|
83
|
+
)
|
|
84
|
+
uv_server = uvicorn.Server(config)
|
|
85
|
+
self._servers.append(uv_server)
|
|
86
|
+
|
|
87
|
+
task = asyncio.create_task(uv_server.serve())
|
|
88
|
+
self._tasks.append(task)
|
|
89
|
+
|
|
90
|
+
# Wait briefly for the server to start accepting connections
|
|
91
|
+
await asyncio.sleep(0.3)
|
|
92
|
+
|
|
93
|
+
logger.info(
|
|
94
|
+
"MCP HTTP bridge started: %s on port %d (%d tools)",
|
|
95
|
+
name, port, len(tool_handlers),
|
|
96
|
+
)
|
|
97
|
+
return port
|
|
98
|
+
|
|
99
|
+
async def stop_all(self) -> None:
|
|
100
|
+
"""Stop all running HTTP MCP servers."""
|
|
101
|
+
for server in self._servers:
|
|
102
|
+
server.should_exit = True
|
|
103
|
+
for task in self._tasks:
|
|
104
|
+
task.cancel()
|
|
105
|
+
try:
|
|
106
|
+
await task
|
|
107
|
+
except (asyncio.CancelledError, Exception):
|
|
108
|
+
pass
|
|
109
|
+
self._servers.clear()
|
|
110
|
+
self._tasks.clear()
|
|
111
|
+
logger.debug("All MCP HTTP bridges stopped")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _make_wrapper(sdk_tool: Any) -> Any:
|
|
115
|
+
"""Create an async wrapper function that bridges SDK tool handler format.
|
|
116
|
+
|
|
117
|
+
SDK tools accept ``args: dict[str, Any]`` and return
|
|
118
|
+
``{"content": [{"type": "text", "text": "..."}]}``.
|
|
119
|
+
|
|
120
|
+
FastMCP expects typed keyword arguments and a return value that it
|
|
121
|
+
serializes. This function dynamically creates a wrapper with the
|
|
122
|
+
correct signature derived from ``input_schema``.
|
|
123
|
+
"""
|
|
124
|
+
schema = sdk_tool.input_schema or {}
|
|
125
|
+
properties = schema.get("properties", {})
|
|
126
|
+
handler = sdk_tool.handler
|
|
127
|
+
|
|
128
|
+
# Build parameter list from input_schema properties
|
|
129
|
+
params = []
|
|
130
|
+
for prop_name, prop_def in properties.items():
|
|
131
|
+
json_type = prop_def.get("type", "string")
|
|
132
|
+
annotation = _json_type_to_python(json_type, prop_def)
|
|
133
|
+
|
|
134
|
+
required = prop_name in schema.get("required", [])
|
|
135
|
+
if required:
|
|
136
|
+
params.append(
|
|
137
|
+
inspect.Parameter(
|
|
138
|
+
prop_name,
|
|
139
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
140
|
+
annotation=annotation,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
default = prop_def.get("default", None)
|
|
145
|
+
params.append(
|
|
146
|
+
inspect.Parameter(
|
|
147
|
+
prop_name,
|
|
148
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
149
|
+
default=default,
|
|
150
|
+
annotation=annotation,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def wrapper(**kwargs: Any) -> str:
|
|
155
|
+
result = await handler(kwargs)
|
|
156
|
+
# Extract text from MCP content format
|
|
157
|
+
if isinstance(result, dict) and "content" in result:
|
|
158
|
+
texts = [
|
|
159
|
+
c["text"]
|
|
160
|
+
for c in result["content"]
|
|
161
|
+
if isinstance(c, dict) and c.get("type") == "text"
|
|
162
|
+
]
|
|
163
|
+
return "\n".join(texts) if texts else str(result)
|
|
164
|
+
return str(result)
|
|
165
|
+
|
|
166
|
+
# Set signature AND annotations so FastMCP/Pydantic can introspect
|
|
167
|
+
wrapper.__signature__ = inspect.Signature(params, return_annotation=str)
|
|
168
|
+
wrapper.__name__ = sdk_tool.name
|
|
169
|
+
wrapper.__doc__ = sdk_tool.description
|
|
170
|
+
wrapper.__annotations__ = {p.name: p.annotation for p in params}
|
|
171
|
+
wrapper.__annotations__["return"] = str
|
|
172
|
+
|
|
173
|
+
return wrapper
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _json_type_to_python(json_type: str, prop_def: dict[str, Any]) -> type:
|
|
177
|
+
"""Map JSON Schema type to Python type annotation."""
|
|
178
|
+
type_map: dict[str, type] = {
|
|
179
|
+
"string": str,
|
|
180
|
+
"integer": int,
|
|
181
|
+
"number": float,
|
|
182
|
+
"boolean": bool,
|
|
183
|
+
}
|
|
184
|
+
if json_type == "array":
|
|
185
|
+
return list
|
|
186
|
+
if json_type == "object":
|
|
187
|
+
return dict
|
|
188
|
+
return type_map.get(json_type, str)
|
|
@@ -65,6 +65,7 @@ class SDKClient:
|
|
|
65
65
|
self._session: Any = None # ACPSession (subprocess mode only)
|
|
66
66
|
self._http_mode = bool(options.server_url)
|
|
67
67
|
self._pending_parts: dict[str, Any] | None = None
|
|
68
|
+
self._mcp_bridge: Any = None # McpHttpBridge for SDK MCP servers
|
|
68
69
|
|
|
69
70
|
async def connect(self) -> None:
|
|
70
71
|
"""Connect to opencode — either via HTTP or subprocess ACP."""
|
|
@@ -99,23 +100,62 @@ class SDKClient:
|
|
|
99
100
|
# Protocol handshake
|
|
100
101
|
await self._session.initialize()
|
|
101
102
|
|
|
102
|
-
#
|
|
103
|
-
|
|
103
|
+
# Start HTTP bridges for SDK-defined MCP servers (those with _tools).
|
|
104
|
+
# SDK tools have Python function handlers that must run in-process.
|
|
105
|
+
# We host them as HTTP MCP servers and tell opencode about them as
|
|
106
|
+
# remote servers.
|
|
107
|
+
effective_servers = dict(self._options.mcp_servers)
|
|
108
|
+
sdk_servers = {
|
|
109
|
+
name: cfg for name, cfg in effective_servers.items()
|
|
110
|
+
if isinstance(cfg, dict) and "_tools" in cfg
|
|
111
|
+
}
|
|
112
|
+
if sdk_servers:
|
|
113
|
+
from ._mcp_bridge import McpHttpBridge
|
|
114
|
+
|
|
115
|
+
self._mcp_bridge = McpHttpBridge()
|
|
116
|
+
for name in sdk_servers:
|
|
117
|
+
port = await self._mcp_bridge.start_server(name)
|
|
118
|
+
# Replace subprocess config with remote HTTP config
|
|
119
|
+
effective_servers[name] = {
|
|
120
|
+
"url": f"http://127.0.0.1:{port}/mcp",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
acp_mcp_servers = _build_mcp_servers(effective_servers)
|
|
104
124
|
|
|
105
125
|
if self._options.resume:
|
|
106
126
|
await self._session.load_session(
|
|
107
127
|
session_id=self._options.resume,
|
|
108
128
|
cwd=self._options.cwd,
|
|
129
|
+
mcp_servers=acp_mcp_servers,
|
|
109
130
|
)
|
|
110
131
|
else:
|
|
111
132
|
await self._session.new_session(
|
|
112
133
|
cwd=self._options.cwd,
|
|
113
|
-
mcp_servers=
|
|
134
|
+
mcp_servers=acp_mcp_servers,
|
|
114
135
|
model=self._options.model or None,
|
|
136
|
+
provider_id=self._options.provider_id or None,
|
|
137
|
+
permission_mode=self._options.permission_mode,
|
|
138
|
+
system_prompt=self._options.system_prompt,
|
|
115
139
|
)
|
|
116
140
|
|
|
141
|
+
def _build_init_data(self, session_id: str) -> dict[str, Any]:
|
|
142
|
+
"""Build the init SystemMessage data dict from options."""
|
|
143
|
+
data: dict[str, Any] = {
|
|
144
|
+
"session_id": session_id,
|
|
145
|
+
"model": self._options.model,
|
|
146
|
+
"cwd": self._options.cwd,
|
|
147
|
+
}
|
|
148
|
+
if self._options.plugins:
|
|
149
|
+
data["plugins"] = self._options.plugins
|
|
150
|
+
if self._options.mcp_servers:
|
|
151
|
+
data["mcp_servers"] = list(self._options.mcp_servers.keys())
|
|
152
|
+
return data
|
|
153
|
+
|
|
117
154
|
async def disconnect(self) -> None:
|
|
118
155
|
"""Shut down the transport and clean up."""
|
|
156
|
+
if self._mcp_bridge:
|
|
157
|
+
await self._mcp_bridge.stop_all()
|
|
158
|
+
self._mcp_bridge = None
|
|
119
159
|
if self._transport:
|
|
120
160
|
await self._transport.close()
|
|
121
161
|
self._transport = None
|
|
@@ -171,11 +211,9 @@ class SDKClient:
|
|
|
171
211
|
# Yield init system message
|
|
172
212
|
yield SystemMessage(
|
|
173
213
|
subtype="init",
|
|
174
|
-
data=
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
"cwd": self._options.cwd,
|
|
178
|
-
},
|
|
214
|
+
data=self._build_init_data(
|
|
215
|
+
session_id=self._transport.session_id,
|
|
216
|
+
),
|
|
179
217
|
)
|
|
180
218
|
|
|
181
219
|
# Stream response parts via SSE
|
|
@@ -192,11 +230,9 @@ class SDKClient:
|
|
|
192
230
|
|
|
193
231
|
yield SystemMessage(
|
|
194
232
|
subtype="init",
|
|
195
|
-
data=
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"cwd": self._options.cwd,
|
|
199
|
-
},
|
|
233
|
+
data=self._build_init_data(
|
|
234
|
+
session_id=self._session.session_id,
|
|
235
|
+
),
|
|
200
236
|
)
|
|
201
237
|
|
|
202
238
|
async for msg in self._session.receive_messages():
|
|
@@ -204,18 +240,43 @@ class SDKClient:
|
|
|
204
240
|
|
|
205
241
|
|
|
206
242
|
def _build_mcp_servers(servers: dict[str, Any]) -> list[dict[str, Any]]:
|
|
207
|
-
"""Convert the mcp_servers dict to ACP mcpServers format.
|
|
243
|
+
"""Convert the mcp_servers dict to ACP mcpServers wire format.
|
|
244
|
+
|
|
245
|
+
ACP distinguishes server types by the presence of a ``type`` field:
|
|
246
|
+
- Local/stdio (no ``type``): ``{name, command, args, env}``
|
|
247
|
+
- Remote HTTP (``type: "http"``): ``{name, type, url, headers}``
|
|
248
|
+
- Remote SSE (``type: "sse"``): ``{name, type, url, headers}``
|
|
249
|
+
|
|
250
|
+
``env`` and ``headers`` use ``[{name, value}]`` array format on the wire,
|
|
251
|
+
not dict format.
|
|
252
|
+
"""
|
|
208
253
|
result = []
|
|
209
254
|
for name, config in servers.items():
|
|
210
|
-
if isinstance(config, dict):
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
255
|
+
if not isinstance(config, dict):
|
|
256
|
+
continue
|
|
257
|
+
entry: dict[str, Any] = {"name": name}
|
|
258
|
+
if "command" in config:
|
|
259
|
+
# Local/stdio server — NO "type" field
|
|
260
|
+
entry["command"] = config["command"]
|
|
261
|
+
entry["args"] = config.get("args", [])
|
|
262
|
+
# Convert env dict to [{name, value}] array
|
|
263
|
+
raw_env = config.get("env", {})
|
|
264
|
+
if isinstance(raw_env, dict):
|
|
265
|
+
entry["env"] = [{"name": k, "value": v} for k, v in raw_env.items()]
|
|
266
|
+
elif isinstance(raw_env, list):
|
|
267
|
+
entry["env"] = raw_env
|
|
268
|
+
else:
|
|
269
|
+
entry["env"] = []
|
|
270
|
+
elif "url" in config:
|
|
271
|
+
# Remote server — "http" for streamable-http, "sse" for SSE
|
|
272
|
+
entry["type"] = config.get("type", "http")
|
|
273
|
+
entry["url"] = config["url"]
|
|
274
|
+
raw_headers = config.get("headers", {})
|
|
275
|
+
if isinstance(raw_headers, dict):
|
|
276
|
+
entry["headers"] = [{"name": k, "value": v} for k, v in raw_headers.items()]
|
|
277
|
+
elif isinstance(raw_headers, list):
|
|
278
|
+
entry["headers"] = raw_headers
|
|
279
|
+
else:
|
|
280
|
+
entry["headers"] = []
|
|
281
|
+
result.append(entry)
|
|
221
282
|
return result
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Generic model registry for mapping user-friendly aliases to model configurations.
|
|
2
|
+
|
|
3
|
+
Provides a pluggable registry that SDK consumers can populate with their own
|
|
4
|
+
model aliases and provider mappings. No vendor-specific defaults are included.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from opencode_agent_sdk.model_registry import ModelConfig, ModelRegistry
|
|
9
|
+
|
|
10
|
+
registry = ModelRegistry()
|
|
11
|
+
registry.register("my-model", ModelConfig("actual-model-id", "my-provider"))
|
|
12
|
+
|
|
13
|
+
config = registry.resolve("my-model")
|
|
14
|
+
if config:
|
|
15
|
+
options = AgentOptions(model=config.model_id, provider_id=config.provider_id)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ModelConfig:
|
|
25
|
+
"""Model configuration mapping an alias to its actual model and provider IDs."""
|
|
26
|
+
|
|
27
|
+
model_id: str
|
|
28
|
+
provider_id: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ModelRegistry:
|
|
32
|
+
"""Registry for model alias → (model_id, provider_id) mappings.
|
|
33
|
+
|
|
34
|
+
Thread-safe for reads; callers should populate the registry at startup
|
|
35
|
+
before concurrent access.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._models: dict[str, ModelConfig] = {}
|
|
40
|
+
|
|
41
|
+
def register(self, alias: str, config: ModelConfig) -> None:
|
|
42
|
+
"""Register a model alias.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
alias: User-friendly name (stored lowercase).
|
|
46
|
+
config: Model configuration with model_id and provider_id.
|
|
47
|
+
"""
|
|
48
|
+
self._models[alias.lower()] = config
|
|
49
|
+
|
|
50
|
+
def register_many(self, models: dict[str, ModelConfig]) -> None:
|
|
51
|
+
"""Register multiple model aliases at once.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
models: Mapping of alias → ModelConfig.
|
|
55
|
+
"""
|
|
56
|
+
for alias, config in models.items():
|
|
57
|
+
self._models[alias.lower()] = config
|
|
58
|
+
|
|
59
|
+
def resolve(self, alias: str) -> ModelConfig | None:
|
|
60
|
+
"""Resolve a user-friendly alias to a ModelConfig.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
alias: Model alias (case-insensitive lookup).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
ModelConfig if alias is known, None otherwise.
|
|
67
|
+
"""
|
|
68
|
+
return self._models.get(alias.lower())
|
|
69
|
+
|
|
70
|
+
def list_models(self) -> dict[str, ModelConfig]:
|
|
71
|
+
"""Return a copy of all registered models."""
|
|
72
|
+
return dict(self._models)
|
|
73
|
+
|
|
74
|
+
def format_help(self) -> str:
|
|
75
|
+
"""Return a formatted list of available models for user-facing help."""
|
|
76
|
+
if not self._models:
|
|
77
|
+
return "No models registered."
|
|
78
|
+
lines = ["Available models:"]
|
|
79
|
+
for alias, config in sorted(self._models.items()):
|
|
80
|
+
lines.append(f" {alias} -> {config.model_id} (provider: {config.provider_id})")
|
|
81
|
+
return "\n".join(lines)
|
{opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-agent-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Open-source Agent SDK backed by OpenCode ACP (drop-in replacement for claude_agent_sdk)
|
|
5
5
|
Author: OpenCode
|
|
6
6
|
License: MIT
|
|
@@ -16,6 +16,7 @@ Requires-Python: >=3.10
|
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
Requires-Dist: anyio
|
|
18
18
|
Requires-Dist: httpx
|
|
19
|
+
Requires-Dist: fastmcp>=2.0.0
|
|
19
20
|
Provides-Extra: opencode-ai
|
|
20
21
|
Requires-Dist: opencode-ai>=0.1.0a36; extra == "opencode-ai"
|
|
21
22
|
Provides-Extra: dev
|
{opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/SOURCES.txt
RENAMED
|
@@ -2,7 +2,9 @@ README.md
|
|
|
2
2
|
pyproject.toml
|
|
3
3
|
src/opencode_agent_sdk/__init__.py
|
|
4
4
|
src/opencode_agent_sdk/_errors.py
|
|
5
|
+
src/opencode_agent_sdk/_mcp_bridge.py
|
|
5
6
|
src/opencode_agent_sdk/client.py
|
|
7
|
+
src/opencode_agent_sdk/model_registry.py
|
|
6
8
|
src/opencode_agent_sdk/tools.py
|
|
7
9
|
src/opencode_agent_sdk/types.py
|
|
8
10
|
src/opencode_agent_sdk.egg-info/PKG-INFO
|
|
@@ -15,5 +17,6 @@ src/opencode_agent_sdk/_internal/acp.py
|
|
|
15
17
|
src/opencode_agent_sdk/_internal/http_transport.py
|
|
16
18
|
src/opencode_agent_sdk/_internal/transport.py
|
|
17
19
|
tests/test_common.py
|
|
20
|
+
tests/test_model_registry.py
|
|
18
21
|
tests/test_opencode_agent.py
|
|
19
22
|
tests/test_runner.py
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Tests for the generic ModelRegistry in the SDK."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from opencode_agent_sdk.model_registry import ModelConfig, ModelRegistry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestModelRegistry:
|
|
8
|
+
def test_register_and_resolve(self):
|
|
9
|
+
registry = ModelRegistry()
|
|
10
|
+
registry.register("test-model", ModelConfig("actual-id", "test-provider"))
|
|
11
|
+
config = registry.resolve("test-model")
|
|
12
|
+
assert config is not None
|
|
13
|
+
assert config.model_id == "actual-id"
|
|
14
|
+
assert config.provider_id == "test-provider"
|
|
15
|
+
|
|
16
|
+
def test_resolve_case_insensitive(self):
|
|
17
|
+
registry = ModelRegistry()
|
|
18
|
+
registry.register("My-Model", ModelConfig("id", "provider"))
|
|
19
|
+
assert registry.resolve("my-model") is not None
|
|
20
|
+
assert registry.resolve("MY-MODEL") is not None
|
|
21
|
+
|
|
22
|
+
def test_resolve_unknown_returns_none(self):
|
|
23
|
+
registry = ModelRegistry()
|
|
24
|
+
assert registry.resolve("nonexistent") is None
|
|
25
|
+
|
|
26
|
+
def test_register_many(self):
|
|
27
|
+
registry = ModelRegistry()
|
|
28
|
+
registry.register_many({
|
|
29
|
+
"model-a": ModelConfig("a-id", "a-provider"),
|
|
30
|
+
"model-b": ModelConfig("b-id", "b-provider"),
|
|
31
|
+
})
|
|
32
|
+
assert registry.resolve("model-a") is not None
|
|
33
|
+
assert registry.resolve("model-b") is not None
|
|
34
|
+
|
|
35
|
+
def test_list_models(self):
|
|
36
|
+
registry = ModelRegistry()
|
|
37
|
+
registry.register("x", ModelConfig("x-id", "x-provider"))
|
|
38
|
+
models = registry.list_models()
|
|
39
|
+
assert "x" in models
|
|
40
|
+
assert models["x"].model_id == "x-id"
|
|
41
|
+
|
|
42
|
+
def test_list_models_returns_copy(self):
|
|
43
|
+
registry = ModelRegistry()
|
|
44
|
+
registry.register("x", ModelConfig("x-id", "x-provider"))
|
|
45
|
+
models = registry.list_models()
|
|
46
|
+
models["y"] = ModelConfig("y-id", "y-provider")
|
|
47
|
+
assert registry.resolve("y") is None
|
|
48
|
+
|
|
49
|
+
def test_format_help_empty(self):
|
|
50
|
+
registry = ModelRegistry()
|
|
51
|
+
assert "No models registered" in registry.format_help()
|
|
52
|
+
|
|
53
|
+
def test_format_help_with_models(self):
|
|
54
|
+
registry = ModelRegistry()
|
|
55
|
+
registry.register("my-model", ModelConfig("real-id", "my-provider"))
|
|
56
|
+
help_text = registry.format_help()
|
|
57
|
+
assert "my-model" in help_text
|
|
58
|
+
assert "real-id" in help_text
|
|
59
|
+
assert "my-provider" in help_text
|
|
60
|
+
|
|
61
|
+
def test_model_config_frozen(self):
|
|
62
|
+
config = ModelConfig("id", "provider")
|
|
63
|
+
with pytest.raises(AttributeError):
|
|
64
|
+
config.model_id = "changed"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|