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/static/.keep
ADDED
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Testing utilities and mock response generation for CCProxy.
|
|
2
|
+
|
|
3
|
+
This package provides comprehensive testing utilities including:
|
|
4
|
+
- Mock response generation for bypass mode
|
|
5
|
+
- Request payload builders for dual-format testing
|
|
6
|
+
- Response processing and metrics collection
|
|
7
|
+
- Traffic pattern generation and scenario management
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ccproxy.testing.config import (
|
|
11
|
+
MockResponseConfig,
|
|
12
|
+
RequestScenario,
|
|
13
|
+
TrafficConfig,
|
|
14
|
+
TrafficMetrics,
|
|
15
|
+
)
|
|
16
|
+
from ccproxy.testing.content_generation import MessageContentGenerator, PayloadBuilder
|
|
17
|
+
from ccproxy.testing.mock_responses import RealisticMockResponseGenerator
|
|
18
|
+
from ccproxy.testing.response_handlers import MetricsExtractor, ResponseHandler
|
|
19
|
+
from ccproxy.testing.scenarios import ScenarioGenerator, TrafficPatternAnalyzer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"MockResponseConfig",
|
|
24
|
+
"RequestScenario",
|
|
25
|
+
"TrafficConfig",
|
|
26
|
+
"TrafficMetrics",
|
|
27
|
+
"MessageContentGenerator",
|
|
28
|
+
"PayloadBuilder",
|
|
29
|
+
"RealisticMockResponseGenerator",
|
|
30
|
+
"MetricsExtractor",
|
|
31
|
+
"ResponseHandler",
|
|
32
|
+
"ScenarioGenerator",
|
|
33
|
+
"TrafficPatternAnalyzer",
|
|
34
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Configuration models for testing utilities."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Type aliases for traffic patterns and response types
|
|
11
|
+
TrafficPattern = Literal["constant", "burst", "ramping", "realistic"]
|
|
12
|
+
ResponseType = Literal["success", "error", "mixed", "unavailable"]
|
|
13
|
+
AuthType = Literal["none", "bearer", "configured", "credentials"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MockResponseConfig(BaseModel):
|
|
17
|
+
"""Configuration for realistic mock responses."""
|
|
18
|
+
|
|
19
|
+
# Token range configurations
|
|
20
|
+
input_token_range: tuple[int, int] = (10, 500) # Min/max input tokens
|
|
21
|
+
output_token_range: tuple[int, int] = (5, 1000) # Min/max output tokens
|
|
22
|
+
cache_token_probability: float = 0.3 # Chance of cache tokens
|
|
23
|
+
cache_read_range: tuple[int, int] = (50, 200) # Cache read token range
|
|
24
|
+
cache_write_range: tuple[int, int] = (20, 100) # Cache write token range
|
|
25
|
+
|
|
26
|
+
# Latency simulation
|
|
27
|
+
base_latency_ms: tuple[int, int] = (100, 2000) # Base response latency
|
|
28
|
+
streaming_chunk_delay_ms: tuple[int, int] = (10, 100) # Per-chunk delay
|
|
29
|
+
|
|
30
|
+
# Content variation
|
|
31
|
+
response_length_variety: bool = True # Vary response length
|
|
32
|
+
short_response_range: tuple[int, int] = (1, 3) # Short response sentences
|
|
33
|
+
long_response_range: tuple[int, int] = (5, 15) # Long response sentences
|
|
34
|
+
|
|
35
|
+
# Error simulation
|
|
36
|
+
simulate_errors: bool = True # Include error scenarios
|
|
37
|
+
error_probability: float = 0.05 # Chance of error response
|
|
38
|
+
|
|
39
|
+
# Realistic timing
|
|
40
|
+
token_generation_rate: float = 50.0 # Tokens per second for streaming
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TrafficConfig(BaseModel):
|
|
44
|
+
"""Configuration for traffic generation scenarios."""
|
|
45
|
+
|
|
46
|
+
# Basic settings
|
|
47
|
+
duration_seconds: int = 60
|
|
48
|
+
requests_per_second: float = 1.0
|
|
49
|
+
pattern: TrafficPattern = "constant"
|
|
50
|
+
|
|
51
|
+
# Target Configuration
|
|
52
|
+
target_url: str = "http://localhost:8000" # Proxy server URL
|
|
53
|
+
api_formats: list[str] = ["anthropic", "openai"] # Which formats to test
|
|
54
|
+
format_distribution: dict[str, float] = { # % distribution of formats
|
|
55
|
+
"anthropic": 0.7,
|
|
56
|
+
"openai": 0.3,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Request configuration
|
|
60
|
+
models: list[str] = ["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022"]
|
|
61
|
+
message_types: list[str] = ["short", "long", "tool_use"]
|
|
62
|
+
streaming_probability: float = 0.3
|
|
63
|
+
|
|
64
|
+
# Advanced Request Types
|
|
65
|
+
advanced_scenarios: bool = False # Enable complex scenarios
|
|
66
|
+
tool_use_probability: float = 0.2 # Specific probability for tool use
|
|
67
|
+
|
|
68
|
+
# Response configuration
|
|
69
|
+
response_type: ResponseType = "mixed"
|
|
70
|
+
error_probability: float = 0.1
|
|
71
|
+
latency_ms_min: int = 100
|
|
72
|
+
latency_ms_max: int = 2000
|
|
73
|
+
|
|
74
|
+
# Authentication and Testing
|
|
75
|
+
bypass_mode: bool = True # Use bypass headers (test mode)
|
|
76
|
+
real_api_keys: dict[str, str] = {} # Real API keys when bypass_mode=False
|
|
77
|
+
|
|
78
|
+
# Timeframe simulation
|
|
79
|
+
simulate_historical: bool = False
|
|
80
|
+
start_timestamp: datetime | None = None
|
|
81
|
+
end_timestamp: datetime | None = None
|
|
82
|
+
|
|
83
|
+
# Output configuration
|
|
84
|
+
output_file: Path | None = None
|
|
85
|
+
log_requests: bool = True
|
|
86
|
+
log_responses: bool = False
|
|
87
|
+
log_format_conversions: bool = True # Log API format transformations
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RequestScenario(BaseModel):
|
|
91
|
+
"""Individual request scenario configuration."""
|
|
92
|
+
|
|
93
|
+
model: str
|
|
94
|
+
message_type: str
|
|
95
|
+
streaming: bool
|
|
96
|
+
response_type: ResponseType
|
|
97
|
+
timestamp: datetime
|
|
98
|
+
|
|
99
|
+
# API Format and Endpoint Control
|
|
100
|
+
api_format: Literal["anthropic", "openai"] = "anthropic"
|
|
101
|
+
endpoint_path: str = (
|
|
102
|
+
"/api/v1/messages" # "/api/v1/messages" or "/api/v1/chat/completions"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Request Control
|
|
106
|
+
bypass_upstream: bool = True # Add bypass header to prevent real API calls
|
|
107
|
+
use_real_auth: bool = False # Use real API keys vs test mode
|
|
108
|
+
|
|
109
|
+
# Enhanced Headers
|
|
110
|
+
headers: dict[str, str] = {} # All request headers
|
|
111
|
+
|
|
112
|
+
# Target URL
|
|
113
|
+
target_url: str = "http://localhost:8000" # Full base URL for request
|
|
114
|
+
|
|
115
|
+
# Payload Customization
|
|
116
|
+
custom_payload: dict[str, Any] | None = None # Override default payload generation
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TrafficMetrics(BaseModel):
|
|
120
|
+
"""Enhanced metrics for dual-format testing."""
|
|
121
|
+
|
|
122
|
+
total_requests: int = 0
|
|
123
|
+
successful_requests: int = 0
|
|
124
|
+
failed_requests: int = 0
|
|
125
|
+
error_requests: int = 0
|
|
126
|
+
average_latency_ms: float = 0.0
|
|
127
|
+
requests_per_second: float = 0.0
|
|
128
|
+
start_time: datetime
|
|
129
|
+
end_time: datetime | None = None
|
|
130
|
+
|
|
131
|
+
# Format-specific metrics
|
|
132
|
+
anthropic_requests: int = 0
|
|
133
|
+
openai_requests: int = 0
|
|
134
|
+
|
|
135
|
+
# Streaming vs non-streaming
|
|
136
|
+
streaming_requests: int = 0
|
|
137
|
+
standard_requests: int = 0
|
|
138
|
+
|
|
139
|
+
# Format validation
|
|
140
|
+
format_validation_errors: int = 0
|
|
141
|
+
|
|
142
|
+
# Response time by format
|
|
143
|
+
anthropic_avg_latency_ms: float = 0.0
|
|
144
|
+
openai_avg_latency_ms: float = 0.0
|
|
145
|
+
|
|
146
|
+
# Token usage
|
|
147
|
+
total_input_tokens: int = 0
|
|
148
|
+
total_output_tokens: int = 0
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Content generation utilities for testing requests and responses."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ccproxy.testing.config import RequestScenario
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MessageContentGenerator:
|
|
10
|
+
"""Generate realistic message content for testing."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.response_templates = self._load_response_templates()
|
|
14
|
+
self.request_templates = self._load_request_templates()
|
|
15
|
+
|
|
16
|
+
def _load_response_templates(self) -> dict[str, list[str]]:
|
|
17
|
+
"""Load variety of response templates."""
|
|
18
|
+
return {
|
|
19
|
+
"short": [
|
|
20
|
+
"Hello! How can I help you today?",
|
|
21
|
+
"I'm happy to assist you.",
|
|
22
|
+
"What would you like to know?",
|
|
23
|
+
"I'm here to help!",
|
|
24
|
+
"How may I assist you?",
|
|
25
|
+
],
|
|
26
|
+
"medium": [
|
|
27
|
+
"I'd be happy to help you with that. Let me provide you with some information that should be useful for your question.",
|
|
28
|
+
"That's an interesting question. Here's what I can tell you about this topic based on my knowledge.",
|
|
29
|
+
"I understand what you're asking about. Let me break this down into a clear explanation for you.",
|
|
30
|
+
],
|
|
31
|
+
"long": [
|
|
32
|
+
"This is a comprehensive topic that requires a detailed explanation. Let me walk you through the key concepts step by step. First, it's important to understand the foundational principles. Then we can explore the more advanced aspects. Finally, I'll provide some practical examples to illustrate the concepts.",
|
|
33
|
+
"That's an excellent question that touches on several important areas. To give you a complete answer, I need to cover multiple aspects. Let me start with the basic framework, then dive into the specifics, and conclude with some recommendations based on best practices in this field.",
|
|
34
|
+
],
|
|
35
|
+
"tool_use": [
|
|
36
|
+
"I'll help you with that calculation.",
|
|
37
|
+
"Let me solve that mathematical problem for you.",
|
|
38
|
+
"I can compute that result using the calculator tool.",
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def _load_request_templates(self) -> dict[str, list[str]]:
|
|
43
|
+
"""Load variety of request message templates."""
|
|
44
|
+
return {
|
|
45
|
+
"short": [
|
|
46
|
+
"Hello!",
|
|
47
|
+
"How are you?",
|
|
48
|
+
"What's the weather like?",
|
|
49
|
+
"Tell me a joke.",
|
|
50
|
+
"What time is it?",
|
|
51
|
+
],
|
|
52
|
+
"long": [
|
|
53
|
+
"I need help writing a detailed technical document about API design patterns. Can you provide a comprehensive guide covering REST principles, authentication methods, error handling, and best practices for scalable API development?",
|
|
54
|
+
"Please explain the differences between various machine learning algorithms including supervised learning, unsupervised learning, and reinforcement learning. Include examples of when to use each approach and their respective advantages and disadvantages.",
|
|
55
|
+
"I'm planning a complex software architecture for a distributed system. Can you help me understand microservices patterns, database sharding strategies, caching layers, and how to handle eventual consistency in distributed transactions?",
|
|
56
|
+
],
|
|
57
|
+
"tool_use": [
|
|
58
|
+
"Calculate 23 * 45 + 67 for me",
|
|
59
|
+
"What's the result of (150 / 3) * 2.5?",
|
|
60
|
+
"Help me calculate the compound interest on $1000 at 5% for 3 years",
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def get_request_message_content(self, message_type: str) -> str:
|
|
65
|
+
"""Get request message content based on type."""
|
|
66
|
+
if message_type in self.request_templates:
|
|
67
|
+
return random.choice(self.request_templates[message_type])
|
|
68
|
+
else:
|
|
69
|
+
# Fallback to short message for unknown types
|
|
70
|
+
return random.choice(self.request_templates["short"])
|
|
71
|
+
|
|
72
|
+
def get_response_content(
|
|
73
|
+
self, message_type: str, model: str
|
|
74
|
+
) -> tuple[str, int, int]:
|
|
75
|
+
"""Generate response content with realistic token counts."""
|
|
76
|
+
# Select base template
|
|
77
|
+
if message_type == "tool_use":
|
|
78
|
+
base_content = random.choice(self.response_templates["tool_use"])
|
|
79
|
+
# Add calculation result
|
|
80
|
+
result = random.randint(1, 1000)
|
|
81
|
+
content = f"{base_content} The result is {result}."
|
|
82
|
+
elif message_type in self.response_templates:
|
|
83
|
+
content = random.choice(self.response_templates[message_type])
|
|
84
|
+
else:
|
|
85
|
+
# Mix of different lengths for unknown types
|
|
86
|
+
template_type = random.choice(["short", "medium", "long"])
|
|
87
|
+
content = random.choice(self.response_templates[template_type])
|
|
88
|
+
|
|
89
|
+
# Calculate realistic token counts based on content
|
|
90
|
+
# Rough estimate: ~4 characters per token
|
|
91
|
+
estimated_output_tokens = max(1, len(content) // 4)
|
|
92
|
+
|
|
93
|
+
# Add some randomness but keep it realistic
|
|
94
|
+
output_tokens = random.randint(
|
|
95
|
+
max(1, estimated_output_tokens - 10), estimated_output_tokens + 20
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Input tokens based on typical request sizes (10-500 range)
|
|
99
|
+
input_tokens = random.randint(10, 500)
|
|
100
|
+
|
|
101
|
+
return content, input_tokens, output_tokens
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PayloadBuilder:
|
|
105
|
+
"""Build request payloads for different API formats."""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self.content_generator = MessageContentGenerator()
|
|
109
|
+
|
|
110
|
+
def build_anthropic_payload(self, scenario: RequestScenario) -> dict[str, Any]:
|
|
111
|
+
"""Build Anthropic format payload."""
|
|
112
|
+
payload = {
|
|
113
|
+
"model": scenario.model,
|
|
114
|
+
"messages": [
|
|
115
|
+
{
|
|
116
|
+
"role": "user",
|
|
117
|
+
"content": self.content_generator.get_request_message_content(
|
|
118
|
+
scenario.message_type
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
],
|
|
122
|
+
"stream": scenario.streaming,
|
|
123
|
+
"max_tokens": random.randint(100, 4000), # Realistic token limits
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if scenario.message_type == "tool_use":
|
|
127
|
+
payload["tools"] = [
|
|
128
|
+
{
|
|
129
|
+
"name": "calculator",
|
|
130
|
+
"description": "Perform basic calculations",
|
|
131
|
+
"input_schema": {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"properties": {
|
|
134
|
+
"expression": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"description": "Math expression to evaluate",
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"required": ["expression"],
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
return payload
|
|
145
|
+
|
|
146
|
+
def build_openai_payload(self, scenario: RequestScenario) -> dict[str, Any]:
|
|
147
|
+
"""Build OpenAI format payload."""
|
|
148
|
+
messages = [
|
|
149
|
+
{
|
|
150
|
+
"role": "user",
|
|
151
|
+
"content": self.content_generator.get_request_message_content(
|
|
152
|
+
scenario.message_type
|
|
153
|
+
),
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
payload = {
|
|
158
|
+
"model": scenario.model,
|
|
159
|
+
"messages": messages,
|
|
160
|
+
"stream": scenario.streaming,
|
|
161
|
+
"max_tokens": random.randint(100, 4000), # Realistic token limits
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if scenario.message_type == "tool_use":
|
|
165
|
+
payload["tools"] = [
|
|
166
|
+
{
|
|
167
|
+
"type": "function",
|
|
168
|
+
"function": {
|
|
169
|
+
"name": "calculator",
|
|
170
|
+
"description": "Perform basic calculations",
|
|
171
|
+
"parameters": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"expression": {
|
|
175
|
+
"type": "string",
|
|
176
|
+
"description": "Math expression to evaluate",
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"required": ["expression"],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
return payload
|
|
186
|
+
|
|
187
|
+
def build_payload(self, scenario: RequestScenario) -> dict[str, Any]:
|
|
188
|
+
"""Build request payload based on scenario format."""
|
|
189
|
+
# Use custom payload if provided
|
|
190
|
+
if scenario.custom_payload:
|
|
191
|
+
return scenario.custom_payload
|
|
192
|
+
|
|
193
|
+
# Build format-specific payload
|
|
194
|
+
if scenario.api_format == "openai":
|
|
195
|
+
return self.build_openai_payload(scenario)
|
|
196
|
+
else:
|
|
197
|
+
return self.build_anthropic_payload(scenario)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Mock response generation for realistic testing."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ccproxy.testing.config import MockResponseConfig
|
|
9
|
+
from ccproxy.testing.content_generation import MessageContentGenerator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RealisticMockResponseGenerator:
|
|
13
|
+
"""Generate realistic mock responses with proper randomization."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: MockResponseConfig | None = None):
|
|
16
|
+
self.config = config or MockResponseConfig()
|
|
17
|
+
self.content_generator: MessageContentGenerator = MessageContentGenerator()
|
|
18
|
+
|
|
19
|
+
def generate_response_content(
|
|
20
|
+
self, message_type: str, model: str
|
|
21
|
+
) -> tuple[str, int, int]:
|
|
22
|
+
"""Generate response content with realistic token counts."""
|
|
23
|
+
return self.content_generator.get_response_content(message_type, model)
|
|
24
|
+
|
|
25
|
+
def generate_cache_tokens(self) -> tuple[int, int]:
|
|
26
|
+
"""Generate realistic cache token counts."""
|
|
27
|
+
if random.random() < self.config.cache_token_probability:
|
|
28
|
+
cache_read = random.randint(*self.config.cache_read_range)
|
|
29
|
+
cache_write = random.randint(*self.config.cache_write_range)
|
|
30
|
+
return cache_read, cache_write
|
|
31
|
+
return 0, 0
|
|
32
|
+
|
|
33
|
+
def should_simulate_error(self) -> bool:
|
|
34
|
+
"""Determine if this response should be an error."""
|
|
35
|
+
return (
|
|
36
|
+
self.config.simulate_errors
|
|
37
|
+
and random.random() < self.config.error_probability
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def generate_error_response(self, api_format: str) -> tuple[dict[str, Any], int]:
|
|
41
|
+
"""Generate realistic error response."""
|
|
42
|
+
error_types = [
|
|
43
|
+
{
|
|
44
|
+
"type": "rate_limit_error",
|
|
45
|
+
"message": "Rate limit exceeded. Please try again later.",
|
|
46
|
+
"status_code": 429,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"type": "invalid_request_error",
|
|
50
|
+
"message": "Invalid request format.",
|
|
51
|
+
"status_code": 400,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "overloaded_error",
|
|
55
|
+
"message": "Service temporarily overloaded.",
|
|
56
|
+
"status_code": 503,
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
error = random.choice(error_types)
|
|
61
|
+
status_code: int = error["status_code"] # type: ignore[assignment]
|
|
62
|
+
|
|
63
|
+
if api_format == "openai":
|
|
64
|
+
return {
|
|
65
|
+
"error": {
|
|
66
|
+
"message": error["message"],
|
|
67
|
+
"type": error["type"],
|
|
68
|
+
"code": error["type"],
|
|
69
|
+
}
|
|
70
|
+
}, status_code
|
|
71
|
+
else:
|
|
72
|
+
return {
|
|
73
|
+
"type": "error",
|
|
74
|
+
"error": {"type": error["type"], "message": error["message"]},
|
|
75
|
+
}, status_code
|
|
76
|
+
|
|
77
|
+
def generate_realistic_anthropic_stream(
|
|
78
|
+
self,
|
|
79
|
+
request_id: str,
|
|
80
|
+
model: str,
|
|
81
|
+
content: str,
|
|
82
|
+
input_tokens: int,
|
|
83
|
+
output_tokens: int,
|
|
84
|
+
cache_read_tokens: int,
|
|
85
|
+
cache_write_tokens: int,
|
|
86
|
+
) -> list[dict[str, Any]]:
|
|
87
|
+
"""Generate realistic Anthropic streaming chunks."""
|
|
88
|
+
|
|
89
|
+
chunks = []
|
|
90
|
+
|
|
91
|
+
# Message start
|
|
92
|
+
chunks.append(
|
|
93
|
+
{
|
|
94
|
+
"type": "message_start",
|
|
95
|
+
"message": {
|
|
96
|
+
"id": request_id,
|
|
97
|
+
"type": "message",
|
|
98
|
+
"role": "assistant",
|
|
99
|
+
"content": [],
|
|
100
|
+
"model": model,
|
|
101
|
+
"stop_reason": None,
|
|
102
|
+
"stop_sequence": None,
|
|
103
|
+
"usage": {"input_tokens": input_tokens, "output_tokens": 0},
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Content block start
|
|
109
|
+
chunk_start: dict[str, Any] = {
|
|
110
|
+
"type": "content_block_start",
|
|
111
|
+
"index": 0,
|
|
112
|
+
"content_block": {"type": "text", "text": ""},
|
|
113
|
+
}
|
|
114
|
+
chunks.append(chunk_start)
|
|
115
|
+
|
|
116
|
+
# Split content into realistic chunks (by words)
|
|
117
|
+
words = content.split()
|
|
118
|
+
chunk_sizes = []
|
|
119
|
+
|
|
120
|
+
# Generate realistic chunk sizes
|
|
121
|
+
i = 0
|
|
122
|
+
while i < len(words):
|
|
123
|
+
# Random chunk size between 1-5 words
|
|
124
|
+
chunk_size = random.randint(1, min(5, len(words) - i))
|
|
125
|
+
chunk_sizes.append(chunk_size)
|
|
126
|
+
i += chunk_size
|
|
127
|
+
|
|
128
|
+
# Generate content deltas
|
|
129
|
+
word_index = 0
|
|
130
|
+
for chunk_size in chunk_sizes:
|
|
131
|
+
chunk_words = words[word_index : word_index + chunk_size]
|
|
132
|
+
chunk_text = (
|
|
133
|
+
" " + " ".join(chunk_words) if word_index > 0 else " ".join(chunk_words)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
chunk_delta: dict[str, Any] = {
|
|
137
|
+
"type": "content_block_delta",
|
|
138
|
+
"index": 0,
|
|
139
|
+
"delta": {"type": "text_delta", "text": chunk_text},
|
|
140
|
+
}
|
|
141
|
+
chunks.append(chunk_delta)
|
|
142
|
+
word_index += chunk_size
|
|
143
|
+
|
|
144
|
+
# Content block stop
|
|
145
|
+
chunk_stop: dict[str, Any] = {"type": "content_block_stop", "index": 0}
|
|
146
|
+
chunks.append(chunk_stop)
|
|
147
|
+
|
|
148
|
+
# Message delta with final usage
|
|
149
|
+
chunks.append(
|
|
150
|
+
{
|
|
151
|
+
"type": "message_delta",
|
|
152
|
+
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
|
153
|
+
"usage": {
|
|
154
|
+
"output_tokens": output_tokens,
|
|
155
|
+
"cache_creation_input_tokens": cache_write_tokens,
|
|
156
|
+
"cache_read_input_tokens": cache_read_tokens,
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Message stop
|
|
162
|
+
chunks.append({"type": "message_stop"})
|
|
163
|
+
|
|
164
|
+
return chunks
|
|
165
|
+
|
|
166
|
+
def generate_realistic_openai_stream(
|
|
167
|
+
self,
|
|
168
|
+
request_id: str,
|
|
169
|
+
model: str,
|
|
170
|
+
content: str,
|
|
171
|
+
input_tokens: int,
|
|
172
|
+
output_tokens: int,
|
|
173
|
+
) -> list[dict[str, Any]]:
|
|
174
|
+
"""Generate realistic OpenAI streaming chunks by converting Anthropic format."""
|
|
175
|
+
|
|
176
|
+
# Generate Anthropic chunks first
|
|
177
|
+
anthropic_chunks = self.generate_realistic_anthropic_stream(
|
|
178
|
+
request_id, model, content, input_tokens, output_tokens, 0, 0
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Convert to OpenAI format
|
|
182
|
+
openai_chunks = []
|
|
183
|
+
for chunk in anthropic_chunks:
|
|
184
|
+
# Use simplified conversion logic
|
|
185
|
+
if chunk.get("type") == "message_start":
|
|
186
|
+
openai_chunks.append(
|
|
187
|
+
{
|
|
188
|
+
"id": f"chatcmpl-{request_id}",
|
|
189
|
+
"object": "chat.completion.chunk",
|
|
190
|
+
"created": int(time.time()),
|
|
191
|
+
"model": model,
|
|
192
|
+
"choices": [
|
|
193
|
+
{
|
|
194
|
+
"index": 0,
|
|
195
|
+
"delta": {"role": "assistant", "content": ""},
|
|
196
|
+
"finish_reason": None,
|
|
197
|
+
}
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
elif chunk.get("type") == "content_block_delta":
|
|
202
|
+
delta_text = chunk.get("delta", {}).get("text", "")
|
|
203
|
+
openai_chunks.append(
|
|
204
|
+
{
|
|
205
|
+
"id": f"chatcmpl-{request_id}",
|
|
206
|
+
"object": "chat.completion.chunk",
|
|
207
|
+
"created": int(time.time()),
|
|
208
|
+
"model": model,
|
|
209
|
+
"choices": [
|
|
210
|
+
{
|
|
211
|
+
"index": 0,
|
|
212
|
+
"delta": {"content": delta_text},
|
|
213
|
+
"finish_reason": None,
|
|
214
|
+
}
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
elif chunk.get("type") == "message_stop":
|
|
219
|
+
openai_chunks.append(
|
|
220
|
+
{
|
|
221
|
+
"id": f"chatcmpl-{request_id}",
|
|
222
|
+
"object": "chat.completion.chunk",
|
|
223
|
+
"created": int(time.time()),
|
|
224
|
+
"model": model,
|
|
225
|
+
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return openai_chunks
|
|
230
|
+
|
|
231
|
+
def calculate_realistic_cost(
|
|
232
|
+
self,
|
|
233
|
+
input_tokens: int,
|
|
234
|
+
output_tokens: int,
|
|
235
|
+
model: str,
|
|
236
|
+
cache_read_tokens: int,
|
|
237
|
+
cache_write_tokens: int,
|
|
238
|
+
) -> float:
|
|
239
|
+
"""Calculate realistic cost based on current Claude pricing."""
|
|
240
|
+
|
|
241
|
+
# Simplified pricing (should use actual cost calculator)
|
|
242
|
+
if "sonnet" in model.lower():
|
|
243
|
+
input_cost_per_token = 0.000003 # $3 per million tokens
|
|
244
|
+
output_cost_per_token = 0.000015 # $15 per million tokens
|
|
245
|
+
elif "haiku" in model.lower():
|
|
246
|
+
input_cost_per_token = 0.00000025 # $0.25 per million tokens
|
|
247
|
+
output_cost_per_token = 0.00000125 # $1.25 per million tokens
|
|
248
|
+
else:
|
|
249
|
+
input_cost_per_token = 0.000003
|
|
250
|
+
output_cost_per_token = 0.000015
|
|
251
|
+
|
|
252
|
+
base_cost = (
|
|
253
|
+
input_tokens * input_cost_per_token + output_tokens * output_cost_per_token
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Cache costs (typically lower)
|
|
257
|
+
cache_cost = (
|
|
258
|
+
cache_read_tokens * input_cost_per_token * 0.1 # 10% of input cost
|
|
259
|
+
+ cache_write_tokens * input_cost_per_token * 0.5 # 50% of input cost
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return round(base_cost + cache_cost, 6)
|