foundry-mcp 0.3.3__py3-none-any.whl

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 (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +123 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,616 @@
1
+ """
2
+ OpenCode AI provider implementation.
3
+
4
+ Bridges the OpenCode AI Node.js SDK wrapper to the ProviderContext contract by
5
+ handling availability checks, server management, wrapper script execution,
6
+ response parsing, and token usage normalization.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import socket
15
+ import subprocess
16
+ import tempfile
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Protocol, Sequence
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ from .base import (
24
+ ModelDescriptor,
25
+ ProviderCapability,
26
+ ProviderContext,
27
+ ProviderExecutionError,
28
+ ProviderHooks,
29
+ ProviderMetadata,
30
+ ProviderRequest,
31
+ ProviderResult,
32
+ ProviderStatus,
33
+ ProviderTimeoutError,
34
+ ProviderUnavailableError,
35
+ StreamChunk,
36
+ TokenUsage,
37
+ )
38
+ from .detectors import detect_provider_availability
39
+ from .registry import register_provider
40
+
41
+ DEFAULT_BINARY = "node"
42
+ DEFAULT_WRAPPER_SCRIPT = Path(__file__).parent / "opencode_wrapper.js"
43
+ DEFAULT_TIMEOUT_SECONDS = 360
44
+ DEFAULT_SERVER_URL = "http://localhost:4096"
45
+ SERVER_STARTUP_TIMEOUT = 30
46
+ AVAILABILITY_OVERRIDE_ENV = "OPENCODE_AVAILABLE_OVERRIDE"
47
+ CUSTOM_BINARY_ENV = "OPENCODE_BINARY"
48
+ CUSTOM_WRAPPER_ENV = "OPENCODE_WRAPPER_SCRIPT"
49
+
50
+ # Read-only tools configuration for OpenCode server
51
+ # Uses dual-layer protection: tool disabling + permission denial
52
+ READONLY_TOOLS_CONFIG = {
53
+ "$schema": "https://opencode.ai/config.json",
54
+ "tools": {
55
+ # Disable write operations
56
+ "write": False,
57
+ "edit": False,
58
+ "patch": False,
59
+ "todowrite": False,
60
+ # Disable shell execution
61
+ "bash": False,
62
+ # Enable read operations
63
+ "read": True,
64
+ "grep": True,
65
+ "glob": True,
66
+ "list": True,
67
+ "todoread": True,
68
+ "task": True,
69
+ # Disable web operations (data exfiltration risk)
70
+ "webfetch": False,
71
+ },
72
+ "permission": {
73
+ # Double-guard with permission denials
74
+ "edit": "deny",
75
+ "bash": "deny",
76
+ "webfetch": "deny",
77
+ "external_directory": "deny",
78
+ },
79
+ }
80
+
81
+ # System prompt warning about tool limitations
82
+ SHELL_COMMAND_WARNING = """
83
+ IMPORTANT SECURITY NOTE: This session is running in read-only mode with the following restrictions:
84
+ 1. File write operations (write, edit, patch) are disabled
85
+ 2. Shell command execution (bash) is disabled
86
+ 3. Web operations (webfetch) are disabled to prevent data exfiltration
87
+ 4. Only read operations are available (read, grep, glob, list)
88
+ 5. Attempts to modify files, execute commands, or access the web will be blocked by the server
89
+ """
90
+
91
+
92
+ class RunnerProtocol(Protocol):
93
+ """Callable signature used for executing Node.js wrapper commands."""
94
+
95
+ def __call__(
96
+ self,
97
+ command: Sequence[str],
98
+ *,
99
+ timeout: Optional[int] = None,
100
+ env: Optional[Dict[str, str]] = None,
101
+ input_data: Optional[str] = None,
102
+ ) -> subprocess.CompletedProcess[str]:
103
+ raise NotImplementedError
104
+
105
+
106
+ def _default_runner(
107
+ command: Sequence[str],
108
+ *,
109
+ timeout: Optional[int] = None,
110
+ env: Optional[Dict[str, str]] = None,
111
+ input_data: Optional[str] = None,
112
+ ) -> subprocess.CompletedProcess[str]:
113
+ """Invoke the OpenCode wrapper via subprocess."""
114
+ return subprocess.run( # noqa: S603,S607 - intentional wrapper invocation
115
+ list(command),
116
+ capture_output=True,
117
+ text=True,
118
+ input=input_data,
119
+ timeout=timeout,
120
+ env=env,
121
+ check=False,
122
+ )
123
+
124
+
125
+ OPENCODE_MODELS: List[ModelDescriptor] = [
126
+ ModelDescriptor(
127
+ id="openai/gpt-5.1-codex-mini",
128
+ display_name="OpenAI GPT-5.1 Codex Mini (via OpenCode)",
129
+ capabilities={
130
+ ProviderCapability.TEXT,
131
+ ProviderCapability.STREAMING,
132
+ },
133
+ routing_hints={
134
+ "configurable": True,
135
+ "source": "opencode config",
136
+ "note": "Accepts any model ID - validated by opencode CLI",
137
+ },
138
+ ),
139
+ ]
140
+
141
+ OPENCODE_METADATA = ProviderMetadata(
142
+ provider_id="opencode",
143
+ display_name="OpenCode AI SDK",
144
+ models=OPENCODE_MODELS,
145
+ default_model="openai/gpt-5.1-codex-mini",
146
+ capabilities={ProviderCapability.TEXT, ProviderCapability.STREAMING},
147
+ security_flags={"writes_allowed": False, "read_only": True},
148
+ extra={
149
+ "wrapper": "opencode_wrapper.js",
150
+ "server_url": DEFAULT_SERVER_URL,
151
+ "configurable": True,
152
+ "readonly_config": READONLY_TOOLS_CONFIG,
153
+ },
154
+ )
155
+
156
+
157
+ class OpenCodeProvider(ProviderContext):
158
+ """ProviderContext implementation backed by the OpenCode AI wrapper."""
159
+
160
+ def __init__(
161
+ self,
162
+ metadata: ProviderMetadata,
163
+ hooks: ProviderHooks,
164
+ *,
165
+ model: Optional[str] = None,
166
+ binary: Optional[str] = None,
167
+ wrapper_path: Optional[Path] = None,
168
+ runner: Optional[RunnerProtocol] = None,
169
+ env: Optional[Dict[str, str]] = None,
170
+ timeout: Optional[int] = None,
171
+ ):
172
+ super().__init__(metadata, hooks)
173
+ self._runner = runner or _default_runner
174
+ self._binary = binary or os.environ.get(CUSTOM_BINARY_ENV, DEFAULT_BINARY)
175
+ self._wrapper_path = wrapper_path or Path(
176
+ os.environ.get(CUSTOM_WRAPPER_ENV, str(DEFAULT_WRAPPER_SCRIPT))
177
+ )
178
+
179
+ # Prepare environment for subprocess with secure API key handling
180
+ self._env = self._prepare_subprocess_env(env)
181
+
182
+ self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
183
+ self._model = self._ensure_model(model or metadata.default_model or self._first_model_id())
184
+ self._server_process: Optional[subprocess.Popen[str]] = None
185
+ self._config_file_path: Optional[Path] = None
186
+
187
+ def __del__(self) -> None:
188
+ """Clean up server process and config file on provider destruction."""
189
+ # Clean up server process
190
+ if hasattr(self, "_server_process") and self._server_process is not None:
191
+ try:
192
+ self._server_process.terminate()
193
+ # Give it a moment to terminate gracefully
194
+ try:
195
+ self._server_process.wait(timeout=5)
196
+ except subprocess.TimeoutExpired:
197
+ # Force kill if it doesn't terminate
198
+ self._server_process.kill()
199
+ except (OSError, ProcessLookupError):
200
+ # Process already terminated, ignore
201
+ pass
202
+ finally:
203
+ self._server_process = None
204
+
205
+ # Clean up config file
206
+ self._cleanup_config_file()
207
+
208
+ def _prepare_subprocess_env(self, custom_env: Optional[Dict[str, str]]) -> Dict[str, str]:
209
+ """
210
+ Prepare environment variables for subprocess execution.
211
+
212
+ Merges current process environment with custom overrides and ensures
213
+ required OpenCode variables are present.
214
+ """
215
+ # Start with a copy of the current environment
216
+ subprocess_env = os.environ.copy()
217
+
218
+ # Merge custom environment if provided
219
+ if custom_env:
220
+ subprocess_env.update(custom_env)
221
+
222
+ # Ensure OPENCODE_SERVER_URL is set (use default if not provided)
223
+ if "OPENCODE_SERVER_URL" not in subprocess_env:
224
+ subprocess_env["OPENCODE_SERVER_URL"] = DEFAULT_SERVER_URL
225
+
226
+ # Note: OPENCODE_API_KEY should be provided via environment or custom_env
227
+ # We don't set a default value for security reasons
228
+
229
+ return subprocess_env
230
+
231
+ def _create_readonly_config(self) -> Path:
232
+ """
233
+ Create temporary opencode.json with read-only tool restrictions.
234
+
235
+ Returns:
236
+ Path to the temporary config file
237
+
238
+ Note:
239
+ - Tool blocking may not work for MCP tools (OpenCode issue #3756)
240
+ - Config is server-wide, affecting all sessions on this server instance
241
+ """
242
+ # Create temp directory for config
243
+ temp_dir = Path(tempfile.mkdtemp(prefix="opencode_readonly_"))
244
+
245
+ # Create config file
246
+ config_path = temp_dir / "opencode.json"
247
+ with open(config_path, "w") as f:
248
+ json.dump(READONLY_TOOLS_CONFIG, f, indent=2)
249
+
250
+ return config_path
251
+
252
+ def _cleanup_config_file(self) -> None:
253
+ """Remove temporary config file and directory."""
254
+ if hasattr(self, "_config_file_path") and self._config_file_path is not None:
255
+ try:
256
+ # Remove config file
257
+ if self._config_file_path.exists():
258
+ self._config_file_path.unlink()
259
+
260
+ # Remove temp directory
261
+ temp_dir = self._config_file_path.parent
262
+ if temp_dir.exists():
263
+ temp_dir.rmdir()
264
+ except (OSError, FileNotFoundError):
265
+ # File already removed or doesn't exist, ignore
266
+ pass
267
+ finally:
268
+ self._config_file_path = None
269
+
270
+ def _first_model_id(self) -> str:
271
+ if not self.metadata.models:
272
+ raise ProviderUnavailableError(
273
+ "OpenCode provider metadata is missing model descriptors.",
274
+ provider=self.metadata.provider_id,
275
+ )
276
+ return self.metadata.models[0].id
277
+
278
+ def _ensure_model(self, candidate: str) -> str:
279
+ # Validate that the model is not empty
280
+ if not candidate or not candidate.strip():
281
+ raise ProviderExecutionError(
282
+ "Model identifier cannot be empty",
283
+ provider=self.metadata.provider_id,
284
+ )
285
+
286
+ # For opencode, we accept any model ID and let opencode CLI validate it
287
+ # This avoids maintaining a hardcoded list that would become stale
288
+ # opencode CLI supports many models across providers (OpenAI, Anthropic, etc.)
289
+ return candidate
290
+
291
+ def _is_port_open(self, port: int, host: str = "localhost") -> bool:
292
+ """Check if a TCP port is open and accepting connections."""
293
+ try:
294
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
295
+ sock.settimeout(1)
296
+ result = sock.connect_ex((host, port))
297
+ return result == 0
298
+ except (socket.error, OSError):
299
+ return False
300
+
301
+ def _ensure_server_running(self) -> None:
302
+ """Ensure OpenCode server is running, start if necessary."""
303
+ # Extract port from server URL (default: 4096)
304
+ server_url = (
305
+ self._env.get("OPENCODE_SERVER_URL", DEFAULT_SERVER_URL)
306
+ if self._env
307
+ else DEFAULT_SERVER_URL
308
+ )
309
+ try:
310
+ # Parse port from URL (e.g., "http://localhost:4096" -> 4096)
311
+ port = int(server_url.split(":")[-1].rstrip("/"))
312
+ except (ValueError, IndexError):
313
+ port = 4096
314
+
315
+ # Check if server is already running
316
+ if self._is_port_open(port):
317
+ return
318
+
319
+ # Server not running - need to start it
320
+ # Look for opencode binary in node_modules/.bin first
321
+ opencode_binary = None
322
+ node_modules_bin = Path("node_modules/.bin/opencode")
323
+
324
+ if node_modules_bin.exists():
325
+ opencode_binary = str(node_modules_bin)
326
+ else:
327
+ # Fall back to global opencode if available
328
+ try:
329
+ result = subprocess.run(
330
+ ["which", "opencode"],
331
+ capture_output=True,
332
+ text=True,
333
+ timeout=5,
334
+ check=False,
335
+ )
336
+ if result.returncode == 0 and result.stdout.strip():
337
+ opencode_binary = result.stdout.strip()
338
+ except (subprocess.TimeoutExpired, FileNotFoundError):
339
+ pass
340
+
341
+ if not opencode_binary:
342
+ raise ProviderUnavailableError(
343
+ "OpenCode server not running and 'opencode' binary not found in node_modules/.bin or PATH",
344
+ provider=self.metadata.provider_id,
345
+ )
346
+
347
+ # Create read-only configuration file
348
+ self._config_file_path = self._create_readonly_config()
349
+
350
+ # Start server in background
351
+ # Prepare environment with API keys and configuration
352
+ server_env = self._prepare_subprocess_env(self._env)
353
+ # Set OPENCODE_CONFIG to point to our readonly config
354
+ server_env["OPENCODE_CONFIG"] = str(self._config_file_path)
355
+
356
+ try:
357
+ self._server_process = subprocess.Popen(
358
+ [opencode_binary, "serve", "--hostname=127.0.0.1", f"--port={port}"],
359
+ stdout=subprocess.PIPE,
360
+ stderr=subprocess.PIPE,
361
+ env=server_env, # Pass environment variables to server
362
+ start_new_session=True, # Detach from parent
363
+ )
364
+ except (OSError, subprocess.SubprocessError) as e:
365
+ raise ProviderExecutionError(
366
+ f"Failed to start OpenCode server: {e}",
367
+ provider=self.metadata.provider_id,
368
+ ) from e
369
+
370
+ # Wait for server to become available
371
+ start_time = time.time()
372
+ while time.time() - start_time < SERVER_STARTUP_TIMEOUT:
373
+ if self._is_port_open(port):
374
+ return
375
+ time.sleep(0.5)
376
+
377
+ # Timeout - server didn't start
378
+ if self._server_process:
379
+ self._server_process.terminate()
380
+ self._server_process = None
381
+
382
+ raise ProviderTimeoutError(
383
+ f"OpenCode server failed to start within {SERVER_STARTUP_TIMEOUT} seconds",
384
+ provider=self.metadata.provider_id,
385
+ )
386
+
387
+ def _validate_request(self, request: ProviderRequest) -> None:
388
+ """Validate request parameters supported by OpenCode."""
389
+ unsupported: List[str] = []
390
+ if request.attachments:
391
+ unsupported.append("attachments")
392
+ if unsupported:
393
+ raise ProviderExecutionError(
394
+ f"OpenCode does not support: {', '.join(unsupported)}",
395
+ provider=self.metadata.provider_id,
396
+ )
397
+
398
+ def _build_prompt(self, request: ProviderRequest) -> str:
399
+ """Build the prompt with system prompt and security warning."""
400
+ system_parts = []
401
+ if request.system_prompt:
402
+ system_parts.append(request.system_prompt.strip())
403
+ system_parts.append(SHELL_COMMAND_WARNING.strip())
404
+
405
+ if system_parts:
406
+ return f"{chr(10).join(system_parts)}\n\n{request.prompt}"
407
+ return request.prompt
408
+
409
+ def _resolve_model(self, request: ProviderRequest) -> str:
410
+ """Resolve model from request metadata or use default."""
411
+ model_override = request.metadata.get("model") if request.metadata else None
412
+ if model_override:
413
+ return self._ensure_model(str(model_override))
414
+ return self._model
415
+
416
+ def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
417
+ """Emit streaming chunk if streaming is enabled."""
418
+ if not stream or not content:
419
+ return
420
+ self._emit_stream_chunk(StreamChunk(content=content, index=0))
421
+
422
+ def _execute(self, request: ProviderRequest) -> ProviderResult:
423
+ """Execute generation request via OpenCode wrapper."""
424
+ self._validate_request(request)
425
+
426
+ # Ensure server is running before making request
427
+ self._ensure_server_running()
428
+
429
+ model = self._resolve_model(request)
430
+
431
+ # Build JSON payload for wrapper stdin
432
+ payload = {
433
+ "prompt": self._build_prompt(request),
434
+ "system_prompt": request.system_prompt,
435
+ "config": {
436
+ "model": model,
437
+ "temperature": request.temperature,
438
+ "max_tokens": request.max_tokens,
439
+ },
440
+ }
441
+
442
+ # Build command to invoke wrapper
443
+ command = [self._binary, str(self._wrapper_path)]
444
+ if request.stream:
445
+ command.append("--stream")
446
+
447
+ # Execute wrapper with JSON payload via stdin
448
+ timeout = request.timeout or self._timeout
449
+ try:
450
+ completed = self._runner(
451
+ command,
452
+ timeout=int(timeout) if timeout else None,
453
+ env=self._env,
454
+ input_data=json.dumps(payload),
455
+ )
456
+ except FileNotFoundError as exc:
457
+ raise ProviderUnavailableError(
458
+ f"Node.js binary '{self._binary}' not found",
459
+ provider=self.metadata.provider_id,
460
+ ) from exc
461
+ except subprocess.TimeoutExpired as exc:
462
+ raise ProviderTimeoutError(
463
+ f"OpenCode wrapper timed out after {timeout}s",
464
+ provider=self.metadata.provider_id,
465
+ ) from exc
466
+
467
+ if completed.returncode != 0:
468
+ stderr = (completed.stderr or "").strip()
469
+ logger.debug(f"OpenCode wrapper stderr: {stderr or 'no stderr'}")
470
+ raise ProviderExecutionError(
471
+ f"OpenCode wrapper exited with code {completed.returncode}",
472
+ provider=self.metadata.provider_id,
473
+ )
474
+
475
+ # Parse line-delimited JSON output
476
+ content_parts: List[str] = []
477
+ final_usage: Optional[TokenUsage] = None
478
+ raw_payload: Dict[str, Any] = {}
479
+ reported_model = model
480
+
481
+ for line in completed.stdout.strip().split("\n"):
482
+ if not line.strip():
483
+ continue
484
+
485
+ try:
486
+ msg = json.loads(line)
487
+ except json.JSONDecodeError as exc:
488
+ logger.debug(f"OpenCode wrapper JSON parse error: {exc}")
489
+ raise ProviderExecutionError(
490
+ "OpenCode wrapper returned invalid JSON response",
491
+ provider=self.metadata.provider_id,
492
+ ) from exc
493
+
494
+ msg_type = msg.get("type")
495
+
496
+ if msg_type == "chunk":
497
+ # Streaming chunk
498
+ chunk_content = msg.get("content", "")
499
+ content_parts.append(chunk_content)
500
+ if request.stream:
501
+ self._emit_stream_chunk(
502
+ StreamChunk(content=chunk_content, index=len(content_parts) - 1)
503
+ )
504
+
505
+ elif msg_type == "done":
506
+ # Final response with metadata
507
+ response_data = msg.get("response", {})
508
+ final_text = response_data.get("text", "")
509
+ if final_text and not content_parts:
510
+ content_parts.append(final_text)
511
+
512
+ # Extract model from response
513
+ reported_model = response_data.get("model", model)
514
+
515
+ # Extract token usage
516
+ usage_data = response_data.get("usage", {})
517
+ final_usage = TokenUsage(
518
+ input_tokens=usage_data.get("prompt_tokens", 0),
519
+ output_tokens=usage_data.get("completion_tokens", 0),
520
+ total_tokens=usage_data.get("total_tokens", 0),
521
+ )
522
+ raw_payload = response_data
523
+
524
+ elif msg_type == "error":
525
+ # Error from wrapper
526
+ error_msg = msg.get("message", "Unknown error")
527
+ raise ProviderExecutionError(
528
+ f"OpenCode wrapper error: {error_msg}",
529
+ provider=self.metadata.provider_id,
530
+ )
531
+
532
+ # Combine all content parts
533
+ final_content = "".join(content_parts)
534
+
535
+ # Emit final content if streaming was requested
536
+ self._emit_stream_if_requested(final_content, stream=request.stream)
537
+
538
+ # Use default usage if not provided
539
+ if final_usage is None:
540
+ final_usage = TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
541
+
542
+ return ProviderResult(
543
+ content=final_content,
544
+ provider_id=self.metadata.provider_id,
545
+ model_used=f"{self.metadata.provider_id}:{reported_model}",
546
+ status=ProviderStatus.SUCCESS,
547
+ tokens=final_usage,
548
+ stderr=(completed.stderr or "").strip() or None,
549
+ raw_payload=raw_payload,
550
+ )
551
+
552
+
553
+ def is_opencode_available() -> bool:
554
+ """OpenCode provider availability check."""
555
+ return detect_provider_availability("opencode")
556
+
557
+
558
+ def create_provider(
559
+ *,
560
+ hooks: ProviderHooks,
561
+ model: Optional[str] = None,
562
+ dependencies: Optional[Dict[str, object]] = None,
563
+ overrides: Optional[Dict[str, object]] = None,
564
+ ) -> OpenCodeProvider:
565
+ """
566
+ Factory function for creating OpenCodeProvider instances.
567
+
568
+ Args:
569
+ hooks: Provider hooks for callbacks
570
+ model: Optional model ID override
571
+ dependencies: Optional dependencies (runner, env, binary)
572
+ overrides: Optional parameter overrides
573
+
574
+ Returns:
575
+ Configured OpenCodeProvider instance
576
+ """
577
+ dependencies = dependencies or {}
578
+ overrides = overrides or {}
579
+
580
+ runner = dependencies.get("runner")
581
+ env = dependencies.get("env")
582
+ binary = overrides.get("binary") or dependencies.get("binary")
583
+ wrapper_path = overrides.get("wrapper_path") or dependencies.get("wrapper_path")
584
+ timeout = overrides.get("timeout") or dependencies.get("timeout")
585
+ selected_model = overrides.get("model") if overrides.get("model") else model
586
+
587
+ return OpenCodeProvider(
588
+ metadata=OPENCODE_METADATA,
589
+ hooks=hooks,
590
+ model=selected_model, # type: ignore[arg-type]
591
+ binary=binary, # type: ignore[arg-type]
592
+ wrapper_path=wrapper_path, # type: ignore[arg-type]
593
+ runner=runner if runner is not None else None, # type: ignore[arg-type]
594
+ env=env if env is not None else None, # type: ignore[arg-type]
595
+ timeout=timeout if timeout is not None else None, # type: ignore[arg-type]
596
+ )
597
+
598
+
599
+ # Register the provider immediately so consumers can resolve it by id.
600
+ register_provider(
601
+ "opencode",
602
+ factory=create_provider,
603
+ metadata=OPENCODE_METADATA,
604
+ availability_check=is_opencode_available,
605
+ description="OpenCode AI SDK adapter with Node.js wrapper",
606
+ tags=("sdk", "text", "streaming", "read-only"),
607
+ replace=True,
608
+ )
609
+
610
+
611
+ __all__ = [
612
+ "OpenCodeProvider",
613
+ "create_provider",
614
+ "is_opencode_available",
615
+ "OPENCODE_METADATA",
616
+ ]