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.
Files changed (24) hide show
  1. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/PKG-INFO +2 -1
  2. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/pyproject.toml +3 -1
  3. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/__init__.py +3 -0
  4. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_internal/acp.py +48 -10
  5. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_internal/transport.py +46 -1
  6. opencode_agent_sdk-0.4.6/src/opencode_agent_sdk/_mcp_bridge.py +250 -0
  7. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/client.py +99 -25
  8. opencode_agent_sdk-0.4.6/src/opencode_agent_sdk/model_registry.py +81 -0
  9. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/PKG-INFO +2 -1
  10. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/SOURCES.txt +3 -0
  11. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/requires.txt +1 -0
  12. opencode_agent_sdk-0.4.6/tests/test_model_registry.py +64 -0
  13. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/README.md +0 -0
  14. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/setup.cfg +0 -0
  15. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_errors.py +0 -0
  16. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_internal/__init__.py +0 -0
  17. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/_internal/http_transport.py +0 -0
  18. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/tools.py +0 -0
  19. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk/types.py +0 -0
  20. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/dependency_links.txt +0 -0
  21. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/src/opencode_agent_sdk.egg-info/top_level.txt +0 -0
  22. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/tests/test_common.py +0 -0
  23. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.6}/tests/test_opencode_agent.py +0 -0
  24. {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.2.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.2.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",
@@ -23,7 +23,7 @@ from .transport import SubprocessTransport
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
- _PROTOCOL_VERSION = "2025-01-01"
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: sessionUpdate
132
- if method == "sessionUpdate":
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. Returns the session ID."""
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
- result = await self._send_request("newSession", params)
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(self, session_id: str, cwd: str) -> str:
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("loadSession", params)
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
 
@@ -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
- # Create or resume session
103
- mcp_servers = _build_mcp_servers(self._options.mcp_servers)
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=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
- "session_id": self._transport.session_id,
176
- "model": self._options.model,
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
- "session_id": self._session.session_id,
197
- "model": self._options.model,
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
- entry: dict[str, Any] = {"name": name}
212
- if "command" in config:
213
- entry["transport"] = "stdio"
214
- entry["command"] = config["command"]
215
- entry["args"] = config.get("args", [])
216
- entry["env"] = config.get("env", {})
217
- elif "url" in config:
218
- entry["transport"] = "sse"
219
- entry["url"] = config["url"]
220
- result.append(entry)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-agent-sdk
3
- Version: 0.2.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
@@ -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
@@ -1,5 +1,6 @@
1
1
  anyio
2
2
  httpx
3
+ fastmcp>=2.0.0
3
4
 
4
5
  [dev]
5
6
  pytest>=8.0
@@ -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"