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,534 @@
|
|
|
1
|
+
"""Settings configuration for Claude Proxy API Server."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
from ccproxy import __version__
|
|
15
|
+
from ccproxy.config.discovery import find_toml_config_file, get_claude_cli_config_dir
|
|
16
|
+
from ccproxy.core.async_utils import format_version, get_package_dir, patched_typing
|
|
17
|
+
|
|
18
|
+
from .auth import AuthSettings
|
|
19
|
+
from .claude import ClaudeSettings
|
|
20
|
+
from .cors import CORSSettings
|
|
21
|
+
from .docker_settings import DockerSettings
|
|
22
|
+
from .observability import ObservabilitySettings
|
|
23
|
+
from .pricing import PricingSettings
|
|
24
|
+
from .reverse_proxy import ReverseProxySettings
|
|
25
|
+
from .scheduler import SchedulerSettings
|
|
26
|
+
from .security import SecuritySettings
|
|
27
|
+
from .server import ServerSettings
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Settings",
|
|
32
|
+
"ConfigurationError",
|
|
33
|
+
"ConfigurationManager",
|
|
34
|
+
"config_manager",
|
|
35
|
+
"get_settings",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfigurationError(Exception):
|
|
40
|
+
"""Raised when configuration loading or validation fails."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# PoolSettings class removed - connection pooling functionality has been removed
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Settings(BaseSettings):
|
|
49
|
+
"""
|
|
50
|
+
Configuration settings for the Claude Proxy API Server.
|
|
51
|
+
|
|
52
|
+
Settings are loaded from environment variables, .env files, and TOML configuration files.
|
|
53
|
+
Environment variables take precedence over .env file values.
|
|
54
|
+
TOML configuration files are loaded in the following order:
|
|
55
|
+
1. .ccproxy.toml in current directory
|
|
56
|
+
2. ccproxy.toml in git repository root
|
|
57
|
+
3. config.toml in XDG_CONFIG_HOME/ccproxy/
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
model_config = SettingsConfigDict(
|
|
61
|
+
env_file=".env",
|
|
62
|
+
env_file_encoding="utf-8",
|
|
63
|
+
case_sensitive=False,
|
|
64
|
+
extra="ignore",
|
|
65
|
+
env_nested_delimiter="__",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Core application settings
|
|
69
|
+
server: ServerSettings = Field(
|
|
70
|
+
default_factory=ServerSettings,
|
|
71
|
+
description="Server configuration settings",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
security: SecuritySettings = Field(
|
|
75
|
+
default_factory=SecuritySettings,
|
|
76
|
+
description="Security configuration settings",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
cors: CORSSettings = Field(
|
|
80
|
+
default_factory=CORSSettings,
|
|
81
|
+
description="CORS configuration settings",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Claude-specific settings
|
|
85
|
+
claude: ClaudeSettings = Field(
|
|
86
|
+
default_factory=ClaudeSettings,
|
|
87
|
+
description="Claude-specific configuration settings",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Proxy and authentication
|
|
91
|
+
reverse_proxy: ReverseProxySettings = Field(
|
|
92
|
+
default_factory=ReverseProxySettings,
|
|
93
|
+
description="Reverse proxy configuration settings",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
auth: AuthSettings = Field(
|
|
97
|
+
default_factory=AuthSettings,
|
|
98
|
+
description="Authentication and credentials configuration",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Container settings
|
|
102
|
+
docker: DockerSettings = Field(
|
|
103
|
+
default_factory=DockerSettings,
|
|
104
|
+
description="Docker configuration for running Claude commands in containers",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Observability settings
|
|
108
|
+
observability: ObservabilitySettings = Field(
|
|
109
|
+
default_factory=ObservabilitySettings,
|
|
110
|
+
description="Observability configuration settings",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Scheduler settings
|
|
114
|
+
scheduler: SchedulerSettings = Field(
|
|
115
|
+
default_factory=SchedulerSettings,
|
|
116
|
+
description="Task scheduler configuration settings",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Pricing settings
|
|
120
|
+
pricing: PricingSettings = Field(
|
|
121
|
+
default_factory=PricingSettings,
|
|
122
|
+
description="Pricing and cost calculation configuration settings",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@field_validator("server", mode="before")
|
|
126
|
+
@classmethod
|
|
127
|
+
def validate_server(cls, v: Any) -> Any:
|
|
128
|
+
"""Validate and convert server settings."""
|
|
129
|
+
if v is None:
|
|
130
|
+
return ServerSettings()
|
|
131
|
+
if isinstance(v, ServerSettings):
|
|
132
|
+
return v
|
|
133
|
+
if isinstance(v, dict):
|
|
134
|
+
return ServerSettings(**v)
|
|
135
|
+
return v
|
|
136
|
+
|
|
137
|
+
@field_validator("security", mode="before")
|
|
138
|
+
@classmethod
|
|
139
|
+
def validate_security(cls, v: Any) -> Any:
|
|
140
|
+
"""Validate and convert security settings."""
|
|
141
|
+
if v is None:
|
|
142
|
+
return SecuritySettings()
|
|
143
|
+
if isinstance(v, SecuritySettings):
|
|
144
|
+
return v
|
|
145
|
+
if isinstance(v, dict):
|
|
146
|
+
return SecuritySettings(**v)
|
|
147
|
+
return v
|
|
148
|
+
|
|
149
|
+
@field_validator("cors", mode="before")
|
|
150
|
+
@classmethod
|
|
151
|
+
def validate_cors(cls, v: Any) -> Any:
|
|
152
|
+
"""Validate and convert CORS settings."""
|
|
153
|
+
if v is None:
|
|
154
|
+
return CORSSettings()
|
|
155
|
+
if isinstance(v, CORSSettings):
|
|
156
|
+
return v
|
|
157
|
+
if isinstance(v, dict):
|
|
158
|
+
return CORSSettings(**v)
|
|
159
|
+
return v
|
|
160
|
+
|
|
161
|
+
@field_validator("claude", mode="before")
|
|
162
|
+
@classmethod
|
|
163
|
+
def validate_claude(cls, v: Any) -> Any:
|
|
164
|
+
"""Validate and convert Claude settings."""
|
|
165
|
+
if v is None:
|
|
166
|
+
return ClaudeSettings()
|
|
167
|
+
if isinstance(v, ClaudeSettings):
|
|
168
|
+
return v
|
|
169
|
+
if isinstance(v, dict):
|
|
170
|
+
return ClaudeSettings(**v)
|
|
171
|
+
return v
|
|
172
|
+
|
|
173
|
+
@field_validator("reverse_proxy", mode="before")
|
|
174
|
+
@classmethod
|
|
175
|
+
def validate_reverse_proxy(cls, v: Any) -> Any:
|
|
176
|
+
"""Validate and convert reverse proxy settings."""
|
|
177
|
+
if v is None:
|
|
178
|
+
return ReverseProxySettings()
|
|
179
|
+
if isinstance(v, ReverseProxySettings):
|
|
180
|
+
return v
|
|
181
|
+
if isinstance(v, dict):
|
|
182
|
+
return ReverseProxySettings(**v)
|
|
183
|
+
return v
|
|
184
|
+
|
|
185
|
+
@field_validator("auth", mode="before")
|
|
186
|
+
@classmethod
|
|
187
|
+
def validate_auth(cls, v: Any) -> Any:
|
|
188
|
+
"""Validate and convert auth settings."""
|
|
189
|
+
if v is None:
|
|
190
|
+
return AuthSettings()
|
|
191
|
+
if isinstance(v, AuthSettings):
|
|
192
|
+
return v
|
|
193
|
+
if isinstance(v, dict):
|
|
194
|
+
return AuthSettings(**v)
|
|
195
|
+
return v
|
|
196
|
+
|
|
197
|
+
@field_validator("docker", mode="before")
|
|
198
|
+
@classmethod
|
|
199
|
+
def validate_docker_settings(cls, v: Any) -> Any:
|
|
200
|
+
"""Validate and convert Docker settings."""
|
|
201
|
+
if v is None:
|
|
202
|
+
return DockerSettings()
|
|
203
|
+
|
|
204
|
+
# If it's already a DockerSettings instance, return as-is
|
|
205
|
+
if isinstance(v, DockerSettings):
|
|
206
|
+
return v
|
|
207
|
+
|
|
208
|
+
# If it's a dict, create DockerSettings from it
|
|
209
|
+
if isinstance(v, dict):
|
|
210
|
+
return DockerSettings(**v)
|
|
211
|
+
|
|
212
|
+
# Try to convert to dict if possible
|
|
213
|
+
if hasattr(v, "model_dump"):
|
|
214
|
+
return DockerSettings(**v.model_dump())
|
|
215
|
+
elif hasattr(v, "__dict__"):
|
|
216
|
+
return DockerSettings(**v.__dict__)
|
|
217
|
+
|
|
218
|
+
return v
|
|
219
|
+
|
|
220
|
+
@field_validator("observability", mode="before")
|
|
221
|
+
@classmethod
|
|
222
|
+
def validate_observability(cls, v: Any) -> Any:
|
|
223
|
+
"""Validate and convert observability settings."""
|
|
224
|
+
if v is None:
|
|
225
|
+
return ObservabilitySettings()
|
|
226
|
+
if isinstance(v, ObservabilitySettings):
|
|
227
|
+
return v
|
|
228
|
+
if isinstance(v, dict):
|
|
229
|
+
return ObservabilitySettings(**v)
|
|
230
|
+
return v
|
|
231
|
+
|
|
232
|
+
@field_validator("scheduler", mode="before")
|
|
233
|
+
@classmethod
|
|
234
|
+
def validate_scheduler(cls, v: Any) -> Any:
|
|
235
|
+
"""Validate and convert scheduler settings."""
|
|
236
|
+
if v is None:
|
|
237
|
+
return SchedulerSettings()
|
|
238
|
+
if isinstance(v, SchedulerSettings):
|
|
239
|
+
return v
|
|
240
|
+
if isinstance(v, dict):
|
|
241
|
+
return SchedulerSettings(**v)
|
|
242
|
+
return v
|
|
243
|
+
|
|
244
|
+
@field_validator("pricing", mode="before")
|
|
245
|
+
@classmethod
|
|
246
|
+
def validate_pricing(cls, v: Any) -> Any:
|
|
247
|
+
"""Validate and convert pricing settings."""
|
|
248
|
+
if v is None:
|
|
249
|
+
return PricingSettings()
|
|
250
|
+
if isinstance(v, PricingSettings):
|
|
251
|
+
return v
|
|
252
|
+
if isinstance(v, dict):
|
|
253
|
+
return PricingSettings(**v)
|
|
254
|
+
return v
|
|
255
|
+
|
|
256
|
+
# validate_pool_settings method removed - connection pooling functionality has been removed
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def server_url(self) -> str:
|
|
260
|
+
"""Get the complete server URL."""
|
|
261
|
+
return f"http://{self.server.host}:{self.server.port}"
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def is_development(self) -> bool:
|
|
265
|
+
"""Check if running in development mode."""
|
|
266
|
+
return self.server.reload or self.server.log_level == "DEBUG"
|
|
267
|
+
|
|
268
|
+
@model_validator(mode="after")
|
|
269
|
+
def setup_claude_cli_path(self) -> "Settings":
|
|
270
|
+
"""Set up Claude CLI path in environment if provided or found."""
|
|
271
|
+
# If not explicitly set, try to find it
|
|
272
|
+
if not self.claude.cli_path:
|
|
273
|
+
found_path, found_in_path = self.claude.find_claude_cli()
|
|
274
|
+
if found_path:
|
|
275
|
+
self.claude.cli_path = found_path
|
|
276
|
+
# Only add to PATH if it wasn't found via which()
|
|
277
|
+
if not found_in_path:
|
|
278
|
+
cli_dir = str(Path(self.claude.cli_path).parent)
|
|
279
|
+
current_path = os.environ.get("PATH", "")
|
|
280
|
+
if cli_dir not in current_path:
|
|
281
|
+
os.environ["PATH"] = f"{cli_dir}:{current_path}"
|
|
282
|
+
elif self.claude.cli_path:
|
|
283
|
+
# If explicitly set, always add to PATH
|
|
284
|
+
cli_dir = str(Path(self.claude.cli_path).parent)
|
|
285
|
+
current_path = os.environ.get("PATH", "")
|
|
286
|
+
if cli_dir not in current_path:
|
|
287
|
+
os.environ["PATH"] = f"{cli_dir}:{current_path}"
|
|
288
|
+
return self
|
|
289
|
+
|
|
290
|
+
def model_dump_safe(self) -> dict[str, Any]:
|
|
291
|
+
"""
|
|
292
|
+
Dump model data with sensitive information masked.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
dict: Configuration with sensitive data masked
|
|
296
|
+
"""
|
|
297
|
+
return self.model_dump()
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
def load_toml_config(cls, toml_path: Path) -> dict[str, Any]:
|
|
301
|
+
"""Load configuration from a TOML file.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
toml_path: Path to the TOML configuration file
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
dict: Configuration data from the TOML file
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: If the TOML file is invalid or cannot be read
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
with toml_path.open("rb") as f:
|
|
314
|
+
return tomllib.load(f)
|
|
315
|
+
except OSError as e:
|
|
316
|
+
raise ValueError(f"Cannot read TOML config file {toml_path}: {e}") from e
|
|
317
|
+
except tomllib.TOMLDecodeError as e:
|
|
318
|
+
raise ValueError(f"Invalid TOML syntax in {toml_path}: {e}") from e
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def load_config_file(cls, config_path: Path) -> dict[str, Any]:
|
|
322
|
+
"""Load configuration from a file based on its extension.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
config_path: Path to the configuration file
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
dict: Configuration data from the file
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
ValueError: If the file format is unsupported or invalid
|
|
332
|
+
"""
|
|
333
|
+
suffix = config_path.suffix.lower()
|
|
334
|
+
|
|
335
|
+
if suffix in [".toml"]:
|
|
336
|
+
return cls.load_toml_config(config_path)
|
|
337
|
+
else:
|
|
338
|
+
raise ValueError(
|
|
339
|
+
f"Unsupported config file format: {suffix}. "
|
|
340
|
+
"Only TOML (.toml) files are supported."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@classmethod
|
|
344
|
+
def from_toml(cls, toml_path: Path | None = None, **kwargs: Any) -> "Settings":
|
|
345
|
+
"""Create Settings instance from TOML configuration.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
toml_path: Path to TOML configuration file. If None, auto-discovers file.
|
|
349
|
+
**kwargs: Additional keyword arguments to override config values
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Settings: Configured Settings instance
|
|
353
|
+
"""
|
|
354
|
+
# Use the more generic from_config method
|
|
355
|
+
return cls.from_config(config_path=toml_path, **kwargs)
|
|
356
|
+
|
|
357
|
+
@classmethod
|
|
358
|
+
def from_config(
|
|
359
|
+
cls, config_path: Path | str | None = None, **kwargs: Any
|
|
360
|
+
) -> "Settings":
|
|
361
|
+
"""Create Settings instance from configuration file.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
config_path: Path to configuration file. Can be:
|
|
365
|
+
- None: Auto-discover config file or use CONFIG_FILE env var
|
|
366
|
+
- Path or str: Use this specific config file
|
|
367
|
+
**kwargs: Additional keyword arguments to override config values
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Settings: Configured Settings instance
|
|
371
|
+
"""
|
|
372
|
+
# Check for CONFIG_FILE environment variable first
|
|
373
|
+
if config_path is None:
|
|
374
|
+
config_path_env = os.environ.get("CONFIG_FILE")
|
|
375
|
+
if config_path_env:
|
|
376
|
+
config_path = Path(config_path_env)
|
|
377
|
+
|
|
378
|
+
# Convert string to Path if needed
|
|
379
|
+
if isinstance(config_path, str):
|
|
380
|
+
config_path = Path(config_path)
|
|
381
|
+
|
|
382
|
+
# Auto-discover config file if not provided
|
|
383
|
+
if config_path is None:
|
|
384
|
+
config_path = find_toml_config_file()
|
|
385
|
+
|
|
386
|
+
# Load config if found
|
|
387
|
+
config_data = {}
|
|
388
|
+
if config_path and config_path.exists():
|
|
389
|
+
config_data = cls.load_config_file(config_path)
|
|
390
|
+
|
|
391
|
+
# Merge config with kwargs (kwargs take precedence)
|
|
392
|
+
merged_config = {**config_data, **kwargs}
|
|
393
|
+
|
|
394
|
+
# Create Settings instance with merged config
|
|
395
|
+
return cls(**merged_config)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class ConfigurationManager:
|
|
399
|
+
"""Centralized configuration management for CLI and server."""
|
|
400
|
+
|
|
401
|
+
def __init__(self) -> None:
|
|
402
|
+
self._settings: Settings | None = None
|
|
403
|
+
self._config_path: Path | None = None
|
|
404
|
+
self._logging_configured = False
|
|
405
|
+
|
|
406
|
+
def load_settings(
|
|
407
|
+
self,
|
|
408
|
+
config_path: Path | None = None,
|
|
409
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
410
|
+
) -> Settings:
|
|
411
|
+
"""Load settings with CLI overrides and caching."""
|
|
412
|
+
if self._settings is None or config_path != self._config_path:
|
|
413
|
+
try:
|
|
414
|
+
self._settings = Settings.from_config(
|
|
415
|
+
config_path=config_path, **(cli_overrides or {})
|
|
416
|
+
)
|
|
417
|
+
self._config_path = config_path
|
|
418
|
+
except Exception as e:
|
|
419
|
+
raise ConfigurationError(f"Failed to load configuration: {e}") from e
|
|
420
|
+
|
|
421
|
+
return self._settings
|
|
422
|
+
|
|
423
|
+
def setup_logging(self, log_level: str | None = None) -> None:
|
|
424
|
+
"""Configure logging once based on settings."""
|
|
425
|
+
if self._logging_configured:
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
# Import here to avoid circular import
|
|
429
|
+
|
|
430
|
+
effective_level = log_level or (
|
|
431
|
+
self._settings.server.log_level if self._settings else "INFO"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Determine format based on log level - Rich for DEBUG, JSON for production
|
|
435
|
+
format_type = "rich" if effective_level.upper() == "DEBUG" else "json"
|
|
436
|
+
|
|
437
|
+
# setup_dual_logging(
|
|
438
|
+
# level=effective_level,
|
|
439
|
+
# format_type=format_type,
|
|
440
|
+
# configure_uvicorn=True,
|
|
441
|
+
# verbose_tracebacks=effective_level.upper() == "DEBUG",
|
|
442
|
+
# )
|
|
443
|
+
self._logging_configured = True
|
|
444
|
+
|
|
445
|
+
def get_cli_overrides_from_args(self, **cli_args: Any) -> dict[str, Any]:
|
|
446
|
+
"""Extract non-None CLI arguments as configuration overrides."""
|
|
447
|
+
overrides = {}
|
|
448
|
+
|
|
449
|
+
# Server settings
|
|
450
|
+
server_settings = {}
|
|
451
|
+
for key in ["host", "port", "reload", "log_level", "log_file"]:
|
|
452
|
+
if cli_args.get(key) is not None:
|
|
453
|
+
server_settings[key] = cli_args[key]
|
|
454
|
+
if server_settings:
|
|
455
|
+
overrides["server"] = server_settings
|
|
456
|
+
|
|
457
|
+
# Security settings
|
|
458
|
+
if cli_args.get("auth_token") is not None:
|
|
459
|
+
overrides["security"] = {"auth_token": cli_args["auth_token"]}
|
|
460
|
+
|
|
461
|
+
# Claude settings
|
|
462
|
+
claude_settings = {}
|
|
463
|
+
if cli_args.get("claude_cli_path") is not None:
|
|
464
|
+
claude_settings["cli_path"] = cli_args["claude_cli_path"]
|
|
465
|
+
|
|
466
|
+
# Claude Code options
|
|
467
|
+
claude_opts = {}
|
|
468
|
+
for key in [
|
|
469
|
+
"max_thinking_tokens",
|
|
470
|
+
"permission_mode",
|
|
471
|
+
"cwd",
|
|
472
|
+
"max_turns",
|
|
473
|
+
"append_system_prompt",
|
|
474
|
+
"permission_prompt_tool_name",
|
|
475
|
+
"continue_conversation",
|
|
476
|
+
]:
|
|
477
|
+
if cli_args.get(key) is not None:
|
|
478
|
+
claude_opts[key] = cli_args[key]
|
|
479
|
+
|
|
480
|
+
# Handle comma-separated lists
|
|
481
|
+
for key in ["allowed_tools", "disallowed_tools"]:
|
|
482
|
+
if cli_args.get(key):
|
|
483
|
+
claude_opts[key] = [tool.strip() for tool in cli_args[key].split(",")]
|
|
484
|
+
|
|
485
|
+
if claude_opts:
|
|
486
|
+
claude_settings["code_options"] = claude_opts
|
|
487
|
+
|
|
488
|
+
if claude_settings:
|
|
489
|
+
overrides["claude"] = claude_settings
|
|
490
|
+
|
|
491
|
+
# CORS settings
|
|
492
|
+
if cli_args.get("cors_origins"):
|
|
493
|
+
overrides["cors"] = {
|
|
494
|
+
"origins": [
|
|
495
|
+
origin.strip() for origin in cli_args["cors_origins"].split(",")
|
|
496
|
+
]
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return overrides
|
|
500
|
+
|
|
501
|
+
def reset(self) -> None:
|
|
502
|
+
"""Reset configuration state (useful for testing)."""
|
|
503
|
+
self._settings = None
|
|
504
|
+
self._config_path = None
|
|
505
|
+
self._logging_configured = False
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# Global configuration manager instance
|
|
509
|
+
config_manager = ConfigurationManager()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def get_settings(config_path: Path | str | None = None) -> Settings:
|
|
513
|
+
"""Get the global settings instance with configuration file support.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
config_path: Optional path to configuration file. If None, uses CONFIG_FILE env var
|
|
517
|
+
or auto-discovers config file.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
Settings: Configured Settings instance
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
# Check for CLI overrides from environment variable
|
|
524
|
+
cli_overrides = {}
|
|
525
|
+
cli_overrides_json = os.environ.get("CCPROXY_CONFIG_OVERRIDES")
|
|
526
|
+
if cli_overrides_json:
|
|
527
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
528
|
+
cli_overrides = json.loads(cli_overrides_json)
|
|
529
|
+
|
|
530
|
+
return Settings.from_config(config_path=config_path, **cli_overrides)
|
|
531
|
+
except Exception as e:
|
|
532
|
+
# If settings can't be loaded (e.g., missing API key),
|
|
533
|
+
# this will be handled by the caller
|
|
534
|
+
raise ValueError(f"Configuration error: {e}") from e
|