codex-lb 0.3.1__py3-none-any.whl → 0.4.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.
- app/core/clients/proxy.py +33 -3
- app/core/config/settings.py +1 -0
- app/core/openai/requests.py +21 -3
- app/core/openai/v1_requests.py +148 -0
- app/db/models.py +3 -3
- app/main.py +1 -0
- app/modules/accounts/repository.py +4 -1
- app/modules/proxy/api.py +36 -0
- app/modules/proxy/service.py +29 -0
- app/modules/request_logs/api.py +61 -7
- app/modules/request_logs/repository.py +128 -16
- app/modules/request_logs/schemas.py +11 -2
- app/modules/request_logs/service.py +97 -20
- app/modules/usage/updater.py +58 -26
- app/static/index.css +378 -1
- app/static/index.html +183 -8
- app/static/index.js +308 -13
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/METADATA +41 -3
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/RECORD +22 -21
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/WHEEL +0 -0
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/entry_points.txt +0 -0
- {codex_lb-0.3.1.dist-info → codex_lb-0.4.0.dist-info}/licenses/LICENSE +0 -0
app/core/clients/proxy.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
from typing import AsyncIterator, Mapping
|
|
4
|
+
from typing import AsyncIterator, Mapping, Protocol, TypeAlias
|
|
5
5
|
|
|
6
6
|
import aiohttp
|
|
7
7
|
|
|
@@ -28,6 +28,18 @@ class StreamIdleTimeoutError(Exception):
|
|
|
28
28
|
pass
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
class ErrorResponseProtocol(Protocol):
|
|
32
|
+
status: int
|
|
33
|
+
reason: str | None
|
|
34
|
+
|
|
35
|
+
async def json(self, *, content_type: str | None = None) -> object: ...
|
|
36
|
+
|
|
37
|
+
async def text(self, *, encoding: str | None = None, errors: str = "strict") -> str: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ErrorResponse: TypeAlias = aiohttp.ClientResponse | ErrorResponseProtocol
|
|
41
|
+
|
|
42
|
+
|
|
31
43
|
class ProxyResponseError(Exception):
|
|
32
44
|
def __init__(self, status_code: int, payload: OpenAIErrorEnvelope) -> None:
|
|
33
45
|
super().__init__(f"Proxy response error ({status_code})")
|
|
@@ -88,8 +100,10 @@ async def _iter_sse_lines(
|
|
|
88
100
|
yield line
|
|
89
101
|
|
|
90
102
|
|
|
91
|
-
async def _error_event_from_response(resp:
|
|
103
|
+
async def _error_event_from_response(resp: ErrorResponse) -> ResponseFailedEvent:
|
|
92
104
|
fallback_message = f"Upstream error: HTTP {resp.status}"
|
|
105
|
+
if resp.reason:
|
|
106
|
+
fallback_message += f" {resp.reason}"
|
|
93
107
|
try:
|
|
94
108
|
data = await resp.json(content_type=None)
|
|
95
109
|
except Exception:
|
|
@@ -112,11 +126,16 @@ async def _error_event_from_response(resp: aiohttp.ClientResponse) -> ResponseFa
|
|
|
112
126
|
if key in payload:
|
|
113
127
|
event["response"]["error"][key] = payload[key]
|
|
114
128
|
return event
|
|
129
|
+
message = _extract_upstream_message(data)
|
|
130
|
+
if message:
|
|
131
|
+
return response_failed_event("upstream_error", message, response_id=get_request_id())
|
|
115
132
|
return response_failed_event("upstream_error", fallback_message, response_id=get_request_id())
|
|
116
133
|
|
|
117
134
|
|
|
118
|
-
async def _error_payload_from_response(resp:
|
|
135
|
+
async def _error_payload_from_response(resp: ErrorResponse) -> OpenAIErrorEnvelope:
|
|
119
136
|
fallback_message = f"Upstream error: HTTP {resp.status}"
|
|
137
|
+
if resp.reason:
|
|
138
|
+
fallback_message += f" {resp.reason}"
|
|
120
139
|
try:
|
|
121
140
|
data = await resp.json(content_type=None)
|
|
122
141
|
except Exception:
|
|
@@ -128,9 +147,20 @@ async def _error_payload_from_response(resp: aiohttp.ClientResponse) -> OpenAIEr
|
|
|
128
147
|
error = parse_error_payload(data)
|
|
129
148
|
if error:
|
|
130
149
|
return {"error": error.model_dump(exclude_none=True)}
|
|
150
|
+
message = _extract_upstream_message(data)
|
|
151
|
+
if message:
|
|
152
|
+
return openai_error("upstream_error", message)
|
|
131
153
|
return openai_error("upstream_error", fallback_message)
|
|
132
154
|
|
|
133
155
|
|
|
156
|
+
def _extract_upstream_message(data: dict) -> str | None:
|
|
157
|
+
for key in ("message", "detail", "error"):
|
|
158
|
+
value = data.get(key)
|
|
159
|
+
if isinstance(value, str) and value.strip():
|
|
160
|
+
return value
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
134
164
|
async def stream_responses(
|
|
135
165
|
payload: ResponsesRequest,
|
|
136
166
|
headers: Mapping[str, str],
|
app/core/config/settings.py
CHANGED
|
@@ -42,6 +42,7 @@ class Settings(BaseSettings):
|
|
|
42
42
|
database_migrations_fail_fast: bool = True
|
|
43
43
|
log_proxy_request_shape: bool = False
|
|
44
44
|
log_proxy_request_shape_raw_cache_key: bool = False
|
|
45
|
+
log_proxy_request_payload: bool = False
|
|
45
46
|
|
|
46
47
|
@field_validator("database_url")
|
|
47
48
|
@classmethod
|
app/core/openai/requests.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
4
4
|
|
|
5
5
|
from app.core.types import JsonObject, JsonValue
|
|
6
6
|
|
|
@@ -44,8 +44,16 @@ class ResponsesRequest(BaseModel):
|
|
|
44
44
|
prompt_cache_key: str | None = None
|
|
45
45
|
text: ResponsesTextControls | None = None
|
|
46
46
|
|
|
47
|
+
@field_validator("store")
|
|
48
|
+
@classmethod
|
|
49
|
+
def _ensure_store_false(cls, value: bool | None) -> bool | None:
|
|
50
|
+
if value is True:
|
|
51
|
+
raise ValueError("store must be false")
|
|
52
|
+
return value
|
|
53
|
+
|
|
47
54
|
def to_payload(self) -> JsonObject:
|
|
48
|
-
|
|
55
|
+
payload = self.model_dump(mode="json", exclude_none=True)
|
|
56
|
+
return _strip_unsupported_fields(payload)
|
|
49
57
|
|
|
50
58
|
|
|
51
59
|
class ResponsesCompactRequest(BaseModel):
|
|
@@ -56,4 +64,14 @@ class ResponsesCompactRequest(BaseModel):
|
|
|
56
64
|
input: list[JsonValue]
|
|
57
65
|
|
|
58
66
|
def to_payload(self) -> JsonObject:
|
|
59
|
-
|
|
67
|
+
payload = self.model_dump(mode="json", exclude_none=True)
|
|
68
|
+
return _strip_unsupported_fields(payload)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_UNSUPPORTED_UPSTREAM_FIELDS = {"max_output_tokens"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _strip_unsupported_fields(payload: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
|
75
|
+
for key in _UNSUPPORTED_UPSTREAM_FIELDS:
|
|
76
|
+
payload.pop(key, None)
|
|
77
|
+
return payload
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
from app.core.openai.requests import (
|
|
8
|
+
ResponsesCompactRequest,
|
|
9
|
+
ResponsesReasoning,
|
|
10
|
+
ResponsesRequest,
|
|
11
|
+
ResponsesTextControls,
|
|
12
|
+
)
|
|
13
|
+
from app.core.types import JsonValue
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class V1ResponsesRequest(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="allow")
|
|
18
|
+
|
|
19
|
+
model: str = Field(min_length=1)
|
|
20
|
+
messages: list[JsonValue] | None = None
|
|
21
|
+
input: list[JsonValue] | None = None
|
|
22
|
+
instructions: str | None = None
|
|
23
|
+
tools: list[JsonValue] = Field(default_factory=list)
|
|
24
|
+
tool_choice: str | None = None
|
|
25
|
+
parallel_tool_calls: bool | None = None
|
|
26
|
+
reasoning: ResponsesReasoning | None = None
|
|
27
|
+
store: bool | None = None
|
|
28
|
+
stream: bool | None = None
|
|
29
|
+
include: list[str] = Field(default_factory=list)
|
|
30
|
+
prompt_cache_key: str | None = None
|
|
31
|
+
text: ResponsesTextControls | None = None
|
|
32
|
+
|
|
33
|
+
@field_validator("store")
|
|
34
|
+
@classmethod
|
|
35
|
+
def _ensure_store_false(cls, value: bool | None) -> bool | None:
|
|
36
|
+
if value is True:
|
|
37
|
+
raise ValueError("store must be false")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
@model_validator(mode="after")
|
|
41
|
+
def _validate_input(self) -> "V1ResponsesRequest":
|
|
42
|
+
if self.messages is None and self.input is None:
|
|
43
|
+
raise ValueError("Provide either 'input' or 'messages'.")
|
|
44
|
+
if self.messages is not None and self.input not in (None, []):
|
|
45
|
+
raise ValueError("Provide either 'input' or 'messages', not both.")
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def to_responses_request(self) -> ResponsesRequest:
|
|
49
|
+
data = self.model_dump(mode="json", exclude_none=True)
|
|
50
|
+
messages = data.pop("messages", None)
|
|
51
|
+
instructions = data.get("instructions")
|
|
52
|
+
instruction_text = instructions if isinstance(instructions, str) else ""
|
|
53
|
+
input_value = data.get("input")
|
|
54
|
+
input_items: list[JsonValue] = input_value if isinstance(input_value, list) else []
|
|
55
|
+
|
|
56
|
+
if messages is not None:
|
|
57
|
+
instruction_text, input_items = _coerce_messages(instruction_text, messages)
|
|
58
|
+
|
|
59
|
+
data["instructions"] = instruction_text
|
|
60
|
+
data["input"] = input_items
|
|
61
|
+
return ResponsesRequest.model_validate(data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class V1ResponsesCompactRequest(BaseModel):
|
|
65
|
+
model_config = ConfigDict(extra="allow")
|
|
66
|
+
|
|
67
|
+
model: str = Field(min_length=1)
|
|
68
|
+
messages: list[JsonValue] | None = None
|
|
69
|
+
input: list[JsonValue] | None = None
|
|
70
|
+
instructions: str | None = None
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def _validate_input(self) -> "V1ResponsesCompactRequest":
|
|
74
|
+
if self.messages is None and self.input is None:
|
|
75
|
+
raise ValueError("Provide either 'input' or 'messages'.")
|
|
76
|
+
if self.messages is not None and self.input not in (None, []):
|
|
77
|
+
raise ValueError("Provide either 'input' or 'messages', not both.")
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def to_compact_request(self) -> ResponsesCompactRequest:
|
|
81
|
+
data = self.model_dump(mode="json", exclude_none=True)
|
|
82
|
+
messages = data.pop("messages", None)
|
|
83
|
+
instructions = data.get("instructions")
|
|
84
|
+
instruction_text = instructions if isinstance(instructions, str) else ""
|
|
85
|
+
input_value = data.get("input")
|
|
86
|
+
input_items: list[JsonValue] = input_value if isinstance(input_value, list) else []
|
|
87
|
+
|
|
88
|
+
if messages is not None:
|
|
89
|
+
instruction_text, input_items = _coerce_messages(instruction_text, messages)
|
|
90
|
+
|
|
91
|
+
data["instructions"] = instruction_text
|
|
92
|
+
data["input"] = input_items
|
|
93
|
+
return ResponsesCompactRequest.model_validate(data)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _coerce_messages(existing_instructions: str, messages: list[JsonValue]) -> tuple[str, list[JsonValue]]:
|
|
97
|
+
instruction_parts: list[str] = []
|
|
98
|
+
input_messages: list[JsonValue] = []
|
|
99
|
+
for message in messages:
|
|
100
|
+
if not isinstance(message, dict):
|
|
101
|
+
raise ValueError("Each message must be an object.")
|
|
102
|
+
message_dict = cast(dict[str, JsonValue], message)
|
|
103
|
+
role_value = message_dict.get("role")
|
|
104
|
+
role = role_value if isinstance(role_value, str) else None
|
|
105
|
+
if role in ("system", "developer"):
|
|
106
|
+
content_text = _content_to_text(message_dict.get("content"))
|
|
107
|
+
if content_text:
|
|
108
|
+
instruction_parts.append(content_text)
|
|
109
|
+
continue
|
|
110
|
+
input_messages.append(cast(JsonValue, message_dict))
|
|
111
|
+
merged = _merge_instructions(existing_instructions, instruction_parts)
|
|
112
|
+
return merged, input_messages
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _merge_instructions(existing: str, extra_parts: list[str]) -> str:
|
|
116
|
+
if not extra_parts:
|
|
117
|
+
return existing
|
|
118
|
+
extra = "\n".join([part for part in extra_parts if part])
|
|
119
|
+
if not extra:
|
|
120
|
+
return existing
|
|
121
|
+
if existing:
|
|
122
|
+
return f"{existing}\n{extra}"
|
|
123
|
+
return extra
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _content_to_text(content: object) -> str | None:
|
|
127
|
+
if content is None:
|
|
128
|
+
return None
|
|
129
|
+
if isinstance(content, str):
|
|
130
|
+
return content
|
|
131
|
+
if isinstance(content, list):
|
|
132
|
+
parts: list[str] = []
|
|
133
|
+
for part in content:
|
|
134
|
+
if isinstance(part, str):
|
|
135
|
+
parts.append(part)
|
|
136
|
+
elif isinstance(part, dict):
|
|
137
|
+
part_dict = cast(dict[str, JsonValue], part)
|
|
138
|
+
text = part_dict.get("text")
|
|
139
|
+
if isinstance(text, str):
|
|
140
|
+
parts.append(text)
|
|
141
|
+
return "\n".join([part for part in parts if part])
|
|
142
|
+
if isinstance(content, dict):
|
|
143
|
+
content_dict = cast(dict[str, JsonValue], content)
|
|
144
|
+
text = content_dict.get("text")
|
|
145
|
+
if isinstance(text, str):
|
|
146
|
+
return text
|
|
147
|
+
return None
|
|
148
|
+
return None
|
app/db/models.py
CHANGED
|
@@ -48,7 +48,7 @@ class UsageHistory(Base):
|
|
|
48
48
|
__tablename__ = "usage_history"
|
|
49
49
|
|
|
50
50
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
51
|
-
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id"), nullable=False)
|
|
51
|
+
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
|
|
52
52
|
recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
|
53
53
|
window: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
54
54
|
used_percent: Mapped[float] = mapped_column(Float, nullable=False)
|
|
@@ -65,7 +65,7 @@ class RequestLog(Base):
|
|
|
65
65
|
__tablename__ = "request_logs"
|
|
66
66
|
|
|
67
67
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
68
|
-
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id"), nullable=False)
|
|
68
|
+
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
|
|
69
69
|
request_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
70
70
|
requested_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
|
71
71
|
model: Mapped[str] = mapped_column(String, nullable=False)
|
|
@@ -84,7 +84,7 @@ class StickySession(Base):
|
|
|
84
84
|
__tablename__ = "sticky_sessions"
|
|
85
85
|
|
|
86
86
|
key: Mapped[str] = mapped_column(String, primary_key=True)
|
|
87
|
-
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id"), nullable=False)
|
|
87
|
+
account_id: Mapped[str] = mapped_column(String, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
|
|
88
88
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
|
89
89
|
updated_at: Mapped[datetime] = mapped_column(
|
|
90
90
|
DateTime,
|
app/main.py
CHANGED
|
@@ -102,6 +102,7 @@ def create_app() -> FastAPI:
|
|
|
102
102
|
return await http_exception_handler(request, exc)
|
|
103
103
|
|
|
104
104
|
app.include_router(proxy_api.router)
|
|
105
|
+
app.include_router(proxy_api.v1_router)
|
|
105
106
|
app.include_router(proxy_api.usage_router)
|
|
106
107
|
app.include_router(accounts_api.router)
|
|
107
108
|
app.include_router(usage_api.router)
|
|
@@ -5,7 +5,7 @@ from datetime import datetime
|
|
|
5
5
|
from sqlalchemy import delete, select, update
|
|
6
6
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
7
|
|
|
8
|
-
from app.db.models import Account, AccountStatus
|
|
8
|
+
from app.db.models import Account, AccountStatus, RequestLog, StickySession, UsageHistory
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class AccountsRepository:
|
|
@@ -54,6 +54,9 @@ class AccountsRepository:
|
|
|
54
54
|
return result.scalar_one_or_none() is not None
|
|
55
55
|
|
|
56
56
|
async def delete(self, account_id: str) -> bool:
|
|
57
|
+
await self._session.execute(delete(UsageHistory).where(UsageHistory.account_id == account_id))
|
|
58
|
+
await self._session.execute(delete(RequestLog).where(RequestLog.account_id == account_id))
|
|
59
|
+
await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
|
|
57
60
|
result = await self._session.execute(delete(Account).where(Account.id == account_id).returning(Account.id))
|
|
58
61
|
await self._session.commit()
|
|
59
62
|
return result.scalar_one_or_none() is not None
|
app/modules/proxy/api.py
CHANGED
|
@@ -8,10 +8,12 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
|
|
8
8
|
from app.core.clients.proxy import ProxyResponseError
|
|
9
9
|
from app.core.errors import openai_error
|
|
10
10
|
from app.core.openai.requests import ResponsesCompactRequest, ResponsesRequest
|
|
11
|
+
from app.core.openai.v1_requests import V1ResponsesCompactRequest, V1ResponsesRequest
|
|
11
12
|
from app.dependencies import ProxyContext, get_proxy_context
|
|
12
13
|
from app.modules.proxy.schemas import RateLimitStatusPayload
|
|
13
14
|
|
|
14
15
|
router = APIRouter(prefix="/backend-api/codex", tags=["proxy"])
|
|
16
|
+
v1_router = APIRouter(prefix="/v1", tags=["proxy"])
|
|
15
17
|
usage_router = APIRouter(tags=["proxy"])
|
|
16
18
|
|
|
17
19
|
|
|
@@ -20,6 +22,23 @@ async def responses(
|
|
|
20
22
|
request: Request,
|
|
21
23
|
payload: ResponsesRequest = Body(...),
|
|
22
24
|
context: ProxyContext = Depends(get_proxy_context),
|
|
25
|
+
) -> Response:
|
|
26
|
+
return await _stream_responses(request, payload, context)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@v1_router.post("/responses")
|
|
30
|
+
async def v1_responses(
|
|
31
|
+
request: Request,
|
|
32
|
+
payload: V1ResponsesRequest = Body(...),
|
|
33
|
+
context: ProxyContext = Depends(get_proxy_context),
|
|
34
|
+
) -> Response:
|
|
35
|
+
return await _stream_responses(request, payload.to_responses_request(), context)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _stream_responses(
|
|
39
|
+
request: Request,
|
|
40
|
+
payload: ResponsesRequest,
|
|
41
|
+
context: ProxyContext,
|
|
23
42
|
) -> Response:
|
|
24
43
|
rate_limit_headers = await context.service.rate_limit_headers()
|
|
25
44
|
stream = context.service.stream_responses(
|
|
@@ -49,6 +68,23 @@ async def responses_compact(
|
|
|
49
68
|
request: Request,
|
|
50
69
|
payload: ResponsesCompactRequest = Body(...),
|
|
51
70
|
context: ProxyContext = Depends(get_proxy_context),
|
|
71
|
+
) -> JSONResponse:
|
|
72
|
+
return await _compact_responses(request, payload, context)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@v1_router.post("/responses/compact")
|
|
76
|
+
async def v1_responses_compact(
|
|
77
|
+
request: Request,
|
|
78
|
+
payload: V1ResponsesCompactRequest = Body(...),
|
|
79
|
+
context: ProxyContext = Depends(get_proxy_context),
|
|
80
|
+
) -> JSONResponse:
|
|
81
|
+
return await _compact_responses(request, payload.to_compact_request(), context)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _compact_responses(
|
|
85
|
+
request: Request,
|
|
86
|
+
payload: ResponsesCompactRequest,
|
|
87
|
+
context: ProxyContext,
|
|
52
88
|
) -> JSONResponse:
|
|
53
89
|
rate_limit_headers = await context.service.rate_limit_headers()
|
|
54
90
|
try:
|
app/modules/proxy/service.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import logging
|
|
4
5
|
import time
|
|
5
6
|
from collections.abc import Sequence
|
|
@@ -80,6 +81,7 @@ class ProxyService:
|
|
|
80
81
|
*,
|
|
81
82
|
propagate_http_errors: bool = False,
|
|
82
83
|
) -> AsyncIterator[str]:
|
|
84
|
+
_maybe_log_proxy_request_payload("stream", payload, headers)
|
|
83
85
|
_maybe_log_proxy_request_shape("stream", payload, headers)
|
|
84
86
|
filtered = filter_inbound_headers(headers)
|
|
85
87
|
return self._stream_with_retry(
|
|
@@ -93,6 +95,7 @@ class ProxyService:
|
|
|
93
95
|
payload: ResponsesCompactRequest,
|
|
94
96
|
headers: Mapping[str, str],
|
|
95
97
|
) -> OpenAIResponsePayload:
|
|
98
|
+
_maybe_log_proxy_request_payload("compact", payload, headers)
|
|
96
99
|
_maybe_log_proxy_request_shape("compact", payload, headers)
|
|
97
100
|
filtered = filter_inbound_headers(headers)
|
|
98
101
|
settings = await self._settings_repo.get_or_create()
|
|
@@ -526,6 +529,32 @@ def _maybe_log_proxy_request_shape(
|
|
|
526
529
|
)
|
|
527
530
|
|
|
528
531
|
|
|
532
|
+
def _maybe_log_proxy_request_payload(
|
|
533
|
+
kind: str,
|
|
534
|
+
payload: ResponsesRequest | ResponsesCompactRequest,
|
|
535
|
+
headers: Mapping[str, str],
|
|
536
|
+
) -> None:
|
|
537
|
+
settings = get_settings()
|
|
538
|
+
if not settings.log_proxy_request_payload:
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
request_id = get_request_id()
|
|
542
|
+
payload_dict = payload.model_dump(mode="json", exclude_none=True)
|
|
543
|
+
extra = payload.model_extra or {}
|
|
544
|
+
if extra:
|
|
545
|
+
payload_dict = {**payload_dict, "_extra": extra}
|
|
546
|
+
header_keys = _interesting_header_keys(headers)
|
|
547
|
+
payload_json = json.dumps(payload_dict, ensure_ascii=True, separators=(",", ":"))
|
|
548
|
+
|
|
549
|
+
logger.warning(
|
|
550
|
+
"proxy_request_payload request_id=%s kind=%s payload=%s headers=%s",
|
|
551
|
+
request_id,
|
|
552
|
+
kind,
|
|
553
|
+
payload_json,
|
|
554
|
+
header_keys,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
529
558
|
def _hash_identifier(value: str) -> str:
|
|
530
559
|
digest = sha256(value.encode("utf-8")).hexdigest()
|
|
531
560
|
return f"sha256:{digest[:12]}"
|
app/modules/request_logs/api.py
CHANGED
|
@@ -5,27 +5,81 @@ from datetime import datetime
|
|
|
5
5
|
from fastapi import APIRouter, Depends, Query
|
|
6
6
|
|
|
7
7
|
from app.dependencies import RequestLogsContext, get_request_logs_context
|
|
8
|
-
from app.modules.request_logs.schemas import
|
|
8
|
+
from app.modules.request_logs.schemas import (
|
|
9
|
+
RequestLogFilterOptionsResponse,
|
|
10
|
+
RequestLogModelOption,
|
|
11
|
+
RequestLogsResponse,
|
|
12
|
+
)
|
|
13
|
+
from app.modules.request_logs.service import RequestLogModelOption as ServiceRequestLogModelOption
|
|
9
14
|
|
|
10
15
|
router = APIRouter(prefix="/api/request-logs", tags=["dashboard"])
|
|
11
16
|
|
|
17
|
+
_MODEL_OPTION_DELIMITER = ":::"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_model_option(value: str) -> ServiceRequestLogModelOption | None:
|
|
21
|
+
raw = (value or "").strip()
|
|
22
|
+
if not raw:
|
|
23
|
+
return None
|
|
24
|
+
if _MODEL_OPTION_DELIMITER not in raw:
|
|
25
|
+
return ServiceRequestLogModelOption(model=raw, reasoning_effort=None)
|
|
26
|
+
model, effort = raw.split(_MODEL_OPTION_DELIMITER, 1)
|
|
27
|
+
model = model.strip()
|
|
28
|
+
effort = effort.strip()
|
|
29
|
+
if not model:
|
|
30
|
+
return None
|
|
31
|
+
return ServiceRequestLogModelOption(model=model, reasoning_effort=effort or None)
|
|
32
|
+
|
|
12
33
|
|
|
13
34
|
@router.get("", response_model=RequestLogsResponse)
|
|
14
35
|
async def list_request_logs(
|
|
15
|
-
limit: int = Query(50, ge=1, le=
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
36
|
+
limit: int = Query(50, ge=1, le=1000),
|
|
37
|
+
offset: int = Query(0, ge=0),
|
|
38
|
+
search: str | None = Query(default=None),
|
|
39
|
+
account_id: list[str] | None = Query(default=None, alias="accountId"),
|
|
40
|
+
status: list[str] | None = Query(default=None),
|
|
41
|
+
model: list[str] | None = Query(default=None),
|
|
42
|
+
reasoning_effort: list[str] | None = Query(default=None, alias="reasoningEffort"),
|
|
43
|
+
model_option: list[str] | None = Query(default=None, alias="modelOption"),
|
|
19
44
|
since: datetime | None = Query(default=None),
|
|
20
45
|
until: datetime | None = Query(default=None),
|
|
21
46
|
context: RequestLogsContext = Depends(get_request_logs_context),
|
|
22
47
|
) -> RequestLogsResponse:
|
|
48
|
+
parsed_options: list[ServiceRequestLogModelOption] | None = None
|
|
49
|
+
if model_option:
|
|
50
|
+
parsed = [_parse_model_option(value) for value in model_option]
|
|
51
|
+
parsed_options = [value for value in parsed if value is not None] or None
|
|
23
52
|
logs = await context.service.list_recent(
|
|
24
53
|
limit=limit,
|
|
54
|
+
offset=offset,
|
|
55
|
+
search=search,
|
|
25
56
|
since=since,
|
|
26
57
|
until=until,
|
|
27
|
-
|
|
28
|
-
|
|
58
|
+
account_ids=account_id,
|
|
59
|
+
model_options=parsed_options,
|
|
60
|
+
models=model,
|
|
61
|
+
reasoning_efforts=reasoning_effort,
|
|
29
62
|
status=status,
|
|
30
63
|
)
|
|
31
64
|
return RequestLogsResponse(requests=logs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/options", response_model=RequestLogFilterOptionsResponse)
|
|
68
|
+
async def list_request_log_filter_options(
|
|
69
|
+
status: list[str] | None = Query(default=None),
|
|
70
|
+
since: datetime | None = Query(default=None),
|
|
71
|
+
until: datetime | None = Query(default=None),
|
|
72
|
+
context: RequestLogsContext = Depends(get_request_logs_context),
|
|
73
|
+
) -> RequestLogFilterOptionsResponse:
|
|
74
|
+
options = await context.service.list_filter_options(
|
|
75
|
+
status=status,
|
|
76
|
+
since=since,
|
|
77
|
+
until=until,
|
|
78
|
+
)
|
|
79
|
+
return RequestLogFilterOptionsResponse(
|
|
80
|
+
account_ids=options.account_ids,
|
|
81
|
+
model_options=[
|
|
82
|
+
RequestLogModelOption(model=option.model, reasoning_effort=option.reasoning_effort)
|
|
83
|
+
for option in options.model_options
|
|
84
|
+
],
|
|
85
|
+
)
|