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
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
@@ -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"