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.
Files changed (24) hide show
  1. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/PKG-INFO +2 -1
  2. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/pyproject.toml +3 -1
  3. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/__init__.py +3 -0
  4. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/acp.py +48 -10
  5. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/transport.py +46 -1
  6. opencode_agent_sdk-0.4.4/src/opencode_agent_sdk/_mcp_bridge.py +188 -0
  7. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/client.py +86 -25
  8. opencode_agent_sdk-0.4.4/src/opencode_agent_sdk/model_registry.py +81 -0
  9. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/PKG-INFO +2 -1
  10. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/SOURCES.txt +3 -0
  11. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/requires.txt +1 -0
  12. opencode_agent_sdk-0.4.4/tests/test_model_registry.py +64 -0
  13. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/README.md +0 -0
  14. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/setup.cfg +0 -0
  15. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_errors.py +0 -0
  16. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/__init__.py +0 -0
  17. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/_internal/http_transport.py +0 -0
  18. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/tools.py +0 -0
  19. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk/types.py +0 -0
  20. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/dependency_links.txt +0 -0
  21. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/src/opencode_agent_sdk.egg-info/top_level.txt +0 -0
  22. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/tests/test_common.py +0 -0
  23. {opencode_agent_sdk-0.2.0 → opencode_agent_sdk-0.4.4}/tests/test_opencode_agent.py +0 -0
  24. {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.2.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.2.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",
@@ -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,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
- # Create or resume session
103
- mcp_servers = _build_mcp_servers(self._options.mcp_servers)
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=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
- "session_id": self._transport.session_id,
176
- "model": self._options.model,
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
- "session_id": self._session.session_id,
197
- "model": self._options.model,
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
- 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)
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)
@@ -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.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
@@ -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"