ccproxy-api 0.1.2__py3-none-any.whl → 0.1.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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/openai/__init__.py +1 -2
- ccproxy/adapters/openai/adapter.py +218 -180
- ccproxy/adapters/openai/streaming.py +247 -65
- ccproxy/api/__init__.py +0 -3
- ccproxy/api/app.py +173 -40
- ccproxy/api/dependencies.py +62 -3
- ccproxy/api/middleware/errors.py +3 -7
- ccproxy/api/middleware/headers.py +0 -2
- ccproxy/api/middleware/logging.py +4 -3
- ccproxy/api/middleware/request_content_logging.py +297 -0
- ccproxy/api/middleware/request_id.py +5 -0
- ccproxy/api/middleware/server_header.py +0 -4
- ccproxy/api/routes/__init__.py +9 -1
- ccproxy/api/routes/claude.py +23 -32
- ccproxy/api/routes/health.py +58 -4
- ccproxy/api/routes/mcp.py +171 -0
- ccproxy/api/routes/metrics.py +4 -8
- ccproxy/api/routes/permissions.py +217 -0
- ccproxy/api/routes/proxy.py +0 -53
- ccproxy/api/services/__init__.py +6 -0
- ccproxy/api/services/permission_service.py +368 -0
- ccproxy/api/ui/__init__.py +6 -0
- ccproxy/api/ui/permission_handler_protocol.py +33 -0
- ccproxy/api/ui/terminal_permission_handler.py +593 -0
- ccproxy/auth/conditional.py +2 -2
- ccproxy/auth/dependencies.py +1 -1
- ccproxy/auth/oauth/models.py +0 -1
- ccproxy/auth/oauth/routes.py +1 -3
- ccproxy/auth/storage/json_file.py +0 -1
- ccproxy/auth/storage/keyring.py +0 -3
- ccproxy/claude_sdk/__init__.py +2 -0
- ccproxy/claude_sdk/client.py +91 -8
- ccproxy/claude_sdk/converter.py +405 -210
- ccproxy/claude_sdk/options.py +76 -29
- ccproxy/claude_sdk/parser.py +200 -0
- ccproxy/claude_sdk/streaming.py +286 -0
- ccproxy/cli/commands/__init__.py +5 -2
- ccproxy/cli/commands/auth.py +2 -4
- ccproxy/cli/commands/permission_handler.py +553 -0
- ccproxy/cli/commands/serve.py +30 -12
- ccproxy/cli/docker/params.py +0 -4
- ccproxy/cli/helpers.py +0 -2
- ccproxy/cli/main.py +5 -16
- ccproxy/cli/options/claude_options.py +19 -1
- ccproxy/cli/options/core_options.py +0 -3
- ccproxy/cli/options/security_options.py +0 -2
- ccproxy/cli/options/server_options.py +3 -2
- ccproxy/config/auth.py +0 -1
- ccproxy/config/claude.py +78 -2
- ccproxy/config/discovery.py +0 -1
- ccproxy/config/docker_settings.py +0 -1
- ccproxy/config/loader.py +1 -4
- ccproxy/config/scheduler.py +20 -0
- ccproxy/config/security.py +7 -2
- ccproxy/config/server.py +5 -0
- ccproxy/config/settings.py +13 -7
- ccproxy/config/validators.py +1 -1
- ccproxy/core/async_utils.py +1 -4
- ccproxy/core/errors.py +45 -1
- ccproxy/core/http_transformers.py +4 -3
- ccproxy/core/interfaces.py +2 -2
- ccproxy/core/logging.py +97 -95
- ccproxy/core/middleware.py +1 -1
- ccproxy/core/proxy.py +1 -1
- ccproxy/core/transformers.py +1 -1
- ccproxy/core/types.py +1 -1
- ccproxy/docker/models.py +1 -1
- ccproxy/docker/protocol.py +0 -3
- ccproxy/models/__init__.py +41 -0
- ccproxy/models/claude_sdk.py +420 -0
- ccproxy/models/messages.py +45 -18
- ccproxy/models/permissions.py +115 -0
- ccproxy/models/requests.py +1 -1
- ccproxy/models/responses.py +29 -2
- ccproxy/observability/access_logger.py +1 -2
- ccproxy/observability/context.py +17 -1
- ccproxy/observability/metrics.py +1 -3
- ccproxy/observability/pushgateway.py +0 -2
- ccproxy/observability/stats_printer.py +2 -4
- ccproxy/observability/storage/duckdb_simple.py +1 -1
- ccproxy/observability/storage/models.py +0 -1
- ccproxy/pricing/cache.py +0 -1
- ccproxy/pricing/loader.py +5 -21
- ccproxy/pricing/updater.py +0 -1
- ccproxy/scheduler/__init__.py +1 -0
- ccproxy/scheduler/core.py +6 -6
- ccproxy/scheduler/manager.py +35 -7
- ccproxy/scheduler/registry.py +1 -1
- ccproxy/scheduler/tasks.py +127 -2
- ccproxy/services/claude_sdk_service.py +220 -328
- ccproxy/services/credentials/manager.py +0 -1
- ccproxy/services/credentials/oauth_client.py +1 -2
- ccproxy/services/proxy_service.py +93 -222
- ccproxy/testing/config.py +1 -1
- ccproxy/testing/mock_responses.py +0 -1
- ccproxy/utils/model_mapping.py +197 -0
- ccproxy/utils/models_provider.py +150 -0
- ccproxy/utils/simple_request_logger.py +284 -0
- ccproxy/utils/version_checker.py +184 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.3.dist-info/RECORD +166 -0
- ccproxy/cli/commands/permission.py +0 -128
- ccproxy_api-0.1.2.dist-info/RECORD +0 -150
- /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""CLI command for handling confirmation requests via SSE stream."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import structlog
|
|
12
|
+
import typer
|
|
13
|
+
from structlog import get_logger
|
|
14
|
+
|
|
15
|
+
from ccproxy.api.services.permission_service import PermissionRequest
|
|
16
|
+
from ccproxy.api.ui.permission_handler_protocol import ConfirmationHandlerProtocol
|
|
17
|
+
from ccproxy.api.ui.terminal_permission_handler import (
|
|
18
|
+
TerminalPermissionHandler as TextualPermissionHandler,
|
|
19
|
+
)
|
|
20
|
+
from ccproxy.config.settings import get_settings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="confirmation-handler",
|
|
27
|
+
help="Connect to the API server and handle confirmation requests",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SSEConfirmationHandler:
|
|
33
|
+
"""Handles confirmation requests received via SSE stream."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_url: str,
|
|
38
|
+
terminal_handler: ConfirmationHandlerProtocol,
|
|
39
|
+
ui: bool = True,
|
|
40
|
+
auth_token: str | None = None,
|
|
41
|
+
auto_reconnect: bool = True,
|
|
42
|
+
):
|
|
43
|
+
self.api_url = api_url.rstrip("/")
|
|
44
|
+
self.terminal_handler = terminal_handler
|
|
45
|
+
self.client: httpx.AsyncClient | None = None
|
|
46
|
+
self.max_retries = 5
|
|
47
|
+
self.base_delay = 1.0
|
|
48
|
+
self.max_delay = 60.0
|
|
49
|
+
self.ui = ui
|
|
50
|
+
self.auth_token = auth_token
|
|
51
|
+
self.auto_reconnect = auto_reconnect
|
|
52
|
+
|
|
53
|
+
self._ongoing_requests: dict[str, asyncio.Task[bool]] = {}
|
|
54
|
+
self._resolved_requests: dict[str, tuple[bool, str]] = {}
|
|
55
|
+
self._resolved_by_us: set[str] = set()
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> "SSEConfirmationHandler":
|
|
58
|
+
"""Async context manager entry."""
|
|
59
|
+
headers = {}
|
|
60
|
+
if self.auth_token:
|
|
61
|
+
headers["Authorization"] = f"Bearer {self.auth_token}"
|
|
62
|
+
|
|
63
|
+
self.client = httpx.AsyncClient(timeout=300.0, headers=headers) # 5 minutes
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
async def __aexit__(
|
|
67
|
+
self,
|
|
68
|
+
exc_type: type[BaseException] | None,
|
|
69
|
+
exc_val: BaseException | None,
|
|
70
|
+
exc_tb: object,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Async context manager exit."""
|
|
73
|
+
if self.client:
|
|
74
|
+
await self.client.aclose()
|
|
75
|
+
self.client = None
|
|
76
|
+
|
|
77
|
+
async def handle_event(self, event_type: str, data: dict[str, Any]) -> None:
|
|
78
|
+
"""Handle an SSE event by dispatching to specific handlers.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
event_type: Type of the event
|
|
82
|
+
data: Event data
|
|
83
|
+
"""
|
|
84
|
+
if event_type == "ping":
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
from ccproxy.models.permissions import EventType
|
|
88
|
+
|
|
89
|
+
handler_map = {
|
|
90
|
+
EventType.PERMISSION_REQUEST.value: self._handle_permission_request,
|
|
91
|
+
EventType.PERMISSION_RESOLVED.value: self._handle_permission_resolved,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
handler = handler_map.get(event_type)
|
|
95
|
+
if handler:
|
|
96
|
+
await handler(data)
|
|
97
|
+
else:
|
|
98
|
+
logger.warning("unhandled_sse_event", event_type=event_type)
|
|
99
|
+
|
|
100
|
+
async def _handle_permission_request(self, data: dict[str, Any]) -> None:
|
|
101
|
+
"""Handle a confirmation request event.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
data: Event data containing request details
|
|
105
|
+
"""
|
|
106
|
+
request_id = data.get("request_id")
|
|
107
|
+
if not request_id:
|
|
108
|
+
logger.warning("permission_request_missing_id", data=data)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Check if this request was already resolved by another handler
|
|
112
|
+
if request_id in self._resolved_requests:
|
|
113
|
+
allowed, reason = self._resolved_requests[request_id]
|
|
114
|
+
logger.info(
|
|
115
|
+
"permission_already_resolved_by_other_handler",
|
|
116
|
+
request_id=request_id,
|
|
117
|
+
allowed=allowed,
|
|
118
|
+
reason=reason,
|
|
119
|
+
)
|
|
120
|
+
logger.info(
|
|
121
|
+
"permission_already_handled",
|
|
122
|
+
request_id=request_id[:8],
|
|
123
|
+
reason=reason,
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
logger.info(
|
|
128
|
+
"permission_request_received",
|
|
129
|
+
request_id=request_id,
|
|
130
|
+
tool_name=data.get("tool_name"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
# Map request_id to id field for PermissionRequest model
|
|
135
|
+
request_data = dict(data)
|
|
136
|
+
if "request_id" in request_data:
|
|
137
|
+
request_data["id"] = request_data.pop("request_id")
|
|
138
|
+
request = PermissionRequest.model_validate(request_data)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.error(
|
|
141
|
+
"permission_request_validation_failed", data=data, error=str(e)
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
if self.ui and request_id is not None:
|
|
146
|
+
task = asyncio.create_task(
|
|
147
|
+
self._handle_permission_with_cancellation(request)
|
|
148
|
+
)
|
|
149
|
+
self._ongoing_requests[request_id] = task
|
|
150
|
+
|
|
151
|
+
async def _handle_permission_resolved(self, data: dict[str, Any]) -> None:
|
|
152
|
+
"""Handle a confirmation resolved event.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
data: Event data containing resolution details
|
|
156
|
+
"""
|
|
157
|
+
request_id = data.get("request_id")
|
|
158
|
+
allowed = data.get("allowed", False)
|
|
159
|
+
|
|
160
|
+
if request_id is not None and allowed is not None:
|
|
161
|
+
reason = (
|
|
162
|
+
"approved by another handler"
|
|
163
|
+
if allowed
|
|
164
|
+
else "denied by another handler"
|
|
165
|
+
)
|
|
166
|
+
self._resolved_requests[request_id] = (allowed, reason)
|
|
167
|
+
|
|
168
|
+
was_resolved_by_us = (
|
|
169
|
+
request_id is not None and request_id in self._resolved_by_us
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if request_id is not None and request_id in self._ongoing_requests:
|
|
173
|
+
task = self._ongoing_requests[request_id]
|
|
174
|
+
if not task.done() and not was_resolved_by_us:
|
|
175
|
+
logger.info(
|
|
176
|
+
"cancelling_ongoing_confirmation",
|
|
177
|
+
request_id=request_id,
|
|
178
|
+
allowed=allowed,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
status_text = "approved" if allowed else "denied"
|
|
182
|
+
self.terminal_handler.cancel_confirmation(
|
|
183
|
+
request_id, f"{status_text} by another handler"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
task.cancel()
|
|
187
|
+
|
|
188
|
+
with contextlib.suppress(TimeoutError, asyncio.CancelledError):
|
|
189
|
+
await asyncio.wait_for(task, timeout=0.1)
|
|
190
|
+
|
|
191
|
+
logger.info(
|
|
192
|
+
"permission_cancelled_by_other_handler",
|
|
193
|
+
request_id=request_id[:8],
|
|
194
|
+
status=status_text,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if request_id is not None:
|
|
198
|
+
self._ongoing_requests.pop(request_id, None)
|
|
199
|
+
|
|
200
|
+
if request_id is not None:
|
|
201
|
+
self._resolved_by_us.discard(request_id)
|
|
202
|
+
|
|
203
|
+
async def _handle_permission_with_cancellation(
|
|
204
|
+
self, request: PermissionRequest
|
|
205
|
+
) -> bool:
|
|
206
|
+
"""Handle permission with cancellation support.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
request: The permission request to handle
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
allowed = await self.terminal_handler.handle_permission(request)
|
|
213
|
+
|
|
214
|
+
if request.id in self._resolved_requests:
|
|
215
|
+
logger.info(
|
|
216
|
+
"permission_resolved_while_processing",
|
|
217
|
+
request_id=request.id,
|
|
218
|
+
our_result=allowed,
|
|
219
|
+
)
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
self._resolved_by_us.add(request.id)
|
|
223
|
+
|
|
224
|
+
await self.send_response(request.id, allowed)
|
|
225
|
+
|
|
226
|
+
await asyncio.sleep(0.5)
|
|
227
|
+
|
|
228
|
+
return allowed
|
|
229
|
+
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
logger.info(
|
|
232
|
+
"permission_cancelled",
|
|
233
|
+
request_id=request.id,
|
|
234
|
+
)
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.error(
|
|
239
|
+
"permission_handling_error",
|
|
240
|
+
request_id=request.id,
|
|
241
|
+
error=str(e),
|
|
242
|
+
exc_info=True,
|
|
243
|
+
)
|
|
244
|
+
# Only send response if not already resolved
|
|
245
|
+
if request.id not in self._resolved_requests:
|
|
246
|
+
# If response fails, it might already be resolved
|
|
247
|
+
with contextlib.suppress(Exception):
|
|
248
|
+
await self.send_response(request.id, False)
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
async def send_response(self, request_id: str, allowed: bool) -> None:
|
|
252
|
+
"""Send a confirmation response to the API.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
request_id: ID of the confirmation request
|
|
256
|
+
allowed: Whether to allow or deny
|
|
257
|
+
"""
|
|
258
|
+
if not self.client:
|
|
259
|
+
logger.error("send_response_no_client", request_id=request_id)
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
response = await self.client.post(
|
|
264
|
+
f"{self.api_url}/permissions/{request_id}/respond",
|
|
265
|
+
json={"allowed": allowed},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if response.status_code == 200:
|
|
269
|
+
logger.info(
|
|
270
|
+
"permission_response_sent",
|
|
271
|
+
request_id=request_id,
|
|
272
|
+
allowed=allowed,
|
|
273
|
+
)
|
|
274
|
+
elif response.status_code == 409:
|
|
275
|
+
# Already resolved by another handler
|
|
276
|
+
logger.info(
|
|
277
|
+
"permission_already_resolved",
|
|
278
|
+
request_id=request_id,
|
|
279
|
+
status_code=response.status_code,
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
logger.error(
|
|
283
|
+
"permission_response_failed",
|
|
284
|
+
request_id=request_id,
|
|
285
|
+
status_code=response.status_code,
|
|
286
|
+
response=response.text,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.error(
|
|
291
|
+
"permission_response_error",
|
|
292
|
+
request_id=request_id,
|
|
293
|
+
error=str(e),
|
|
294
|
+
exc_info=True,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
async def parse_sse_stream(
|
|
298
|
+
self, response: httpx.Response
|
|
299
|
+
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
|
300
|
+
"""Parse SSE events from the response stream.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
response: The httpx response with streaming content
|
|
304
|
+
|
|
305
|
+
Yields:
|
|
306
|
+
Tuples of (event_type, data)
|
|
307
|
+
"""
|
|
308
|
+
buffer = ""
|
|
309
|
+
async for chunk in response.aiter_text():
|
|
310
|
+
buffer += chunk
|
|
311
|
+
|
|
312
|
+
buffer = buffer.replace("\r\n", "\n")
|
|
313
|
+
|
|
314
|
+
while "\n\n" in buffer:
|
|
315
|
+
event_text, buffer = buffer.split("\n\n", 1)
|
|
316
|
+
|
|
317
|
+
if not event_text.strip():
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
event_type = "message"
|
|
321
|
+
data_lines = []
|
|
322
|
+
|
|
323
|
+
for line in event_text.split("\n"):
|
|
324
|
+
line = line.strip()
|
|
325
|
+
if line.startswith("event:"):
|
|
326
|
+
event_type = line[6:].strip()
|
|
327
|
+
elif line.startswith("data:"):
|
|
328
|
+
data_lines.append(line[5:].strip())
|
|
329
|
+
|
|
330
|
+
if data_lines:
|
|
331
|
+
try:
|
|
332
|
+
data_json = " ".join(data_lines)
|
|
333
|
+
data = json.loads(data_json)
|
|
334
|
+
yield event_type, data
|
|
335
|
+
except json.JSONDecodeError as e:
|
|
336
|
+
logger.error(
|
|
337
|
+
"sse_parse_error",
|
|
338
|
+
event_type=event_type,
|
|
339
|
+
data=" ".join(data_lines),
|
|
340
|
+
error=str(e),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
async def run(self) -> None:
|
|
344
|
+
"""Run the SSE client with reconnection logic."""
|
|
345
|
+
if not self.client:
|
|
346
|
+
logger.error("run_no_client")
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
stream_url = f"{self.api_url}/permissions/stream"
|
|
350
|
+
retry_count = 0
|
|
351
|
+
|
|
352
|
+
logger.info(
|
|
353
|
+
"connecting_to_permission_stream",
|
|
354
|
+
url=stream_url,
|
|
355
|
+
)
|
|
356
|
+
print(f"Connecting to confirmation stream at {stream_url}...")
|
|
357
|
+
|
|
358
|
+
while retry_count <= self.max_retries:
|
|
359
|
+
try:
|
|
360
|
+
await self._connect_and_handle_stream(stream_url)
|
|
361
|
+
# If we get here, connection ended gracefully
|
|
362
|
+
if self.auto_reconnect:
|
|
363
|
+
# Reset retry count and reconnect
|
|
364
|
+
retry_count = 0
|
|
365
|
+
print("Connection closed. Reconnecting...")
|
|
366
|
+
await asyncio.sleep(1.0) # Brief pause before reconnecting
|
|
367
|
+
continue
|
|
368
|
+
else:
|
|
369
|
+
print("Connection closed. Exiting (auto-reconnect disabled).")
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
except KeyboardInterrupt:
|
|
373
|
+
logger.info("permission_handler_shutdown_requested")
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
except (
|
|
377
|
+
httpx.ConnectError,
|
|
378
|
+
httpx.TimeoutException,
|
|
379
|
+
httpx.ReadTimeout,
|
|
380
|
+
) as e:
|
|
381
|
+
retry_count += 1
|
|
382
|
+
if retry_count > self.max_retries:
|
|
383
|
+
logger.error(
|
|
384
|
+
"connection_failed_max_retries",
|
|
385
|
+
max_retries=self.max_retries,
|
|
386
|
+
)
|
|
387
|
+
raise typer.Exit(1) from None
|
|
388
|
+
|
|
389
|
+
# Exponential backoff with jitter
|
|
390
|
+
delay = min(self.base_delay * (2 ** (retry_count - 1)), self.max_delay)
|
|
391
|
+
|
|
392
|
+
logger.warning(
|
|
393
|
+
"connection_failed_retrying",
|
|
394
|
+
attempt=retry_count,
|
|
395
|
+
max_retries=self.max_retries,
|
|
396
|
+
retry_delay=delay,
|
|
397
|
+
error=str(e),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
print(
|
|
401
|
+
f"Connection failed (attempt {retry_count}/{self.max_retries}). Retrying in {delay}s..."
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
await asyncio.sleep(delay)
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.error("sse_client_error", error=str(e), exc_info=True)
|
|
409
|
+
raise typer.Exit(1) from e
|
|
410
|
+
|
|
411
|
+
async def _connect_and_handle_stream(self, stream_url: str) -> None:
|
|
412
|
+
"""Connect to the stream and handle events."""
|
|
413
|
+
if not self.client:
|
|
414
|
+
logger.error("connect_no_client")
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
async with self.client.stream("GET", stream_url) as response:
|
|
418
|
+
if response.status_code != 200:
|
|
419
|
+
error_text = ""
|
|
420
|
+
try:
|
|
421
|
+
error_bytes = await response.aread()
|
|
422
|
+
error_text = error_bytes.decode("utf-8")
|
|
423
|
+
except Exception:
|
|
424
|
+
error_text = "Unable to read error response"
|
|
425
|
+
|
|
426
|
+
logger.error(
|
|
427
|
+
"sse_connection_failed",
|
|
428
|
+
status_code=response.status_code,
|
|
429
|
+
response=error_text,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if response.status_code in (502, 503, 504):
|
|
433
|
+
# Server errors - retry
|
|
434
|
+
raise httpx.ConnectError(
|
|
435
|
+
f"Server error: HTTP {response.status_code}"
|
|
436
|
+
)
|
|
437
|
+
else:
|
|
438
|
+
# Client errors - don't retry
|
|
439
|
+
logger.error(
|
|
440
|
+
"sse_connection_client_error",
|
|
441
|
+
status_code=response.status_code,
|
|
442
|
+
response=error_text,
|
|
443
|
+
)
|
|
444
|
+
raise typer.Exit(1)
|
|
445
|
+
|
|
446
|
+
logger.info(
|
|
447
|
+
"sse_connection_established",
|
|
448
|
+
url=stream_url,
|
|
449
|
+
message="Connected to confirmation stream. Waiting for requests...",
|
|
450
|
+
)
|
|
451
|
+
print("✓ Connected to confirmation stream. Waiting for requests...")
|
|
452
|
+
|
|
453
|
+
async for event_type, data in self.parse_sse_stream(response):
|
|
454
|
+
try:
|
|
455
|
+
await self.handle_event(event_type, data)
|
|
456
|
+
except Exception as e:
|
|
457
|
+
logger.error(
|
|
458
|
+
"sse_event_error",
|
|
459
|
+
event_type=event_type,
|
|
460
|
+
error=str(e),
|
|
461
|
+
exc_info=True,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@app.command()
|
|
466
|
+
def connect(
|
|
467
|
+
api_url: str | None = typer.Option(
|
|
468
|
+
None,
|
|
469
|
+
"--api-url",
|
|
470
|
+
"-u",
|
|
471
|
+
help="API server URL (defaults to settings)",
|
|
472
|
+
),
|
|
473
|
+
no_ui: bool = typer.Option(False, "--no-ui", help="Disable UI mode"),
|
|
474
|
+
verbose: int = typer.Option(
|
|
475
|
+
0,
|
|
476
|
+
"-v",
|
|
477
|
+
"--verbose",
|
|
478
|
+
count=True,
|
|
479
|
+
help="Increase verbosity (-v for INFO, -vv for DEBUG)",
|
|
480
|
+
),
|
|
481
|
+
auth_token: str | None = typer.Option(
|
|
482
|
+
None,
|
|
483
|
+
"--auth-token",
|
|
484
|
+
"-t",
|
|
485
|
+
help="Bearer token for API authentication (overrides config)",
|
|
486
|
+
envvar="CCPROXY_AUTH_TOKEN",
|
|
487
|
+
),
|
|
488
|
+
no_reconnect: bool = typer.Option(
|
|
489
|
+
False,
|
|
490
|
+
"--no-reconnect",
|
|
491
|
+
help="Disable automatic reconnection when connection is lost",
|
|
492
|
+
),
|
|
493
|
+
) -> None:
|
|
494
|
+
"""Connect to the API server and handle confirmation requests.
|
|
495
|
+
|
|
496
|
+
This command connects to the CCProxy API server via Server-Sent Events
|
|
497
|
+
and handles permission confirmation requests in the terminal.
|
|
498
|
+
|
|
499
|
+
"""
|
|
500
|
+
# Configure logging level based on verbosity
|
|
501
|
+
# Handle case where verbose might be OptionInfo (in tests) or int (runtime)
|
|
502
|
+
verbose_count = verbose if isinstance(verbose, int) else 0
|
|
503
|
+
|
|
504
|
+
if verbose_count >= 2:
|
|
505
|
+
log_level = logging.DEBUG
|
|
506
|
+
elif verbose_count >= 1:
|
|
507
|
+
log_level = logging.INFO
|
|
508
|
+
else:
|
|
509
|
+
log_level = logging.WARNING
|
|
510
|
+
|
|
511
|
+
# Configure root logger level
|
|
512
|
+
logging.basicConfig(level=log_level)
|
|
513
|
+
|
|
514
|
+
# Configure structlog to respect the same log level
|
|
515
|
+
structlog.configure(
|
|
516
|
+
wrapper_class=structlog.make_filtering_bound_logger(log_level),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
settings = get_settings()
|
|
520
|
+
|
|
521
|
+
# Use provided URL or default from settings
|
|
522
|
+
if not api_url:
|
|
523
|
+
api_url = f"http://{settings.server.host}:{settings.server.port}"
|
|
524
|
+
|
|
525
|
+
# Determine auth token: CLI arg > config setting > None
|
|
526
|
+
token = auth_token or settings.security.auth_token
|
|
527
|
+
|
|
528
|
+
# Create handlers based on UI mode selection
|
|
529
|
+
terminal_handler: ConfirmationHandlerProtocol = TextualPermissionHandler()
|
|
530
|
+
|
|
531
|
+
async def run_handler() -> None:
|
|
532
|
+
"""Run the handler with proper resource management."""
|
|
533
|
+
async with SSEConfirmationHandler(
|
|
534
|
+
api_url,
|
|
535
|
+
terminal_handler,
|
|
536
|
+
not no_ui,
|
|
537
|
+
auth_token=token,
|
|
538
|
+
auto_reconnect=not no_reconnect,
|
|
539
|
+
) as sse_handler:
|
|
540
|
+
await sse_handler.run()
|
|
541
|
+
|
|
542
|
+
# Run the async handler
|
|
543
|
+
try:
|
|
544
|
+
asyncio.run(run_handler())
|
|
545
|
+
except KeyboardInterrupt:
|
|
546
|
+
logger.info("permission_handler_stopped")
|
|
547
|
+
except Exception as e:
|
|
548
|
+
logger.error("permission_handler_error", error=str(e), exc_info=True)
|
|
549
|
+
raise typer.Exit(1) from e
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
if __name__ == "__main__":
|
|
553
|
+
app()
|
ccproxy/cli/commands/serve.py
CHANGED
|
@@ -10,7 +10,6 @@ import uvicorn
|
|
|
10
10
|
from click import get_current_context
|
|
11
11
|
from structlog import get_logger
|
|
12
12
|
|
|
13
|
-
from ccproxy._version import __version__
|
|
14
13
|
from ccproxy.cli.helpers import (
|
|
15
14
|
get_rich_toolkit,
|
|
16
15
|
is_running_in_docker,
|
|
@@ -36,6 +35,7 @@ from ..options.claude_options import (
|
|
|
36
35
|
validate_max_thinking_tokens,
|
|
37
36
|
validate_max_turns,
|
|
38
37
|
validate_permission_mode,
|
|
38
|
+
validate_sdk_message_mode,
|
|
39
39
|
)
|
|
40
40
|
from ..options.security_options import SecurityOptions, validate_auth_token
|
|
41
41
|
from ..options.server_options import (
|
|
@@ -45,10 +45,6 @@ from ..options.server_options import (
|
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
# Logger will be configured by configuration manager
|
|
49
|
-
logger = get_logger(__name__)
|
|
50
|
-
|
|
51
|
-
|
|
52
48
|
def get_config_path_from_context() -> Path | None:
|
|
53
49
|
"""Get config path from typer context if available."""
|
|
54
50
|
try:
|
|
@@ -328,7 +324,7 @@ def api(
|
|
|
328
324
|
str | None,
|
|
329
325
|
typer.Option(
|
|
330
326
|
"--log-level",
|
|
331
|
-
help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
|
327
|
+
help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Use WARNING for minimal output.",
|
|
332
328
|
callback=validate_log_level,
|
|
333
329
|
rich_help_panel="Server Settings",
|
|
334
330
|
),
|
|
@@ -341,6 +337,14 @@ def api(
|
|
|
341
337
|
rich_help_panel="Server Settings",
|
|
342
338
|
),
|
|
343
339
|
] = None,
|
|
340
|
+
use_terminal_permission_handler: Annotated[
|
|
341
|
+
bool,
|
|
342
|
+
typer.Option(
|
|
343
|
+
"--terminal-permission-handler",
|
|
344
|
+
help="Enable terminal permission terminal handler",
|
|
345
|
+
rich_help_panel="Server Settings",
|
|
346
|
+
),
|
|
347
|
+
] = False,
|
|
344
348
|
# Security options
|
|
345
349
|
auth_token: Annotated[
|
|
346
350
|
str | None,
|
|
@@ -429,6 +433,15 @@ def api(
|
|
|
429
433
|
rich_help_panel="Claude Settings",
|
|
430
434
|
),
|
|
431
435
|
] = None,
|
|
436
|
+
sdk_message_mode: Annotated[
|
|
437
|
+
str | None,
|
|
438
|
+
typer.Option(
|
|
439
|
+
"--sdk-message-mode",
|
|
440
|
+
help="SDK message handling mode: forward (direct SDK blocks), ignore (skip blocks), formatted (XML tags with JSON data)",
|
|
441
|
+
callback=validate_sdk_message_mode,
|
|
442
|
+
rich_help_panel="Claude Settings",
|
|
443
|
+
),
|
|
444
|
+
] = None,
|
|
432
445
|
# Core settings
|
|
433
446
|
docker: Annotated[
|
|
434
447
|
bool,
|
|
@@ -546,6 +559,7 @@ def api(
|
|
|
546
559
|
reload=reload,
|
|
547
560
|
log_level=log_level,
|
|
548
561
|
log_file=log_file,
|
|
562
|
+
use_terminal_confirmation_handler=use_terminal_permission_handler,
|
|
549
563
|
)
|
|
550
564
|
|
|
551
565
|
claude_options = ClaudeOptions(
|
|
@@ -558,6 +572,7 @@ def api(
|
|
|
558
572
|
max_turns=max_turns,
|
|
559
573
|
cwd=cwd,
|
|
560
574
|
permission_prompt_tool_name=permission_prompt_tool_name,
|
|
575
|
+
sdk_message_mode=sdk_message_mode,
|
|
561
576
|
)
|
|
562
577
|
|
|
563
578
|
security_options = SecurityOptions(auth_token=auth_token)
|
|
@@ -570,6 +585,7 @@ def api(
|
|
|
570
585
|
reload=server_options.reload,
|
|
571
586
|
log_level=server_options.log_level,
|
|
572
587
|
log_file=server_options.log_file,
|
|
588
|
+
use_terminal_confirmation_handler=server_options.use_terminal_confirmation_handler,
|
|
573
589
|
# Security options
|
|
574
590
|
auth_token=security_options.auth_token,
|
|
575
591
|
# Claude options
|
|
@@ -582,6 +598,7 @@ def api(
|
|
|
582
598
|
max_turns=claude_options.max_turns,
|
|
583
599
|
permission_prompt_tool_name=claude_options.permission_prompt_tool_name,
|
|
584
600
|
cwd=claude_options.cwd,
|
|
601
|
+
sdk_message_mode=claude_options.sdk_message_mode,
|
|
585
602
|
)
|
|
586
603
|
|
|
587
604
|
# Load settings with CLI overrides
|
|
@@ -591,17 +608,16 @@ def api(
|
|
|
591
608
|
|
|
592
609
|
# Set up logging once with the effective log level
|
|
593
610
|
# Import here to avoid circular import
|
|
594
|
-
import structlog
|
|
595
611
|
|
|
596
612
|
from ccproxy.core.logging import setup_logging
|
|
597
613
|
|
|
598
614
|
# Always reconfigure logging to ensure log level changes are picked up
|
|
599
615
|
# Use JSON logs if explicitly requested via env var
|
|
600
|
-
|
|
616
|
+
print(f"{settings.server.log_level} {settings.server.log_file}")
|
|
601
617
|
setup_logging(
|
|
602
|
-
json_logs=
|
|
603
|
-
|
|
604
|
-
log_file=
|
|
618
|
+
json_logs=settings.server.log_format == "json",
|
|
619
|
+
log_level_name=settings.server.log_level,
|
|
620
|
+
log_file=settings.server.log_file,
|
|
605
621
|
)
|
|
606
622
|
|
|
607
623
|
# Re-get logger after logging is configured
|
|
@@ -624,7 +640,7 @@ def api(
|
|
|
624
640
|
)
|
|
625
641
|
|
|
626
642
|
# Log effective configuration
|
|
627
|
-
logger.
|
|
643
|
+
logger.debug(
|
|
628
644
|
"configuration_loaded",
|
|
629
645
|
host=settings.server.host,
|
|
630
646
|
port=settings.server.port,
|
|
@@ -778,6 +794,8 @@ def claude(
|
|
|
778
794
|
toolkit = get_rich_toolkit()
|
|
779
795
|
|
|
780
796
|
try:
|
|
797
|
+
# Logger will be configured by configuration manager
|
|
798
|
+
logger = get_logger(__name__)
|
|
781
799
|
# Log CLI command execution start
|
|
782
800
|
logger.info(
|
|
783
801
|
"cli_command_starting",
|
ccproxy/cli/docker/params.py
CHANGED
|
@@ -82,8 +82,6 @@ def validate_docker_home(
|
|
|
82
82
|
if value is None:
|
|
83
83
|
return None
|
|
84
84
|
|
|
85
|
-
from pathlib import Path
|
|
86
|
-
|
|
87
85
|
from ccproxy.config.docker_settings import validate_host_path
|
|
88
86
|
|
|
89
87
|
try:
|
|
@@ -116,8 +114,6 @@ def validate_docker_workspace(
|
|
|
116
114
|
if value is None:
|
|
117
115
|
return None
|
|
118
116
|
|
|
119
|
-
from pathlib import Path
|
|
120
|
-
|
|
121
117
|
from ccproxy.config.docker_settings import validate_host_path
|
|
122
118
|
|
|
123
119
|
try:
|