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.
Files changed (80) hide show
  1. app/__init__.py +5 -0
  2. app/cli.py +24 -0
  3. app/core/__init__.py +0 -0
  4. app/core/auth/__init__.py +96 -0
  5. app/core/auth/models.py +49 -0
  6. app/core/auth/refresh.py +144 -0
  7. app/core/balancer/__init__.py +19 -0
  8. app/core/balancer/logic.py +140 -0
  9. app/core/balancer/types.py +9 -0
  10. app/core/clients/__init__.py +0 -0
  11. app/core/clients/http.py +39 -0
  12. app/core/clients/oauth.py +340 -0
  13. app/core/clients/proxy.py +265 -0
  14. app/core/clients/usage.py +143 -0
  15. app/core/config/__init__.py +0 -0
  16. app/core/config/settings.py +69 -0
  17. app/core/crypto.py +37 -0
  18. app/core/errors.py +73 -0
  19. app/core/openai/__init__.py +0 -0
  20. app/core/openai/models.py +122 -0
  21. app/core/openai/parsing.py +55 -0
  22. app/core/openai/requests.py +59 -0
  23. app/core/types.py +4 -0
  24. app/core/usage/__init__.py +185 -0
  25. app/core/usage/logs.py +57 -0
  26. app/core/usage/models.py +35 -0
  27. app/core/usage/pricing.py +172 -0
  28. app/core/usage/types.py +95 -0
  29. app/core/utils/__init__.py +0 -0
  30. app/core/utils/request_id.py +30 -0
  31. app/core/utils/retry.py +16 -0
  32. app/core/utils/sse.py +13 -0
  33. app/core/utils/time.py +19 -0
  34. app/db/__init__.py +0 -0
  35. app/db/models.py +82 -0
  36. app/db/session.py +44 -0
  37. app/dependencies.py +123 -0
  38. app/main.py +124 -0
  39. app/modules/__init__.py +0 -0
  40. app/modules/accounts/__init__.py +0 -0
  41. app/modules/accounts/api.py +81 -0
  42. app/modules/accounts/repository.py +80 -0
  43. app/modules/accounts/schemas.py +66 -0
  44. app/modules/accounts/service.py +211 -0
  45. app/modules/health/__init__.py +0 -0
  46. app/modules/health/api.py +10 -0
  47. app/modules/oauth/__init__.py +0 -0
  48. app/modules/oauth/api.py +57 -0
  49. app/modules/oauth/schemas.py +32 -0
  50. app/modules/oauth/service.py +356 -0
  51. app/modules/oauth/templates/oauth_success.html +122 -0
  52. app/modules/proxy/__init__.py +0 -0
  53. app/modules/proxy/api.py +76 -0
  54. app/modules/proxy/auth_manager.py +51 -0
  55. app/modules/proxy/load_balancer.py +208 -0
  56. app/modules/proxy/schemas.py +85 -0
  57. app/modules/proxy/service.py +707 -0
  58. app/modules/proxy/types.py +37 -0
  59. app/modules/proxy/usage_updater.py +147 -0
  60. app/modules/request_logs/__init__.py +0 -0
  61. app/modules/request_logs/api.py +31 -0
  62. app/modules/request_logs/repository.py +86 -0
  63. app/modules/request_logs/schemas.py +25 -0
  64. app/modules/request_logs/service.py +77 -0
  65. app/modules/shared/__init__.py +0 -0
  66. app/modules/shared/schemas.py +8 -0
  67. app/modules/usage/__init__.py +0 -0
  68. app/modules/usage/api.py +31 -0
  69. app/modules/usage/repository.py +113 -0
  70. app/modules/usage/schemas.py +62 -0
  71. app/modules/usage/service.py +246 -0
  72. app/static/7.css +1336 -0
  73. app/static/index.css +543 -0
  74. app/static/index.html +457 -0
  75. app/static/index.js +1898 -0
  76. codex_lb-0.1.2.dist-info/METADATA +108 -0
  77. codex_lb-0.1.2.dist-info/RECORD +80 -0
  78. codex_lb-0.1.2.dist-info/WHEEL +4 -0
  79. codex_lb-0.1.2.dist-info/entry_points.txt +2 -0
  80. 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
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ type JsonValue = bool | int | float | str | None | list[JsonValue] | dict[str, JsonValue]
4
+ type JsonObject = dict[str, JsonValue]