codex-autorunner 0.1.0__py3-none-any.whl → 0.1.2__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.
- codex_autorunner/agents/__init__.py +21 -0
- codex_autorunner/agents/base.py +62 -0
- codex_autorunner/agents/codex/__init__.py +5 -0
- codex_autorunner/agents/codex/harness.py +220 -0
- codex_autorunner/agents/execution/policy.py +292 -0
- codex_autorunner/agents/factory.py +52 -0
- codex_autorunner/agents/opencode/__init__.py +18 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +553 -0
- codex_autorunner/agents/opencode/events.py +67 -0
- codex_autorunner/agents/opencode/harness.py +263 -0
- codex_autorunner/agents/opencode/logging.py +209 -0
- codex_autorunner/agents/opencode/run_prompt.py +260 -0
- codex_autorunner/agents/opencode/runtime.py +1491 -0
- codex_autorunner/agents/opencode/supervisor.py +520 -0
- codex_autorunner/agents/orchestrator.py +358 -0
- codex_autorunner/agents/registry.py +130 -0
- codex_autorunner/agents/types.py +42 -0
- codex_autorunner/bootstrap.py +32 -23
- codex_autorunner/cli.py +389 -116
- codex_autorunner/codex_cli.py +5 -0
- codex_autorunner/core/about_car.py +20 -7
- codex_autorunner/core/app_server_events.py +192 -0
- codex_autorunner/core/app_server_logging.py +205 -0
- codex_autorunner/core/app_server_prompts.py +378 -0
- codex_autorunner/core/app_server_threads.py +195 -0
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/config.py +888 -82
- codex_autorunner/core/doc_chat.py +1248 -349
- codex_autorunner/core/docs.py +83 -6
- codex_autorunner/core/engine.py +2037 -120
- codex_autorunner/core/exceptions.py +60 -0
- codex_autorunner/core/git_utils.py +28 -0
- codex_autorunner/core/hub.py +232 -99
- codex_autorunner/core/locks.py +230 -3
- codex_autorunner/core/logging_utils.py +14 -7
- codex_autorunner/core/optional_dependencies.py +7 -4
- codex_autorunner/core/patch_utils.py +224 -0
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +4 -31
- codex_autorunner/core/request_context.py +18 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +164 -0
- codex_autorunner/core/run_index.py +217 -0
- codex_autorunner/core/runner_controller.py +56 -1
- codex_autorunner/core/runner_process.py +27 -1
- codex_autorunner/core/snapshot.py +136 -132
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +379 -58
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +15 -1
- codex_autorunner/core/usage.py +760 -69
- codex_autorunner/core/utils.py +167 -5
- codex_autorunner/discovery.py +115 -30
- codex_autorunner/integrations/app_server/client.py +161 -73
- codex_autorunner/integrations/app_server/env.py +110 -0
- codex_autorunner/integrations/app_server/supervisor.py +1 -0
- codex_autorunner/integrations/github/chatops.py +268 -0
- codex_autorunner/integrations/github/pr_flow.py +1314 -0
- codex_autorunner/integrations/github/service.py +269 -1
- codex_autorunner/integrations/telegram/adapter.py +395 -41
- codex_autorunner/integrations/telegram/config.py +161 -2
- codex_autorunner/integrations/telegram/constants.py +17 -0
- codex_autorunner/integrations/telegram/dispatch.py +82 -40
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +27 -2
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +27 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2599 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1412 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +2229 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +1243 -3422
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +10 -12
- codex_autorunner/integrations/telegram/handlers/messages.py +398 -46
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +79 -1
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +129 -121
- codex_autorunner/integrations/telegram/notifications.py +422 -26
- codex_autorunner/integrations/telegram/outbox.py +77 -56
- codex_autorunner/integrations/telegram/overflow.py +194 -0
- codex_autorunner/integrations/telegram/progress_stream.py +237 -0
- codex_autorunner/integrations/telegram/runtime.py +33 -20
- codex_autorunner/integrations/telegram/service.py +427 -34
- codex_autorunner/integrations/telegram/state.py +1225 -330
- codex_autorunner/integrations/telegram/transport.py +89 -9
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +48 -1
- codex_autorunner/routes/__init__.py +14 -0
- codex_autorunner/routes/agents.py +138 -0
- codex_autorunner/routes/app_server.py +132 -0
- codex_autorunner/routes/base.py +202 -47
- codex_autorunner/routes/docs.py +132 -26
- codex_autorunner/routes/github.py +136 -6
- codex_autorunner/routes/repos.py +76 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/runs.py +250 -0
- codex_autorunner/routes/sessions.py +49 -10
- codex_autorunner/routes/settings.py +169 -0
- codex_autorunner/routes/shared.py +149 -10
- codex_autorunner/routes/system.py +16 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/server.py +0 -7
- codex_autorunner/spec_ingest.py +778 -79
- codex_autorunner/static/agentControls.js +351 -0
- codex_autorunner/static/app.js +85 -78
- codex_autorunner/static/autoRefresh.js +118 -147
- codex_autorunner/static/bootstrap.js +117 -99
- codex_autorunner/static/bus.js +16 -17
- codex_autorunner/static/cache.js +26 -41
- codex_autorunner/static/constants.js +44 -45
- codex_autorunner/static/dashboard.js +723 -717
- codex_autorunner/static/docChatActions.js +287 -0
- codex_autorunner/static/docChatEvents.js +300 -0
- codex_autorunner/static/docChatRender.js +205 -0
- codex_autorunner/static/docChatStream.js +361 -0
- codex_autorunner/static/docs.js +18 -1512
- codex_autorunner/static/docsClipboard.js +69 -0
- codex_autorunner/static/docsCrud.js +257 -0
- codex_autorunner/static/docsDocUpdates.js +62 -0
- codex_autorunner/static/docsDrafts.js +16 -0
- codex_autorunner/static/docsElements.js +69 -0
- codex_autorunner/static/docsInit.js +285 -0
- codex_autorunner/static/docsParse.js +160 -0
- codex_autorunner/static/docsSnapshot.js +87 -0
- codex_autorunner/static/docsSpecIngest.js +263 -0
- codex_autorunner/static/docsState.js +127 -0
- codex_autorunner/static/docsThreadRegistry.js +44 -0
- codex_autorunner/static/docsUi.js +153 -0
- codex_autorunner/static/docsVoice.js +56 -0
- codex_autorunner/static/env.js +29 -79
- codex_autorunner/static/github.js +489 -153
- codex_autorunner/static/hub.js +1235 -1331
- codex_autorunner/static/index.html +407 -49
- codex_autorunner/static/liveUpdates.js +58 -0
- codex_autorunner/static/loader.js +26 -26
- codex_autorunner/static/logs.js +598 -610
- codex_autorunner/static/mobileCompact.js +215 -263
- codex_autorunner/static/review.js +157 -0
- codex_autorunner/static/runs.js +418 -0
- codex_autorunner/static/settings.js +341 -0
- codex_autorunner/static/snapshot.js +104 -96
- codex_autorunner/static/state.js +76 -69
- codex_autorunner/static/styles.css +1905 -436
- codex_autorunner/static/tabs.js +34 -43
- codex_autorunner/static/terminal.js +6 -15
- codex_autorunner/static/terminalManager.js +3532 -3468
- codex_autorunner/static/todoPreview.js +25 -23
- codex_autorunner/static/utils.js +567 -534
- codex_autorunner/static/voice.js +498 -537
- codex_autorunner/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +907 -172
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +54 -18
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +144 -0
- codex_autorunner/web/static_assets.py +57 -0
- codex_autorunner/web/static_refresh.py +86 -0
- {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/METADATA +17 -8
- codex_autorunner-0.1.2.dist-info/RECORD +222 -0
- {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/WHEEL +1 -1
- codex_autorunner/static/types.d.ts +0 -8
- codex_autorunner-0.1.0.dist-info/RECORD +0 -147
- {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def ensure_agent_config(
|
|
12
|
+
workspace_root: Path,
|
|
13
|
+
agent_id: str,
|
|
14
|
+
model: Optional[str],
|
|
15
|
+
title: Optional[str] = None,
|
|
16
|
+
description: Optional[str] = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Ensure .opencode/agent/<agent_id>.md exists with frontmatter config.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
workspace_root: Path to the workspace root
|
|
22
|
+
agent_id: Agent ID (e.g., "subagent")
|
|
23
|
+
model: Model ID in format "providerID/modelID" (e.g., "zai-coding-plan/glm-4.7-flashx")
|
|
24
|
+
title: Optional title for the agent
|
|
25
|
+
description: Optional description for the agent
|
|
26
|
+
"""
|
|
27
|
+
if model is None:
|
|
28
|
+
logger.debug(f"Skipping agent config for {agent_id}: no model configured")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
agent_dir = workspace_root / ".opencode" / "agent"
|
|
32
|
+
agent_file = agent_dir / f"{agent_id}.md"
|
|
33
|
+
|
|
34
|
+
# Check if file already exists and has the correct model
|
|
35
|
+
if agent_file.exists():
|
|
36
|
+
existing_content = agent_file.read_text(encoding="utf-8")
|
|
37
|
+
existing_model = _extract_model_from_frontmatter(existing_content)
|
|
38
|
+
if existing_model == model:
|
|
39
|
+
logger.debug(f"Agent config already exists for {agent_id}: {agent_file}")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Create agent directory if needed
|
|
43
|
+
await asyncio.to_thread(agent_dir.mkdir, parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
# Build agent markdown with frontmatter
|
|
46
|
+
content = _build_agent_md(
|
|
47
|
+
agent_id=agent_id,
|
|
48
|
+
model=model,
|
|
49
|
+
title=title or agent_id,
|
|
50
|
+
description=description or f"Subagent for {agent_id} tasks",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Write atomically
|
|
54
|
+
await asyncio.to_thread(agent_file.write_text, content, encoding="utf-8")
|
|
55
|
+
logger.info(f"Created agent config: {agent_file} with model {model}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_agent_md(
|
|
59
|
+
agent_id: str,
|
|
60
|
+
model: str,
|
|
61
|
+
title: str,
|
|
62
|
+
description: str,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Generate markdown with YAML frontmatter.
|
|
65
|
+
|
|
66
|
+
Frontmatter format per OpenCode config schema:
|
|
67
|
+
---
|
|
68
|
+
agent: <agent_id>
|
|
69
|
+
title: "<title>"
|
|
70
|
+
description: "<description>"
|
|
71
|
+
model: <providerID>/<modelID>
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
<Optional agent instructions go here>
|
|
75
|
+
"""
|
|
76
|
+
return f"""---
|
|
77
|
+
agent: {agent_id}
|
|
78
|
+
title: "{title}"
|
|
79
|
+
description: "{description}"
|
|
80
|
+
model: {model}
|
|
81
|
+
---
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_model_from_frontmatter(content: str) -> Optional[str]:
|
|
86
|
+
"""Extract model value from YAML frontmatter.
|
|
87
|
+
|
|
88
|
+
Returns None if frontmatter or model field is not found.
|
|
89
|
+
"""
|
|
90
|
+
lines = content.splitlines()
|
|
91
|
+
if not lines or not lines[0].startswith("---"):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
for _i, line in enumerate(lines[1:], start=1):
|
|
95
|
+
if line.startswith("---"):
|
|
96
|
+
break
|
|
97
|
+
if line.startswith("model:"):
|
|
98
|
+
model = line.split(":", 1)[1].strip()
|
|
99
|
+
return model if model else None
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["ensure_agent_config"]
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, AsyncIterator, Iterable, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ...core.logging_utils import log_event
|
|
12
|
+
from .events import SSEEvent, parse_sse_lines
|
|
13
|
+
|
|
14
|
+
_MAX_INVALID_JSON_PREVIEW_BYTES = 512
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass
|
|
18
|
+
class OpenCodeApiProfile:
|
|
19
|
+
"""Detected OpenCode API capabilities from OpenAPI spec."""
|
|
20
|
+
|
|
21
|
+
supports_prompt_async: bool = True
|
|
22
|
+
supports_global_endpoints: bool = True
|
|
23
|
+
spec_fetched: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenCodeProtocolError(Exception):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
status_code: Optional[int] = None,
|
|
32
|
+
content_type: Optional[str] = None,
|
|
33
|
+
body_preview: Optional[str] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.content_type = content_type
|
|
38
|
+
self.body_preview = body_preview
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalize_sse_event(event: SSEEvent) -> SSEEvent:
|
|
42
|
+
event_type = event.event
|
|
43
|
+
raw_data = event.data or ""
|
|
44
|
+
payload_obj: Optional[dict[str, Any]] = None
|
|
45
|
+
try:
|
|
46
|
+
payload_obj = json.loads(raw_data) if raw_data else None
|
|
47
|
+
except (json.JSONDecodeError, TypeError):
|
|
48
|
+
payload_obj = None
|
|
49
|
+
|
|
50
|
+
if isinstance(payload_obj, dict) and isinstance(payload_obj.get("payload"), dict):
|
|
51
|
+
payload_obj = payload_obj["payload"]
|
|
52
|
+
|
|
53
|
+
if isinstance(payload_obj, dict):
|
|
54
|
+
payload_type = payload_obj.get("type")
|
|
55
|
+
if isinstance(payload_type, str) and payload_type:
|
|
56
|
+
event_type = payload_type
|
|
57
|
+
raw_data = json.dumps(payload_obj)
|
|
58
|
+
|
|
59
|
+
return SSEEvent(
|
|
60
|
+
event=event_type,
|
|
61
|
+
data=raw_data,
|
|
62
|
+
id=event.id,
|
|
63
|
+
retry=event.retry,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OpenCodeClient:
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
base_url: str,
|
|
71
|
+
*,
|
|
72
|
+
auth: Optional[tuple[str, str]] = None,
|
|
73
|
+
timeout: Optional[float] = None,
|
|
74
|
+
logger: Optional[logging.Logger] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self._client = httpx.AsyncClient(
|
|
77
|
+
base_url=base_url,
|
|
78
|
+
auth=auth,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
self._logger = logger or logging.getLogger(__name__)
|
|
82
|
+
self._api_profile: Optional[OpenCodeApiProfile] = None
|
|
83
|
+
self._api_profile_lock = asyncio.Lock()
|
|
84
|
+
|
|
85
|
+
async def close(self) -> None:
|
|
86
|
+
await self._client.aclose()
|
|
87
|
+
|
|
88
|
+
async def detect_api_shape(self) -> OpenCodeApiProfile:
|
|
89
|
+
"""Detect OpenCode API capabilities by fetching and parsing OpenAPI spec.
|
|
90
|
+
Results are cached for the lifetime of the client instance.
|
|
91
|
+
Thread-safe: multiple concurrent calls will wait for first detection to complete.
|
|
92
|
+
"""
|
|
93
|
+
async with self._api_profile_lock:
|
|
94
|
+
if self._api_profile is not None:
|
|
95
|
+
return self._api_profile
|
|
96
|
+
|
|
97
|
+
profile = OpenCodeApiProfile()
|
|
98
|
+
try:
|
|
99
|
+
spec = await self.fetch_openapi_spec()
|
|
100
|
+
profile.spec_fetched = True
|
|
101
|
+
|
|
102
|
+
if isinstance(spec, dict):
|
|
103
|
+
# Check if /session/{id}/prompt_async exists
|
|
104
|
+
profile.supports_prompt_async = self.has_endpoint(
|
|
105
|
+
spec, "post", "/session/{session_id}/prompt_async"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Check if /global/* endpoints exist
|
|
109
|
+
profile.supports_global_endpoints = self.has_endpoint(
|
|
110
|
+
spec, "get", "/global/health"
|
|
111
|
+
) or self.has_endpoint(spec, "get", "/global/event")
|
|
112
|
+
|
|
113
|
+
log_event(
|
|
114
|
+
self._logger,
|
|
115
|
+
logging.INFO,
|
|
116
|
+
"opencode.api_shape_detected",
|
|
117
|
+
supports_prompt_async=profile.supports_prompt_async,
|
|
118
|
+
supports_global_endpoints=profile.supports_global_endpoints,
|
|
119
|
+
)
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
self._logger.warning(
|
|
122
|
+
"Failed to detect API shape, assuming modern OpenCode: %s", exc
|
|
123
|
+
)
|
|
124
|
+
# Default to assuming modern OpenCode with all features
|
|
125
|
+
profile.supports_prompt_async = True
|
|
126
|
+
profile.supports_global_endpoints = True
|
|
127
|
+
|
|
128
|
+
self._api_profile = profile
|
|
129
|
+
return profile
|
|
130
|
+
|
|
131
|
+
def _get_api_profile(self) -> OpenCodeApiProfile:
|
|
132
|
+
"""Get API profile, detecting if needed. Synchronous for use in sync methods."""
|
|
133
|
+
if self._api_profile is None:
|
|
134
|
+
# Return default profile if not yet detected
|
|
135
|
+
return OpenCodeApiProfile()
|
|
136
|
+
return self._api_profile
|
|
137
|
+
|
|
138
|
+
def _dir_params(self, directory: Optional[str]) -> dict[str, str]:
|
|
139
|
+
return {"directory": directory} if directory else {}
|
|
140
|
+
|
|
141
|
+
async def _request(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
path: str,
|
|
145
|
+
*,
|
|
146
|
+
params: Optional[dict[str, Any]] = None,
|
|
147
|
+
json_body: Optional[dict[str, Any]] = None,
|
|
148
|
+
expect_json: bool = True,
|
|
149
|
+
) -> Any:
|
|
150
|
+
response = await self._client.request(
|
|
151
|
+
method, path, params=params, json=json_body
|
|
152
|
+
)
|
|
153
|
+
response.raise_for_status()
|
|
154
|
+
raw = response.content
|
|
155
|
+
if not raw or not raw.strip():
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
return json.loads(raw)
|
|
159
|
+
except json.JSONDecodeError as exc:
|
|
160
|
+
self._log_invalid_json(
|
|
161
|
+
method,
|
|
162
|
+
path,
|
|
163
|
+
response,
|
|
164
|
+
raw,
|
|
165
|
+
expect_json=expect_json,
|
|
166
|
+
)
|
|
167
|
+
if expect_json:
|
|
168
|
+
preview = (
|
|
169
|
+
raw[:_MAX_INVALID_JSON_PREVIEW_BYTES]
|
|
170
|
+
.decode("utf-8", errors="replace")
|
|
171
|
+
.strip()
|
|
172
|
+
)
|
|
173
|
+
content_type = response.headers.get("content-type")
|
|
174
|
+
hint = ""
|
|
175
|
+
if content_type and "text/html" in content_type.lower():
|
|
176
|
+
hint = (
|
|
177
|
+
" Response looks like HTML; the OpenCode server may have "
|
|
178
|
+
"proxied the request instead of handling an API route."
|
|
179
|
+
)
|
|
180
|
+
elif preview.startswith("<"):
|
|
181
|
+
hint = (
|
|
182
|
+
" Response looks like HTML; check that the OpenCode API "
|
|
183
|
+
"endpoint is correct."
|
|
184
|
+
)
|
|
185
|
+
raise OpenCodeProtocolError(
|
|
186
|
+
f"OpenCode returned invalid JSON.{hint}",
|
|
187
|
+
status_code=response.status_code,
|
|
188
|
+
content_type=content_type,
|
|
189
|
+
body_preview=preview or None,
|
|
190
|
+
) from exc
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def _log_invalid_json(
|
|
194
|
+
self,
|
|
195
|
+
method: str,
|
|
196
|
+
path: str,
|
|
197
|
+
response: httpx.Response,
|
|
198
|
+
raw: bytes,
|
|
199
|
+
*,
|
|
200
|
+
expect_json: bool,
|
|
201
|
+
) -> None:
|
|
202
|
+
preview = raw[:_MAX_INVALID_JSON_PREVIEW_BYTES].decode(
|
|
203
|
+
"utf-8", errors="replace"
|
|
204
|
+
)
|
|
205
|
+
log_event(
|
|
206
|
+
self._logger,
|
|
207
|
+
logging.WARNING,
|
|
208
|
+
"opencode.response.invalid_json",
|
|
209
|
+
method=method,
|
|
210
|
+
path=path,
|
|
211
|
+
status_code=response.status_code,
|
|
212
|
+
content_length=len(raw),
|
|
213
|
+
content_type=response.headers.get("content-type"),
|
|
214
|
+
expect_json=expect_json,
|
|
215
|
+
preview=preview,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def providers(self, directory: Optional[str] = None) -> Any:
|
|
219
|
+
return await self._request(
|
|
220
|
+
"GET",
|
|
221
|
+
"/config/providers",
|
|
222
|
+
params=self._dir_params(directory),
|
|
223
|
+
expect_json=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def create_session(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
title: Optional[str] = None,
|
|
230
|
+
directory: Optional[str] = None,
|
|
231
|
+
) -> Any:
|
|
232
|
+
payload: dict[str, Any] = {}
|
|
233
|
+
if title:
|
|
234
|
+
payload["title"] = title
|
|
235
|
+
if directory:
|
|
236
|
+
payload["directory"] = directory
|
|
237
|
+
return await self._request(
|
|
238
|
+
"POST", "/session", json_body=payload, expect_json=True
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
async def list_sessions(self, directory: Optional[str] = None) -> Any:
|
|
242
|
+
return await self._request(
|
|
243
|
+
"GET", "/session", params=self._dir_params(directory), expect_json=True
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def get_session(self, session_id: str) -> Any:
|
|
247
|
+
return await self._request("GET", f"/session/{session_id}", expect_json=True)
|
|
248
|
+
|
|
249
|
+
async def session_status(self, *, directory: Optional[str] = None) -> Any:
|
|
250
|
+
return await self._request(
|
|
251
|
+
"GET",
|
|
252
|
+
"/session/status",
|
|
253
|
+
params=self._dir_params(directory),
|
|
254
|
+
expect_json=True,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def send_message(
|
|
258
|
+
self,
|
|
259
|
+
session_id: str,
|
|
260
|
+
*,
|
|
261
|
+
message: str,
|
|
262
|
+
agent: Optional[str] = None,
|
|
263
|
+
model: Optional[dict[str, str]] = None,
|
|
264
|
+
variant: Optional[str] = None,
|
|
265
|
+
) -> Any:
|
|
266
|
+
payload: dict[str, Any] = {
|
|
267
|
+
"parts": [{"type": "text", "text": message}],
|
|
268
|
+
}
|
|
269
|
+
if agent:
|
|
270
|
+
payload["agent"] = agent
|
|
271
|
+
if model:
|
|
272
|
+
payload["model"] = model
|
|
273
|
+
if variant:
|
|
274
|
+
payload["variant"] = variant
|
|
275
|
+
return await self._request(
|
|
276
|
+
"POST",
|
|
277
|
+
f"/session/{session_id}/message",
|
|
278
|
+
json_body=payload,
|
|
279
|
+
expect_json=False,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
async def prompt(
|
|
283
|
+
self,
|
|
284
|
+
session_id: str,
|
|
285
|
+
*,
|
|
286
|
+
message: str,
|
|
287
|
+
agent: Optional[str] = None,
|
|
288
|
+
model: Optional[dict[str, str]] = None,
|
|
289
|
+
variant: Optional[str] = None,
|
|
290
|
+
) -> Any:
|
|
291
|
+
payload: dict[str, Any] = {
|
|
292
|
+
"parts": [{"type": "text", "text": message}],
|
|
293
|
+
}
|
|
294
|
+
if agent:
|
|
295
|
+
payload["agent"] = agent
|
|
296
|
+
if model:
|
|
297
|
+
payload["model"] = model
|
|
298
|
+
if variant:
|
|
299
|
+
payload["variant"] = variant
|
|
300
|
+
|
|
301
|
+
profile = await self.detect_api_shape()
|
|
302
|
+
if profile.supports_prompt_async:
|
|
303
|
+
return await self._request(
|
|
304
|
+
"POST",
|
|
305
|
+
f"/session/{session_id}/prompt_async",
|
|
306
|
+
json_body=payload,
|
|
307
|
+
expect_json=False,
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
return await self._request(
|
|
311
|
+
"POST",
|
|
312
|
+
f"/session/{session_id}/message",
|
|
313
|
+
json_body=payload,
|
|
314
|
+
expect_json=True,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def prompt_async(
|
|
318
|
+
self,
|
|
319
|
+
session_id: str,
|
|
320
|
+
*,
|
|
321
|
+
message: str,
|
|
322
|
+
agent: Optional[str] = None,
|
|
323
|
+
model: Optional[dict[str, str]] = None,
|
|
324
|
+
variant: Optional[str] = None,
|
|
325
|
+
) -> Any:
|
|
326
|
+
payload: dict[str, Any] = {
|
|
327
|
+
"parts": [{"type": "text", "text": message}],
|
|
328
|
+
}
|
|
329
|
+
if agent:
|
|
330
|
+
payload["agent"] = agent
|
|
331
|
+
if model:
|
|
332
|
+
payload["model"] = model
|
|
333
|
+
if variant:
|
|
334
|
+
payload["variant"] = variant
|
|
335
|
+
|
|
336
|
+
profile = await self.detect_api_shape()
|
|
337
|
+
if profile.supports_prompt_async:
|
|
338
|
+
return await self._request(
|
|
339
|
+
"POST",
|
|
340
|
+
f"/session/{session_id}/prompt_async",
|
|
341
|
+
json_body=payload,
|
|
342
|
+
expect_json=False,
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
return await self._request(
|
|
346
|
+
"POST",
|
|
347
|
+
f"/session/{session_id}/message",
|
|
348
|
+
json_body=payload,
|
|
349
|
+
expect_json=True,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def send_command(
|
|
353
|
+
self,
|
|
354
|
+
session_id: str,
|
|
355
|
+
*,
|
|
356
|
+
command: str,
|
|
357
|
+
arguments: Optional[str] = None,
|
|
358
|
+
model: Optional[str] = None,
|
|
359
|
+
agent: Optional[str] = None,
|
|
360
|
+
) -> Any:
|
|
361
|
+
payload: dict[str, Any] = {
|
|
362
|
+
"command": command,
|
|
363
|
+
"arguments": arguments or "",
|
|
364
|
+
}
|
|
365
|
+
if model:
|
|
366
|
+
payload["model"] = model
|
|
367
|
+
if agent:
|
|
368
|
+
payload["agent"] = agent
|
|
369
|
+
return await self._request(
|
|
370
|
+
"POST",
|
|
371
|
+
f"/session/{session_id}/command",
|
|
372
|
+
json_body=payload,
|
|
373
|
+
expect_json=False,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
async def summarize(
|
|
377
|
+
self,
|
|
378
|
+
session_id: str,
|
|
379
|
+
*,
|
|
380
|
+
provider_id: str,
|
|
381
|
+
model_id: str,
|
|
382
|
+
auto: Optional[bool] = None,
|
|
383
|
+
) -> Any:
|
|
384
|
+
payload: dict[str, Any] = {
|
|
385
|
+
"providerID": provider_id,
|
|
386
|
+
"modelID": model_id,
|
|
387
|
+
}
|
|
388
|
+
if auto is not None:
|
|
389
|
+
payload["auto"] = auto
|
|
390
|
+
return await self._request(
|
|
391
|
+
"POST",
|
|
392
|
+
f"/session/{session_id}/summarize",
|
|
393
|
+
json_body=payload,
|
|
394
|
+
expect_json=True,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
async def respond_permission(
|
|
398
|
+
self,
|
|
399
|
+
*,
|
|
400
|
+
request_id: str,
|
|
401
|
+
reply: str,
|
|
402
|
+
message: Optional[str] = None,
|
|
403
|
+
) -> Any:
|
|
404
|
+
payload: dict[str, Any] = {"reply": reply}
|
|
405
|
+
if message:
|
|
406
|
+
payload["message"] = message
|
|
407
|
+
return await self._request(
|
|
408
|
+
"POST",
|
|
409
|
+
f"/permission/{request_id}/reply",
|
|
410
|
+
json_body=payload,
|
|
411
|
+
expect_json=False,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
async def list_questions(self) -> Any:
|
|
415
|
+
return await self._request("GET", "/question", expect_json=True)
|
|
416
|
+
|
|
417
|
+
async def reply_question(self, request_id: str, *, answers: list[list[str]]) -> Any:
|
|
418
|
+
payload: dict[str, Any] = {"answers": answers}
|
|
419
|
+
return await self._request(
|
|
420
|
+
"POST",
|
|
421
|
+
f"/question/{request_id}/reply",
|
|
422
|
+
json_body=payload,
|
|
423
|
+
expect_json=False,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
async def reject_question(self, request_id: str) -> Any:
|
|
427
|
+
return await self._request(
|
|
428
|
+
"POST",
|
|
429
|
+
f"/question/{request_id}/reject",
|
|
430
|
+
expect_json=False,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def abort(self, session_id: str) -> Any:
|
|
434
|
+
return await self._request(
|
|
435
|
+
"POST", f"/session/{session_id}/abort", expect_json=False
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
async def health(self) -> Any:
|
|
439
|
+
"""Check OpenCode server health using /global/health or /health endpoint."""
|
|
440
|
+
profile = await self.detect_api_shape()
|
|
441
|
+
if profile.supports_global_endpoints:
|
|
442
|
+
return await self._request("GET", "/global/health", expect_json=True)
|
|
443
|
+
else:
|
|
444
|
+
return await self._request("GET", "/health", expect_json=True)
|
|
445
|
+
|
|
446
|
+
async def dispose(self, session_id: str) -> Any:
|
|
447
|
+
"""Dispose of a session using /global/dispose/{id} or /session/{id}/dispose endpoint."""
|
|
448
|
+
profile = await self.detect_api_shape()
|
|
449
|
+
if profile.supports_global_endpoints:
|
|
450
|
+
return await self._request(
|
|
451
|
+
"POST", f"/global/dispose/{session_id}", expect_json=False
|
|
452
|
+
)
|
|
453
|
+
else:
|
|
454
|
+
return await self._request(
|
|
455
|
+
"POST", f"/session/{session_id}/dispose", expect_json=False
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
async def stream_events(
|
|
459
|
+
self,
|
|
460
|
+
*,
|
|
461
|
+
directory: Optional[str] = None,
|
|
462
|
+
ready_event: Optional[asyncio.Event] = None,
|
|
463
|
+
paths: Optional[Iterable[str]] = None,
|
|
464
|
+
) -> AsyncIterator[SSEEvent]:
|
|
465
|
+
params = self._dir_params(directory)
|
|
466
|
+
|
|
467
|
+
if paths is not None:
|
|
468
|
+
event_paths = list(paths)
|
|
469
|
+
else:
|
|
470
|
+
profile = await self.detect_api_shape()
|
|
471
|
+
if profile.supports_global_endpoints:
|
|
472
|
+
event_paths = (
|
|
473
|
+
["/event", "/global/event"]
|
|
474
|
+
if directory
|
|
475
|
+
else ["/global/event", "/event"]
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
event_paths = ["/event"]
|
|
479
|
+
|
|
480
|
+
last_error: Optional[BaseException] = None
|
|
481
|
+
for path in event_paths:
|
|
482
|
+
try:
|
|
483
|
+
async with self._client.stream(
|
|
484
|
+
"GET", path, params=params, timeout=None
|
|
485
|
+
) as response:
|
|
486
|
+
response.raise_for_status()
|
|
487
|
+
if ready_event is not None:
|
|
488
|
+
ready_event.set()
|
|
489
|
+
async for sse in parse_sse_lines(response.aiter_lines()):
|
|
490
|
+
yield _normalize_sse_event(sse)
|
|
491
|
+
return
|
|
492
|
+
except httpx.HTTPStatusError as exc:
|
|
493
|
+
last_error = exc
|
|
494
|
+
status_code = exc.response.status_code
|
|
495
|
+
if status_code in (404, 405):
|
|
496
|
+
continue
|
|
497
|
+
raise
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
last_error = exc
|
|
500
|
+
raise
|
|
501
|
+
if ready_event is not None and not ready_event.is_set():
|
|
502
|
+
ready_event.set()
|
|
503
|
+
if last_error is not None:
|
|
504
|
+
raise last_error
|
|
505
|
+
|
|
506
|
+
async def fetch_openapi_spec(self) -> dict[str, Any]:
|
|
507
|
+
"""Fetch OpenAPI spec from /doc endpoint for capability negotiation."""
|
|
508
|
+
async with self._client.stream("GET", "/doc") as response:
|
|
509
|
+
response.raise_for_status()
|
|
510
|
+
content = response.content
|
|
511
|
+
try:
|
|
512
|
+
spec = json.loads(content) if content else {}
|
|
513
|
+
log_event(
|
|
514
|
+
self._logger,
|
|
515
|
+
logging.INFO,
|
|
516
|
+
"opencode.openapi.fetched",
|
|
517
|
+
paths=len(spec.get("paths", {})) if isinstance(spec, dict) else 0,
|
|
518
|
+
has_components=(
|
|
519
|
+
"components" in spec if isinstance(spec, dict) else False
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
return spec
|
|
523
|
+
except Exception as exc:
|
|
524
|
+
log_event(
|
|
525
|
+
self._logger,
|
|
526
|
+
logging.WARNING,
|
|
527
|
+
"opencode.openapi.parse_failed",
|
|
528
|
+
exc=exc,
|
|
529
|
+
)
|
|
530
|
+
raise OpenCodeProtocolError(
|
|
531
|
+
f"Failed to parse OpenAPI spec: {exc}",
|
|
532
|
+
status_code=response.status_code,
|
|
533
|
+
content_type=(
|
|
534
|
+
response.headers.get("content-type") if response else None
|
|
535
|
+
),
|
|
536
|
+
) from exc
|
|
537
|
+
|
|
538
|
+
def has_endpoint(
|
|
539
|
+
self, openapi_spec: dict[str, Any], method: str, path: str
|
|
540
|
+
) -> bool:
|
|
541
|
+
"""Check if endpoint is available in OpenAPI spec."""
|
|
542
|
+
if not isinstance(openapi_spec, dict):
|
|
543
|
+
return False
|
|
544
|
+
paths = openapi_spec.get("paths", {})
|
|
545
|
+
if not isinstance(paths, dict):
|
|
546
|
+
return False
|
|
547
|
+
path_info = paths.get(path)
|
|
548
|
+
if not isinstance(path_info, dict):
|
|
549
|
+
return False
|
|
550
|
+
return method in path_info
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
__all__ = ["OpenCodeClient", "OpenCodeProtocolError", "OpenCodeApiProfile"]
|