ccproxy-api 0.1.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.
Files changed (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,766 @@
1
+ """Main config commands for CCProxy API."""
2
+
3
+ import json
4
+ import secrets
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+ from click import get_current_context
10
+ from pydantic import BaseModel
11
+ from pydantic.fields import FieldInfo
12
+
13
+ from ccproxy._version import __version__
14
+ from ccproxy.cli.helpers import get_rich_toolkit
15
+ from ccproxy.config.settings import Settings, get_settings
16
+
17
+
18
+ def _create_config_table(title: str, rows: list[tuple[str, str, str]]) -> Any:
19
+ """Create a configuration table with standard styling."""
20
+ from rich.table import Table
21
+
22
+ table = Table(title=title, show_header=True, header_style="bold magenta")
23
+ table.add_column("Setting", style="cyan", width=20)
24
+ table.add_column("Value", style="green")
25
+ table.add_column("Description", style="dim")
26
+
27
+ for setting, value, description in rows:
28
+ table.add_row(setting, value, description)
29
+
30
+ return table
31
+
32
+
33
+ def _format_value(value: Any) -> str:
34
+ """Format a configuration value for display."""
35
+ if value is None:
36
+ return "[dim]Auto-detect[/dim]"
37
+ elif isinstance(value, bool | int | float):
38
+ return str(value)
39
+ elif isinstance(value, str):
40
+ if not value:
41
+ return "[dim]Not set[/dim]"
42
+ # Special handling for sensitive values
43
+ if any(
44
+ keyword in value.lower()
45
+ for keyword in ["token", "key", "secret", "password"]
46
+ ):
47
+ return "[green]Set[/green]"
48
+ return value
49
+ elif isinstance(value, list):
50
+ if not value:
51
+ return "[dim]None[/dim]"
52
+ if len(value) == 1:
53
+ return str(value[0])
54
+ return "\n".join(str(item) for item in value)
55
+ elif isinstance(value, dict):
56
+ if not value:
57
+ return "[dim]None[/dim]"
58
+ return "\n".join(f"{k}={v}" for k, v in value.items())
59
+ else:
60
+ return str(value)
61
+
62
+
63
+ def _get_field_description(field_info: FieldInfo) -> str:
64
+ """Get a human-readable description from a Pydantic field."""
65
+ if field_info.description:
66
+ return field_info.description
67
+ # Generate a basic description from the field name
68
+ return "Configuration setting"
69
+
70
+
71
+ def _generate_config_rows_from_model(
72
+ model: BaseModel, prefix: str = ""
73
+ ) -> list[tuple[str, str, str]]:
74
+ """Generate configuration rows from a Pydantic model dynamically."""
75
+ rows = []
76
+
77
+ for field_name, _field_info in model.model_fields.items():
78
+ field_value = getattr(model, field_name)
79
+ display_name = f"{prefix}{field_name}" if prefix else field_name
80
+
81
+ # If the field value is also a BaseModel, we might want to flatten it
82
+ if isinstance(field_value, BaseModel):
83
+ # For nested models, we can either flatten or show as a summary
84
+ # For now, let's show a summary and then add sub-rows
85
+ model_name = field_value.__class__.__name__
86
+ rows.append(
87
+ (
88
+ display_name,
89
+ f"[dim]{model_name} configuration[/dim]",
90
+ _get_field_description(_field_info),
91
+ )
92
+ )
93
+
94
+ # Add sub-rows for the nested model
95
+ sub_rows = _generate_config_rows_from_model(field_value, f"{display_name}_")
96
+ rows.extend(sub_rows)
97
+ else:
98
+ # Regular field
99
+ formatted_value = _format_value(field_value)
100
+ description = _get_field_description(_field_info)
101
+ rows.append((display_name, formatted_value, description))
102
+
103
+ return rows
104
+
105
+
106
+ def _group_config_rows(
107
+ rows: list[tuple[str, str, str]],
108
+ ) -> dict[str, list[tuple[str, str, str]]]:
109
+ """Group configuration rows by their top-level section."""
110
+ groups: dict[str, list[tuple[str, str, str]]] = {}
111
+
112
+ for setting, value, description in rows:
113
+ # Determine the group based on the setting name
114
+ if setting.startswith("server"):
115
+ group_name = "Server Configuration"
116
+ elif setting.startswith("security"):
117
+ group_name = "Security Configuration"
118
+ elif setting.startswith("cors"):
119
+ group_name = "CORS Configuration"
120
+ elif setting.startswith("claude"):
121
+ group_name = "Claude CLI Configuration"
122
+ elif setting.startswith("reverse_proxy"):
123
+ group_name = "Reverse Proxy Configuration"
124
+ elif setting.startswith("auth"):
125
+ group_name = "Authentication Configuration"
126
+ elif setting.startswith("docker"):
127
+ group_name = "Docker Configuration"
128
+ elif setting.startswith("observability"):
129
+ group_name = "Observability Configuration"
130
+ elif setting.startswith("scheduler"):
131
+ group_name = "Scheduler Configuration"
132
+ elif setting.startswith("pricing"):
133
+ group_name = "Pricing Configuration"
134
+ else:
135
+ group_name = "General Configuration"
136
+
137
+ if group_name not in groups:
138
+ groups[group_name] = []
139
+
140
+ # Clean up the setting name by removing the prefix
141
+ clean_setting = setting.split("_", 1)[1] if "_" in setting else setting
142
+ groups[group_name].append((clean_setting, value, description))
143
+
144
+ return groups
145
+
146
+
147
+ def get_config_path_from_context() -> Path | None:
148
+ """Get config path from typer context if available."""
149
+ try:
150
+ ctx = get_current_context()
151
+ if ctx and ctx.obj and "config_path" in ctx.obj:
152
+ config_path = ctx.obj["config_path"]
153
+ return config_path if config_path is None else Path(config_path)
154
+ except RuntimeError:
155
+ # No active click context (e.g., in tests)
156
+ pass
157
+ return None
158
+
159
+
160
+ app = typer.Typer(
161
+ name="config",
162
+ help="Configuration management commands",
163
+ rich_markup_mode="rich",
164
+ add_completion=True,
165
+ no_args_is_help=True,
166
+ )
167
+
168
+
169
+ @app.command(name="list")
170
+ def config_list() -> None:
171
+ """Show current configuration."""
172
+ toolkit = get_rich_toolkit()
173
+
174
+ try:
175
+ settings = get_settings(config_path=get_config_path_from_context())
176
+
177
+ from rich.console import Console
178
+ from rich.panel import Panel
179
+ from rich.text import Text
180
+
181
+ console = Console()
182
+
183
+ # Generate configuration rows dynamically from the Settings model
184
+ all_rows = _generate_config_rows_from_model(settings)
185
+
186
+ # Add computed fields that aren't part of the model but are useful to display
187
+ all_rows.append(
188
+ ("server_url", settings.server_url, "Complete server URL (computed)")
189
+ )
190
+
191
+ # Group rows by configuration section
192
+ grouped_rows = _group_config_rows(all_rows)
193
+
194
+ # Display header
195
+ console.print(
196
+ Panel.fit(
197
+ f"[bold]CCProxy API Configuration[/bold]\n[dim]Version: {__version__}[/dim]",
198
+ border_style="blue",
199
+ )
200
+ )
201
+ console.print()
202
+
203
+ # Display each configuration section as a table
204
+ for section_name, section_rows in grouped_rows.items():
205
+ if section_rows: # Only show sections that have data
206
+ table = _create_config_table(section_name, section_rows)
207
+ console.print(table)
208
+ console.print()
209
+
210
+ # Show configuration file sources
211
+ info_text = Text()
212
+ info_text.append("Configuration loaded from: ", style="bold")
213
+ info_text.append(
214
+ "environment variables, .env file, and TOML configuration files",
215
+ style="dim",
216
+ )
217
+ console.print(
218
+ Panel(info_text, title="Configuration Sources", border_style="green")
219
+ )
220
+
221
+ except Exception as e:
222
+ toolkit.print(f"Error loading configuration: {e}", tag="error")
223
+ raise typer.Exit(1) from e
224
+
225
+
226
+ @app.command(name="init")
227
+ def config_init(
228
+ format: str = typer.Option(
229
+ "toml",
230
+ "--format",
231
+ "-f",
232
+ help="Configuration file format (only toml is supported)",
233
+ ),
234
+ output_dir: Path | None = typer.Option(
235
+ None,
236
+ "--output-dir",
237
+ "-o",
238
+ help="Output directory for example config files (default: XDG_CONFIG_HOME/ccproxy)",
239
+ ),
240
+ force: bool = typer.Option(
241
+ False,
242
+ "--force",
243
+ help="Overwrite existing configuration files",
244
+ ),
245
+ ) -> None:
246
+ """Generate example configuration files.
247
+
248
+ This command creates example configuration files with all available options
249
+ and documentation comments.
250
+
251
+ Examples:
252
+ ccproxy config init # Create TOML config in default location
253
+ ccproxy config init --output-dir ./config # Create in specific directory
254
+ """
255
+ # Validate format
256
+ if format != "toml":
257
+ toolkit = get_rich_toolkit()
258
+ toolkit.print(
259
+ f"Error: Invalid format '{format}'. Only 'toml' format is supported.",
260
+ tag="error",
261
+ )
262
+ raise typer.Exit(1)
263
+
264
+ toolkit = get_rich_toolkit()
265
+
266
+ try:
267
+ from ccproxy.config.discovery import get_ccproxy_config_dir
268
+
269
+ # Determine output directory
270
+ if output_dir is None:
271
+ output_dir = get_ccproxy_config_dir()
272
+
273
+ # Create output directory if it doesn't exist
274
+ output_dir.mkdir(parents=True, exist_ok=True)
275
+
276
+ # Generate configuration dynamically from Settings model
277
+ example_config = _generate_default_config_from_model(Settings)
278
+
279
+ # Determine output file name
280
+ if format == "toml":
281
+ output_file = output_dir / "config.toml"
282
+ if output_file.exists() and not force:
283
+ toolkit.print(
284
+ f"Error: {output_file} already exists. Use --force to overwrite.",
285
+ tag="error",
286
+ )
287
+ raise typer.Exit(1)
288
+
289
+ # Write TOML with comments using dynamic generation
290
+ _write_toml_config_with_comments(output_file, example_config, Settings)
291
+
292
+ toolkit.print(
293
+ f"Created example configuration file: {output_file}", tag="success"
294
+ )
295
+ toolkit.print_line()
296
+ toolkit.print("To use this configuration:", tag="info")
297
+ toolkit.print(f" ccproxy --config {output_file} api", tag="command")
298
+ toolkit.print_line()
299
+ toolkit.print("Or set the CONFIG_FILE environment variable:", tag="info")
300
+ toolkit.print(f" export CONFIG_FILE={output_file}", tag="command")
301
+ toolkit.print(" ccproxy api", tag="command")
302
+
303
+ except Exception as e:
304
+ toolkit.print(f"Error creating configuration file: {e}", tag="error")
305
+ raise typer.Exit(1) from e
306
+
307
+
308
+ @app.command(name="generate-token")
309
+ def generate_token(
310
+ save: bool = typer.Option(
311
+ False,
312
+ "--save",
313
+ "--write",
314
+ help="Save the token to configuration file",
315
+ ),
316
+ config_file: Path | None = typer.Option(
317
+ None,
318
+ "--config-file",
319
+ "-c",
320
+ help="Configuration file to update (default: auto-detect or create .ccproxy.toml)",
321
+ ),
322
+ force: bool = typer.Option(
323
+ False,
324
+ "--force",
325
+ help="Overwrite existing auth_token without confirmation",
326
+ ),
327
+ ) -> None:
328
+ """Generate a secure random token for API authentication.
329
+
330
+ This command generates a secure authentication token that can be used with
331
+ both Anthropic and OpenAI compatible APIs.
332
+
333
+ Use --save to write the token to a TOML configuration file.
334
+
335
+ Examples:
336
+ ccproxy config generate-token # Generate and display token
337
+ ccproxy config generate-token --save # Generate and save to config
338
+ ccproxy config generate-token --save --config-file custom.toml # Save to TOML config
339
+ ccproxy config generate-token --save --force # Overwrite existing token
340
+ """
341
+ toolkit = get_rich_toolkit()
342
+
343
+ try:
344
+ # Generate a secure token
345
+ token = secrets.token_urlsafe(32)
346
+
347
+ from rich.console import Console
348
+ from rich.panel import Panel
349
+
350
+ console = Console()
351
+
352
+ # Display the generated token
353
+ console.print()
354
+ console.print(
355
+ Panel.fit(
356
+ f"[bold green]Generated Authentication Token[/bold green]\n[dim]Token: [/dim][bold]{token}[/bold]",
357
+ border_style="green",
358
+ )
359
+ )
360
+ console.print()
361
+
362
+ # Show environment variable commands - server first, then clients
363
+ console.print("[bold]Server Environment Variables:[/bold]")
364
+ console.print(f"[cyan]export AUTH_TOKEN={token}[/cyan]")
365
+ console.print()
366
+
367
+ console.print("[bold]Client Environment Variables:[/bold]")
368
+ console.print()
369
+
370
+ console.print("[dim]For Anthropic Python SDK clients:[/dim]")
371
+ console.print(f"[cyan]export ANTHROPIC_API_KEY={token}[/cyan]")
372
+ console.print("[cyan]export ANTHROPIC_BASE_URL=http://localhost:8000[/cyan]")
373
+ console.print()
374
+
375
+ console.print("[dim]For OpenAI Python SDK clients:[/dim]")
376
+ console.print(f"[cyan]export OPENAI_API_KEY={token}[/cyan]")
377
+ console.print(
378
+ "[cyan]export OPENAI_BASE_URL=http://localhost:8000/openai[/cyan]"
379
+ )
380
+ console.print()
381
+
382
+ console.print("[bold]For .env file:[/bold]")
383
+ console.print(f"[cyan]AUTH_TOKEN={token}[/cyan]")
384
+ console.print()
385
+
386
+ console.print("[bold]Usage with curl (using environment variables):[/bold]")
387
+ console.print("[dim]Anthropic API:[/dim]")
388
+ console.print('[cyan]curl -H "x-api-key: $ANTHROPIC_API_KEY" \\\\[/cyan]')
389
+ console.print('[cyan] -H "Content-Type: application/json" \\\\[/cyan]')
390
+ console.print('[cyan] "$ANTHROPIC_BASE_URL/v1/messages"[/cyan]')
391
+ console.print()
392
+ console.print("[dim]OpenAI API:[/dim]")
393
+ console.print(
394
+ '[cyan]curl -H "Authorization: Bearer $OPENAI_API_KEY" \\\\[/cyan]'
395
+ )
396
+ console.print('[cyan] -H "Content-Type: application/json" \\\\[/cyan]')
397
+ console.print('[cyan] "$OPENAI_BASE_URL/v1/chat/completions"[/cyan]')
398
+ console.print()
399
+
400
+ # Mention the save functionality if not using it
401
+ if not save:
402
+ console.print(
403
+ "[dim]Tip: Use --save to write this token to a configuration file[/dim]"
404
+ )
405
+ console.print()
406
+
407
+ # Save to config file if requested
408
+ if save:
409
+ # Determine config file path
410
+ if config_file is None:
411
+ # Try to find existing config file or create default
412
+ from ccproxy.config.discovery import find_toml_config_file
413
+
414
+ config_file = find_toml_config_file()
415
+
416
+ if config_file is None:
417
+ # Create default config file in current directory
418
+ config_file = Path(".ccproxy.toml")
419
+
420
+ console.print(
421
+ f"[bold]Saving token to configuration file:[/bold] {config_file}"
422
+ )
423
+
424
+ # Detect file format from extension
425
+ file_format = _detect_config_format(config_file)
426
+ console.print(f"[dim]Detected format: {file_format.upper()}[/dim]")
427
+
428
+ # Read existing config or create new one using existing Settings functionality
429
+ config_data = {}
430
+ existing_token = None
431
+
432
+ if config_file.exists():
433
+ try:
434
+ from ccproxy.config.settings import Settings
435
+
436
+ config_data = Settings.load_config_file(config_file)
437
+ existing_token = config_data.get("auth_token")
438
+ console.print("[dim]Found existing configuration file[/dim]")
439
+ except Exception as e:
440
+ console.print(
441
+ f"[yellow]Warning: Could not read existing config file: {e}[/yellow]"
442
+ )
443
+ console.print("[dim]Will create new configuration file[/dim]")
444
+ else:
445
+ console.print("[dim]Will create new configuration file[/dim]")
446
+
447
+ # Check for existing token and ask for confirmation if needed
448
+ if existing_token and not force:
449
+ console.print()
450
+ console.print(
451
+ "[yellow]Warning: Configuration file already contains an auth_token[/yellow]"
452
+ )
453
+ console.print(f"[dim]Current token: {existing_token[:16]}...[/dim]")
454
+ console.print(f"[dim]New token: {token[:16]}...[/dim]")
455
+ console.print()
456
+
457
+ if not typer.confirm("Do you want to overwrite the existing token?"):
458
+ console.print("[dim]Token generation cancelled[/dim]")
459
+ return
460
+
461
+ # Update auth_token in config
462
+ config_data["auth_token"] = token
463
+
464
+ # Write updated config in the appropriate format
465
+ _write_config_file(config_file, config_data, file_format)
466
+
467
+ console.print(f"[green]✓[/green] Token saved to {config_file}")
468
+ console.print()
469
+ console.print("[bold]To use this configuration:[/bold]")
470
+ console.print(f"[cyan]ccproxy --config {config_file} api[/cyan]")
471
+ console.print()
472
+ console.print("[dim]Or set CONFIG_FILE environment variable:[/dim]")
473
+ console.print(f"[cyan]export CONFIG_FILE={config_file}[/cyan]")
474
+ console.print("[cyan]ccproxy api[/cyan]")
475
+
476
+ except Exception as e:
477
+ toolkit.print(f"Error generating token: {e}", tag="error")
478
+ raise typer.Exit(1) from e
479
+
480
+
481
+ def _detect_config_format(config_file: Path) -> str:
482
+ """Detect configuration file format from extension."""
483
+ suffix = config_file.suffix.lower()
484
+ if suffix in [".toml"]:
485
+ return "toml"
486
+ else:
487
+ # Only TOML is supported
488
+ return "toml"
489
+
490
+
491
+ def _generate_default_config_from_model(
492
+ settings_class: type[Settings],
493
+ ) -> dict[str, Any]:
494
+ """Generate a default configuration dictionary from the Settings model."""
495
+ # Create a default instance to get all default values
496
+ default_settings = settings_class()
497
+
498
+ config_data = {}
499
+
500
+ # Iterate through all fields and extract their default values
501
+ for field_name, _field_info in settings_class.model_fields.items():
502
+ field_value = getattr(default_settings, field_name)
503
+
504
+ if isinstance(field_value, BaseModel):
505
+ # For nested models, recursively generate their config
506
+ config_data[field_name] = _generate_nested_config_from_model(field_value)
507
+ else:
508
+ # Convert Path objects to strings for JSON serialization
509
+ if isinstance(field_value, Path):
510
+ config_data[field_name] = str(field_value) # type: ignore[assignment]
511
+ else:
512
+ config_data[field_name] = field_value
513
+
514
+ return config_data
515
+
516
+
517
+ def _generate_nested_config_from_model(model: BaseModel) -> dict[str, Any]:
518
+ """Generate configuration for nested models."""
519
+ config_data = {}
520
+
521
+ for field_name, _field_info in model.model_fields.items():
522
+ field_value = getattr(model, field_name)
523
+
524
+ if isinstance(field_value, BaseModel):
525
+ config_data[field_name] = _generate_nested_config_from_model(field_value)
526
+ else:
527
+ # Convert Path objects to strings for JSON serialization
528
+ if isinstance(field_value, Path):
529
+ config_data[field_name] = str(field_value) # type: ignore[assignment]
530
+ else:
531
+ config_data[field_name] = field_value
532
+
533
+ return config_data
534
+
535
+
536
+ def _write_toml_config_with_comments(
537
+ config_file: Path, config_data: dict[str, Any], settings_class: type[Settings]
538
+ ) -> None:
539
+ """Write configuration data to a TOML file with comments and proper formatting."""
540
+ with config_file.open("w", encoding="utf-8") as f:
541
+ f.write("# CCProxy API Configuration\n")
542
+ f.write("# This file configures the ccproxy server settings\n")
543
+ f.write("# Most settings are commented out with their default values\n")
544
+ f.write("# Uncomment and modify as needed\n\n")
545
+
546
+ # Write each top-level section
547
+ for field_name, _field_info in settings_class.model_fields.items():
548
+ field_value = config_data.get(field_name)
549
+ description = _get_field_description(_field_info)
550
+
551
+ f.write(f"# {description}\n")
552
+
553
+ if isinstance(field_value, dict):
554
+ # This is a nested model - write as a TOML section
555
+ f.write(f"# [{field_name}]\n")
556
+ _write_toml_section(f, field_value, prefix="# ", level=0)
557
+ else:
558
+ # Simple field - write as commented line
559
+ formatted_value = _format_config_value_for_toml(field_value)
560
+ f.write(f"# {field_name} = {formatted_value}\n")
561
+
562
+ f.write("\n")
563
+
564
+
565
+ def _write_toml_section(
566
+ f: Any, data: dict[str, Any], prefix: str = "", level: int = 0
567
+ ) -> None:
568
+ """Write a TOML section with proper indentation and commenting."""
569
+ for key, value in data.items():
570
+ if isinstance(value, dict):
571
+ # Nested section
572
+ f.write(f"{prefix}[{key}]\n")
573
+ _write_toml_section(f, value, prefix, level + 1)
574
+ else:
575
+ # Simple value
576
+ formatted_value = _format_config_value_for_toml(value)
577
+ f.write(f"{prefix}{key} = {formatted_value}\n")
578
+
579
+
580
+ def _format_config_value_for_toml(value: Any) -> str:
581
+ """Format a configuration value for TOML output."""
582
+ if value is None:
583
+ return "null"
584
+ elif isinstance(value, bool):
585
+ return "true" if value else "false"
586
+ elif isinstance(value, str):
587
+ return f'"{value}"'
588
+ elif isinstance(value, int | float):
589
+ return str(value)
590
+ elif isinstance(value, list):
591
+ if not value:
592
+ return "[]"
593
+ # Format list items
594
+ formatted_items = []
595
+ for item in value:
596
+ if isinstance(item, str):
597
+ formatted_items.append(f'"{item}"')
598
+ else:
599
+ formatted_items.append(str(item))
600
+ return f"[{', '.join(formatted_items)}]"
601
+ elif isinstance(value, dict):
602
+ if not value:
603
+ return "{}"
604
+ # Format dict as inline table
605
+ formatted_items = []
606
+ for k, v in value.items():
607
+ if isinstance(v, str):
608
+ formatted_items.append(f'{k} = "{v}"')
609
+ else:
610
+ formatted_items.append(f"{k} = {v}")
611
+ return f"{{{', '.join(formatted_items)}}}"
612
+ else:
613
+ return str(value)
614
+
615
+
616
+ def _write_json_config_with_comments(
617
+ config_file: Path, config_data: dict[str, Any]
618
+ ) -> None:
619
+ """Write configuration data to a JSON file with formatting."""
620
+
621
+ def convert_for_json(obj: Any) -> Any:
622
+ """Convert objects to JSON-serializable format."""
623
+ if isinstance(obj, Path):
624
+ return str(obj)
625
+ elif isinstance(obj, dict):
626
+ return {k: convert_for_json(v) for k, v in obj.items()}
627
+ elif isinstance(obj, list):
628
+ return [convert_for_json(item) for item in obj]
629
+ elif hasattr(obj, "__dict__"):
630
+ # Handle complex objects by converting to string
631
+ return str(obj)
632
+ else:
633
+ return obj
634
+
635
+ serializable_data = convert_for_json(config_data)
636
+
637
+ with config_file.open("w", encoding="utf-8") as f:
638
+ json.dump(serializable_data, f, indent=2, sort_keys=True)
639
+ f.write("\n")
640
+
641
+
642
+ def _write_config_file(
643
+ config_file: Path, config_data: dict[str, Any], file_format: str
644
+ ) -> None:
645
+ """Write configuration data to file in the specified format."""
646
+ if file_format == "toml":
647
+ _write_toml_config_with_comments(config_file, config_data, Settings)
648
+ else:
649
+ raise ValueError(
650
+ f"Unsupported config format: {file_format}. Only TOML is supported."
651
+ )
652
+
653
+
654
+ def _write_toml_config(config_file: Path, config_data: dict[str, Any]) -> None:
655
+ """Write configuration data to a TOML file with proper formatting."""
656
+ try:
657
+ # Create a nicely formatted TOML file
658
+ with config_file.open("w", encoding="utf-8") as f:
659
+ f.write("# CCProxy API Configuration\n")
660
+ f.write("# Generated by ccproxy config generate-token\n\n")
661
+
662
+ # Write server settings
663
+ if any(
664
+ key in config_data
665
+ for key in ["host", "port", "log_level", "workers", "reload"]
666
+ ):
667
+ f.write("# Server configuration\n")
668
+ if "host" in config_data:
669
+ f.write(f'host = "{config_data["host"]}"\n')
670
+ if "port" in config_data:
671
+ f.write(f"port = {config_data['port']}\n")
672
+ if "log_level" in config_data:
673
+ f.write(f'log_level = "{config_data["log_level"]}"\n')
674
+ if "workers" in config_data:
675
+ f.write(f"workers = {config_data['workers']}\n")
676
+ if "reload" in config_data:
677
+ f.write(f"reload = {str(config_data['reload']).lower()}\n")
678
+ f.write("\n")
679
+
680
+ # Write security settings
681
+ if any(key in config_data for key in ["auth_token", "cors_origins"]):
682
+ f.write("# Security configuration\n")
683
+ if "auth_token" in config_data:
684
+ f.write(f'auth_token = "{config_data["auth_token"]}"\n')
685
+ if "cors_origins" in config_data:
686
+ origins = config_data["cors_origins"]
687
+ if isinstance(origins, list):
688
+ origins_str = '", "'.join(origins)
689
+ f.write(f'cors_origins = ["{origins_str}"]\n')
690
+ else:
691
+ f.write(f'cors_origins = ["{origins}"]\n')
692
+ f.write("\n")
693
+
694
+ # Write Claude CLI configuration
695
+ if "claude_cli_path" in config_data:
696
+ f.write("# Claude CLI configuration\n")
697
+ if config_data["claude_cli_path"]:
698
+ f.write(f'claude_cli_path = "{config_data["claude_cli_path"]}"\n')
699
+ else:
700
+ f.write(
701
+ '# claude_cli_path = "/path/to/claude" # Auto-detect if not set\n'
702
+ )
703
+ f.write("\n")
704
+
705
+ # Write Docker settings
706
+ if "docker" in config_data:
707
+ docker_settings = config_data["docker"]
708
+ f.write("# Docker configuration\n")
709
+ f.write("[docker]\n")
710
+
711
+ for key, value in docker_settings.items():
712
+ if isinstance(value, str):
713
+ f.write(f'{key} = "{value}"\n')
714
+ elif isinstance(value, bool):
715
+ f.write(f"{key} = {str(value).lower()}\n")
716
+ elif isinstance(value, int | float):
717
+ f.write(f"{key} = {value}\n")
718
+ elif isinstance(value, list):
719
+ if value: # Only write non-empty lists
720
+ if all(isinstance(item, str) for item in value):
721
+ items_str = '", "'.join(value)
722
+ f.write(f'{key} = ["{items_str}"]\n')
723
+ else:
724
+ f.write(f"{key} = {value}\n")
725
+ else:
726
+ f.write(f"{key} = []\n")
727
+ elif isinstance(value, dict):
728
+ if value: # Only write non-empty dicts
729
+ f.write(f"{key} = {json.dumps(value)}\n")
730
+ else:
731
+ f.write(f"{key} = {{}}\n")
732
+ elif value is None:
733
+ f.write(f"# {key} = null # Not configured\n")
734
+ f.write("\n")
735
+
736
+ # Write any remaining top-level settings
737
+ written_keys = {
738
+ "host",
739
+ "port",
740
+ "log_level",
741
+ "workers",
742
+ "reload",
743
+ "auth_token",
744
+ "cors_origins",
745
+ "claude_cli_path",
746
+ "docker",
747
+ }
748
+ remaining_keys = set(config_data.keys()) - written_keys
749
+
750
+ if remaining_keys:
751
+ f.write("# Additional settings\n")
752
+ for key in sorted(remaining_keys):
753
+ value = config_data[key]
754
+ if isinstance(value, str):
755
+ f.write(f'{key} = "{value}"\n')
756
+ elif isinstance(value, bool):
757
+ f.write(f"{key} = {str(value).lower()}\n")
758
+ elif isinstance(value, int | float):
759
+ f.write(f"{key} = {value}\n")
760
+ elif isinstance(value, list | dict):
761
+ f.write(f"{key} = {json.dumps(value)}\n")
762
+ elif value is None:
763
+ f.write(f"# {key} = null\n")
764
+
765
+ except Exception as e:
766
+ raise ValueError(f"Failed to write TOML configuration: {e}") from e