ccproxy-api 0.1.2__py3-none-any.whl → 0.1.4__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/_version.py +2 -2
- ccproxy/adapters/openai/__init__.py +1 -2
- ccproxy/adapters/openai/adapter.py +218 -180
- ccproxy/adapters/openai/streaming.py +247 -65
- ccproxy/api/__init__.py +0 -3
- ccproxy/api/app.py +173 -40
- ccproxy/api/dependencies.py +62 -3
- ccproxy/api/middleware/errors.py +3 -7
- ccproxy/api/middleware/headers.py +0 -2
- ccproxy/api/middleware/logging.py +4 -3
- ccproxy/api/middleware/request_content_logging.py +297 -0
- ccproxy/api/middleware/request_id.py +5 -0
- ccproxy/api/middleware/server_header.py +0 -4
- ccproxy/api/routes/__init__.py +9 -1
- ccproxy/api/routes/claude.py +23 -32
- ccproxy/api/routes/health.py +58 -4
- ccproxy/api/routes/mcp.py +171 -0
- ccproxy/api/routes/metrics.py +4 -8
- ccproxy/api/routes/permissions.py +217 -0
- ccproxy/api/routes/proxy.py +0 -53
- ccproxy/api/services/__init__.py +6 -0
- ccproxy/api/services/permission_service.py +368 -0
- ccproxy/api/ui/__init__.py +6 -0
- ccproxy/api/ui/permission_handler_protocol.py +33 -0
- ccproxy/api/ui/terminal_permission_handler.py +593 -0
- ccproxy/auth/conditional.py +2 -2
- ccproxy/auth/dependencies.py +1 -1
- ccproxy/auth/oauth/models.py +0 -1
- ccproxy/auth/oauth/routes.py +1 -3
- ccproxy/auth/storage/json_file.py +0 -1
- ccproxy/auth/storage/keyring.py +0 -3
- ccproxy/claude_sdk/__init__.py +2 -0
- ccproxy/claude_sdk/client.py +91 -8
- ccproxy/claude_sdk/converter.py +405 -210
- ccproxy/claude_sdk/options.py +76 -29
- ccproxy/claude_sdk/parser.py +200 -0
- ccproxy/claude_sdk/streaming.py +286 -0
- ccproxy/cli/commands/__init__.py +5 -2
- ccproxy/cli/commands/auth.py +2 -4
- ccproxy/cli/commands/permission_handler.py +553 -0
- ccproxy/cli/commands/serve.py +30 -12
- ccproxy/cli/docker/params.py +0 -4
- ccproxy/cli/helpers.py +0 -2
- ccproxy/cli/main.py +5 -16
- ccproxy/cli/options/claude_options.py +19 -1
- ccproxy/cli/options/core_options.py +0 -3
- ccproxy/cli/options/security_options.py +0 -2
- ccproxy/cli/options/server_options.py +3 -2
- ccproxy/config/auth.py +0 -1
- ccproxy/config/claude.py +78 -2
- ccproxy/config/discovery.py +0 -1
- ccproxy/config/docker_settings.py +0 -1
- ccproxy/config/loader.py +1 -4
- ccproxy/config/scheduler.py +20 -0
- ccproxy/config/security.py +7 -2
- ccproxy/config/server.py +5 -0
- ccproxy/config/settings.py +13 -7
- ccproxy/config/validators.py +1 -1
- ccproxy/core/async_utils.py +1 -4
- ccproxy/core/errors.py +45 -1
- ccproxy/core/http_transformers.py +4 -3
- ccproxy/core/interfaces.py +2 -2
- ccproxy/core/logging.py +97 -95
- ccproxy/core/middleware.py +1 -1
- ccproxy/core/proxy.py +1 -1
- ccproxy/core/transformers.py +1 -1
- ccproxy/core/types.py +1 -1
- ccproxy/docker/models.py +1 -1
- ccproxy/docker/protocol.py +0 -3
- ccproxy/models/__init__.py +41 -0
- ccproxy/models/claude_sdk.py +420 -0
- ccproxy/models/messages.py +45 -18
- ccproxy/models/permissions.py +115 -0
- ccproxy/models/requests.py +1 -1
- ccproxy/models/responses.py +29 -2
- ccproxy/observability/access_logger.py +1 -2
- ccproxy/observability/context.py +17 -1
- ccproxy/observability/metrics.py +1 -3
- ccproxy/observability/pushgateway.py +0 -2
- ccproxy/observability/stats_printer.py +2 -4
- ccproxy/observability/storage/duckdb_simple.py +1 -1
- ccproxy/observability/storage/models.py +0 -1
- ccproxy/pricing/cache.py +0 -1
- ccproxy/pricing/loader.py +5 -21
- ccproxy/pricing/updater.py +0 -1
- ccproxy/scheduler/__init__.py +1 -0
- ccproxy/scheduler/core.py +6 -6
- ccproxy/scheduler/manager.py +35 -7
- ccproxy/scheduler/registry.py +1 -1
- ccproxy/scheduler/tasks.py +127 -2
- ccproxy/services/claude_sdk_service.py +220 -328
- ccproxy/services/credentials/manager.py +0 -1
- ccproxy/services/credentials/oauth_client.py +1 -2
- ccproxy/services/proxy_service.py +93 -222
- ccproxy/testing/config.py +1 -1
- ccproxy/testing/mock_responses.py +0 -1
- ccproxy/utils/model_mapping.py +197 -0
- ccproxy/utils/models_provider.py +150 -0
- ccproxy/utils/simple_request_logger.py +284 -0
- ccproxy/utils/version_checker.py +184 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.4.dist-info/RECORD +166 -0
- ccproxy/cli/commands/permission.py +0 -128
- ccproxy_api-0.1.2.dist-info/RECORD +0 -150
- /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Unified model mapping utilities for OpenAI and Claude models.
|
|
2
|
+
|
|
3
|
+
This module provides a single source of truth for all model mappings,
|
|
4
|
+
consolidating OpenAI→Claude mappings and Claude alias resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Combined mapping: OpenAI models → Claude models AND Claude aliases → canonical Claude models
|
|
11
|
+
MODEL_MAPPING: dict[str, str] = {
|
|
12
|
+
# OpenAI GPT-4 models → Claude 3.5 Sonnet (most comparable)
|
|
13
|
+
"gpt-4": "claude-3-5-sonnet-20241022",
|
|
14
|
+
"gpt-4-turbo": "claude-3-5-sonnet-20241022",
|
|
15
|
+
"gpt-4-turbo-preview": "claude-3-5-sonnet-20241022",
|
|
16
|
+
"gpt-4-1106-preview": "claude-3-5-sonnet-20241022",
|
|
17
|
+
"gpt-4-0125-preview": "claude-3-5-sonnet-20241022",
|
|
18
|
+
"gpt-4-turbo-2024-04-09": "claude-3-5-sonnet-20241022",
|
|
19
|
+
# OpenAI GPT-4o models → Claude 3.7 Sonnet
|
|
20
|
+
"gpt-4o": "claude-3-7-sonnet-20250219",
|
|
21
|
+
"gpt-4o-2024-05-13": "claude-3-7-sonnet-20250219",
|
|
22
|
+
"gpt-4o-2024-08-06": "claude-3-7-sonnet-20250219",
|
|
23
|
+
"gpt-4o-2024-11-20": "claude-3-7-sonnet-20250219",
|
|
24
|
+
# OpenAI GPT-4o-mini models → Claude 3.5 Haiku
|
|
25
|
+
"gpt-4o-mini": "claude-3-5-haiku-latest",
|
|
26
|
+
"gpt-4o-mini-2024-07-18": "claude-3-5-haiku-latest",
|
|
27
|
+
# OpenAI o1 models → Claude models that support thinking
|
|
28
|
+
"o1": "claude-opus-4-20250514",
|
|
29
|
+
"o1-preview": "claude-opus-4-20250514",
|
|
30
|
+
"o1-mini": "claude-sonnet-4-20250514",
|
|
31
|
+
# OpenAI o3 models → Claude Opus 4
|
|
32
|
+
"o3-mini": "claude-opus-4-20250514",
|
|
33
|
+
# OpenAI GPT-3.5 models → Claude 3.5 Haiku (faster, cheaper)
|
|
34
|
+
"gpt-3.5-turbo": "claude-3-5-haiku-20241022",
|
|
35
|
+
"gpt-3.5-turbo-16k": "claude-3-5-haiku-20241022",
|
|
36
|
+
"gpt-3.5-turbo-1106": "claude-3-5-haiku-20241022",
|
|
37
|
+
"gpt-3.5-turbo-0125": "claude-3-5-haiku-20241022",
|
|
38
|
+
# OpenAI text models → Claude 3.5 Sonnet
|
|
39
|
+
"text-davinci-003": "claude-3-5-sonnet-20241022",
|
|
40
|
+
"text-davinci-002": "claude-3-5-sonnet-20241022",
|
|
41
|
+
# Claude model aliases → canonical Claude models
|
|
42
|
+
"claude-3-5-sonnet-latest": "claude-3-5-sonnet-20241022",
|
|
43
|
+
"claude-3-5-sonnet-20240620": "claude-3-5-sonnet-20240620",
|
|
44
|
+
"claude-3-5-sonnet-20241022": "claude-3-5-sonnet-20241022",
|
|
45
|
+
"claude-3-5-haiku-latest": "claude-3-5-haiku-20241022",
|
|
46
|
+
"claude-3-5-haiku-20241022": "claude-3-5-haiku-20241022",
|
|
47
|
+
"claude-3-opus": "claude-3-opus-20240229",
|
|
48
|
+
"claude-3-opus-20240229": "claude-3-opus-20240229",
|
|
49
|
+
"claude-3-sonnet": "claude-3-sonnet-20240229",
|
|
50
|
+
"claude-3-sonnet-20240229": "claude-3-sonnet-20240229",
|
|
51
|
+
"claude-3-haiku": "claude-3-haiku-20240307",
|
|
52
|
+
"claude-3-haiku-20240307": "claude-3-haiku-20240307",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def map_model_to_claude(model_name: str) -> str:
|
|
57
|
+
"""Map any model name to its canonical Claude model name.
|
|
58
|
+
|
|
59
|
+
This function handles:
|
|
60
|
+
- OpenAI model names → Claude equivalents
|
|
61
|
+
- Claude aliases → canonical Claude names
|
|
62
|
+
- Pattern matching for versioned models
|
|
63
|
+
- Pass-through for unknown models
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
model_name: Model identifier (OpenAI, Claude, or alias)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Canonical Claude model identifier
|
|
70
|
+
"""
|
|
71
|
+
# Direct mapping first (handles both OpenAI and Claude aliases)
|
|
72
|
+
claude_model = MODEL_MAPPING.get(model_name)
|
|
73
|
+
if claude_model:
|
|
74
|
+
return claude_model
|
|
75
|
+
|
|
76
|
+
# Pattern matching for versioned OpenAI models
|
|
77
|
+
if model_name.startswith("gpt-4o-mini"):
|
|
78
|
+
return "claude-3-5-haiku-latest"
|
|
79
|
+
elif model_name.startswith("gpt-4o") or model_name.startswith("gpt-4"):
|
|
80
|
+
return "claude-3-7-sonnet-20250219"
|
|
81
|
+
elif model_name.startswith("gpt-3.5"):
|
|
82
|
+
return "claude-3-5-haiku-latest"
|
|
83
|
+
elif model_name.startswith("o1"):
|
|
84
|
+
return "claude-sonnet-4-20250514"
|
|
85
|
+
elif model_name.startswith("o3"):
|
|
86
|
+
return "claude-opus-4-20250514"
|
|
87
|
+
elif model_name.startswith("gpt"):
|
|
88
|
+
return "claude-sonnet-4-20250514"
|
|
89
|
+
|
|
90
|
+
# If it's already a Claude model, pass through unchanged
|
|
91
|
+
if model_name.startswith("claude-"):
|
|
92
|
+
return model_name
|
|
93
|
+
|
|
94
|
+
# For unknown models, pass through unchanged
|
|
95
|
+
return model_name
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_openai_to_claude_mapping() -> dict[str, str]:
|
|
99
|
+
"""Get mapping of OpenAI models to Claude models.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dictionary mapping OpenAI model names to Claude model names
|
|
103
|
+
"""
|
|
104
|
+
return {k: v for k, v in MODEL_MAPPING.items() if not k.startswith("claude-")}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_claude_aliases_mapping() -> dict[str, str]:
|
|
108
|
+
"""Get mapping of Claude aliases to canonical Claude names.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Dictionary mapping Claude aliases to canonical model names
|
|
112
|
+
"""
|
|
113
|
+
return {k: v for k, v in MODEL_MAPPING.items() if k.startswith("claude-")}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_supported_claude_models() -> list[str]:
|
|
117
|
+
"""Get list of supported canonical Claude models.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Sorted list of unique canonical Claude model names
|
|
121
|
+
"""
|
|
122
|
+
return sorted(set(MODEL_MAPPING.values()))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_openai_model(model_name: str) -> bool:
|
|
126
|
+
"""Check if a model name is an OpenAI model.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
model_name: Model identifier to check
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
True if the model is an OpenAI model, False otherwise
|
|
133
|
+
"""
|
|
134
|
+
return (
|
|
135
|
+
model_name.startswith(("gpt-", "o1", "o3", "text-davinci"))
|
|
136
|
+
or model_name in get_openai_to_claude_mapping()
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_claude_model(model_name: str) -> bool:
|
|
141
|
+
"""Check if a model name is a Claude model (canonical or alias).
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
model_name: Model identifier to check
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if the model is a Claude model, False otherwise
|
|
148
|
+
"""
|
|
149
|
+
return (
|
|
150
|
+
model_name.startswith("claude-") or model_name in get_claude_aliases_mapping()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Backward compatibility exports
|
|
155
|
+
OPENAI_TO_CLAUDE_MODEL_MAPPING = get_openai_to_claude_mapping()
|
|
156
|
+
CLAUDE_MODEL_MAPPINGS = get_claude_aliases_mapping()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Legacy function aliases
|
|
160
|
+
def map_openai_model_to_claude(openai_model: str) -> str:
|
|
161
|
+
"""Legacy alias for map_model_to_claude().
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
openai_model: OpenAI model identifier
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Claude model identifier
|
|
168
|
+
"""
|
|
169
|
+
return map_model_to_claude(openai_model)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_canonical_model_name(model_name: str) -> str:
|
|
173
|
+
"""Legacy alias for map_model_to_claude().
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
model_name: Model name (possibly an alias)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Canonical model name
|
|
180
|
+
"""
|
|
181
|
+
return map_model_to_claude(model_name)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
__all__ = [
|
|
185
|
+
"MODEL_MAPPING",
|
|
186
|
+
"map_model_to_claude",
|
|
187
|
+
"get_openai_to_claude_mapping",
|
|
188
|
+
"get_claude_aliases_mapping",
|
|
189
|
+
"get_supported_claude_models",
|
|
190
|
+
"is_openai_model",
|
|
191
|
+
"is_claude_model",
|
|
192
|
+
# Backward compatibility
|
|
193
|
+
"OPENAI_TO_CLAUDE_MODEL_MAPPING",
|
|
194
|
+
"CLAUDE_MODEL_MAPPINGS",
|
|
195
|
+
"map_openai_model_to_claude",
|
|
196
|
+
"get_canonical_model_name",
|
|
197
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Shared models provider for CCProxy API Server.
|
|
2
|
+
|
|
3
|
+
This module provides a centralized source for all available models,
|
|
4
|
+
combining Claude and OpenAI models in a consistent format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ccproxy.utils.model_mapping import get_supported_claude_models
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_anthropic_models() -> list[dict[str, Any]]:
|
|
15
|
+
"""Get list of Anthropic models with metadata.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
List of Anthropic model entries with type, id, display_name, and created_at fields
|
|
19
|
+
"""
|
|
20
|
+
# Model display names mapping
|
|
21
|
+
display_names = {
|
|
22
|
+
"claude-opus-4-20250514": "Claude Opus 4",
|
|
23
|
+
"claude-sonnet-4-20250514": "Claude Sonnet 4",
|
|
24
|
+
"claude-3-7-sonnet-20250219": "Claude Sonnet 3.7",
|
|
25
|
+
"claude-3-5-sonnet-20241022": "Claude Sonnet 3.5 (New)",
|
|
26
|
+
"claude-3-5-haiku-20241022": "Claude Haiku 3.5",
|
|
27
|
+
"claude-3-5-haiku-latest": "Claude Haiku 3.5",
|
|
28
|
+
"claude-3-5-sonnet-20240620": "Claude Sonnet 3.5 (Old)",
|
|
29
|
+
"claude-3-haiku-20240307": "Claude Haiku 3",
|
|
30
|
+
"claude-3-opus-20240229": "Claude Opus 3",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Model creation timestamps
|
|
34
|
+
timestamps = {
|
|
35
|
+
"claude-opus-4-20250514": 1747526400, # 2025-05-22
|
|
36
|
+
"claude-sonnet-4-20250514": 1747526400, # 2025-05-22
|
|
37
|
+
"claude-3-7-sonnet-20250219": 1740268800, # 2025-02-24
|
|
38
|
+
"claude-3-5-sonnet-20241022": 1729555200, # 2024-10-22
|
|
39
|
+
"claude-3-5-haiku-20241022": 1729555200, # 2024-10-22
|
|
40
|
+
"claude-3-5-haiku-latest": 1729555200, # 2024-10-22
|
|
41
|
+
"claude-3-5-sonnet-20240620": 1718841600, # 2024-06-20
|
|
42
|
+
"claude-3-haiku-20240307": 1709769600, # 2024-03-07
|
|
43
|
+
"claude-3-opus-20240229": 1709164800, # 2024-02-29
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Get supported Claude models from existing utility
|
|
47
|
+
supported_models = get_supported_claude_models()
|
|
48
|
+
|
|
49
|
+
# Create Anthropic-style model entries
|
|
50
|
+
models = []
|
|
51
|
+
for model_id in supported_models:
|
|
52
|
+
models.append(
|
|
53
|
+
{
|
|
54
|
+
"type": "model",
|
|
55
|
+
"id": model_id,
|
|
56
|
+
"display_name": display_names.get(model_id, model_id),
|
|
57
|
+
"created_at": timestamps.get(model_id, 1677610602), # Default timestamp
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return models
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_openai_models() -> list[dict[str, Any]]:
|
|
65
|
+
"""Get list of recent OpenAI models with metadata.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of OpenAI model entries with id, object, created, and owned_by fields
|
|
69
|
+
"""
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
"id": "gpt-4o",
|
|
73
|
+
"object": "model",
|
|
74
|
+
"created": 1715367049,
|
|
75
|
+
"owned_by": "openai",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "gpt-4o-mini",
|
|
79
|
+
"object": "model",
|
|
80
|
+
"created": 1721172741,
|
|
81
|
+
"owned_by": "openai",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"id": "gpt-4-turbo",
|
|
85
|
+
"object": "model",
|
|
86
|
+
"created": 1712361441,
|
|
87
|
+
"owned_by": "openai",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "gpt-4-turbo-preview",
|
|
91
|
+
"object": "model",
|
|
92
|
+
"created": 1706037777,
|
|
93
|
+
"owned_by": "openai",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "o1",
|
|
97
|
+
"object": "model",
|
|
98
|
+
"created": 1734375816,
|
|
99
|
+
"owned_by": "openai",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"id": "o1-mini",
|
|
103
|
+
"object": "model",
|
|
104
|
+
"created": 1725649008,
|
|
105
|
+
"owned_by": "openai",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "o1-preview",
|
|
109
|
+
"object": "model",
|
|
110
|
+
"created": 1725648897,
|
|
111
|
+
"owned_by": "openai",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"id": "o3",
|
|
115
|
+
"object": "model",
|
|
116
|
+
"created": 1744225308,
|
|
117
|
+
"owned_by": "openai",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"id": "o3-mini",
|
|
121
|
+
"object": "model",
|
|
122
|
+
"created": 1737146383,
|
|
123
|
+
"owned_by": "openai",
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_models_list() -> dict[str, Any]:
|
|
129
|
+
"""Get combined list of available Claude and OpenAI models.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Dictionary with combined list of models in mixed format compatible with both
|
|
133
|
+
Anthropic and OpenAI API specifications
|
|
134
|
+
"""
|
|
135
|
+
anthropic_models = get_anthropic_models()
|
|
136
|
+
openai_models = get_openai_models()
|
|
137
|
+
|
|
138
|
+
# Return combined response in mixed format
|
|
139
|
+
return {
|
|
140
|
+
"data": anthropic_models + openai_models,
|
|
141
|
+
"has_more": False,
|
|
142
|
+
"object": "list",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"get_anthropic_models",
|
|
148
|
+
"get_openai_models",
|
|
149
|
+
"get_models_list",
|
|
150
|
+
]
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Simple request logging utility for content logging across all service layers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
# Global batching settings for streaming logs
|
|
16
|
+
_STREAMING_BATCH_SIZE = 8192 # Batch chunks until we have 8KB
|
|
17
|
+
_STREAMING_BATCH_TIMEOUT = 0.1 # Or flush after 100ms
|
|
18
|
+
_streaming_batches: dict[str, dict[str, Any]] = {} # request_id -> batch info
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def should_log_requests() -> bool:
|
|
22
|
+
"""Check if request logging is enabled via environment variable.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
True if CCPROXY_LOG_REQUESTS is set to 'true' (case-insensitive)
|
|
26
|
+
"""
|
|
27
|
+
return os.environ.get("CCPROXY_LOG_REQUESTS", "false").lower() == "true"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_request_log_dir() -> Path | None:
|
|
31
|
+
"""Get the request log directory from environment variable.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Path object if CCPROXY_REQUEST_LOG_DIR is set and valid, None otherwise
|
|
35
|
+
"""
|
|
36
|
+
log_dir = os.environ.get("CCPROXY_REQUEST_LOG_DIR")
|
|
37
|
+
if not log_dir:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
path = Path(log_dir)
|
|
41
|
+
try:
|
|
42
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return path
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.error(
|
|
46
|
+
"failed_to_create_request_log_dir",
|
|
47
|
+
log_dir=log_dir,
|
|
48
|
+
error=str(e),
|
|
49
|
+
)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_timestamp_prefix() -> str:
|
|
54
|
+
"""Generate timestamp prefix in YYYYMMDDhhmmss format.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Timestamp string in YYYYMMDDhhmmss format (UTC)
|
|
58
|
+
"""
|
|
59
|
+
return datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def write_request_log(
|
|
63
|
+
request_id: str,
|
|
64
|
+
log_type: str,
|
|
65
|
+
data: dict[str, Any],
|
|
66
|
+
timestamp: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Write request/response data to JSON file.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
request_id: Unique request identifier
|
|
72
|
+
log_type: Type of log (e.g., 'middleware_request', 'upstream_response')
|
|
73
|
+
data: Data to log as JSON
|
|
74
|
+
timestamp: Optional timestamp prefix (defaults to current time)
|
|
75
|
+
"""
|
|
76
|
+
if not should_log_requests():
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
log_dir = get_request_log_dir()
|
|
80
|
+
if not log_dir:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
timestamp = timestamp or get_timestamp_prefix()
|
|
84
|
+
filename = f"{timestamp}_{request_id}_{log_type}.json"
|
|
85
|
+
file_path = log_dir / filename
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
# Write JSON data to file asynchronously
|
|
89
|
+
def write_file() -> None:
|
|
90
|
+
with file_path.open("w", encoding="utf-8") as f:
|
|
91
|
+
json.dump(data, f, indent=2, default=str, ensure_ascii=False)
|
|
92
|
+
|
|
93
|
+
# Run in thread pool to avoid blocking
|
|
94
|
+
await asyncio.get_event_loop().run_in_executor(None, write_file)
|
|
95
|
+
|
|
96
|
+
logger.debug(
|
|
97
|
+
"request_log_written",
|
|
98
|
+
request_id=request_id,
|
|
99
|
+
log_type=log_type,
|
|
100
|
+
file_path=str(file_path),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.error(
|
|
105
|
+
"failed_to_write_request_log",
|
|
106
|
+
request_id=request_id,
|
|
107
|
+
log_type=log_type,
|
|
108
|
+
file_path=str(file_path),
|
|
109
|
+
error=str(e),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def write_streaming_log(
|
|
114
|
+
request_id: str,
|
|
115
|
+
log_type: str,
|
|
116
|
+
data: bytes,
|
|
117
|
+
timestamp: str | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Write streaming data to raw file.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
request_id: Unique request identifier
|
|
123
|
+
log_type: Type of log (e.g., 'middleware_streaming', 'upstream_streaming')
|
|
124
|
+
data: Raw bytes to log
|
|
125
|
+
timestamp: Optional timestamp prefix (defaults to current time)
|
|
126
|
+
"""
|
|
127
|
+
if not should_log_requests():
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
log_dir = get_request_log_dir()
|
|
131
|
+
if not log_dir:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
timestamp = timestamp or get_timestamp_prefix()
|
|
135
|
+
filename = f"{timestamp}_{request_id}_{log_type}.raw"
|
|
136
|
+
file_path = log_dir / filename
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
# Write raw data to file asynchronously
|
|
140
|
+
def write_file() -> None:
|
|
141
|
+
with file_path.open("wb") as f:
|
|
142
|
+
f.write(data)
|
|
143
|
+
|
|
144
|
+
# Run in thread pool to avoid blocking
|
|
145
|
+
await asyncio.get_event_loop().run_in_executor(None, write_file)
|
|
146
|
+
|
|
147
|
+
logger.debug(
|
|
148
|
+
"streaming_log_written",
|
|
149
|
+
request_id=request_id,
|
|
150
|
+
log_type=log_type,
|
|
151
|
+
file_path=str(file_path),
|
|
152
|
+
data_size=len(data),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.error(
|
|
157
|
+
"failed_to_write_streaming_log",
|
|
158
|
+
request_id=request_id,
|
|
159
|
+
log_type=log_type,
|
|
160
|
+
file_path=str(file_path),
|
|
161
|
+
error=str(e),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def append_streaming_log(
|
|
166
|
+
request_id: str,
|
|
167
|
+
log_type: str,
|
|
168
|
+
data: bytes,
|
|
169
|
+
timestamp: str | None = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Append streaming data using batching for performance.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
request_id: Unique request identifier
|
|
175
|
+
log_type: Type of log (e.g., 'middleware_streaming', 'upstream_streaming')
|
|
176
|
+
data: Raw bytes to append
|
|
177
|
+
timestamp: Optional timestamp prefix (defaults to current time)
|
|
178
|
+
"""
|
|
179
|
+
if not should_log_requests():
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
log_dir = get_request_log_dir()
|
|
183
|
+
if not log_dir:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
timestamp = timestamp or get_timestamp_prefix()
|
|
187
|
+
batch_key = f"{request_id}_{log_type}"
|
|
188
|
+
|
|
189
|
+
# Get or create batch for this request/log_type combination
|
|
190
|
+
if batch_key not in _streaming_batches:
|
|
191
|
+
_streaming_batches[batch_key] = {
|
|
192
|
+
"request_id": request_id,
|
|
193
|
+
"log_type": log_type,
|
|
194
|
+
"timestamp": timestamp,
|
|
195
|
+
"data": bytearray(),
|
|
196
|
+
"chunk_count": 0,
|
|
197
|
+
"first_chunk_time": asyncio.get_event_loop().time(),
|
|
198
|
+
"last_flush_task": None,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
batch = _streaming_batches[batch_key]
|
|
202
|
+
batch["data"].extend(data)
|
|
203
|
+
batch["chunk_count"] += 1
|
|
204
|
+
|
|
205
|
+
# Cancel previous flush task if it exists
|
|
206
|
+
if batch["last_flush_task"] and not batch["last_flush_task"].done():
|
|
207
|
+
batch["last_flush_task"].cancel()
|
|
208
|
+
|
|
209
|
+
# Check if we should flush now
|
|
210
|
+
should_flush = (
|
|
211
|
+
len(batch["data"]) >= _STREAMING_BATCH_SIZE
|
|
212
|
+
or batch["chunk_count"] >= 50 # Max 50 chunks per batch
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if should_flush:
|
|
216
|
+
await _flush_streaming_batch(batch_key)
|
|
217
|
+
else:
|
|
218
|
+
# Schedule a delayed flush
|
|
219
|
+
batch["last_flush_task"] = asyncio.create_task(
|
|
220
|
+
_delayed_flush_streaming_batch(batch_key, _STREAMING_BATCH_TIMEOUT)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def _delayed_flush_streaming_batch(batch_key: str, delay: float) -> None:
|
|
225
|
+
"""Flush a streaming batch after a delay."""
|
|
226
|
+
try:
|
|
227
|
+
await asyncio.sleep(delay)
|
|
228
|
+
if batch_key in _streaming_batches:
|
|
229
|
+
await _flush_streaming_batch(batch_key)
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
# Task was cancelled, don't flush
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def _flush_streaming_batch(batch_key: str) -> None:
|
|
236
|
+
"""Flush a streaming batch to disk."""
|
|
237
|
+
if batch_key not in _streaming_batches:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
batch = _streaming_batches.pop(batch_key)
|
|
241
|
+
|
|
242
|
+
if not batch["data"]:
|
|
243
|
+
return # Nothing to flush
|
|
244
|
+
|
|
245
|
+
log_dir = get_request_log_dir()
|
|
246
|
+
if not log_dir:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
filename = f"{batch['timestamp']}_{batch['request_id']}_{batch['log_type']}.raw"
|
|
250
|
+
file_path = log_dir / filename
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
# Append batched data to file asynchronously
|
|
254
|
+
def append_file() -> None:
|
|
255
|
+
with file_path.open("ab") as f:
|
|
256
|
+
f.write(batch["data"])
|
|
257
|
+
|
|
258
|
+
# Run in thread pool to avoid blocking
|
|
259
|
+
await asyncio.get_event_loop().run_in_executor(None, append_file)
|
|
260
|
+
|
|
261
|
+
logger.debug(
|
|
262
|
+
"streaming_batch_flushed",
|
|
263
|
+
request_id=batch["request_id"],
|
|
264
|
+
log_type=batch["log_type"],
|
|
265
|
+
file_path=str(file_path),
|
|
266
|
+
batch_size=len(batch["data"]),
|
|
267
|
+
chunk_count=batch["chunk_count"],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.error(
|
|
272
|
+
"failed_to_flush_streaming_batch",
|
|
273
|
+
request_id=batch["request_id"],
|
|
274
|
+
log_type=batch["log_type"],
|
|
275
|
+
file_path=str(file_path),
|
|
276
|
+
error=str(e),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def flush_all_streaming_batches() -> None:
|
|
281
|
+
"""Flush all pending streaming batches. Call this on shutdown."""
|
|
282
|
+
batch_keys = list(_streaming_batches.keys())
|
|
283
|
+
for batch_key in batch_keys:
|
|
284
|
+
await _flush_streaming_batch(batch_key)
|