ccproxy-api 0.1.1__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 +65 -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 +88 -19
- ccproxy/claude_sdk/parser.py +200 -0
- ccproxy/claude_sdk/streaming.py +286 -0
- ccproxy/cli/commands/__init__.py +5 -1
- ccproxy/cli/commands/auth.py +2 -4
- ccproxy/cli/commands/permission_handler.py +553 -0
- ccproxy/cli/commands/serve.py +52 -12
- ccproxy/cli/docker/params.py +0 -4
- ccproxy/cli/helpers.py +0 -2
- ccproxy/cli/main.py +6 -17
- ccproxy/cli/options/claude_options.py +41 -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 +15 -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 +64 -1
- 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 +225 -329
- 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.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.3.dist-info/RECORD +166 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
- ccproxy_api-0.1.1.dist-info/RECORD +0 -149
- /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
- {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.1.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()
|