codex-lb 0.1.2__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.
- app/__init__.py +5 -0
- app/cli.py +24 -0
- app/core/__init__.py +0 -0
- app/core/auth/__init__.py +96 -0
- app/core/auth/models.py +49 -0
- app/core/auth/refresh.py +144 -0
- app/core/balancer/__init__.py +19 -0
- app/core/balancer/logic.py +140 -0
- app/core/balancer/types.py +9 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/http.py +39 -0
- app/core/clients/oauth.py +340 -0
- app/core/clients/proxy.py +265 -0
- app/core/clients/usage.py +143 -0
- app/core/config/__init__.py +0 -0
- app/core/config/settings.py +69 -0
- app/core/crypto.py +37 -0
- app/core/errors.py +73 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/models.py +122 -0
- app/core/openai/parsing.py +55 -0
- app/core/openai/requests.py +59 -0
- app/core/types.py +4 -0
- app/core/usage/__init__.py +185 -0
- app/core/usage/logs.py +57 -0
- app/core/usage/models.py +35 -0
- app/core/usage/pricing.py +172 -0
- app/core/usage/types.py +95 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/request_id.py +30 -0
- app/core/utils/retry.py +16 -0
- app/core/utils/sse.py +13 -0
- app/core/utils/time.py +19 -0
- app/db/__init__.py +0 -0
- app/db/models.py +82 -0
- app/db/session.py +44 -0
- app/dependencies.py +123 -0
- app/main.py +124 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +81 -0
- app/modules/accounts/repository.py +80 -0
- app/modules/accounts/schemas.py +66 -0
- app/modules/accounts/service.py +211 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +10 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +57 -0
- app/modules/oauth/schemas.py +32 -0
- app/modules/oauth/service.py +356 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/api.py +76 -0
- app/modules/proxy/auth_manager.py +51 -0
- app/modules/proxy/load_balancer.py +208 -0
- app/modules/proxy/schemas.py +85 -0
- app/modules/proxy/service.py +707 -0
- app/modules/proxy/types.py +37 -0
- app/modules/proxy/usage_updater.py +147 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +31 -0
- app/modules/request_logs/repository.py +86 -0
- app/modules/request_logs/schemas.py +25 -0
- app/modules/request_logs/service.py +77 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +8 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/api.py +31 -0
- app/modules/usage/repository.py +113 -0
- app/modules/usage/schemas.py +62 -0
- app/modules/usage/service.py +246 -0
- app/static/7.css +1336 -0
- app/static/index.css +543 -0
- app/static/index.html +457 -0
- app/static/index.js +1898 -0
- codex_lb-0.1.2.dist-info/METADATA +108 -0
- codex_lb-0.1.2.dist-info/RECORD +80 -0
- codex_lb-0.1.2.dist-info/WHEEL +4 -0
- codex_lb-0.1.2.dist-info/entry_points.txt +2 -0
- codex_lb-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from aiohttp_retry import ExponentialRetry, RetryClient
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
9
|
+
|
|
10
|
+
from app.core.clients.http import get_http_client
|
|
11
|
+
from app.core.config.settings import get_settings
|
|
12
|
+
from app.core.types import JsonObject
|
|
13
|
+
from app.core.usage.models import UsagePayload
|
|
14
|
+
from app.core.utils.request_id import get_request_id
|
|
15
|
+
|
|
16
|
+
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
|
|
17
|
+
RETRY_START_TIMEOUT = 0.5
|
|
18
|
+
RETRY_MAX_TIMEOUT = 2.0
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UsageErrorDetail(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="ignore")
|
|
25
|
+
|
|
26
|
+
message: str | None = None
|
|
27
|
+
error_description: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UsageErrorEnvelope(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="ignore")
|
|
32
|
+
|
|
33
|
+
error: UsageErrorDetail | str | None = None
|
|
34
|
+
error_description: str | None = None
|
|
35
|
+
message: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UsageFetchError(Exception):
|
|
39
|
+
def __init__(self, status_code: int, message: str) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.status_code = status_code
|
|
42
|
+
self.message = message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def fetch_usage(
|
|
46
|
+
*,
|
|
47
|
+
access_token: str,
|
|
48
|
+
account_id: str | None,
|
|
49
|
+
base_url: str | None = None,
|
|
50
|
+
timeout_seconds: float | None = None,
|
|
51
|
+
max_retries: int | None = None,
|
|
52
|
+
client: RetryClient | None = None,
|
|
53
|
+
) -> UsagePayload:
|
|
54
|
+
settings = get_settings()
|
|
55
|
+
usage_base = base_url or settings.upstream_base_url
|
|
56
|
+
url = _usage_url(usage_base)
|
|
57
|
+
timeout = aiohttp.ClientTimeout(total=timeout_seconds or settings.usage_fetch_timeout_seconds)
|
|
58
|
+
retries = max_retries if max_retries is not None else settings.usage_fetch_max_retries
|
|
59
|
+
headers = _usage_headers(access_token, account_id)
|
|
60
|
+
retry_client = client or get_http_client().retry_client
|
|
61
|
+
retry_options = _retry_options(retries + 1)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
async with retry_client.request(
|
|
65
|
+
"GET",
|
|
66
|
+
url,
|
|
67
|
+
headers=headers,
|
|
68
|
+
timeout=timeout,
|
|
69
|
+
retry_options=retry_options,
|
|
70
|
+
) as resp:
|
|
71
|
+
data = await _safe_json(resp)
|
|
72
|
+
if resp.status >= 400:
|
|
73
|
+
message = _extract_error_message(data) or f"Usage fetch failed ({resp.status})"
|
|
74
|
+
logger.warning(
|
|
75
|
+
"Usage fetch failed request_id=%s status=%s message=%s",
|
|
76
|
+
get_request_id(),
|
|
77
|
+
resp.status,
|
|
78
|
+
message,
|
|
79
|
+
)
|
|
80
|
+
raise UsageFetchError(resp.status, message)
|
|
81
|
+
try:
|
|
82
|
+
return UsagePayload.model_validate(data)
|
|
83
|
+
except ValidationError as exc:
|
|
84
|
+
logger.warning(
|
|
85
|
+
"Usage fetch invalid payload request_id=%s",
|
|
86
|
+
get_request_id(),
|
|
87
|
+
)
|
|
88
|
+
raise UsageFetchError(502, "Invalid usage payload") from exc
|
|
89
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
|
90
|
+
logger.warning(
|
|
91
|
+
"Usage fetch error request_id=%s error=%s",
|
|
92
|
+
get_request_id(),
|
|
93
|
+
exc,
|
|
94
|
+
)
|
|
95
|
+
raise UsageFetchError(0, f"Usage fetch failed: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _usage_url(base_url: str) -> str:
|
|
99
|
+
normalized = base_url.rstrip("/")
|
|
100
|
+
if "/backend-api" not in normalized:
|
|
101
|
+
normalized = f"{normalized}/backend-api"
|
|
102
|
+
return f"{normalized}/wham/usage"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _usage_headers(access_token: str, account_id: str | None) -> dict[str, str]:
|
|
106
|
+
headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
|
|
107
|
+
request_id = get_request_id()
|
|
108
|
+
if request_id:
|
|
109
|
+
headers["x-request-id"] = request_id
|
|
110
|
+
if account_id and not account_id.startswith(("email_", "local_")):
|
|
111
|
+
headers["chatgpt-account-id"] = account_id
|
|
112
|
+
return headers
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _safe_json(resp: aiohttp.ClientResponse) -> JsonObject:
|
|
116
|
+
try:
|
|
117
|
+
data = await resp.json(content_type=None)
|
|
118
|
+
except Exception:
|
|
119
|
+
text = await resp.text()
|
|
120
|
+
return {"error": {"message": text.strip()}}
|
|
121
|
+
return data if isinstance(data, dict) else {"error": {"message": str(data)}}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _extract_error_message(payload: JsonObject) -> str | None:
|
|
125
|
+
envelope = UsageErrorEnvelope.model_validate(payload)
|
|
126
|
+
error = envelope.error
|
|
127
|
+
if isinstance(error, UsageErrorDetail):
|
|
128
|
+
return error.message or error.error_description
|
|
129
|
+
if isinstance(error, str):
|
|
130
|
+
return envelope.error_description or error
|
|
131
|
+
return envelope.message
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _retry_options(attempts: int) -> ExponentialRetry:
|
|
135
|
+
return ExponentialRetry(
|
|
136
|
+
attempts=attempts,
|
|
137
|
+
start_timeout=RETRY_START_TIMEOUT,
|
|
138
|
+
max_timeout=RETRY_MAX_TIMEOUT,
|
|
139
|
+
factor=2.0,
|
|
140
|
+
statuses=RETRYABLE_STATUS,
|
|
141
|
+
exceptions={aiohttp.ClientError, asyncio.TimeoutError},
|
|
142
|
+
retry_all_server_errors=False,
|
|
143
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import field_validator
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
BASE_DIR = Path(__file__).resolve().parents[3]
|
|
10
|
+
|
|
11
|
+
DEFAULT_HOME_DIR = Path.home() / ".codex-lb"
|
|
12
|
+
DEFAULT_DB_PATH = DEFAULT_HOME_DIR / "store.db"
|
|
13
|
+
DEFAULT_ENCRYPTION_KEY_FILE = DEFAULT_HOME_DIR / "encryption.key"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings(BaseSettings):
|
|
17
|
+
model_config = SettingsConfigDict(
|
|
18
|
+
env_prefix="CODEX_LB_",
|
|
19
|
+
env_file=(BASE_DIR / ".env", BASE_DIR / ".env.local"),
|
|
20
|
+
env_file_encoding="utf-8",
|
|
21
|
+
extra="ignore",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
database_url: str = f"sqlite+aiosqlite:///{DEFAULT_DB_PATH}"
|
|
25
|
+
upstream_base_url: str = "https://chatgpt.com/backend-api"
|
|
26
|
+
upstream_connect_timeout_seconds: float = 30.0
|
|
27
|
+
stream_idle_timeout_seconds: float = 300.0
|
|
28
|
+
auth_base_url: str = "https://auth.openai.com"
|
|
29
|
+
oauth_client_id: str = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
30
|
+
oauth_scope: str = "openid profile email"
|
|
31
|
+
oauth_timeout_seconds: float = 30.0
|
|
32
|
+
oauth_redirect_uri: str = "http://localhost:1455/auth/callback"
|
|
33
|
+
oauth_callback_host: str = "127.0.0.1"
|
|
34
|
+
oauth_callback_port: int = 1455 # Do not change the port. OpenAI dislikes changes.
|
|
35
|
+
token_refresh_timeout_seconds: float = 30.0
|
|
36
|
+
token_refresh_interval_days: int = 8
|
|
37
|
+
usage_fetch_timeout_seconds: float = 10.0
|
|
38
|
+
usage_fetch_max_retries: int = 2
|
|
39
|
+
usage_refresh_enabled: bool = True
|
|
40
|
+
usage_refresh_interval_seconds: int = 60
|
|
41
|
+
encryption_key_file: Path = DEFAULT_ENCRYPTION_KEY_FILE
|
|
42
|
+
|
|
43
|
+
@field_validator("database_url")
|
|
44
|
+
@classmethod
|
|
45
|
+
def _normalize_database_url(cls, value: str) -> str:
|
|
46
|
+
if not isinstance(value, str):
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
for prefix in ("sqlite+aiosqlite:///", "sqlite:///"):
|
|
50
|
+
if value.startswith(prefix):
|
|
51
|
+
path = value[len(prefix) :]
|
|
52
|
+
if path.startswith("~"):
|
|
53
|
+
expanded = str(Path(path).expanduser())
|
|
54
|
+
return f"{prefix}{expanded}"
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
@field_validator("encryption_key_file", mode="before")
|
|
58
|
+
@classmethod
|
|
59
|
+
def _normalize_encryption_key_file(cls, value: object) -> Path:
|
|
60
|
+
if isinstance(value, Path):
|
|
61
|
+
return value.expanduser()
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
return Path(value).expanduser()
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@lru_cache(maxsize=1)
|
|
68
|
+
def get_settings() -> Settings:
|
|
69
|
+
return Settings()
|
app/core/crypto.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from cryptography.fernet import Fernet
|
|
6
|
+
|
|
7
|
+
from app.core.config.settings import get_settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_or_create_key(key_file: Path) -> bytes:
|
|
11
|
+
key_file.parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
if key_file.exists():
|
|
13
|
+
return key_file.read_bytes()
|
|
14
|
+
key = Fernet.generate_key()
|
|
15
|
+
key_file.write_bytes(key)
|
|
16
|
+
key_file.chmod(0o600)
|
|
17
|
+
return key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TokenEncryptor:
|
|
21
|
+
def __init__(self, key: bytes | None = None, key_file: Path | None = None) -> None:
|
|
22
|
+
settings = get_settings()
|
|
23
|
+
resolved_file = key_file or settings.encryption_key_file
|
|
24
|
+
resolved_key = key or _get_or_create_key(resolved_file)
|
|
25
|
+
self._fernet = Fernet(resolved_key)
|
|
26
|
+
|
|
27
|
+
def encrypt(self, token: str) -> bytes:
|
|
28
|
+
return self._fernet.encrypt(token.encode())
|
|
29
|
+
|
|
30
|
+
def decrypt(self, encrypted: bytes) -> str:
|
|
31
|
+
return self._fernet.decrypt(encrypted).decode()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_or_create_key(key_file: Path | None = None) -> bytes:
|
|
35
|
+
settings = get_settings()
|
|
36
|
+
resolved_file = key_file or settings.encryption_key_file
|
|
37
|
+
return _get_or_create_key(resolved_file)
|
app/core/errors.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Literal, TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OpenAIErrorDetail(TypedDict, total=False):
|
|
8
|
+
message: str
|
|
9
|
+
type: str
|
|
10
|
+
code: str
|
|
11
|
+
param: str
|
|
12
|
+
plan_type: str
|
|
13
|
+
resets_at: int | float
|
|
14
|
+
resets_in_seconds: int | float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenAIErrorEnvelope(TypedDict):
|
|
18
|
+
error: OpenAIErrorDetail
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DashboardErrorDetail(TypedDict):
|
|
22
|
+
code: str
|
|
23
|
+
message: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DashboardErrorEnvelope(TypedDict):
|
|
27
|
+
error: DashboardErrorDetail
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ResponseFailedResponse(TypedDict, total=False):
|
|
31
|
+
id: str
|
|
32
|
+
object: str
|
|
33
|
+
created_at: int
|
|
34
|
+
status: str
|
|
35
|
+
error: OpenAIErrorDetail
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ResponseFailedEvent(TypedDict):
|
|
39
|
+
type: Literal["response.failed"]
|
|
40
|
+
response: ResponseFailedResponse
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def openai_error(code: str, message: str, error_type: str = "server_error") -> OpenAIErrorEnvelope:
|
|
44
|
+
return {"error": {"message": message, "type": error_type, "code": code}}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dashboard_error(code: str, message: str) -> DashboardErrorEnvelope:
|
|
48
|
+
return {"error": {"code": code, "message": message}}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def response_failed_event(
|
|
52
|
+
code: str,
|
|
53
|
+
message: str,
|
|
54
|
+
error_type: str = "server_error",
|
|
55
|
+
response_id: str | None = None,
|
|
56
|
+
created_at: int | None = None,
|
|
57
|
+
error_param: str | None = None,
|
|
58
|
+
) -> ResponseFailedEvent:
|
|
59
|
+
error = openai_error(code, message, error_type)["error"]
|
|
60
|
+
if error_param:
|
|
61
|
+
error["param"] = error_param
|
|
62
|
+
if created_at is None:
|
|
63
|
+
created_at = int(time.time())
|
|
64
|
+
response: ResponseFailedResponse = {
|
|
65
|
+
"object": "response",
|
|
66
|
+
"status": "failed",
|
|
67
|
+
"error": error,
|
|
68
|
+
}
|
|
69
|
+
if response_id:
|
|
70
|
+
response["id"] = response_id
|
|
71
|
+
if created_at is not None:
|
|
72
|
+
response["created_at"] = created_at
|
|
73
|
+
return {"type": "response.failed", "response": response}
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import (
|
|
4
|
+
BaseModel,
|
|
5
|
+
ConfigDict,
|
|
6
|
+
StrictFloat,
|
|
7
|
+
StrictInt,
|
|
8
|
+
StrictStr,
|
|
9
|
+
ValidationError,
|
|
10
|
+
field_validator,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenAIError(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="allow")
|
|
16
|
+
|
|
17
|
+
message: StrictStr | None = None
|
|
18
|
+
type: StrictStr | None = None
|
|
19
|
+
code: StrictStr | None = None
|
|
20
|
+
param: StrictStr | None = None
|
|
21
|
+
plan_type: StrictStr | None = None
|
|
22
|
+
resets_at: StrictInt | StrictFloat | None = None
|
|
23
|
+
resets_in_seconds: StrictInt | StrictFloat | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenAIErrorEnvelope(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="ignore")
|
|
28
|
+
|
|
29
|
+
error: OpenAIError | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResponseUsageDetails(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="ignore")
|
|
34
|
+
|
|
35
|
+
cached_tokens: StrictInt | None = None
|
|
36
|
+
reasoning_tokens: StrictInt | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ResponseUsage(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="ignore")
|
|
41
|
+
|
|
42
|
+
input_tokens: StrictInt | None = None
|
|
43
|
+
output_tokens: StrictInt | None = None
|
|
44
|
+
total_tokens: StrictInt | None = None
|
|
45
|
+
input_tokens_details: ResponseUsageDetails | None = None
|
|
46
|
+
output_tokens_details: ResponseUsageDetails | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OpenAIResponse(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="ignore")
|
|
51
|
+
|
|
52
|
+
id: StrictStr | None = None
|
|
53
|
+
status: StrictStr | None = None
|
|
54
|
+
error: OpenAIError | None = None
|
|
55
|
+
usage: ResponseUsage | None = None
|
|
56
|
+
|
|
57
|
+
@field_validator("error", mode="before")
|
|
58
|
+
@classmethod
|
|
59
|
+
def _normalize_error(cls, value: object) -> OpenAIError | None:
|
|
60
|
+
if value is None:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return OpenAIError.model_validate(value)
|
|
64
|
+
except ValidationError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
@field_validator("usage", mode="before")
|
|
68
|
+
@classmethod
|
|
69
|
+
def _normalize_usage(cls, value: object) -> ResponseUsage | None:
|
|
70
|
+
if value is None:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
return ResponseUsage.model_validate(value)
|
|
74
|
+
except ValidationError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class OpenAIEvent(BaseModel):
|
|
79
|
+
model_config = ConfigDict(extra="ignore")
|
|
80
|
+
|
|
81
|
+
type: StrictStr
|
|
82
|
+
response: OpenAIResponse | None = None
|
|
83
|
+
error: OpenAIError | None = None
|
|
84
|
+
|
|
85
|
+
@field_validator("error", mode="before")
|
|
86
|
+
@classmethod
|
|
87
|
+
def _normalize_error(cls, value: object) -> OpenAIError | None:
|
|
88
|
+
if value is None:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return OpenAIError.model_validate(value)
|
|
92
|
+
except ValidationError:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class OpenAIResponsePayload(BaseModel):
|
|
97
|
+
model_config = ConfigDict(extra="allow")
|
|
98
|
+
|
|
99
|
+
id: StrictStr | None = None
|
|
100
|
+
status: StrictStr | None = None
|
|
101
|
+
error: OpenAIError | None = None
|
|
102
|
+
usage: ResponseUsage | None = None
|
|
103
|
+
|
|
104
|
+
@field_validator("error", mode="before")
|
|
105
|
+
@classmethod
|
|
106
|
+
def _normalize_error(cls, value: object) -> OpenAIError | None:
|
|
107
|
+
if value is None:
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
return OpenAIError.model_validate(value)
|
|
111
|
+
except ValidationError:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
@field_validator("usage", mode="before")
|
|
115
|
+
@classmethod
|
|
116
|
+
def _normalize_usage(cls, value: object) -> ResponseUsage | None:
|
|
117
|
+
if value is None:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return ResponseUsage.model_validate(value)
|
|
121
|
+
except ValidationError:
|
|
122
|
+
return None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from pydantic import TypeAdapter, ValidationError
|
|
6
|
+
|
|
7
|
+
from app.core.openai.models import OpenAIError, OpenAIErrorEnvelope, OpenAIEvent, OpenAIResponsePayload
|
|
8
|
+
|
|
9
|
+
_EVENT_ADAPTER = TypeAdapter(OpenAIEvent)
|
|
10
|
+
_ERROR_ADAPTER = TypeAdapter(OpenAIErrorEnvelope)
|
|
11
|
+
_RESPONSE_ADAPTER = TypeAdapter(OpenAIResponsePayload)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_sse_event(line: str) -> OpenAIEvent | None:
|
|
15
|
+
data = None
|
|
16
|
+
if line.startswith("data:"):
|
|
17
|
+
data = line[5:].strip()
|
|
18
|
+
elif "\n" in line:
|
|
19
|
+
for part in line.splitlines():
|
|
20
|
+
if part.startswith("data:"):
|
|
21
|
+
data = part[5:].strip()
|
|
22
|
+
break
|
|
23
|
+
if data is None:
|
|
24
|
+
return None
|
|
25
|
+
if not data or data == "[DONE]":
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
payload = json.loads(data)
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
return None
|
|
31
|
+
if not isinstance(payload, dict):
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return _EVENT_ADAPTER.validate_python(payload)
|
|
35
|
+
except ValidationError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_error_payload(payload: object) -> OpenAIError | None:
|
|
40
|
+
if not isinstance(payload, dict):
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
envelope = _ERROR_ADAPTER.validate_python(payload)
|
|
44
|
+
except ValidationError:
|
|
45
|
+
return None
|
|
46
|
+
return envelope.error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_response_payload(payload: object) -> OpenAIResponsePayload | None:
|
|
50
|
+
if not isinstance(payload, dict):
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return _RESPONSE_ADAPTER.validate_python(payload)
|
|
54
|
+
except ValidationError:
|
|
55
|
+
return None
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
from app.core.types import JsonObject, JsonValue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ResponsesReasoning(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="allow")
|
|
10
|
+
|
|
11
|
+
effort: str | None = None
|
|
12
|
+
summary: str | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResponsesTextFormat(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True, serialize_by_alias=True)
|
|
17
|
+
|
|
18
|
+
type: str | None = None
|
|
19
|
+
strict: bool | None = None
|
|
20
|
+
schema_: JsonValue | None = Field(default=None, alias="schema")
|
|
21
|
+
name: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ResponsesTextControls(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="allow")
|
|
26
|
+
|
|
27
|
+
verbosity: str | None = None
|
|
28
|
+
format: ResponsesTextFormat | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResponsesRequest(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="allow")
|
|
33
|
+
|
|
34
|
+
model: str = Field(min_length=1)
|
|
35
|
+
instructions: str
|
|
36
|
+
input: list[JsonValue]
|
|
37
|
+
tools: list[JsonValue] = Field(default_factory=list)
|
|
38
|
+
tool_choice: str | None = None
|
|
39
|
+
parallel_tool_calls: bool | None = None
|
|
40
|
+
reasoning: ResponsesReasoning | None = None
|
|
41
|
+
store: bool | None = None
|
|
42
|
+
stream: bool | None = None
|
|
43
|
+
include: list[str] = Field(default_factory=list)
|
|
44
|
+
prompt_cache_key: str | None = None
|
|
45
|
+
text: ResponsesTextControls | None = None
|
|
46
|
+
|
|
47
|
+
def to_payload(self) -> JsonObject:
|
|
48
|
+
return self.model_dump(mode="json", exclude_none=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ResponsesCompactRequest(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="allow")
|
|
53
|
+
|
|
54
|
+
model: str = Field(min_length=1)
|
|
55
|
+
instructions: str
|
|
56
|
+
input: list[JsonValue]
|
|
57
|
+
|
|
58
|
+
def to_payload(self) -> JsonObject:
|
|
59
|
+
return self.model_dump(mode="json", exclude_none=True)
|
app/core/types.py
ADDED