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 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: aiohttp.ClientResponse) -> ResponseFailedEvent:
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: aiohttp.ClientResponse) -> OpenAIErrorEnvelope:
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],
@@ -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
@@ -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
- return self.model_dump(mode="json", exclude_none=True)
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
- return self.model_dump(mode="json", exclude_none=True)
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:
@@ -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]}"
@@ -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 RequestLogsResponse
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=200),
16
- account_id: str | None = Query(default=None, alias="accountId"),
17
- status: str | None = Query(default=None),
18
- model: str | None = Query(default=None),
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
- account_id=account_id,
28
- model=model,
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
+ )