ccproxy-api 0.1.2__py3-none-any.whl → 0.1.4__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.4.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.4.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.4.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""Terminal UI handler for confirmation requests using Textual with request stacking support."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from structlog import get_logger
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.containers import Container, Vertical
|
|
11
|
+
from textual.events import Key
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.timer import Timer
|
|
15
|
+
from textual.widgets import Label, Static
|
|
16
|
+
|
|
17
|
+
from ccproxy.api.services.permission_service import PermissionRequest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PendingRequest:
|
|
25
|
+
"""Represents a pending confirmation request with its response future."""
|
|
26
|
+
|
|
27
|
+
request: PermissionRequest
|
|
28
|
+
future: asyncio.Future[bool]
|
|
29
|
+
cancelled: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfirmationScreen(ModalScreen[bool]):
|
|
33
|
+
"""Modal screen for displaying a single confirmation request."""
|
|
34
|
+
|
|
35
|
+
BINDINGS = [
|
|
36
|
+
("y", "confirm", "Yes"),
|
|
37
|
+
("n", "deny", "No"),
|
|
38
|
+
("enter", "confirm", "Confirm"),
|
|
39
|
+
("escape", "deny", "Cancel"),
|
|
40
|
+
("ctrl+c", "cancel", "Cancel"),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
def __init__(self, request: PermissionRequest) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.request = request
|
|
46
|
+
self.start_time = time.time()
|
|
47
|
+
self.countdown_timer: Timer | None = None
|
|
48
|
+
|
|
49
|
+
time_remaining = reactive(0.0)
|
|
50
|
+
|
|
51
|
+
def compose(self) -> ComposeResult:
|
|
52
|
+
"""Compose the confirmation dialog."""
|
|
53
|
+
with Container(id="confirmation-dialog"):
|
|
54
|
+
yield Vertical(
|
|
55
|
+
Label("[bold red]Permission Request[/bold red]", id="title"),
|
|
56
|
+
self._create_info_display(),
|
|
57
|
+
Label("Calculating timeout...", id="countdown", classes="countdown"),
|
|
58
|
+
Label(
|
|
59
|
+
"[bold white]Allow this operation? (y/N):[/bold white]",
|
|
60
|
+
id="question",
|
|
61
|
+
),
|
|
62
|
+
id="content",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _create_info_display(self) -> Static:
|
|
66
|
+
"""Create the information display widget."""
|
|
67
|
+
info_lines = [
|
|
68
|
+
f"[bold cyan]Tool:[/bold cyan] {self.request.tool_name}",
|
|
69
|
+
f"[bold cyan]Request ID:[/bold cyan] {self.request.id[:8]}...",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Add input parameters
|
|
73
|
+
for key, value in self.request.input.items():
|
|
74
|
+
display_value = value if len(value) <= 50 else f"{value[:47]}..."
|
|
75
|
+
info_lines.append(f"[bold cyan]{key}:[/bold cyan] {display_value}")
|
|
76
|
+
|
|
77
|
+
return Static("\n".join(info_lines), id="info")
|
|
78
|
+
|
|
79
|
+
def on_mount(self) -> None:
|
|
80
|
+
"""Start the countdown timer when mounted."""
|
|
81
|
+
self.update_countdown()
|
|
82
|
+
self.countdown_timer = self.set_interval(0.1, self.update_countdown)
|
|
83
|
+
|
|
84
|
+
def update_countdown(self) -> None:
|
|
85
|
+
"""Update the countdown display."""
|
|
86
|
+
elapsed = time.time() - self.start_time
|
|
87
|
+
remaining = max(0, self.request.time_remaining() - elapsed)
|
|
88
|
+
self.time_remaining = remaining
|
|
89
|
+
|
|
90
|
+
if remaining <= 0:
|
|
91
|
+
self._timeout()
|
|
92
|
+
else:
|
|
93
|
+
countdown_widget = self.query_one("#countdown", Label)
|
|
94
|
+
if remaining > 10:
|
|
95
|
+
style = "yellow"
|
|
96
|
+
elif remaining > 5:
|
|
97
|
+
style = "orange1"
|
|
98
|
+
else:
|
|
99
|
+
style = "red"
|
|
100
|
+
countdown_widget.update(f"[{style}]Timeout in {remaining:.1f}s[/{style}]")
|
|
101
|
+
|
|
102
|
+
def _timeout(self) -> None:
|
|
103
|
+
"""Handle timeout."""
|
|
104
|
+
if self.countdown_timer:
|
|
105
|
+
self.countdown_timer.stop()
|
|
106
|
+
self.countdown_timer = None
|
|
107
|
+
# Schedule the async result display
|
|
108
|
+
self.call_later(self._show_result, False, "TIMEOUT - DENIED")
|
|
109
|
+
|
|
110
|
+
async def _show_result(self, allowed: bool, message: str) -> None:
|
|
111
|
+
"""Show the result with visual feedback before dismissing.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
allowed: Whether the request was allowed
|
|
115
|
+
message: Message to display
|
|
116
|
+
"""
|
|
117
|
+
# Update the question to show the result
|
|
118
|
+
question_widget = self.query_one("#question", Label)
|
|
119
|
+
if allowed:
|
|
120
|
+
question_widget.update(f"[bold green]✓ {message}[/bold green]")
|
|
121
|
+
else:
|
|
122
|
+
question_widget.update(f"[bold red]✗ {message}[/bold red]")
|
|
123
|
+
|
|
124
|
+
# Update the dialog border color
|
|
125
|
+
dialog = self.query_one("#confirmation-dialog", Container)
|
|
126
|
+
if allowed:
|
|
127
|
+
dialog.styles.border = ("solid", "green")
|
|
128
|
+
else:
|
|
129
|
+
dialog.styles.border = ("solid", "red")
|
|
130
|
+
|
|
131
|
+
# Give user time to see the result
|
|
132
|
+
await asyncio.sleep(1.5)
|
|
133
|
+
self.dismiss(allowed)
|
|
134
|
+
|
|
135
|
+
def action_confirm(self) -> None:
|
|
136
|
+
"""Confirm the request."""
|
|
137
|
+
if self.countdown_timer:
|
|
138
|
+
self.countdown_timer.stop()
|
|
139
|
+
self.countdown_timer = None
|
|
140
|
+
self.call_later(self._show_result, True, "ALLOWED")
|
|
141
|
+
|
|
142
|
+
def action_deny(self) -> None:
|
|
143
|
+
"""Deny the request."""
|
|
144
|
+
if self.countdown_timer:
|
|
145
|
+
self.countdown_timer.stop()
|
|
146
|
+
self.countdown_timer = None
|
|
147
|
+
self.call_later(self._show_result, False, "DENIED")
|
|
148
|
+
|
|
149
|
+
def action_cancel(self) -> None:
|
|
150
|
+
"""Cancel the request (Ctrl+C)."""
|
|
151
|
+
if self.countdown_timer:
|
|
152
|
+
self.countdown_timer.stop()
|
|
153
|
+
self.countdown_timer = None
|
|
154
|
+
self.call_later(self._show_result, False, "CANCELLED")
|
|
155
|
+
# Raise KeyboardInterrupt to forward it up
|
|
156
|
+
raise KeyboardInterrupt("User cancelled confirmation")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ConfirmationApp(App[bool]):
|
|
160
|
+
"""Simple Textual app for a single confirmation request."""
|
|
161
|
+
|
|
162
|
+
CSS = """
|
|
163
|
+
|
|
164
|
+
Screen {
|
|
165
|
+
border: none;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
Static {
|
|
169
|
+
background: $surface;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#confirmation-dialog {
|
|
173
|
+
width: 60;
|
|
174
|
+
height: 18;
|
|
175
|
+
border: round solid $accent;
|
|
176
|
+
background: $surface;
|
|
177
|
+
padding: 1;
|
|
178
|
+
box-sizing: border-box;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#title {
|
|
182
|
+
text-align: center;
|
|
183
|
+
margin-bottom: 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#info {
|
|
187
|
+
border: solid $primary;
|
|
188
|
+
margin: 1;
|
|
189
|
+
padding: 1;
|
|
190
|
+
background: $surface;
|
|
191
|
+
height: auto;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#countdown {
|
|
195
|
+
text-align: center;
|
|
196
|
+
margin: 1;
|
|
197
|
+
background: $surface;
|
|
198
|
+
text-style: bold;
|
|
199
|
+
height: 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#question {
|
|
203
|
+
text-align: center;
|
|
204
|
+
margin: 1;
|
|
205
|
+
background: $surface;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
.countdown {
|
|
210
|
+
text-style: bold;
|
|
211
|
+
}
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
BINDINGS = [
|
|
215
|
+
("y", "confirm", "Yes"),
|
|
216
|
+
("n", "deny", "No"),
|
|
217
|
+
("enter", "confirm", "Confirm"),
|
|
218
|
+
("escape", "deny", "Cancel"),
|
|
219
|
+
("ctrl+c", "cancel", "Cancel"),
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
def __init__(self, request: PermissionRequest) -> None:
|
|
223
|
+
super().__init__()
|
|
224
|
+
self.theme = "textual-ansi"
|
|
225
|
+
self.request = request
|
|
226
|
+
self.result = False
|
|
227
|
+
self.start_time = time.time()
|
|
228
|
+
self.countdown_timer: Timer | None = None
|
|
229
|
+
|
|
230
|
+
time_remaining = reactive(0.0)
|
|
231
|
+
|
|
232
|
+
def compose(self) -> ComposeResult:
|
|
233
|
+
"""Compose the confirmation dialog directly."""
|
|
234
|
+
with Container(id="confirmation-dialog"):
|
|
235
|
+
yield Vertical(
|
|
236
|
+
Label("[bold red]Permission Request[/bold red]", id="title"),
|
|
237
|
+
self._create_info_display(),
|
|
238
|
+
Label("Calculating timeout...", id="countdown", classes="countdown"),
|
|
239
|
+
Label(
|
|
240
|
+
"[bold white]Allow this operation? (y/N):[/bold white]",
|
|
241
|
+
id="question",
|
|
242
|
+
),
|
|
243
|
+
id="content",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _create_info_display(self) -> Static:
|
|
247
|
+
"""Create the information display widget."""
|
|
248
|
+
info_lines = [
|
|
249
|
+
f"[bold cyan]Tool:[/bold cyan] {self.request.tool_name}",
|
|
250
|
+
f"[bold cyan]Request ID:[/bold cyan] {self.request.id[:8]}...",
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
# Add input parameters
|
|
254
|
+
for key, value in self.request.input.items():
|
|
255
|
+
display_value = value if len(value) <= 50 else f"{value[:47]}..."
|
|
256
|
+
info_lines.append(f"[bold cyan]{key}:[/bold cyan] {display_value}")
|
|
257
|
+
|
|
258
|
+
return Static("\n".join(info_lines), id="info")
|
|
259
|
+
|
|
260
|
+
def on_mount(self) -> None:
|
|
261
|
+
"""Start the countdown timer when mounted."""
|
|
262
|
+
self.update_countdown()
|
|
263
|
+
self.countdown_timer = self.set_interval(0.1, self.update_countdown)
|
|
264
|
+
|
|
265
|
+
def update_countdown(self) -> None:
|
|
266
|
+
"""Update the countdown display."""
|
|
267
|
+
elapsed = time.time() - self.start_time
|
|
268
|
+
remaining = max(0, self.request.time_remaining() - elapsed)
|
|
269
|
+
self.time_remaining = remaining
|
|
270
|
+
|
|
271
|
+
if remaining <= 0:
|
|
272
|
+
self._timeout()
|
|
273
|
+
else:
|
|
274
|
+
countdown_widget = self.query_one("#countdown", Label)
|
|
275
|
+
if remaining > 10:
|
|
276
|
+
style = "yellow"
|
|
277
|
+
elif remaining > 5:
|
|
278
|
+
style = "orange1"
|
|
279
|
+
else:
|
|
280
|
+
style = "red"
|
|
281
|
+
countdown_widget.update(f"[{style}]Timeout in {remaining:.1f}s[/{style}]")
|
|
282
|
+
|
|
283
|
+
def _timeout(self) -> None:
|
|
284
|
+
"""Handle timeout."""
|
|
285
|
+
if self.countdown_timer:
|
|
286
|
+
self.countdown_timer.stop()
|
|
287
|
+
self.countdown_timer = None
|
|
288
|
+
# Schedule the async result display
|
|
289
|
+
self.call_later(self._show_result, False, "TIMEOUT - DENIED")
|
|
290
|
+
|
|
291
|
+
async def _show_result(self, allowed: bool, message: str) -> None:
|
|
292
|
+
"""Show the result with visual feedback before exiting.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
allowed: Whether the request was allowed
|
|
296
|
+
message: Message to display
|
|
297
|
+
"""
|
|
298
|
+
# Update the question to show the result
|
|
299
|
+
question_widget = self.query_one("#question", Label)
|
|
300
|
+
if allowed:
|
|
301
|
+
question_widget.update(f"[bold green]✓ {message}[/bold green]")
|
|
302
|
+
else:
|
|
303
|
+
question_widget.update(f"[bold red]✗ {message}[/bold red]")
|
|
304
|
+
|
|
305
|
+
# Update the dialog border color
|
|
306
|
+
dialog = self.query_one("#confirmation-dialog", Container)
|
|
307
|
+
if allowed:
|
|
308
|
+
dialog.styles.border = ("solid", "green")
|
|
309
|
+
else:
|
|
310
|
+
dialog.styles.border = ("solid", "red")
|
|
311
|
+
|
|
312
|
+
# Give user time to see the result
|
|
313
|
+
await asyncio.sleep(1.5)
|
|
314
|
+
self.exit(allowed)
|
|
315
|
+
|
|
316
|
+
def action_confirm(self) -> None:
|
|
317
|
+
"""Confirm the request."""
|
|
318
|
+
if self.countdown_timer:
|
|
319
|
+
self.countdown_timer.stop()
|
|
320
|
+
self.countdown_timer = None
|
|
321
|
+
self.call_later(self._show_result, True, "ALLOWED")
|
|
322
|
+
|
|
323
|
+
def action_deny(self) -> None:
|
|
324
|
+
"""Deny the request."""
|
|
325
|
+
if self.countdown_timer:
|
|
326
|
+
self.countdown_timer.stop()
|
|
327
|
+
self.countdown_timer = None
|
|
328
|
+
self.call_later(self._show_result, False, "DENIED")
|
|
329
|
+
|
|
330
|
+
def action_cancel(self) -> None:
|
|
331
|
+
"""Cancel the request (Ctrl+C)."""
|
|
332
|
+
if self.countdown_timer:
|
|
333
|
+
self.countdown_timer.stop()
|
|
334
|
+
self.countdown_timer = None
|
|
335
|
+
self.call_later(self._show_result, False, "CANCELLED")
|
|
336
|
+
# Raise KeyboardInterrupt to forward it up
|
|
337
|
+
raise KeyboardInterrupt("User cancelled confirmation")
|
|
338
|
+
|
|
339
|
+
async def on_key(self, event: Key) -> None:
|
|
340
|
+
"""Handle global key events, especially Ctrl+C."""
|
|
341
|
+
if event.key == "ctrl+c":
|
|
342
|
+
# Forward the KeyboardInterrupt
|
|
343
|
+
self.exit(False)
|
|
344
|
+
raise KeyboardInterrupt("User cancelled confirmation")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class TerminalPermissionHandler:
|
|
348
|
+
"""Handles confirmation requests in the terminal using Textual with request stacking.
|
|
349
|
+
|
|
350
|
+
Implements ConfirmationHandlerProtocol for type safety and interoperability.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
def __init__(self) -> None:
|
|
354
|
+
"""Initialize the terminal confirmation handler."""
|
|
355
|
+
self._request_queue: (
|
|
356
|
+
asyncio.Queue[tuple[PermissionRequest, asyncio.Future[bool]]] | None
|
|
357
|
+
) = None
|
|
358
|
+
self._cancelled_requests: set[str] = set()
|
|
359
|
+
self._processing_task: asyncio.Task[None] | None = None
|
|
360
|
+
self._active_apps: dict[str, ConfirmationApp] = {}
|
|
361
|
+
|
|
362
|
+
def _get_request_queue(
|
|
363
|
+
self,
|
|
364
|
+
) -> asyncio.Queue[tuple[PermissionRequest, asyncio.Future[bool]]]:
|
|
365
|
+
"""Lazily initialize and return the request queue."""
|
|
366
|
+
if self._request_queue is None:
|
|
367
|
+
self._request_queue = asyncio.Queue()
|
|
368
|
+
return self._request_queue
|
|
369
|
+
|
|
370
|
+
def _safe_set_future_result(
|
|
371
|
+
self, future: asyncio.Future[bool], result: bool
|
|
372
|
+
) -> bool:
|
|
373
|
+
"""Safely set a future result, handling already cancelled futures.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
future: The future to set the result on
|
|
377
|
+
result: The result to set
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
bool: True if result was set successfully, False if future was cancelled
|
|
381
|
+
"""
|
|
382
|
+
if future.cancelled():
|
|
383
|
+
return False
|
|
384
|
+
try:
|
|
385
|
+
future.set_result(result)
|
|
386
|
+
return True
|
|
387
|
+
except asyncio.InvalidStateError:
|
|
388
|
+
# Future was already resolved or cancelled
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
def _safe_set_future_exception(
|
|
392
|
+
self, future: asyncio.Future[bool], exception: BaseException
|
|
393
|
+
) -> bool:
|
|
394
|
+
"""Safely set a future exception, handling already cancelled futures.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
future: The future to set the exception on
|
|
398
|
+
exception: The exception to set
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
bool: True if exception was set successfully, False if future was cancelled
|
|
402
|
+
"""
|
|
403
|
+
if future.cancelled():
|
|
404
|
+
return False
|
|
405
|
+
try:
|
|
406
|
+
future.set_exception(exception)
|
|
407
|
+
return True
|
|
408
|
+
except asyncio.InvalidStateError:
|
|
409
|
+
# Future was already resolved or cancelled
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
async def _process_queue(self) -> None:
|
|
413
|
+
"""Process requests from the queue one by one."""
|
|
414
|
+
while True:
|
|
415
|
+
try:
|
|
416
|
+
request, future = await self._get_request_queue().get()
|
|
417
|
+
|
|
418
|
+
# Check if request is valid for processing
|
|
419
|
+
if not self._is_request_processable(request, future):
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
# Process the request
|
|
423
|
+
await self._process_single_request(request, future)
|
|
424
|
+
|
|
425
|
+
except asyncio.CancelledError:
|
|
426
|
+
break
|
|
427
|
+
except Exception as e:
|
|
428
|
+
logger.error("queue_processing_error", error=str(e), exc_info=True)
|
|
429
|
+
|
|
430
|
+
def _is_request_processable(
|
|
431
|
+
self, request: PermissionRequest, future: asyncio.Future[bool]
|
|
432
|
+
) -> bool:
|
|
433
|
+
"""Check if a request can be processed."""
|
|
434
|
+
# Check if cancelled before processing
|
|
435
|
+
if request.id in self._cancelled_requests:
|
|
436
|
+
self._safe_set_future_result(future, False)
|
|
437
|
+
self._cancelled_requests.discard(request.id)
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
# Check if expired
|
|
441
|
+
if request.time_remaining() <= 0:
|
|
442
|
+
self._safe_set_future_result(future, False)
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
async def _process_single_request(
|
|
448
|
+
self, request: PermissionRequest, future: asyncio.Future[bool]
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Process a single permission request."""
|
|
451
|
+
app = None
|
|
452
|
+
try:
|
|
453
|
+
# Create and run a simple app for this request
|
|
454
|
+
app = ConfirmationApp(request)
|
|
455
|
+
self._active_apps[request.id] = app
|
|
456
|
+
|
|
457
|
+
app_result = await app.run_async(inline=True, inline_no_clear=True)
|
|
458
|
+
result = bool(app_result) if app_result is not None else False
|
|
459
|
+
|
|
460
|
+
# Apply cancellation if it occurred during processing
|
|
461
|
+
if request.id in self._cancelled_requests:
|
|
462
|
+
result = False
|
|
463
|
+
self._cancelled_requests.discard(request.id)
|
|
464
|
+
|
|
465
|
+
self._safe_set_future_result(future, result)
|
|
466
|
+
|
|
467
|
+
except KeyboardInterrupt:
|
|
468
|
+
self._safe_set_future_exception(
|
|
469
|
+
future, KeyboardInterrupt("User cancelled confirmation")
|
|
470
|
+
)
|
|
471
|
+
except Exception as e:
|
|
472
|
+
logger.error(
|
|
473
|
+
"confirmation_app_error",
|
|
474
|
+
request_id=request.id,
|
|
475
|
+
error=str(e),
|
|
476
|
+
exc_info=True,
|
|
477
|
+
)
|
|
478
|
+
self._safe_set_future_result(future, False)
|
|
479
|
+
finally:
|
|
480
|
+
# Always cleanup app reference
|
|
481
|
+
if app:
|
|
482
|
+
self._active_apps.pop(request.id, None)
|
|
483
|
+
|
|
484
|
+
def _ensure_processing_task_running(self) -> None:
|
|
485
|
+
"""Ensure the processing task is running."""
|
|
486
|
+
if self._processing_task is None or self._processing_task.done():
|
|
487
|
+
self._processing_task = asyncio.create_task(self._process_queue())
|
|
488
|
+
|
|
489
|
+
async def _queue_and_wait_for_result(self, request: PermissionRequest) -> bool:
|
|
490
|
+
"""Queue a request and wait for its result."""
|
|
491
|
+
future: asyncio.Future[bool] = asyncio.Future()
|
|
492
|
+
await self._get_request_queue().put((request, future))
|
|
493
|
+
return await future
|
|
494
|
+
|
|
495
|
+
async def handle_permission(self, request: PermissionRequest) -> bool:
|
|
496
|
+
"""Handle a permission request.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
request: The permission request to handle
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
bool: True if the user confirmed, False otherwise
|
|
503
|
+
"""
|
|
504
|
+
try:
|
|
505
|
+
logger.info(
|
|
506
|
+
"handling_confirmation_request",
|
|
507
|
+
request_id=request.id,
|
|
508
|
+
tool_name=request.tool_name,
|
|
509
|
+
time_remaining=request.time_remaining(),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Check if request has already expired
|
|
513
|
+
if request.time_remaining() <= 0:
|
|
514
|
+
logger.info("confirmation_request_expired", request_id=request.id)
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
# Ensure processing task is running
|
|
518
|
+
self._ensure_processing_task_running()
|
|
519
|
+
|
|
520
|
+
# Queue request and wait for result
|
|
521
|
+
result = await self._queue_and_wait_for_result(request)
|
|
522
|
+
|
|
523
|
+
logger.info(
|
|
524
|
+
"confirmation_request_completed", request_id=request.id, result=result
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
return result
|
|
528
|
+
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.error(
|
|
531
|
+
"confirmation_handling_error",
|
|
532
|
+
request_id=request.id,
|
|
533
|
+
error=str(e),
|
|
534
|
+
exc_info=True,
|
|
535
|
+
)
|
|
536
|
+
return False
|
|
537
|
+
|
|
538
|
+
def cancel_confirmation(self, request_id: str, reason: str = "cancelled") -> None:
|
|
539
|
+
"""Cancel an ongoing confirmation request.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
request_id: The ID of the request to cancel
|
|
543
|
+
reason: The reason for cancellation
|
|
544
|
+
"""
|
|
545
|
+
logger.info("cancelling_confirmation", request_id=request_id, reason=reason)
|
|
546
|
+
self._cancelled_requests.add(request_id)
|
|
547
|
+
|
|
548
|
+
# If there's an active dialog for this request, close it immediately
|
|
549
|
+
if request_id in self._active_apps:
|
|
550
|
+
app = self._active_apps[request_id]
|
|
551
|
+
# Schedule the cancellation feedback asynchronously
|
|
552
|
+
asyncio.create_task(self._cancel_active_dialog(app, reason))
|
|
553
|
+
|
|
554
|
+
async def _cancel_active_dialog(self, app: ConfirmationApp, reason: str) -> None:
|
|
555
|
+
"""Cancel an active dialog with visual feedback.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
app: The active ConfirmationApp to cancel
|
|
559
|
+
reason: The reason for cancellation
|
|
560
|
+
"""
|
|
561
|
+
try:
|
|
562
|
+
# Determine the message and result based on reason
|
|
563
|
+
if "approved by another handler" in reason.lower():
|
|
564
|
+
message = "APPROVED BY ANOTHER HANDLER"
|
|
565
|
+
allowed = True
|
|
566
|
+
elif "denied by another handler" in reason.lower():
|
|
567
|
+
message = "DENIED BY ANOTHER HANDLER"
|
|
568
|
+
allowed = False
|
|
569
|
+
else:
|
|
570
|
+
message = f"CANCELLED - {reason.upper()}"
|
|
571
|
+
allowed = False
|
|
572
|
+
|
|
573
|
+
# Show visual feedback through the app's _show_result method
|
|
574
|
+
await app._show_result(allowed, message)
|
|
575
|
+
|
|
576
|
+
except Exception as e:
|
|
577
|
+
logger.error(
|
|
578
|
+
"cancel_dialog_error",
|
|
579
|
+
error=str(e),
|
|
580
|
+
exc_info=True,
|
|
581
|
+
)
|
|
582
|
+
# Fallback: just exit the app without feedback
|
|
583
|
+
with contextlib.suppress(Exception):
|
|
584
|
+
app.exit(False)
|
|
585
|
+
|
|
586
|
+
async def shutdown(self) -> None:
|
|
587
|
+
"""Shutdown the handler and cleanup resources."""
|
|
588
|
+
if self._processing_task and not self._processing_task.done():
|
|
589
|
+
self._processing_task.cancel()
|
|
590
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
591
|
+
await self._processing_task
|
|
592
|
+
|
|
593
|
+
self._processing_task = None
|
ccproxy/auth/conditional.py
CHANGED
|
@@ -5,10 +5,10 @@ from typing import Annotated
|
|
|
5
5
|
from fastapi import Depends, HTTPException, Request, status
|
|
6
6
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
7
7
|
|
|
8
|
+
from ccproxy.api.dependencies import SettingsDep
|
|
8
9
|
from ccproxy.auth.bearer import BearerTokenAuthManager
|
|
9
10
|
from ccproxy.auth.exceptions import AuthenticationError
|
|
10
11
|
from ccproxy.auth.manager import AuthManager
|
|
11
|
-
from ccproxy.config.settings import Settings, get_settings
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
# FastAPI security scheme for bearer tokens
|
|
@@ -17,10 +17,10 @@ bearer_scheme = HTTPBearer(auto_error=False)
|
|
|
17
17
|
|
|
18
18
|
async def get_conditional_auth_manager(
|
|
19
19
|
request: Request,
|
|
20
|
+
settings: SettingsDep,
|
|
20
21
|
credentials: Annotated[
|
|
21
22
|
HTTPAuthorizationCredentials | None, Depends(bearer_scheme)
|
|
22
23
|
] = None,
|
|
23
|
-
settings: Annotated[Settings | None, Depends(get_settings)] = None,
|
|
24
24
|
) -> AuthManager | None:
|
|
25
25
|
"""Get authentication manager only if auth is required.
|
|
26
26
|
|
ccproxy/auth/dependencies.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""FastAPI dependency injection for authentication."""
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING, Annotated
|
|
3
|
+
from typing import TYPE_CHECKING, Annotated
|
|
4
4
|
|
|
5
5
|
from fastapi import Depends, HTTPException, status
|
|
6
6
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
ccproxy/auth/oauth/models.py
CHANGED
ccproxy/auth/oauth/routes.py
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
"""OAuth authentication routes for Anthropic OAuth login."""
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
|
-
import logging
|
|
5
3
|
from pathlib import Path
|
|
6
4
|
from typing import Any
|
|
7
5
|
|
|
8
|
-
from fastapi import APIRouter,
|
|
6
|
+
from fastapi import APIRouter, Query, Request
|
|
9
7
|
from fastapi.responses import HTMLResponse
|
|
10
8
|
from structlog import get_logger
|
|
11
9
|
|
ccproxy/auth/storage/keyring.py
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
"""OS keyring storage implementation for token storage."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from typing import Optional
|
|
5
4
|
|
|
6
|
-
import keyring
|
|
7
5
|
from structlog import get_logger
|
|
8
6
|
|
|
9
7
|
from ccproxy.auth.exceptions import (
|
|
10
|
-
CredentialsInvalidError,
|
|
11
8
|
CredentialsStorageError,
|
|
12
9
|
)
|
|
13
10
|
from ccproxy.auth.models import ClaudeCredentials
|
ccproxy/claude_sdk/__init__.py
CHANGED
|
@@ -8,6 +8,7 @@ from .client import (
|
|
|
8
8
|
)
|
|
9
9
|
from .converter import MessageConverter
|
|
10
10
|
from .options import OptionsHandler
|
|
11
|
+
from .parser import parse_formatted_sdk_content
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
__all__ = [
|
|
@@ -17,4 +18,5 @@ __all__ = [
|
|
|
17
18
|
"ClaudeSDKProcessError",
|
|
18
19
|
"MessageConverter",
|
|
19
20
|
"OptionsHandler",
|
|
21
|
+
"parse_formatted_sdk_content",
|
|
20
22
|
]
|