opencode-agent-sdk 0.2.0__tar.gz → 0.4.6__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.6}/PKG-INFO +2 -1
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/pyproject.toml +3 -1
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/__init__.py +3 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_internal/acp.py +48 -10
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_internal/transport.py +46 -1
- opencode_agent_sdk-0.4.6/src/opencode_agent_sdk/_mcp_bridge.py +250 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/client.py +99 -25
- opencode_agent_sdk-0.4.6/src/opencode_agent_sdk/model_registry.py +81 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/PKG-INFO +2 -1
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/SOURCES.txt +3 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/requires.txt +1 -0
- opencode_agent_sdk-0.4.6/tests/test_model_registry.py +64 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/README.md +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/setup.cfg +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_errors.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_internal/__init__.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_internal/http_transport.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/tools.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/types.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/dependency_links.txt +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/top_level.txt +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/tests/test_common.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/tests/test_opencode_agent.py +0 -0
- {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/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.6
|
|
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.6"
|
|
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.6}/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.6}/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,250 @@
|
|
|
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 contextlib import asynccontextmanager
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _find_free_port() -> int:
|
|
24
|
+
"""Find an available TCP port on localhost."""
|
|
25
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
26
|
+
s.bind(("127.0.0.1", 0))
|
|
27
|
+
return s.getsockname()[1]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class McpHttpBridge:
|
|
31
|
+
"""Hosts SDK MCP tools as HTTP servers for opencode consumption.
|
|
32
|
+
|
|
33
|
+
For each SDK-defined MCP server (those with ``_tools`` in their config),
|
|
34
|
+
starts a FastMCP HTTP server on a random port. The config is then
|
|
35
|
+
converted to a remote server pointing to ``http://127.0.0.1:<port>/mcp``
|
|
36
|
+
so opencode can connect to it.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._servers: list[Any] = [] # uvicorn.Server instances
|
|
41
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
42
|
+
|
|
43
|
+
async def start_server(self, name: str) -> int:
|
|
44
|
+
"""Start an HTTP MCP server for the given tools.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
name: MCP server name (must match a name registered via
|
|
48
|
+
create_sdk_mcp_server).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The port the server is listening on.
|
|
52
|
+
"""
|
|
53
|
+
from .tools import _TOOL_REGISTRY
|
|
54
|
+
|
|
55
|
+
tool_handlers = _TOOL_REGISTRY.get(name, [])
|
|
56
|
+
if not tool_handlers:
|
|
57
|
+
raise ValueError(f"No tool handlers registered for MCP server '{name}'")
|
|
58
|
+
|
|
59
|
+
from fastmcp import FastMCP
|
|
60
|
+
from fastmcp.tools import Tool as FastMCPTool
|
|
61
|
+
|
|
62
|
+
mcp_server = FastMCP(name)
|
|
63
|
+
|
|
64
|
+
for sdk_tool in tool_handlers:
|
|
65
|
+
wrapper = _make_wrapper(sdk_tool)
|
|
66
|
+
mcp_server.add_tool(
|
|
67
|
+
FastMCPTool.from_function(
|
|
68
|
+
wrapper,
|
|
69
|
+
name=sdk_tool.name,
|
|
70
|
+
description=sdk_tool.description,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
app = mcp_server.http_app(transport="streamable-http")
|
|
75
|
+
port = _find_free_port()
|
|
76
|
+
|
|
77
|
+
import uvicorn
|
|
78
|
+
|
|
79
|
+
config = uvicorn.Config(
|
|
80
|
+
app,
|
|
81
|
+
host="127.0.0.1",
|
|
82
|
+
port=port,
|
|
83
|
+
log_level="warning",
|
|
84
|
+
)
|
|
85
|
+
uv_server = uvicorn.Server(config)
|
|
86
|
+
self._servers.append(uv_server)
|
|
87
|
+
|
|
88
|
+
task = asyncio.create_task(uv_server.serve())
|
|
89
|
+
self._tasks.append(task)
|
|
90
|
+
|
|
91
|
+
# Wait briefly for the server to start accepting connections
|
|
92
|
+
await asyncio.sleep(0.3)
|
|
93
|
+
|
|
94
|
+
logger.info(
|
|
95
|
+
"MCP HTTP bridge started: %s on port %d (%d tools)",
|
|
96
|
+
name, port, len(tool_handlers),
|
|
97
|
+
)
|
|
98
|
+
return port
|
|
99
|
+
|
|
100
|
+
async def start_server_from_instance(self, name: str, server_instance: Any) -> int:
|
|
101
|
+
"""Host an existing mcp.server.Server instance as an HTTP MCP server.
|
|
102
|
+
|
|
103
|
+
This enables cross-SDK compatibility: MCP servers created by the
|
|
104
|
+
claude-agent-sdk (``McpSdkServerConfig`` with ``type="sdk"`` and an
|
|
105
|
+
``instance`` field) can be served over HTTP so the opencode ACP layer
|
|
106
|
+
can consume them.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
name: MCP server name (for logging).
|
|
110
|
+
server_instance: An ``mcp.server.Server`` (low-level) with tool
|
|
111
|
+
handlers already registered.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The port the server is listening on.
|
|
115
|
+
"""
|
|
116
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
117
|
+
from fastmcp.server.http import StreamableHTTPASGIApp
|
|
118
|
+
from starlette.applications import Starlette
|
|
119
|
+
from starlette.routing import Route
|
|
120
|
+
|
|
121
|
+
session_manager = StreamableHTTPSessionManager(
|
|
122
|
+
app=server_instance,
|
|
123
|
+
stateless=True,
|
|
124
|
+
)
|
|
125
|
+
asgi_handler = StreamableHTTPASGIApp(session_manager)
|
|
126
|
+
|
|
127
|
+
@asynccontextmanager
|
|
128
|
+
async def lifespan(app: Any) -> Any:
|
|
129
|
+
async with session_manager.run():
|
|
130
|
+
yield
|
|
131
|
+
|
|
132
|
+
starlette_app = Starlette(
|
|
133
|
+
routes=[Route("/mcp", endpoint=asgi_handler, methods=["GET", "POST", "DELETE"])],
|
|
134
|
+
lifespan=lifespan,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
port = _find_free_port()
|
|
138
|
+
|
|
139
|
+
import uvicorn
|
|
140
|
+
|
|
141
|
+
config = uvicorn.Config(
|
|
142
|
+
starlette_app,
|
|
143
|
+
host="127.0.0.1",
|
|
144
|
+
port=port,
|
|
145
|
+
log_level="warning",
|
|
146
|
+
)
|
|
147
|
+
uv_server = uvicorn.Server(config)
|
|
148
|
+
self._servers.append(uv_server)
|
|
149
|
+
|
|
150
|
+
task = asyncio.create_task(uv_server.serve())
|
|
151
|
+
self._tasks.append(task)
|
|
152
|
+
|
|
153
|
+
await asyncio.sleep(0.3)
|
|
154
|
+
|
|
155
|
+
logger.info(
|
|
156
|
+
"MCP HTTP bridge started (from instance): %s on port %d",
|
|
157
|
+
name, port,
|
|
158
|
+
)
|
|
159
|
+
return port
|
|
160
|
+
|
|
161
|
+
async def stop_all(self) -> None:
|
|
162
|
+
"""Stop all running HTTP MCP servers."""
|
|
163
|
+
for server in self._servers:
|
|
164
|
+
server.should_exit = True
|
|
165
|
+
for task in self._tasks:
|
|
166
|
+
task.cancel()
|
|
167
|
+
try:
|
|
168
|
+
await task
|
|
169
|
+
except (asyncio.CancelledError, Exception):
|
|
170
|
+
pass
|
|
171
|
+
self._servers.clear()
|
|
172
|
+
self._tasks.clear()
|
|
173
|
+
logger.debug("All MCP HTTP bridges stopped")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _make_wrapper(sdk_tool: Any) -> Any:
|
|
177
|
+
"""Create an async wrapper function that bridges SDK tool handler format.
|
|
178
|
+
|
|
179
|
+
SDK tools accept ``args: dict[str, Any]`` and return
|
|
180
|
+
``{"content": [{"type": "text", "text": "..."}]}``.
|
|
181
|
+
|
|
182
|
+
FastMCP expects typed keyword arguments and a return value that it
|
|
183
|
+
serializes. This function dynamically creates a wrapper with the
|
|
184
|
+
correct signature derived from ``input_schema``.
|
|
185
|
+
"""
|
|
186
|
+
schema = sdk_tool.input_schema or {}
|
|
187
|
+
properties = schema.get("properties", {})
|
|
188
|
+
handler = sdk_tool.handler
|
|
189
|
+
|
|
190
|
+
# Build parameter list from input_schema properties
|
|
191
|
+
params = []
|
|
192
|
+
for prop_name, prop_def in properties.items():
|
|
193
|
+
json_type = prop_def.get("type", "string")
|
|
194
|
+
annotation = _json_type_to_python(json_type, prop_def)
|
|
195
|
+
|
|
196
|
+
required = prop_name in schema.get("required", [])
|
|
197
|
+
if required:
|
|
198
|
+
params.append(
|
|
199
|
+
inspect.Parameter(
|
|
200
|
+
prop_name,
|
|
201
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
202
|
+
annotation=annotation,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
default = prop_def.get("default", None)
|
|
207
|
+
params.append(
|
|
208
|
+
inspect.Parameter(
|
|
209
|
+
prop_name,
|
|
210
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
211
|
+
default=default,
|
|
212
|
+
annotation=annotation,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def wrapper(**kwargs: Any) -> str:
|
|
217
|
+
result = await handler(kwargs)
|
|
218
|
+
# Extract text from MCP content format
|
|
219
|
+
if isinstance(result, dict) and "content" in result:
|
|
220
|
+
texts = [
|
|
221
|
+
c["text"]
|
|
222
|
+
for c in result["content"]
|
|
223
|
+
if isinstance(c, dict) and c.get("type") == "text"
|
|
224
|
+
]
|
|
225
|
+
return "\n".join(texts) if texts else str(result)
|
|
226
|
+
return str(result)
|
|
227
|
+
|
|
228
|
+
# Set signature AND annotations so FastMCP/Pydantic can introspect
|
|
229
|
+
wrapper.__signature__ = inspect.Signature(params, return_annotation=str)
|
|
230
|
+
wrapper.__name__ = sdk_tool.name
|
|
231
|
+
wrapper.__doc__ = sdk_tool.description
|
|
232
|
+
wrapper.__annotations__ = {p.name: p.annotation for p in params}
|
|
233
|
+
wrapper.__annotations__["return"] = str
|
|
234
|
+
|
|
235
|
+
return wrapper
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _json_type_to_python(json_type: str, prop_def: dict[str, Any]) -> type:
|
|
239
|
+
"""Map JSON Schema type to Python type annotation."""
|
|
240
|
+
type_map: dict[str, type] = {
|
|
241
|
+
"string": str,
|
|
242
|
+
"integer": int,
|
|
243
|
+
"number": float,
|
|
244
|
+
"boolean": bool,
|
|
245
|
+
}
|
|
246
|
+
if json_type == "array":
|
|
247
|
+
return list
|
|
248
|
+
if json_type == "object":
|
|
249
|
+
return dict
|
|
250
|
+
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,75 @@ 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.
|
|
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
|
+
#
|
|
108
|
+
# Two config formats are supported:
|
|
109
|
+
# - Opencode-format: has "_tools" key (from opencode create_sdk_mcp_server)
|
|
110
|
+
# - Claude-format: has type="sdk" + "instance" key (from claude create_sdk_mcp_server)
|
|
111
|
+
effective_servers = dict(self._options.mcp_servers)
|
|
112
|
+
sdk_servers = {
|
|
113
|
+
name: cfg for name, cfg in effective_servers.items()
|
|
114
|
+
if isinstance(cfg, dict) and (
|
|
115
|
+
"_tools" in cfg
|
|
116
|
+
or (cfg.get("type") == "sdk" and "instance" in cfg)
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if sdk_servers:
|
|
120
|
+
from ._mcp_bridge import McpHttpBridge
|
|
121
|
+
|
|
122
|
+
self._mcp_bridge = McpHttpBridge()
|
|
123
|
+
for name, cfg in sdk_servers.items():
|
|
124
|
+
if "_tools" in cfg:
|
|
125
|
+
port = await self._mcp_bridge.start_server(name)
|
|
126
|
+
else:
|
|
127
|
+
# Claude-format: host the mcp.server.Server instance directly
|
|
128
|
+
port = await self._mcp_bridge.start_server_from_instance(
|
|
129
|
+
name, cfg["instance"],
|
|
130
|
+
)
|
|
131
|
+
# Replace config with remote HTTP config
|
|
132
|
+
effective_servers[name] = {
|
|
133
|
+
"url": f"http://127.0.0.1:{port}/mcp",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
acp_mcp_servers = _build_mcp_servers(effective_servers)
|
|
104
137
|
|
|
105
138
|
if self._options.resume:
|
|
106
139
|
await self._session.load_session(
|
|
107
140
|
session_id=self._options.resume,
|
|
108
141
|
cwd=self._options.cwd,
|
|
142
|
+
mcp_servers=acp_mcp_servers,
|
|
109
143
|
)
|
|
110
144
|
else:
|
|
111
145
|
await self._session.new_session(
|
|
112
146
|
cwd=self._options.cwd,
|
|
113
|
-
mcp_servers=
|
|
147
|
+
mcp_servers=acp_mcp_servers,
|
|
114
148
|
model=self._options.model or None,
|
|
149
|
+
provider_id=self._options.provider_id or None,
|
|
150
|
+
permission_mode=self._options.permission_mode,
|
|
151
|
+
system_prompt=self._options.system_prompt,
|
|
115
152
|
)
|
|
116
153
|
|
|
154
|
+
def _build_init_data(self, session_id: str) -> dict[str, Any]:
|
|
155
|
+
"""Build the init SystemMessage data dict from options."""
|
|
156
|
+
data: dict[str, Any] = {
|
|
157
|
+
"session_id": session_id,
|
|
158
|
+
"model": self._options.model,
|
|
159
|
+
"cwd": self._options.cwd,
|
|
160
|
+
}
|
|
161
|
+
if self._options.plugins:
|
|
162
|
+
data["plugins"] = self._options.plugins
|
|
163
|
+
if self._options.mcp_servers:
|
|
164
|
+
data["mcp_servers"] = list(self._options.mcp_servers.keys())
|
|
165
|
+
return data
|
|
166
|
+
|
|
117
167
|
async def disconnect(self) -> None:
|
|
118
168
|
"""Shut down the transport and clean up."""
|
|
169
|
+
if self._mcp_bridge:
|
|
170
|
+
await self._mcp_bridge.stop_all()
|
|
171
|
+
self._mcp_bridge = None
|
|
119
172
|
if self._transport:
|
|
120
173
|
await self._transport.close()
|
|
121
174
|
self._transport = None
|
|
@@ -171,11 +224,9 @@ class SDKClient:
|
|
|
171
224
|
# Yield init system message
|
|
172
225
|
yield SystemMessage(
|
|
173
226
|
subtype="init",
|
|
174
|
-
data=
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
"cwd": self._options.cwd,
|
|
178
|
-
},
|
|
227
|
+
data=self._build_init_data(
|
|
228
|
+
session_id=self._transport.session_id,
|
|
229
|
+
),
|
|
179
230
|
)
|
|
180
231
|
|
|
181
232
|
# Stream response parts via SSE
|
|
@@ -192,11 +243,9 @@ class SDKClient:
|
|
|
192
243
|
|
|
193
244
|
yield SystemMessage(
|
|
194
245
|
subtype="init",
|
|
195
|
-
data=
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"cwd": self._options.cwd,
|
|
199
|
-
},
|
|
246
|
+
data=self._build_init_data(
|
|
247
|
+
session_id=self._session.session_id,
|
|
248
|
+
),
|
|
200
249
|
)
|
|
201
250
|
|
|
202
251
|
async for msg in self._session.receive_messages():
|
|
@@ -204,18 +253,43 @@ class SDKClient:
|
|
|
204
253
|
|
|
205
254
|
|
|
206
255
|
def _build_mcp_servers(servers: dict[str, Any]) -> list[dict[str, Any]]:
|
|
207
|
-
"""Convert the mcp_servers dict to ACP mcpServers format.
|
|
256
|
+
"""Convert the mcp_servers dict to ACP mcpServers wire format.
|
|
257
|
+
|
|
258
|
+
ACP distinguishes server types by the presence of a ``type`` field:
|
|
259
|
+
- Local/stdio (no ``type``): ``{name, command, args, env}``
|
|
260
|
+
- Remote HTTP (``type: "http"``): ``{name, type, url, headers}``
|
|
261
|
+
- Remote SSE (``type: "sse"``): ``{name, type, url, headers}``
|
|
262
|
+
|
|
263
|
+
``env`` and ``headers`` use ``[{name, value}]`` array format on the wire,
|
|
264
|
+
not dict format.
|
|
265
|
+
"""
|
|
208
266
|
result = []
|
|
209
267
|
for name, config in servers.items():
|
|
210
|
-
if isinstance(config, dict):
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
268
|
+
if not isinstance(config, dict):
|
|
269
|
+
continue
|
|
270
|
+
entry: dict[str, Any] = {"name": name}
|
|
271
|
+
if "command" in config:
|
|
272
|
+
# Local/stdio server — NO "type" field
|
|
273
|
+
entry["command"] = config["command"]
|
|
274
|
+
entry["args"] = config.get("args", [])
|
|
275
|
+
# Convert env dict to [{name, value}] array
|
|
276
|
+
raw_env = config.get("env", {})
|
|
277
|
+
if isinstance(raw_env, dict):
|
|
278
|
+
entry["env"] = [{"name": k, "value": v} for k, v in raw_env.items()]
|
|
279
|
+
elif isinstance(raw_env, list):
|
|
280
|
+
entry["env"] = raw_env
|
|
281
|
+
else:
|
|
282
|
+
entry["env"] = []
|
|
283
|
+
elif "url" in config:
|
|
284
|
+
# Remote server — "http" for streamable-http, "sse" for SSE
|
|
285
|
+
entry["type"] = config.get("type", "http")
|
|
286
|
+
entry["url"] = config["url"]
|
|
287
|
+
raw_headers = config.get("headers", {})
|
|
288
|
+
if isinstance(raw_headers, dict):
|
|
289
|
+
entry["headers"] = [{"name": k, "value": v} for k, v in raw_headers.items()]
|
|
290
|
+
elif isinstance(raw_headers, list):
|
|
291
|
+
entry["headers"] = raw_headers
|
|
292
|
+
else:
|
|
293
|
+
entry["headers"] = []
|
|
294
|
+
result.append(entry)
|
|
221
295
|
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.6}/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.6
|
|
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.6}/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.6}/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.6}/src/opencode_agent_sdk.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|