devcopilot 0.2.0__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.
- api/__init__.py +17 -0
- api/admin_config.py +1303 -0
- api/admin_routes.py +287 -0
- api/admin_static/admin.css +459 -0
- api/admin_static/admin.js +497 -0
- api/admin_static/index.html +77 -0
- api/admin_urls.py +34 -0
- api/app.py +194 -0
- api/command_utils.py +164 -0
- api/dependencies.py +144 -0
- api/detection.py +152 -0
- api/gateway_model_ids.py +54 -0
- api/model_catalog.py +133 -0
- api/model_router.py +125 -0
- api/models/__init__.py +45 -0
- api/models/anthropic.py +234 -0
- api/models/openai_responses.py +28 -0
- api/models/responses.py +60 -0
- api/optimization_handlers.py +154 -0
- api/request_pipeline.py +424 -0
- api/routes.py +156 -0
- api/runtime.py +334 -0
- api/validation_log.py +48 -0
- api/web_server_tools.py +22 -0
- api/web_tools/__init__.py +17 -0
- api/web_tools/constants.py +15 -0
- api/web_tools/egress.py +99 -0
- api/web_tools/outbound.py +278 -0
- api/web_tools/parsers.py +104 -0
- api/web_tools/request.py +87 -0
- api/web_tools/streaming.py +206 -0
- cli/__init__.py +5 -0
- cli/claude_env.py +12 -0
- cli/entrypoints.py +166 -0
- cli/env.example +209 -0
- cli/launchers/__init__.py +1 -0
- cli/launchers/claude.py +84 -0
- cli/launchers/codex.py +204 -0
- cli/launchers/codex_model_catalog.py +186 -0
- cli/launchers/common.py +93 -0
- cli/managed/__init__.py +6 -0
- cli/managed/claude.py +215 -0
- cli/managed/manager.py +157 -0
- cli/managed/session.py +260 -0
- cli/process_registry.py +78 -0
- config/__init__.py +5 -0
- config/constants.py +13 -0
- config/logging_config.py +159 -0
- config/nim.py +118 -0
- config/paths.py +91 -0
- config/provider_catalog.py +259 -0
- config/provider_ids.py +7 -0
- config/settings.py +538 -0
- core/__init__.py +1 -0
- core/anthropic/__init__.py +46 -0
- core/anthropic/content.py +31 -0
- core/anthropic/conversion.py +587 -0
- core/anthropic/emitted_sse_tracker.py +346 -0
- core/anthropic/errors.py +70 -0
- core/anthropic/native_messages_request.py +280 -0
- core/anthropic/native_sse_block_policy.py +313 -0
- core/anthropic/provider_stream_error.py +34 -0
- core/anthropic/server_tool_sse.py +14 -0
- core/anthropic/sse.py +440 -0
- core/anthropic/stream_contracts.py +205 -0
- core/anthropic/stream_recovery.py +346 -0
- core/anthropic/stream_recovery_session.py +133 -0
- core/anthropic/thinking.py +140 -0
- core/anthropic/tokens.py +117 -0
- core/anthropic/tools.py +212 -0
- core/anthropic/utils.py +9 -0
- core/openai_responses/__init__.py +5 -0
- core/openai_responses/adapter.py +31 -0
- core/openai_responses/anthropic_sse.py +59 -0
- core/openai_responses/errors.py +22 -0
- core/openai_responses/events.py +19 -0
- core/openai_responses/ids.py +21 -0
- core/openai_responses/input.py +258 -0
- core/openai_responses/items.py +37 -0
- core/openai_responses/reasoning.py +52 -0
- core/openai_responses/stream.py +25 -0
- core/openai_responses/stream_state.py +654 -0
- core/openai_responses/tools.py +374 -0
- core/openai_responses/usage.py +37 -0
- core/rate_limit.py +60 -0
- core/trace.py +216 -0
- devcopilot-0.2.0.dist-info/METADATA +687 -0
- devcopilot-0.2.0.dist-info/RECORD +189 -0
- devcopilot-0.2.0.dist-info/WHEEL +4 -0
- devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
- devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
- messaging/__init__.py +26 -0
- messaging/cli_event_constants.py +67 -0
- messaging/command_context.py +66 -0
- messaging/command_dispatcher.py +37 -0
- messaging/commands.py +275 -0
- messaging/event_parser.py +181 -0
- messaging/limiter.py +300 -0
- messaging/models.py +36 -0
- messaging/node_event_pipeline.py +127 -0
- messaging/node_runner.py +342 -0
- messaging/platforms/__init__.py +15 -0
- messaging/platforms/base.py +228 -0
- messaging/platforms/discord.py +567 -0
- messaging/platforms/factory.py +103 -0
- messaging/platforms/outbox.py +144 -0
- messaging/platforms/telegram.py +688 -0
- messaging/platforms/voice_flow.py +295 -0
- messaging/rendering/__init__.py +3 -0
- messaging/rendering/discord_markdown.py +318 -0
- messaging/rendering/markdown_tables.py +49 -0
- messaging/rendering/profiles.py +55 -0
- messaging/rendering/telegram_markdown.py +327 -0
- messaging/safe_diagnostics.py +17 -0
- messaging/session.py +334 -0
- messaging/transcript.py +581 -0
- messaging/transcription.py +164 -0
- messaging/trees/__init__.py +15 -0
- messaging/trees/data.py +482 -0
- messaging/trees/manager.py +433 -0
- messaging/trees/processor.py +179 -0
- messaging/trees/repository.py +177 -0
- messaging/turn_intake.py +235 -0
- messaging/ui_updates.py +101 -0
- messaging/voice.py +76 -0
- messaging/workflow.py +200 -0
- providers/__init__.py +31 -0
- providers/base.py +152 -0
- providers/cerebras/__init__.py +7 -0
- providers/cerebras/client.py +31 -0
- providers/cerebras/request.py +55 -0
- providers/codestral/__init__.py +7 -0
- providers/codestral/client.py +34 -0
- providers/deepseek/__init__.py +11 -0
- providers/deepseek/client.py +51 -0
- providers/deepseek/request.py +475 -0
- providers/defaults.py +41 -0
- providers/error_mapping.py +309 -0
- providers/exceptions.py +113 -0
- providers/fireworks/__init__.py +5 -0
- providers/fireworks/client.py +45 -0
- providers/fireworks/request.py +48 -0
- providers/gemini/__init__.py +7 -0
- providers/gemini/client.py +49 -0
- providers/gemini/request.py +199 -0
- providers/groq/__init__.py +7 -0
- providers/groq/client.py +31 -0
- providers/groq/request.py +83 -0
- providers/kimi/__init__.py +10 -0
- providers/kimi/client.py +53 -0
- providers/kimi/request.py +42 -0
- providers/llamacpp/__init__.py +3 -0
- providers/llamacpp/client.py +16 -0
- providers/lmstudio/__init__.py +5 -0
- providers/lmstudio/client.py +16 -0
- providers/mistral/__init__.py +7 -0
- providers/mistral/client.py +31 -0
- providers/mistral/request.py +37 -0
- providers/model_listing.py +133 -0
- providers/nvidia_nim/__init__.py +7 -0
- providers/nvidia_nim/client.py +91 -0
- providers/nvidia_nim/request.py +430 -0
- providers/nvidia_nim/voice.py +95 -0
- providers/ollama/__init__.py +7 -0
- providers/ollama/client.py +39 -0
- providers/open_router/__init__.py +7 -0
- providers/open_router/client.py +124 -0
- providers/open_router/request.py +42 -0
- providers/opencode/__init__.py +11 -0
- providers/opencode/client.py +31 -0
- providers/opencode/request.py +35 -0
- providers/rate_limit.py +300 -0
- providers/registry.py +527 -0
- providers/transports/__init__.py +1 -0
- providers/transports/anthropic_messages/__init__.py +5 -0
- providers/transports/anthropic_messages/http.py +118 -0
- providers/transports/anthropic_messages/recovery.py +206 -0
- providers/transports/anthropic_messages/stream.py +295 -0
- providers/transports/anthropic_messages/transport.py +236 -0
- providers/transports/openai_chat/__init__.py +5 -0
- providers/transports/openai_chat/recovery.py +217 -0
- providers/transports/openai_chat/stream.py +384 -0
- providers/transports/openai_chat/tool_calls.py +293 -0
- providers/transports/openai_chat/transport.py +156 -0
- providers/wafer/__init__.py +10 -0
- providers/wafer/client.py +50 -0
- providers/zai/__init__.py +10 -0
- providers/zai/client.py +46 -0
- providers/zai/request.py +42 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Build Codex model catalogs from the FCC model-list route."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from api.gateway_model_ids import (
|
|
13
|
+
GATEWAY_MODEL_ID_PREFIX,
|
|
14
|
+
NO_THINKING_GATEWAY_MODEL_ID_PREFIX,
|
|
15
|
+
)
|
|
16
|
+
from config.provider_ids import SUPPORTED_PROVIDER_IDS
|
|
17
|
+
|
|
18
|
+
SUPPORTED_REASONING_LEVELS = [
|
|
19
|
+
{"effort": "low", "description": "Fast responses with lighter reasoning"},
|
|
20
|
+
{
|
|
21
|
+
"effort": "medium",
|
|
22
|
+
"description": "Balances speed and reasoning depth for everyday tasks",
|
|
23
|
+
},
|
|
24
|
+
{"effort": "high", "description": "Greater reasoning depth for complex problems"},
|
|
25
|
+
{
|
|
26
|
+
"effort": "xhigh",
|
|
27
|
+
"description": "Extra high reasoning depth for complex problems",
|
|
28
|
+
},
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
CODEX_BASE_INSTRUCTIONS = (
|
|
32
|
+
"You are Codex, a coding agent. Help the user understand, modify, test, "
|
|
33
|
+
"and review code in their workspace. Follow the user's instructions, use "
|
|
34
|
+
"tools when needed, and communicate concise progress and verification."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class _CatalogCandidate:
|
|
40
|
+
slug: str
|
|
41
|
+
provider_model_ref: str
|
|
42
|
+
display_name: str
|
|
43
|
+
force_no_thinking: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_codex_model_catalog(models_response: Mapping[str, Any]) -> dict[str, Any]:
|
|
47
|
+
"""Convert FCC `/v1/models` data into Codex `model_catalog_json` payload."""
|
|
48
|
+
|
|
49
|
+
candidates = list(_catalog_candidates(models_response))
|
|
50
|
+
normal_provider_refs = {
|
|
51
|
+
candidate.provider_model_ref
|
|
52
|
+
for candidate in candidates
|
|
53
|
+
if not candidate.force_no_thinking
|
|
54
|
+
}
|
|
55
|
+
models: list[dict[str, Any]] = []
|
|
56
|
+
seen_slugs: set[str] = set()
|
|
57
|
+
|
|
58
|
+
for candidate in candidates:
|
|
59
|
+
if (
|
|
60
|
+
candidate.force_no_thinking
|
|
61
|
+
and candidate.provider_model_ref in normal_provider_refs
|
|
62
|
+
):
|
|
63
|
+
continue
|
|
64
|
+
if candidate.slug in seen_slugs:
|
|
65
|
+
continue
|
|
66
|
+
seen_slugs.add(candidate.slug)
|
|
67
|
+
models.append(_codex_catalog_entry(candidate, priority=len(models)))
|
|
68
|
+
|
|
69
|
+
return {"models": models}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_codex_model_catalog(catalog_path: Path, catalog: Mapping[str, Any]) -> None:
|
|
73
|
+
"""Atomically write a Codex model catalog JSON file."""
|
|
74
|
+
|
|
75
|
+
catalog_path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
temp_path = catalog_path.with_name(f".{catalog_path.name}.{uuid.uuid4().hex}.tmp")
|
|
77
|
+
temp_path.write_text(
|
|
78
|
+
json.dumps(catalog, ensure_ascii=True, indent=2) + "\n",
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
)
|
|
81
|
+
temp_path.replace(catalog_path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _catalog_candidates(
|
|
85
|
+
models_response: Mapping[str, Any],
|
|
86
|
+
) -> list[_CatalogCandidate]:
|
|
87
|
+
data = models_response.get("data")
|
|
88
|
+
if not isinstance(data, list):
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
candidates: list[_CatalogCandidate] = []
|
|
92
|
+
for item in data:
|
|
93
|
+
if not isinstance(item, Mapping):
|
|
94
|
+
continue
|
|
95
|
+
model_id = _string_value(item.get("id"))
|
|
96
|
+
if model_id is None:
|
|
97
|
+
continue
|
|
98
|
+
candidate = _candidate_from_model_id(
|
|
99
|
+
model_id,
|
|
100
|
+
display_name=_string_value(item.get("display_name")) or model_id,
|
|
101
|
+
)
|
|
102
|
+
if candidate is not None:
|
|
103
|
+
candidates.append(candidate)
|
|
104
|
+
return candidates
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _candidate_from_model_id(
|
|
108
|
+
model_id: str, *, display_name: str
|
|
109
|
+
) -> _CatalogCandidate | None:
|
|
110
|
+
prefix, separator, remainder = model_id.partition("/")
|
|
111
|
+
if not separator:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
if prefix == GATEWAY_MODEL_ID_PREFIX:
|
|
115
|
+
if not _is_provider_model_ref(remainder):
|
|
116
|
+
return None
|
|
117
|
+
return _CatalogCandidate(
|
|
118
|
+
slug=remainder,
|
|
119
|
+
provider_model_ref=remainder,
|
|
120
|
+
display_name=display_name,
|
|
121
|
+
force_no_thinking=False,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if prefix == NO_THINKING_GATEWAY_MODEL_ID_PREFIX:
|
|
125
|
+
if not _is_provider_model_ref(remainder):
|
|
126
|
+
return None
|
|
127
|
+
return _CatalogCandidate(
|
|
128
|
+
slug=model_id,
|
|
129
|
+
provider_model_ref=remainder,
|
|
130
|
+
display_name=display_name,
|
|
131
|
+
force_no_thinking=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if prefix in SUPPORTED_PROVIDER_IDS and remainder:
|
|
135
|
+
return _CatalogCandidate(
|
|
136
|
+
slug=model_id,
|
|
137
|
+
provider_model_ref=model_id,
|
|
138
|
+
display_name=display_name,
|
|
139
|
+
force_no_thinking=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _codex_catalog_entry(
|
|
146
|
+
candidate: _CatalogCandidate, *, priority: int
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
return {
|
|
149
|
+
"slug": candidate.slug,
|
|
150
|
+
"display_name": candidate.display_name,
|
|
151
|
+
"description": "DevCopilot provider model",
|
|
152
|
+
"default_reasoning_level": "medium",
|
|
153
|
+
"supported_reasoning_levels": SUPPORTED_REASONING_LEVELS,
|
|
154
|
+
"shell_type": "shell_command",
|
|
155
|
+
"visibility": "list",
|
|
156
|
+
"supported_in_api": True,
|
|
157
|
+
"priority": priority,
|
|
158
|
+
"additional_speed_tiers": [],
|
|
159
|
+
"service_tiers": [],
|
|
160
|
+
"base_instructions": CODEX_BASE_INSTRUCTIONS,
|
|
161
|
+
"supports_reasoning_summaries": True,
|
|
162
|
+
"default_reasoning_summary": "none",
|
|
163
|
+
"support_verbosity": True,
|
|
164
|
+
"default_verbosity": "low",
|
|
165
|
+
"apply_patch_tool_type": "freeform",
|
|
166
|
+
"web_search_tool_type": "text_and_image",
|
|
167
|
+
"truncation_policy": {"mode": "tokens", "limit": 10000},
|
|
168
|
+
"supports_parallel_tool_calls": True,
|
|
169
|
+
"supports_image_detail_original": True,
|
|
170
|
+
"context_window": 200000,
|
|
171
|
+
"max_context_window": 200000,
|
|
172
|
+
"effective_context_window_percent": 95,
|
|
173
|
+
"experimental_supported_tools": [],
|
|
174
|
+
"input_modalities": ["text"],
|
|
175
|
+
"supports_search_tool": True,
|
|
176
|
+
"use_responses_lite": False,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _is_provider_model_ref(value: str) -> bool:
|
|
181
|
+
provider_id, separator, provider_model = value.partition("/")
|
|
182
|
+
return bool(separator and provider_model and provider_id in SUPPORTED_PROVIDER_IDS)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _string_value(value: Any) -> str | None:
|
|
186
|
+
return value if isinstance(value, str) else None
|
cli/launchers/common.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Shared process helpers for installed client CLI launchers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
from cli.process_registry import (
|
|
13
|
+
kill_pid_tree_best_effort,
|
|
14
|
+
register_pid,
|
|
15
|
+
unregister_pid,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
PROXY_PREFLIGHT_PATH = "/health"
|
|
19
|
+
PROXY_PREFLIGHT_TIMEOUT_SECONDS = 1.5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def preflight_proxy(proxy_root_url: str) -> str | None:
|
|
23
|
+
"""Return an error message when the local proxy health check is unreachable."""
|
|
24
|
+
|
|
25
|
+
url = f"{proxy_root_url.rstrip('/')}{PROXY_PREFLIGHT_PATH}"
|
|
26
|
+
request = Request(url, method="GET")
|
|
27
|
+
try:
|
|
28
|
+
with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response:
|
|
29
|
+
status_code = response.getcode()
|
|
30
|
+
except HTTPError as exc:
|
|
31
|
+
return f"returned HTTP {exc.code}"
|
|
32
|
+
except URLError as exc:
|
|
33
|
+
return str(exc.reason)
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
return str(exc)
|
|
36
|
+
|
|
37
|
+
if not 200 <= status_code < 300:
|
|
38
|
+
return f"returned HTTP {status_code}"
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_client_binary(
|
|
43
|
+
*,
|
|
44
|
+
binary_name: str,
|
|
45
|
+
display_name: str,
|
|
46
|
+
install_hint: str,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Resolve an installed client binary or exit with a user-facing hint."""
|
|
49
|
+
|
|
50
|
+
client_command = shutil.which(binary_name)
|
|
51
|
+
if client_command is None:
|
|
52
|
+
print(
|
|
53
|
+
f"Could not find {display_name} command: {binary_name}",
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
print(install_hint, file=sys.stderr)
|
|
57
|
+
raise SystemExit(127)
|
|
58
|
+
return client_command
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_client_process(
|
|
62
|
+
*,
|
|
63
|
+
command: list[str],
|
|
64
|
+
env: Mapping[str, str],
|
|
65
|
+
binary_name: str,
|
|
66
|
+
display_name: str,
|
|
67
|
+
install_hint: str,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Run a client CLI command and mirror its exit code."""
|
|
70
|
+
|
|
71
|
+
process: subprocess.Popen[bytes] | None = None
|
|
72
|
+
try:
|
|
73
|
+
process = subprocess.Popen(command, env=dict(env))
|
|
74
|
+
if process.pid:
|
|
75
|
+
register_pid(process.pid)
|
|
76
|
+
return_code = process.wait()
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
print(
|
|
79
|
+
f"Could not find {display_name} command: {binary_name}",
|
|
80
|
+
file=sys.stderr,
|
|
81
|
+
)
|
|
82
|
+
print(install_hint, file=sys.stderr)
|
|
83
|
+
raise SystemExit(127) from None
|
|
84
|
+
except KeyboardInterrupt:
|
|
85
|
+
if process is not None and process.pid:
|
|
86
|
+
kill_pid_tree_best_effort(process.pid)
|
|
87
|
+
process.wait()
|
|
88
|
+
raise
|
|
89
|
+
finally:
|
|
90
|
+
if process is not None and process.pid:
|
|
91
|
+
unregister_pid(process.pid)
|
|
92
|
+
|
|
93
|
+
raise SystemExit(return_code)
|
cli/managed/__init__.py
ADDED
cli/managed/claude.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Managed Claude Code task command, environment, and stdout parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from cli.claude_env import CLAUDE_CODE_AUTO_COMPACT_WINDOW, claude_auth_token
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ManagedClaudeTaskRequest:
|
|
17
|
+
"""One prompt execution request for a managed Claude Code subprocess."""
|
|
18
|
+
|
|
19
|
+
prompt: str
|
|
20
|
+
session_id: str | None = None
|
|
21
|
+
fork_session: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ManagedClaudeInvocation:
|
|
26
|
+
"""Concrete subprocess invocation assembled for a managed Claude task."""
|
|
27
|
+
|
|
28
|
+
argv: tuple[str, ...]
|
|
29
|
+
env: dict[str, str]
|
|
30
|
+
cwd: str
|
|
31
|
+
trace_metadata: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ManagedClaudeConfig:
|
|
36
|
+
"""Configuration for a managed Claude Code subprocess."""
|
|
37
|
+
|
|
38
|
+
workspace_path: str
|
|
39
|
+
api_url: str
|
|
40
|
+
allowed_dirs: list[str] = field(default_factory=list)
|
|
41
|
+
plans_directory: str | None = None
|
|
42
|
+
claude_bin: str = "claude"
|
|
43
|
+
auth_token: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class ManagedClaudeParseState:
|
|
48
|
+
"""Mutable stdout parser state for one managed Claude Code task run."""
|
|
49
|
+
|
|
50
|
+
log_raw_cli_diagnostics: bool = False
|
|
51
|
+
session_id_extracted: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_managed_claude_invocation(
|
|
55
|
+
*,
|
|
56
|
+
config: ManagedClaudeConfig,
|
|
57
|
+
request: ManagedClaudeTaskRequest,
|
|
58
|
+
base_env: Mapping[str, str],
|
|
59
|
+
) -> ManagedClaudeInvocation:
|
|
60
|
+
"""Build a Claude Code stream-json subprocess invocation."""
|
|
61
|
+
|
|
62
|
+
cmd = build_managed_claude_command(
|
|
63
|
+
claude_bin=config.claude_bin,
|
|
64
|
+
prompt=request.prompt,
|
|
65
|
+
session_id=request.session_id,
|
|
66
|
+
fork_session=request.fork_session,
|
|
67
|
+
allowed_dirs=config.allowed_dirs,
|
|
68
|
+
plans_directory=config.plans_directory,
|
|
69
|
+
)
|
|
70
|
+
resume_session_id = (
|
|
71
|
+
request.session_id
|
|
72
|
+
if request.session_id and not request.session_id.startswith("pending_")
|
|
73
|
+
else None
|
|
74
|
+
)
|
|
75
|
+
return ManagedClaudeInvocation(
|
|
76
|
+
argv=tuple(cmd),
|
|
77
|
+
env=build_managed_claude_env(
|
|
78
|
+
api_url=config.api_url,
|
|
79
|
+
auth_token=config.auth_token,
|
|
80
|
+
base_env=base_env,
|
|
81
|
+
),
|
|
82
|
+
cwd=config.workspace_path,
|
|
83
|
+
trace_metadata={
|
|
84
|
+
"client_cli_id": "claude",
|
|
85
|
+
"resume_session_id": resume_session_id,
|
|
86
|
+
"fork_session": request.fork_session,
|
|
87
|
+
"prompt": request.prompt,
|
|
88
|
+
"cwd": config.workspace_path,
|
|
89
|
+
"claude_binary": config.claude_bin,
|
|
90
|
+
"cli_argv": cmd,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_managed_claude_env(
|
|
96
|
+
*,
|
|
97
|
+
api_url: str,
|
|
98
|
+
auth_token: str,
|
|
99
|
+
base_env: Mapping[str, str],
|
|
100
|
+
) -> dict[str, str]:
|
|
101
|
+
"""Return a Claude Code task environment that targets the local proxy."""
|
|
102
|
+
|
|
103
|
+
env = dict(base_env)
|
|
104
|
+
env["ANTHROPIC_API_URL"] = api_url
|
|
105
|
+
env["ANTHROPIC_BASE_URL"] = api_url[:-3] if api_url.endswith("/v1") else api_url
|
|
106
|
+
env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
|
|
107
|
+
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = CLAUDE_CODE_AUTO_COMPACT_WINDOW
|
|
108
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
109
|
+
env["ANTHROPIC_AUTH_TOKEN"] = claude_auth_token(auth_token)
|
|
110
|
+
env["TERM"] = "dumb"
|
|
111
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
112
|
+
return env
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_managed_claude_command(
|
|
116
|
+
*,
|
|
117
|
+
claude_bin: str,
|
|
118
|
+
prompt: str,
|
|
119
|
+
session_id: str | None,
|
|
120
|
+
fork_session: bool,
|
|
121
|
+
allowed_dirs: list[str],
|
|
122
|
+
plans_directory: str | None,
|
|
123
|
+
) -> list[str]:
|
|
124
|
+
"""Return the Claude Code stream-json command for a managed task."""
|
|
125
|
+
|
|
126
|
+
if session_id and not session_id.startswith("pending_"):
|
|
127
|
+
cmd = [
|
|
128
|
+
claude_bin,
|
|
129
|
+
"--resume",
|
|
130
|
+
session_id,
|
|
131
|
+
]
|
|
132
|
+
if fork_session:
|
|
133
|
+
cmd.append("--fork-session")
|
|
134
|
+
cmd += [
|
|
135
|
+
"-p",
|
|
136
|
+
prompt,
|
|
137
|
+
"--output-format",
|
|
138
|
+
"stream-json",
|
|
139
|
+
"--dangerously-skip-permissions",
|
|
140
|
+
"--verbose",
|
|
141
|
+
]
|
|
142
|
+
else:
|
|
143
|
+
cmd = [
|
|
144
|
+
claude_bin,
|
|
145
|
+
"-p",
|
|
146
|
+
prompt,
|
|
147
|
+
"--output-format",
|
|
148
|
+
"stream-json",
|
|
149
|
+
"--dangerously-skip-permissions",
|
|
150
|
+
"--verbose",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
for directory in allowed_dirs:
|
|
154
|
+
cmd.extend(["--add-dir", directory])
|
|
155
|
+
|
|
156
|
+
if plans_directory is not None:
|
|
157
|
+
cmd.extend(["--settings", json.dumps({"plansDirectory": plans_directory})])
|
|
158
|
+
|
|
159
|
+
return cmd
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_managed_claude_stdout_line(
|
|
163
|
+
line: str, state: ManagedClaudeParseState
|
|
164
|
+
) -> Iterable[Any]:
|
|
165
|
+
"""Parse one Claude Code stream-json stdout line."""
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
event = json.loads(line)
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
if state.log_raw_cli_diagnostics:
|
|
171
|
+
logger.debug("Non-JSON output: {}", line)
|
|
172
|
+
else:
|
|
173
|
+
logger.debug("Non-JSON CLI line: char_len={}", len(line))
|
|
174
|
+
yield {"type": "raw", "content": line}
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
if not state.session_id_extracted:
|
|
178
|
+
extracted_id = extract_managed_claude_session_id(event)
|
|
179
|
+
if extracted_id:
|
|
180
|
+
state.session_id_extracted = True
|
|
181
|
+
logger.info("Extracted session ID: {}", extracted_id)
|
|
182
|
+
yield {"type": "session_info", "session_id": extracted_id}
|
|
183
|
+
|
|
184
|
+
yield event
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def extract_managed_claude_session_id(event: Any) -> str | None:
|
|
188
|
+
"""Extract a Claude Code session ID from supported stream-json event shapes."""
|
|
189
|
+
|
|
190
|
+
if not isinstance(event, dict):
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
if session_id := _string_value(event.get("session_id")):
|
|
194
|
+
return session_id
|
|
195
|
+
if session_id := _string_value(event.get("sessionId")):
|
|
196
|
+
return session_id
|
|
197
|
+
|
|
198
|
+
for key in ("init", "system", "result", "metadata"):
|
|
199
|
+
nested = event.get(key)
|
|
200
|
+
if not isinstance(nested, dict):
|
|
201
|
+
continue
|
|
202
|
+
if session_id := _string_value(nested.get("session_id")):
|
|
203
|
+
return session_id
|
|
204
|
+
if session_id := _string_value(nested.get("sessionId")):
|
|
205
|
+
return session_id
|
|
206
|
+
|
|
207
|
+
conversation = event.get("conversation")
|
|
208
|
+
if isinstance(conversation, dict):
|
|
209
|
+
return _string_value(conversation.get("id"))
|
|
210
|
+
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _string_value(value: Any) -> str | None:
|
|
215
|
+
return value if isinstance(value, str) else None
|
cli/managed/manager.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Managed Claude Code session pool for messaging."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from .session import ManagedClaudeSession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ManagedClaudeSessionManager:
|
|
12
|
+
"""
|
|
13
|
+
Manages multiple Claude Code sessions for parallel conversation processing.
|
|
14
|
+
|
|
15
|
+
Each new conversation gets its own subprocess. Replies to existing
|
|
16
|
+
conversations reuse the same session instance.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
workspace_path: str,
|
|
22
|
+
api_url: str,
|
|
23
|
+
allowed_dirs: list[str] | None = None,
|
|
24
|
+
plans_directory: str | None = None,
|
|
25
|
+
claude_bin: str = "claude",
|
|
26
|
+
auth_token: str = "",
|
|
27
|
+
*,
|
|
28
|
+
log_raw_cli_diagnostics: bool = False,
|
|
29
|
+
log_messaging_error_details: bool = False,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Initialize the session manager.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
workspace_path: Working directory for CLI processes
|
|
36
|
+
api_url: API URL for the proxy
|
|
37
|
+
allowed_dirs: Directories the CLI is allowed to access
|
|
38
|
+
plans_directory: Directory for Claude Code CLI plan files (passed via --settings)
|
|
39
|
+
"""
|
|
40
|
+
self.workspace = workspace_path
|
|
41
|
+
self.api_url = api_url
|
|
42
|
+
self.allowed_dirs = allowed_dirs or []
|
|
43
|
+
self.plans_directory = plans_directory
|
|
44
|
+
self.claude_bin = claude_bin
|
|
45
|
+
self.auth_token = auth_token
|
|
46
|
+
self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
|
|
47
|
+
self._log_messaging_error_details = log_messaging_error_details
|
|
48
|
+
|
|
49
|
+
self._sessions: dict[str, ManagedClaudeSession] = {}
|
|
50
|
+
self._pending_sessions: dict[str, ManagedClaudeSession] = {}
|
|
51
|
+
self._temp_to_real: dict[str, str] = {}
|
|
52
|
+
self._real_to_temp: dict[str, str] = {}
|
|
53
|
+
self._lock = asyncio.Lock()
|
|
54
|
+
|
|
55
|
+
async def get_or_create_session(
|
|
56
|
+
self, session_id: str | None = None
|
|
57
|
+
) -> tuple[ManagedClaudeSession, str, bool]:
|
|
58
|
+
"""
|
|
59
|
+
Get an existing session or create a new one.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Tuple of (session instance, session_id, is_new_session)
|
|
63
|
+
"""
|
|
64
|
+
async with self._lock:
|
|
65
|
+
if session_id:
|
|
66
|
+
lookup_id = self._temp_to_real.get(session_id, session_id)
|
|
67
|
+
|
|
68
|
+
if lookup_id in self._sessions:
|
|
69
|
+
return self._sessions[lookup_id], lookup_id, False
|
|
70
|
+
if lookup_id in self._pending_sessions:
|
|
71
|
+
return self._pending_sessions[lookup_id], lookup_id, False
|
|
72
|
+
|
|
73
|
+
temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}"
|
|
74
|
+
|
|
75
|
+
new_session = ManagedClaudeSession(
|
|
76
|
+
workspace_path=self.workspace,
|
|
77
|
+
api_url=self.api_url,
|
|
78
|
+
allowed_dirs=self.allowed_dirs,
|
|
79
|
+
plans_directory=self.plans_directory,
|
|
80
|
+
claude_bin=self.claude_bin,
|
|
81
|
+
auth_token=self.auth_token,
|
|
82
|
+
log_raw_cli_diagnostics=self._log_raw_cli_diagnostics,
|
|
83
|
+
)
|
|
84
|
+
self._pending_sessions[temp_id] = new_session
|
|
85
|
+
|
|
86
|
+
return new_session, temp_id, True
|
|
87
|
+
|
|
88
|
+
async def register_real_session_id(
|
|
89
|
+
self, temp_id: str, real_session_id: str
|
|
90
|
+
) -> bool:
|
|
91
|
+
"""Register the real session ID from CLI output."""
|
|
92
|
+
async with self._lock:
|
|
93
|
+
if temp_id not in self._pending_sessions:
|
|
94
|
+
logger.warning(f"Temp session {temp_id} not found")
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
session = self._pending_sessions.pop(temp_id)
|
|
98
|
+
self._sessions[real_session_id] = session
|
|
99
|
+
self._temp_to_real[temp_id] = real_session_id
|
|
100
|
+
self._real_to_temp[real_session_id] = temp_id
|
|
101
|
+
|
|
102
|
+
logger.info(f"Registered session: {temp_id} -> {real_session_id}")
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
async def remove_session(self, session_id: str) -> bool:
|
|
106
|
+
"""Remove a session from the manager."""
|
|
107
|
+
async with self._lock:
|
|
108
|
+
if session_id in self._pending_sessions:
|
|
109
|
+
session = self._pending_sessions.pop(session_id)
|
|
110
|
+
await session.stop()
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
if session_id in self._sessions:
|
|
114
|
+
session = self._sessions.pop(session_id)
|
|
115
|
+
await session.stop()
|
|
116
|
+
temp_id = self._real_to_temp.pop(session_id, None)
|
|
117
|
+
if temp_id is not None:
|
|
118
|
+
self._temp_to_real.pop(temp_id, None)
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
async def stop_all(self):
|
|
124
|
+
"""Stop all sessions."""
|
|
125
|
+
async with self._lock:
|
|
126
|
+
all_sessions = list(self._sessions.values()) + list(
|
|
127
|
+
self._pending_sessions.values()
|
|
128
|
+
)
|
|
129
|
+
for session in all_sessions:
|
|
130
|
+
try:
|
|
131
|
+
await session.stop()
|
|
132
|
+
except Exception as e:
|
|
133
|
+
if self._log_messaging_error_details:
|
|
134
|
+
logger.error(
|
|
135
|
+
"Error stopping session: {}: {}",
|
|
136
|
+
type(e).__name__,
|
|
137
|
+
e,
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
logger.error(
|
|
141
|
+
"Error stopping session: exc_type={}",
|
|
142
|
+
type(e).__name__,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
self._sessions.clear()
|
|
146
|
+
self._pending_sessions.clear()
|
|
147
|
+
self._temp_to_real.clear()
|
|
148
|
+
self._real_to_temp.clear()
|
|
149
|
+
logger.info("All sessions stopped")
|
|
150
|
+
|
|
151
|
+
def get_stats(self) -> dict:
|
|
152
|
+
"""Get session statistics."""
|
|
153
|
+
return {
|
|
154
|
+
"active_sessions": len(self._sessions),
|
|
155
|
+
"pending_sessions": len(self._pending_sessions),
|
|
156
|
+
"busy_count": sum(1 for s in self._sessions.values() if s.is_busy),
|
|
157
|
+
}
|