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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|