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
ccproxy/config/auth.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Authentication and credentials configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_default_storage_paths() -> list[Path]:
|
|
11
|
+
"""Get default storage paths"""
|
|
12
|
+
return [
|
|
13
|
+
Path("~/.config/claude/.credentials.json"),
|
|
14
|
+
Path("~/.claude/.credentials.json"),
|
|
15
|
+
Path("~/.config/ccproxy/credentials.json"),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OAuthSettings(BaseModel):
|
|
20
|
+
"""OAuth-specific settings."""
|
|
21
|
+
|
|
22
|
+
base_url: str = Field(
|
|
23
|
+
default="https://console.anthropic.com",
|
|
24
|
+
description="Base URL for OAuth API endpoints",
|
|
25
|
+
)
|
|
26
|
+
beta_version: str = Field(
|
|
27
|
+
default="oauth-2025-04-20",
|
|
28
|
+
description="OAuth beta version header",
|
|
29
|
+
)
|
|
30
|
+
token_url: str = Field(
|
|
31
|
+
default="https://console.anthropic.com/v1/oauth/token",
|
|
32
|
+
description="OAuth token endpoint URL",
|
|
33
|
+
)
|
|
34
|
+
authorize_url: str = Field(
|
|
35
|
+
default="https://claude.ai/oauth/authorize",
|
|
36
|
+
description="OAuth authorization endpoint URL",
|
|
37
|
+
)
|
|
38
|
+
profile_url: str = Field(
|
|
39
|
+
default="https://api.anthropic.com/api/oauth/profile",
|
|
40
|
+
description="OAuth profile endpoint URL",
|
|
41
|
+
)
|
|
42
|
+
client_id: str = Field(
|
|
43
|
+
default="9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
44
|
+
description="OAuth client ID",
|
|
45
|
+
)
|
|
46
|
+
redirect_uri: str = Field(
|
|
47
|
+
default="http://localhost:54545/callback",
|
|
48
|
+
description="OAuth redirect URI",
|
|
49
|
+
)
|
|
50
|
+
scopes: list[str] = Field(
|
|
51
|
+
default_factory=lambda: [
|
|
52
|
+
"org:create_api_key",
|
|
53
|
+
"user:profile",
|
|
54
|
+
"user:inference",
|
|
55
|
+
],
|
|
56
|
+
description="OAuth scopes to request",
|
|
57
|
+
)
|
|
58
|
+
request_timeout: int = Field(
|
|
59
|
+
default=30,
|
|
60
|
+
description="Timeout in seconds for OAuth requests",
|
|
61
|
+
)
|
|
62
|
+
user_agent: str = Field(
|
|
63
|
+
default="Claude-Code/1.0.43",
|
|
64
|
+
description="User agent string for OAuth requests",
|
|
65
|
+
)
|
|
66
|
+
callback_timeout: int = Field(
|
|
67
|
+
default=300,
|
|
68
|
+
description="Timeout in seconds for OAuth callback",
|
|
69
|
+
ge=60,
|
|
70
|
+
le=600,
|
|
71
|
+
)
|
|
72
|
+
callback_port: int = Field(
|
|
73
|
+
default=54545,
|
|
74
|
+
description="Port for OAuth callback server",
|
|
75
|
+
ge=1024,
|
|
76
|
+
le=65535,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CredentialStorageSettings(BaseModel):
|
|
81
|
+
"""Settings for credential storage locations."""
|
|
82
|
+
|
|
83
|
+
storage_paths: list[Path] = Field(
|
|
84
|
+
default_factory=lambda: _get_default_storage_paths(),
|
|
85
|
+
description="Paths to search for credentials files",
|
|
86
|
+
)
|
|
87
|
+
auto_refresh: bool = Field(
|
|
88
|
+
default=True,
|
|
89
|
+
description="Automatically refresh expired tokens",
|
|
90
|
+
)
|
|
91
|
+
refresh_buffer_seconds: int = Field(
|
|
92
|
+
default=300,
|
|
93
|
+
description="Refresh token this many seconds before expiry",
|
|
94
|
+
ge=0,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AuthSettings(BaseModel):
|
|
99
|
+
"""Combined authentication and credentials configuration."""
|
|
100
|
+
|
|
101
|
+
oauth: OAuthSettings = Field(
|
|
102
|
+
default_factory=OAuthSettings,
|
|
103
|
+
description="OAuth configuration",
|
|
104
|
+
)
|
|
105
|
+
storage: CredentialStorageSettings = Field(
|
|
106
|
+
default_factory=CredentialStorageSettings,
|
|
107
|
+
description="Credential storage configuration",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@field_validator("oauth", mode="before")
|
|
111
|
+
@classmethod
|
|
112
|
+
def validate_oauth(cls, v: Any) -> Any:
|
|
113
|
+
"""Validate and convert OAuth configuration."""
|
|
114
|
+
if v is None:
|
|
115
|
+
return OAuthSettings()
|
|
116
|
+
|
|
117
|
+
# If it's already an OAuthSettings instance, return as-is
|
|
118
|
+
if isinstance(v, OAuthSettings):
|
|
119
|
+
return v
|
|
120
|
+
|
|
121
|
+
# If it's a dict, create OAuthSettings from it
|
|
122
|
+
if isinstance(v, dict):
|
|
123
|
+
return OAuthSettings(**v)
|
|
124
|
+
|
|
125
|
+
# Try to convert to dict if possible
|
|
126
|
+
if hasattr(v, "model_dump"):
|
|
127
|
+
return OAuthSettings(**v.model_dump())
|
|
128
|
+
elif hasattr(v, "__dict__"):
|
|
129
|
+
return OAuthSettings(**v.__dict__)
|
|
130
|
+
|
|
131
|
+
return v
|
|
132
|
+
|
|
133
|
+
@field_validator("storage", mode="before")
|
|
134
|
+
@classmethod
|
|
135
|
+
def validate_storage(cls, v: Any) -> Any:
|
|
136
|
+
"""Validate and convert storage configuration."""
|
|
137
|
+
if v is None:
|
|
138
|
+
return CredentialStorageSettings()
|
|
139
|
+
|
|
140
|
+
# If it's already a CredentialStorageSettings instance, return as-is
|
|
141
|
+
if isinstance(v, CredentialStorageSettings):
|
|
142
|
+
return v
|
|
143
|
+
|
|
144
|
+
# If it's a dict, create CredentialStorageSettings from it
|
|
145
|
+
if isinstance(v, dict):
|
|
146
|
+
return CredentialStorageSettings(**v)
|
|
147
|
+
|
|
148
|
+
# Try to convert to dict if possible
|
|
149
|
+
if hasattr(v, "model_dump"):
|
|
150
|
+
return CredentialStorageSettings(**v.model_dump())
|
|
151
|
+
elif hasattr(v, "__dict__"):
|
|
152
|
+
return CredentialStorageSettings(**v.__dict__)
|
|
153
|
+
|
|
154
|
+
return v
|
ccproxy/config/claude.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Claude-specific configuration settings."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from ccproxy.core.async_utils import get_package_dir, patched_typing
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# For further information visit https://errors.pydantic.dev/2.11/u/typed-dict-version
|
|
14
|
+
with patched_typing():
|
|
15
|
+
from claude_code_sdk import ClaudeCodeOptions # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ClaudeSettings(BaseModel):
|
|
19
|
+
"""Claude-specific configuration settings."""
|
|
20
|
+
|
|
21
|
+
cli_path: str | None = Field(
|
|
22
|
+
default=None,
|
|
23
|
+
description="Path to Claude CLI executable",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
code_options: ClaudeCodeOptions = Field(
|
|
27
|
+
default_factory=lambda: ClaudeCodeOptions(),
|
|
28
|
+
description="Claude Code SDK options configuration",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@field_validator("cli_path")
|
|
32
|
+
@classmethod
|
|
33
|
+
def validate_claude_cli_path(cls, v: str | None) -> str | None:
|
|
34
|
+
"""Validate Claude CLI path if provided."""
|
|
35
|
+
if v is not None:
|
|
36
|
+
path = Path(v)
|
|
37
|
+
if not path.exists():
|
|
38
|
+
raise ValueError(f"Claude CLI path does not exist: {v}")
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
raise ValueError(f"Claude CLI path is not a file: {v}")
|
|
41
|
+
if not os.access(path, os.X_OK):
|
|
42
|
+
raise ValueError(f"Claude CLI path is not executable: {v}")
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
@field_validator("code_options", mode="before")
|
|
46
|
+
@classmethod
|
|
47
|
+
def validate_claude_code_options(cls, v: Any) -> Any:
|
|
48
|
+
"""Validate and convert Claude Code options."""
|
|
49
|
+
if v is None:
|
|
50
|
+
return ClaudeCodeOptions()
|
|
51
|
+
|
|
52
|
+
# If it's already a ClaudeCodeOptions instance, return as-is
|
|
53
|
+
if isinstance(v, ClaudeCodeOptions):
|
|
54
|
+
return v
|
|
55
|
+
|
|
56
|
+
# Try to convert to dict if possible
|
|
57
|
+
if hasattr(v, "model_dump"):
|
|
58
|
+
return v.model_dump()
|
|
59
|
+
elif hasattr(v, "__dict__"):
|
|
60
|
+
return v.__dict__
|
|
61
|
+
|
|
62
|
+
return v
|
|
63
|
+
|
|
64
|
+
def find_claude_cli(self) -> tuple[str | None, bool]:
|
|
65
|
+
"""Find Claude CLI executable in PATH or specified location.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
tuple: (path_to_claude, found_in_path)
|
|
69
|
+
"""
|
|
70
|
+
if self.cli_path:
|
|
71
|
+
return self.cli_path, False
|
|
72
|
+
|
|
73
|
+
# Try to find claude in PATH
|
|
74
|
+
claude_path = shutil.which("claude")
|
|
75
|
+
if claude_path:
|
|
76
|
+
return claude_path, True
|
|
77
|
+
|
|
78
|
+
# Common installation paths (in order of preference)
|
|
79
|
+
common_paths = [
|
|
80
|
+
# User-specific Claude installation
|
|
81
|
+
Path.home() / ".claude" / "local" / "claude",
|
|
82
|
+
# User's global node_modules (npm install -g)
|
|
83
|
+
Path.home() / "node_modules" / ".bin" / "claude",
|
|
84
|
+
# Package installation directory node_modules
|
|
85
|
+
get_package_dir() / "node_modules" / ".bin" / "claude",
|
|
86
|
+
# Current working directory node_modules
|
|
87
|
+
Path.cwd() / "node_modules" / ".bin" / "claude",
|
|
88
|
+
# System-wide installations
|
|
89
|
+
Path("/usr/local/bin/claude"),
|
|
90
|
+
Path("/opt/homebrew/bin/claude"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
for path in common_paths:
|
|
94
|
+
if path.exists() and path.is_file() and os.access(path, os.X_OK):
|
|
95
|
+
return str(path), False
|
|
96
|
+
|
|
97
|
+
return None, False
|
|
98
|
+
|
|
99
|
+
def get_searched_paths(self) -> list[str]:
|
|
100
|
+
"""Get list of paths that would be searched for Claude CLI auto-detection."""
|
|
101
|
+
paths = []
|
|
102
|
+
|
|
103
|
+
# PATH search
|
|
104
|
+
paths.append("PATH environment variable")
|
|
105
|
+
|
|
106
|
+
# Common installation paths (in order of preference)
|
|
107
|
+
common_paths = [
|
|
108
|
+
# User-specific Claude installation
|
|
109
|
+
Path.home() / ".claude" / "local" / "claude",
|
|
110
|
+
# User's global node_modules (npm install -g)
|
|
111
|
+
Path.home() / "node_modules" / ".bin" / "claude",
|
|
112
|
+
# Package installation directory node_modules
|
|
113
|
+
get_package_dir() / "node_modules" / ".bin" / "claude",
|
|
114
|
+
# Current working directory node_modules
|
|
115
|
+
Path.cwd() / "node_modules" / ".bin" / "claude",
|
|
116
|
+
# System-wide installations
|
|
117
|
+
Path("/usr/local/bin/claude"),
|
|
118
|
+
Path("/opt/homebrew/bin/claude"),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
for path in common_paths:
|
|
122
|
+
paths.append(str(path))
|
|
123
|
+
|
|
124
|
+
return paths
|
ccproxy/config/cors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""CORS configuration settings."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CORSSettings(BaseModel):
|
|
7
|
+
"""CORS-specific configuration settings."""
|
|
8
|
+
|
|
9
|
+
origins: list[str] = Field(
|
|
10
|
+
default_factory=lambda: ["*"],
|
|
11
|
+
description="CORS allowed origins",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
credentials: bool = Field(
|
|
15
|
+
default=True,
|
|
16
|
+
description="CORS allow credentials",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
methods: list[str] = Field(
|
|
20
|
+
default_factory=lambda: ["*"],
|
|
21
|
+
description="CORS allowed methods",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
headers: list[str] = Field(
|
|
25
|
+
default_factory=lambda: ["*"],
|
|
26
|
+
description="CORS allowed headers",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
origin_regex: str | None = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="CORS origin regex pattern",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expose_headers: list[str] = Field(
|
|
35
|
+
default_factory=list,
|
|
36
|
+
description="CORS exposed headers",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
max_age: int = Field(
|
|
40
|
+
default=600,
|
|
41
|
+
description="CORS preflight max age in seconds",
|
|
42
|
+
ge=0,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@field_validator("origins", mode="before")
|
|
46
|
+
@classmethod
|
|
47
|
+
def validate_cors_origins(cls, v: str | list[str]) -> list[str]:
|
|
48
|
+
"""Parse CORS origins from string or list."""
|
|
49
|
+
if isinstance(v, str):
|
|
50
|
+
# Split comma-separated string
|
|
51
|
+
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
|
52
|
+
return v
|
|
53
|
+
|
|
54
|
+
@field_validator("methods", mode="before")
|
|
55
|
+
@classmethod
|
|
56
|
+
def validate_cors_methods(cls, v: str | list[str]) -> list[str]:
|
|
57
|
+
"""Parse CORS methods from string or list."""
|
|
58
|
+
if isinstance(v, str):
|
|
59
|
+
# Split comma-separated string
|
|
60
|
+
return [method.strip().upper() for method in v.split(",") if method.strip()]
|
|
61
|
+
return [method.upper() for method in v]
|
|
62
|
+
|
|
63
|
+
@field_validator("headers", mode="before")
|
|
64
|
+
@classmethod
|
|
65
|
+
def validate_cors_headers(cls, v: str | list[str]) -> list[str]:
|
|
66
|
+
"""Parse CORS headers from string or list."""
|
|
67
|
+
if isinstance(v, str):
|
|
68
|
+
# Split comma-separated string
|
|
69
|
+
return [header.strip() for header in v.split(",") if header.strip()]
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
@field_validator("expose_headers", mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def validate_cors_expose_headers(cls, v: str | list[str]) -> list[str]:
|
|
75
|
+
"""Parse CORS expose headers from string or list."""
|
|
76
|
+
if isinstance(v, str):
|
|
77
|
+
# Split comma-separated string
|
|
78
|
+
return [header.strip() for header in v.split(",") if header.strip()]
|
|
79
|
+
return v
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from ccproxy.core.system import get_xdg_config_home
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_toml_config_file() -> Path | None:
|
|
8
|
+
"""Find the TOML configuration file for ccproxy.
|
|
9
|
+
|
|
10
|
+
Searches in the following order:
|
|
11
|
+
1. .ccproxy.toml in current directory
|
|
12
|
+
2. ccproxy.toml in git repository root (if in a git repo)
|
|
13
|
+
3. config.toml in XDG_CONFIG_HOME/ccproxy/
|
|
14
|
+
"""
|
|
15
|
+
# Check current directory first
|
|
16
|
+
candidates = [
|
|
17
|
+
Path(".ccproxy.toml").resolve(),
|
|
18
|
+
Path("ccproxy.toml").resolve(),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# Check git repo root
|
|
22
|
+
git_root = find_git_root()
|
|
23
|
+
if git_root:
|
|
24
|
+
candidates.extend(
|
|
25
|
+
[
|
|
26
|
+
git_root / ".ccproxy.toml",
|
|
27
|
+
git_root / "ccproxy.toml",
|
|
28
|
+
]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Check XDG config directory
|
|
32
|
+
config_dir = get_ccproxy_config_dir()
|
|
33
|
+
candidates.append(config_dir / "config.toml")
|
|
34
|
+
|
|
35
|
+
# Return first existing file
|
|
36
|
+
for candidate in candidates:
|
|
37
|
+
if candidate.exists() and candidate.is_file():
|
|
38
|
+
return candidate
|
|
39
|
+
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_git_root(path: Path | None = None) -> Path | None:
|
|
44
|
+
"""Find the root directory of a git repository."""
|
|
45
|
+
import subprocess
|
|
46
|
+
|
|
47
|
+
if path is None:
|
|
48
|
+
path = Path.cwd()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
53
|
+
cwd=path,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
check=True,
|
|
57
|
+
)
|
|
58
|
+
return Path(result.stdout.strip())
|
|
59
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_ccproxy_config_dir() -> Path:
|
|
64
|
+
"""Get the ccproxy configuration directory.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Path to the ccproxy configuration directory within XDG_CONFIG_HOME.
|
|
68
|
+
"""
|
|
69
|
+
return get_xdg_config_home() / "ccproxy"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_claude_cli_config_dir() -> Path:
|
|
73
|
+
"""Get the Claude CLI configuration directory.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Path to the Claude CLI configuration directory within XDG_CONFIG_HOME.
|
|
77
|
+
"""
|
|
78
|
+
return get_xdg_config_home() / "claude"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_claude_docker_home_dir() -> Path:
|
|
82
|
+
"""Get the Claude Docker home directory.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Path to the Claude Docker home directory within XDG_DATA_HOME.
|
|
86
|
+
"""
|
|
87
|
+
return get_ccproxy_config_dir() / "home"
|