codex-lb 0.4.0__py3-none-any.whl → 0.5.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/config/settings.py +8 -8
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +39 -0
- app/core/middleware/__init__.py +9 -0
- app/core/middleware/api_errors.py +33 -0
- app/core/middleware/request_decompression.py +101 -0
- app/core/middleware/request_id.py +27 -0
- app/core/openai/chat_requests.py +172 -0
- app/core/openai/chat_responses.py +534 -0
- app/core/openai/message_coercion.py +60 -0
- app/core/openai/models_catalog.py +72 -0
- app/core/openai/requests.py +4 -4
- app/core/openai/v1_requests.py +4 -60
- app/db/session.py +25 -8
- app/dependencies.py +43 -16
- app/main.py +12 -67
- app/modules/accounts/repository.py +21 -9
- app/modules/proxy/api.py +58 -0
- app/modules/proxy/load_balancer.py +75 -58
- app/modules/proxy/repo_bundle.py +23 -0
- app/modules/proxy/service.py +98 -102
- app/modules/request_logs/repository.py +3 -0
- app/modules/usage/service.py +65 -4
- {codex_lb-0.4.0.dist-info → codex_lb-0.5.0.dist-info}/METADATA +3 -2
- {codex_lb-0.4.0.dist-info → codex_lb-0.5.0.dist-info}/RECORD +28 -17
- {codex_lb-0.4.0.dist-info → codex_lb-0.5.0.dist-info}/WHEEL +0 -0
- {codex_lb-0.4.0.dist-info → codex_lb-0.5.0.dist-info}/entry_points.txt +0 -0
- {codex_lb-0.4.0.dist-info → codex_lb-0.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import AsyncIterator, Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from app.core.errors import openai_error
|
|
12
|
+
from app.core.types import JsonValue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChatToolCallFunction(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="forbid")
|
|
17
|
+
|
|
18
|
+
name: str | None = None
|
|
19
|
+
arguments: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChatToolCallDelta(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
index: int
|
|
26
|
+
id: str | None = None
|
|
27
|
+
type: str = "function"
|
|
28
|
+
function: ChatToolCallFunction | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ChatChunkDelta(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
role: str | None = None
|
|
35
|
+
content: str | None = None
|
|
36
|
+
tool_calls: list[ChatToolCallDelta] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChatChunkChoice(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="forbid")
|
|
41
|
+
|
|
42
|
+
index: int
|
|
43
|
+
delta: ChatChunkDelta
|
|
44
|
+
finish_reason: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ChatCompletionChunk(BaseModel):
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
id: str
|
|
51
|
+
object: str = "chat.completion.chunk"
|
|
52
|
+
created: int
|
|
53
|
+
model: str
|
|
54
|
+
choices: list[ChatChunkChoice]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ChatMessageToolCall(BaseModel):
|
|
58
|
+
model_config = ConfigDict(extra="forbid")
|
|
59
|
+
|
|
60
|
+
id: str | None = None
|
|
61
|
+
type: str = "function"
|
|
62
|
+
function: ChatToolCallFunction | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ChatCompletionMessage(BaseModel):
|
|
66
|
+
model_config = ConfigDict(extra="forbid")
|
|
67
|
+
|
|
68
|
+
role: str
|
|
69
|
+
content: str | None = None
|
|
70
|
+
tool_calls: list[ChatMessageToolCall] | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ChatCompletionChoice(BaseModel):
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
|
|
76
|
+
index: int
|
|
77
|
+
message: ChatCompletionMessage
|
|
78
|
+
finish_reason: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ChatCompletionUsage(BaseModel):
|
|
82
|
+
model_config = ConfigDict(extra="forbid")
|
|
83
|
+
|
|
84
|
+
prompt_tokens: int | None = None
|
|
85
|
+
completion_tokens: int | None = None
|
|
86
|
+
total_tokens: int | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ChatCompletion(BaseModel):
|
|
90
|
+
model_config = ConfigDict(extra="forbid")
|
|
91
|
+
|
|
92
|
+
id: str
|
|
93
|
+
object: str = "chat.completion"
|
|
94
|
+
created: int
|
|
95
|
+
model: str
|
|
96
|
+
choices: list[ChatCompletionChoice]
|
|
97
|
+
usage: ChatCompletionUsage | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class ToolCallIndex:
|
|
102
|
+
indexes: dict[str, int] = field(default_factory=dict)
|
|
103
|
+
next_index: int = 0
|
|
104
|
+
|
|
105
|
+
def index_for(self, call_id: str | None, name: str | None) -> int:
|
|
106
|
+
key = _tool_call_key(call_id, name)
|
|
107
|
+
if key is None:
|
|
108
|
+
return 0
|
|
109
|
+
if key not in self.indexes:
|
|
110
|
+
self.indexes[key] = self.next_index
|
|
111
|
+
self.next_index += 1
|
|
112
|
+
return self.indexes[key]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class _ChatChunkState:
|
|
117
|
+
tool_index: ToolCallIndex = field(default_factory=ToolCallIndex)
|
|
118
|
+
saw_tool_call: bool = False
|
|
119
|
+
sent_role: bool = False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class ToolCallDelta:
|
|
124
|
+
index: int
|
|
125
|
+
call_id: str | None
|
|
126
|
+
name: str | None
|
|
127
|
+
arguments: str | None
|
|
128
|
+
tool_type: str | None
|
|
129
|
+
|
|
130
|
+
def to_chunk_call(self) -> ChatToolCallDelta:
|
|
131
|
+
function = _build_tool_call_function(self.name, self.arguments)
|
|
132
|
+
return ChatToolCallDelta(
|
|
133
|
+
index=self.index,
|
|
134
|
+
id=self.call_id,
|
|
135
|
+
type=self.tool_type or "function",
|
|
136
|
+
function=function,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ToolCallState:
|
|
142
|
+
index: int
|
|
143
|
+
call_id: str | None = None
|
|
144
|
+
name: str | None = None
|
|
145
|
+
arguments: str = ""
|
|
146
|
+
tool_type: str = "function"
|
|
147
|
+
|
|
148
|
+
def apply_delta(self, delta: ToolCallDelta) -> None:
|
|
149
|
+
if delta.call_id:
|
|
150
|
+
self.call_id = delta.call_id
|
|
151
|
+
if delta.name:
|
|
152
|
+
self.name = delta.name
|
|
153
|
+
if delta.arguments:
|
|
154
|
+
self.arguments += delta.arguments
|
|
155
|
+
if delta.tool_type:
|
|
156
|
+
self.tool_type = delta.tool_type
|
|
157
|
+
|
|
158
|
+
def to_message_tool_call(self) -> ChatMessageToolCall | None:
|
|
159
|
+
function = _build_tool_call_function(self.name, self.arguments or None)
|
|
160
|
+
if self.call_id is None and function is None:
|
|
161
|
+
return None
|
|
162
|
+
return ChatMessageToolCall(
|
|
163
|
+
id=self.call_id,
|
|
164
|
+
type=self.tool_type or "function",
|
|
165
|
+
function=function,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_tool_call_function(name: str | None, arguments: str | None) -> ChatToolCallFunction | None:
|
|
170
|
+
if name is None and arguments is None:
|
|
171
|
+
return None
|
|
172
|
+
return ChatToolCallFunction(name=name, arguments=arguments)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _parse_data(line: str) -> dict[str, JsonValue] | None:
|
|
176
|
+
if line.startswith("data:"):
|
|
177
|
+
data = line[5:].strip()
|
|
178
|
+
if not data or data == "[DONE]":
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
payload = json.loads(data)
|
|
182
|
+
except json.JSONDecodeError:
|
|
183
|
+
return None
|
|
184
|
+
if isinstance(payload, dict):
|
|
185
|
+
return cast(dict[str, JsonValue], payload)
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def iter_chat_chunks(
|
|
190
|
+
lines: Iterable[str],
|
|
191
|
+
model: str,
|
|
192
|
+
*,
|
|
193
|
+
created: int | None = None,
|
|
194
|
+
state: _ChatChunkState | None = None,
|
|
195
|
+
) -> Iterable[str]:
|
|
196
|
+
created = created or int(time.time())
|
|
197
|
+
state = state or _ChatChunkState()
|
|
198
|
+
for line in lines:
|
|
199
|
+
payload = _parse_data(line)
|
|
200
|
+
if not payload:
|
|
201
|
+
continue
|
|
202
|
+
event_type = payload.get("type")
|
|
203
|
+
if event_type == "response.output_text.delta":
|
|
204
|
+
delta = payload.get("delta")
|
|
205
|
+
role = None
|
|
206
|
+
if not state.sent_role:
|
|
207
|
+
role = "assistant"
|
|
208
|
+
chunk = ChatCompletionChunk(
|
|
209
|
+
id="chatcmpl_temp",
|
|
210
|
+
created=created,
|
|
211
|
+
model=model,
|
|
212
|
+
choices=[
|
|
213
|
+
ChatChunkChoice(
|
|
214
|
+
index=0,
|
|
215
|
+
delta=ChatChunkDelta(
|
|
216
|
+
role=role,
|
|
217
|
+
content=delta if isinstance(delta, str) else None,
|
|
218
|
+
),
|
|
219
|
+
finish_reason=None,
|
|
220
|
+
)
|
|
221
|
+
],
|
|
222
|
+
)
|
|
223
|
+
yield _dump_chunk(chunk)
|
|
224
|
+
if role is not None:
|
|
225
|
+
state.sent_role = True
|
|
226
|
+
tool_delta = _tool_call_delta_from_payload(payload, state.tool_index)
|
|
227
|
+
if tool_delta is not None:
|
|
228
|
+
state.saw_tool_call = True
|
|
229
|
+
role = None
|
|
230
|
+
if not state.sent_role:
|
|
231
|
+
role = "assistant"
|
|
232
|
+
chunk = ChatCompletionChunk(
|
|
233
|
+
id="chatcmpl_temp",
|
|
234
|
+
created=created,
|
|
235
|
+
model=model,
|
|
236
|
+
choices=[
|
|
237
|
+
ChatChunkChoice(
|
|
238
|
+
index=0,
|
|
239
|
+
delta=ChatChunkDelta(
|
|
240
|
+
role=role,
|
|
241
|
+
tool_calls=[tool_delta.to_chunk_call()],
|
|
242
|
+
),
|
|
243
|
+
finish_reason=None,
|
|
244
|
+
)
|
|
245
|
+
],
|
|
246
|
+
)
|
|
247
|
+
yield _dump_chunk(chunk)
|
|
248
|
+
if role is not None:
|
|
249
|
+
state.sent_role = True
|
|
250
|
+
if event_type in ("response.failed", "error"):
|
|
251
|
+
error = None
|
|
252
|
+
if event_type == "response.failed":
|
|
253
|
+
response = payload.get("response")
|
|
254
|
+
if isinstance(response, dict):
|
|
255
|
+
maybe_error = response.get("error")
|
|
256
|
+
if isinstance(maybe_error, dict):
|
|
257
|
+
error = maybe_error
|
|
258
|
+
else:
|
|
259
|
+
maybe_error = payload.get("error")
|
|
260
|
+
if isinstance(maybe_error, dict):
|
|
261
|
+
error = maybe_error
|
|
262
|
+
if error is not None:
|
|
263
|
+
error_payload = {"error": error}
|
|
264
|
+
yield _dump_sse(error_payload)
|
|
265
|
+
yield "data: [DONE]\n\n"
|
|
266
|
+
return
|
|
267
|
+
if event_type == "response.completed":
|
|
268
|
+
finish_reason = "tool_calls" if state.saw_tool_call else "stop"
|
|
269
|
+
done = ChatCompletionChunk(
|
|
270
|
+
id="chatcmpl_temp",
|
|
271
|
+
created=created,
|
|
272
|
+
model=model,
|
|
273
|
+
choices=[
|
|
274
|
+
ChatChunkChoice(
|
|
275
|
+
index=0,
|
|
276
|
+
delta=ChatChunkDelta(),
|
|
277
|
+
finish_reason=finish_reason,
|
|
278
|
+
)
|
|
279
|
+
],
|
|
280
|
+
)
|
|
281
|
+
yield _dump_chunk(done)
|
|
282
|
+
yield "data: [DONE]\n\n"
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def stream_chat_chunks(stream: AsyncIterator[str], model: str) -> AsyncIterator[str]:
|
|
287
|
+
created = int(time.time())
|
|
288
|
+
state = _ChatChunkState()
|
|
289
|
+
async for line in stream:
|
|
290
|
+
for chunk in iter_chat_chunks([line], model=model, created=created, state=state):
|
|
291
|
+
yield chunk
|
|
292
|
+
if chunk.strip() == "data: [DONE]":
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def collect_chat_completion(stream: AsyncIterator[str], model: str) -> dict[str, JsonValue]:
|
|
297
|
+
created = int(time.time())
|
|
298
|
+
content_parts: list[str] = []
|
|
299
|
+
response_id: str | None = None
|
|
300
|
+
usage: dict[str, JsonValue] | None = None
|
|
301
|
+
tool_index = ToolCallIndex()
|
|
302
|
+
tool_calls: list[ToolCallState] = []
|
|
303
|
+
|
|
304
|
+
async for line in stream:
|
|
305
|
+
payload = _parse_data(line)
|
|
306
|
+
if not payload:
|
|
307
|
+
continue
|
|
308
|
+
event_type = payload.get("type")
|
|
309
|
+
if event_type == "response.output_text.delta":
|
|
310
|
+
delta = payload.get("delta")
|
|
311
|
+
if isinstance(delta, str):
|
|
312
|
+
content_parts.append(delta)
|
|
313
|
+
tool_delta = _tool_call_delta_from_payload(payload, tool_index)
|
|
314
|
+
if tool_delta is not None:
|
|
315
|
+
_merge_tool_call_delta(tool_calls, tool_delta)
|
|
316
|
+
if event_type in ("response.failed", "error"):
|
|
317
|
+
error = None
|
|
318
|
+
if event_type == "response.failed":
|
|
319
|
+
response = payload.get("response")
|
|
320
|
+
if isinstance(response, dict):
|
|
321
|
+
maybe_error = response.get("error")
|
|
322
|
+
if isinstance(maybe_error, dict):
|
|
323
|
+
error = maybe_error
|
|
324
|
+
else:
|
|
325
|
+
maybe_error = payload.get("error")
|
|
326
|
+
if isinstance(maybe_error, dict):
|
|
327
|
+
error = maybe_error
|
|
328
|
+
if error is not None:
|
|
329
|
+
return {"error": error}
|
|
330
|
+
return cast(dict[str, JsonValue], openai_error("upstream_error", "Upstream error"))
|
|
331
|
+
if event_type == "response.completed":
|
|
332
|
+
response = payload.get("response")
|
|
333
|
+
if isinstance(response, dict):
|
|
334
|
+
response_id_value = response.get("id")
|
|
335
|
+
if isinstance(response_id_value, str):
|
|
336
|
+
response_id = response_id_value
|
|
337
|
+
usage_value = response.get("usage")
|
|
338
|
+
if isinstance(usage_value, dict):
|
|
339
|
+
usage = usage_value
|
|
340
|
+
|
|
341
|
+
message_content = "".join(content_parts)
|
|
342
|
+
message_tool_calls = _compact_tool_calls(tool_calls)
|
|
343
|
+
has_tool_calls = bool(message_tool_calls)
|
|
344
|
+
message = ChatCompletionMessage(
|
|
345
|
+
role="assistant",
|
|
346
|
+
content=message_content if message_content or not has_tool_calls else None,
|
|
347
|
+
tool_calls=message_tool_calls or None,
|
|
348
|
+
)
|
|
349
|
+
choice = ChatCompletionChoice(
|
|
350
|
+
index=0,
|
|
351
|
+
message=message,
|
|
352
|
+
finish_reason="tool_calls" if has_tool_calls else "stop",
|
|
353
|
+
)
|
|
354
|
+
completion = ChatCompletion(
|
|
355
|
+
id=response_id or "chatcmpl_temp",
|
|
356
|
+
created=created,
|
|
357
|
+
model=model,
|
|
358
|
+
choices=[choice],
|
|
359
|
+
usage=_map_usage(usage),
|
|
360
|
+
)
|
|
361
|
+
return _dump_completion(completion)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _map_usage(usage: dict[str, JsonValue] | None) -> ChatCompletionUsage | None:
|
|
365
|
+
if not usage:
|
|
366
|
+
return None
|
|
367
|
+
prompt_tokens = usage.get("input_tokens")
|
|
368
|
+
completion_tokens = usage.get("output_tokens")
|
|
369
|
+
total_tokens = usage.get("total_tokens")
|
|
370
|
+
if not isinstance(prompt_tokens, int):
|
|
371
|
+
prompt_tokens = None
|
|
372
|
+
if not isinstance(completion_tokens, int):
|
|
373
|
+
completion_tokens = None
|
|
374
|
+
if not isinstance(total_tokens, int):
|
|
375
|
+
total_tokens = None
|
|
376
|
+
if prompt_tokens is None and completion_tokens is None and total_tokens is None:
|
|
377
|
+
return None
|
|
378
|
+
return ChatCompletionUsage(
|
|
379
|
+
prompt_tokens=prompt_tokens,
|
|
380
|
+
completion_tokens=completion_tokens,
|
|
381
|
+
total_tokens=total_tokens,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _dump_chunk(chunk: ChatCompletionChunk) -> str:
|
|
386
|
+
payload = chunk.model_dump(mode="json", exclude_none=True)
|
|
387
|
+
return _dump_sse(payload)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _dump_completion(completion: ChatCompletion) -> dict[str, JsonValue]:
|
|
391
|
+
payload = completion.model_dump(mode="json", exclude_none=True)
|
|
392
|
+
return cast(dict[str, JsonValue], payload)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _dump_sse(payload: dict[str, JsonValue]) -> str:
|
|
396
|
+
return f"data: {json.dumps(payload)}\n\n"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _tool_call_delta_from_payload(payload: Mapping[str, JsonValue], indexer: ToolCallIndex) -> ToolCallDelta | None:
|
|
400
|
+
if not _is_tool_call_event(payload):
|
|
401
|
+
return None
|
|
402
|
+
fields = _extract_tool_call_fields(payload)
|
|
403
|
+
if fields is None:
|
|
404
|
+
return None
|
|
405
|
+
call_id, name, arguments, tool_type = fields
|
|
406
|
+
index = indexer.index_for(call_id, name)
|
|
407
|
+
return ToolCallDelta(
|
|
408
|
+
index=index,
|
|
409
|
+
call_id=call_id,
|
|
410
|
+
name=name,
|
|
411
|
+
arguments=arguments,
|
|
412
|
+
tool_type=tool_type,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _is_tool_call_event(payload: Mapping[str, JsonValue]) -> bool:
|
|
417
|
+
event_type = payload.get("type")
|
|
418
|
+
if isinstance(event_type, str) and ("tool_call" in event_type or "function_call" in event_type):
|
|
419
|
+
return True
|
|
420
|
+
item = _as_mapping(payload.get("item"))
|
|
421
|
+
if item is not None:
|
|
422
|
+
item_type = item.get("type")
|
|
423
|
+
if isinstance(item_type, str) and ("tool" in item_type or "function" in item_type):
|
|
424
|
+
return True
|
|
425
|
+
if any(key in item for key in ("call_id", "tool_call_id", "arguments", "function", "name")):
|
|
426
|
+
return True
|
|
427
|
+
if any(key in payload for key in ("call_id", "tool_call_id")):
|
|
428
|
+
return True
|
|
429
|
+
if "arguments" in payload and ("name" in payload or "function" in payload):
|
|
430
|
+
return True
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _extract_tool_call_fields(
|
|
435
|
+
payload: Mapping[str, JsonValue],
|
|
436
|
+
) -> tuple[str | None, str | None, str | None, str | None] | None:
|
|
437
|
+
candidate = _select_tool_call_candidate(payload)
|
|
438
|
+
delta = candidate.get("delta")
|
|
439
|
+
delta_map = _as_mapping(delta)
|
|
440
|
+
delta_text = delta if isinstance(delta, str) else None
|
|
441
|
+
|
|
442
|
+
call_id = _first_str(
|
|
443
|
+
candidate.get("call_id"),
|
|
444
|
+
candidate.get("tool_call_id"),
|
|
445
|
+
candidate.get("id"),
|
|
446
|
+
)
|
|
447
|
+
if call_id is None and delta_map is not None:
|
|
448
|
+
call_id = _first_str(
|
|
449
|
+
delta_map.get("id"),
|
|
450
|
+
delta_map.get("call_id"),
|
|
451
|
+
delta_map.get("tool_call_id"),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
name = _first_str(candidate.get("name"), candidate.get("tool_name"))
|
|
455
|
+
if name is None and delta_map is not None:
|
|
456
|
+
name = _first_str(delta_map.get("name"))
|
|
457
|
+
if name is None:
|
|
458
|
+
function = _as_mapping(candidate.get("function"))
|
|
459
|
+
if function is not None:
|
|
460
|
+
name = _first_str(function.get("name"))
|
|
461
|
+
if name is None and delta_map is not None:
|
|
462
|
+
function = _as_mapping(delta_map.get("function"))
|
|
463
|
+
if function is not None:
|
|
464
|
+
name = _first_str(function.get("name"))
|
|
465
|
+
|
|
466
|
+
arguments = None
|
|
467
|
+
if isinstance(candidate.get("arguments"), str):
|
|
468
|
+
arguments = cast(str, candidate.get("arguments"))
|
|
469
|
+
if arguments is None and isinstance(delta_text, str):
|
|
470
|
+
arguments = delta_text
|
|
471
|
+
if arguments is None and delta_map is not None:
|
|
472
|
+
if isinstance(delta_map.get("arguments"), str):
|
|
473
|
+
arguments = cast(str, delta_map.get("arguments"))
|
|
474
|
+
else:
|
|
475
|
+
function = _as_mapping(delta_map.get("function"))
|
|
476
|
+
if function is not None and isinstance(function.get("arguments"), str):
|
|
477
|
+
arguments = cast(str, function.get("arguments"))
|
|
478
|
+
|
|
479
|
+
tool_type = _first_str(candidate.get("tool_type"), candidate.get("type"))
|
|
480
|
+
if tool_type and tool_type.startswith("response."):
|
|
481
|
+
tool_type = None
|
|
482
|
+
if tool_type in ("tool_call", "function_call"):
|
|
483
|
+
tool_type = "function"
|
|
484
|
+
|
|
485
|
+
if call_id is None and name is None and arguments is None:
|
|
486
|
+
return None
|
|
487
|
+
return call_id, name, arguments, tool_type
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _select_tool_call_candidate(payload: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
|
|
491
|
+
item = _as_mapping(payload.get("item"))
|
|
492
|
+
if item is not None:
|
|
493
|
+
item_type = item.get("type")
|
|
494
|
+
if isinstance(item_type, str) and ("tool" in item_type or "function" in item_type):
|
|
495
|
+
return item
|
|
496
|
+
if any(key in item for key in ("call_id", "tool_call_id", "arguments", "function", "name")):
|
|
497
|
+
return item
|
|
498
|
+
return payload
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _tool_call_key(call_id: str | None, name: str | None) -> str | None:
|
|
502
|
+
if call_id:
|
|
503
|
+
return f"id:{call_id}"
|
|
504
|
+
if name:
|
|
505
|
+
return f"name:{name}"
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _as_mapping(value: JsonValue) -> Mapping[str, JsonValue] | None:
|
|
510
|
+
if isinstance(value, Mapping):
|
|
511
|
+
return cast(Mapping[str, JsonValue], value)
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _first_str(*values: object) -> str | None:
|
|
516
|
+
for value in values:
|
|
517
|
+
if isinstance(value, str) and value:
|
|
518
|
+
return value
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _merge_tool_call_delta(tool_calls: list[ToolCallState], delta: ToolCallDelta) -> None:
|
|
523
|
+
while len(tool_calls) <= delta.index:
|
|
524
|
+
tool_calls.append(ToolCallState(index=len(tool_calls)))
|
|
525
|
+
tool_calls[delta.index].apply_delta(delta)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _compact_tool_calls(tool_calls: list[ToolCallState]) -> list[ChatMessageToolCall]:
|
|
529
|
+
cleaned: list[ChatMessageToolCall] = []
|
|
530
|
+
for call in tool_calls:
|
|
531
|
+
tool_call = call.to_message_tool_call()
|
|
532
|
+
if tool_call is not None:
|
|
533
|
+
cleaned.append(tool_call)
|
|
534
|
+
return cleaned
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from app.core.types import JsonValue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def coerce_messages(existing_instructions: str, messages: list[JsonValue]) -> tuple[str, list[JsonValue]]:
|
|
9
|
+
instruction_parts: list[str] = []
|
|
10
|
+
input_messages: list[JsonValue] = []
|
|
11
|
+
for message in messages:
|
|
12
|
+
if not isinstance(message, dict):
|
|
13
|
+
raise ValueError("Each message must be an object.")
|
|
14
|
+
message_dict = cast(dict[str, JsonValue], message)
|
|
15
|
+
role_value = message_dict.get("role")
|
|
16
|
+
role = role_value if isinstance(role_value, str) else None
|
|
17
|
+
if role in ("system", "developer"):
|
|
18
|
+
content_text = _content_to_text(message_dict.get("content"))
|
|
19
|
+
if content_text:
|
|
20
|
+
instruction_parts.append(content_text)
|
|
21
|
+
continue
|
|
22
|
+
input_messages.append(cast(JsonValue, message_dict))
|
|
23
|
+
merged = _merge_instructions(existing_instructions, instruction_parts)
|
|
24
|
+
return merged, input_messages
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _merge_instructions(existing: str, extra_parts: list[str]) -> str:
|
|
28
|
+
if not extra_parts:
|
|
29
|
+
return existing
|
|
30
|
+
extra = "\n".join([part for part in extra_parts if part])
|
|
31
|
+
if not extra:
|
|
32
|
+
return existing
|
|
33
|
+
if existing:
|
|
34
|
+
return f"{existing}\n{extra}"
|
|
35
|
+
return extra
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _content_to_text(content: object) -> str | None:
|
|
39
|
+
if content is None:
|
|
40
|
+
return None
|
|
41
|
+
if isinstance(content, str):
|
|
42
|
+
return content
|
|
43
|
+
if isinstance(content, list):
|
|
44
|
+
parts: list[str] = []
|
|
45
|
+
for part in content:
|
|
46
|
+
if isinstance(part, str):
|
|
47
|
+
parts.append(part)
|
|
48
|
+
elif isinstance(part, dict):
|
|
49
|
+
part_dict = cast(dict[str, JsonValue], part)
|
|
50
|
+
text = part_dict.get("text")
|
|
51
|
+
if isinstance(text, str):
|
|
52
|
+
parts.append(text)
|
|
53
|
+
return "\n".join([part for part in parts if part])
|
|
54
|
+
if isinstance(content, dict):
|
|
55
|
+
content_dict = cast(dict[str, JsonValue], content)
|
|
56
|
+
text = content_dict.get("text")
|
|
57
|
+
if isinstance(text, str):
|
|
58
|
+
return text
|
|
59
|
+
return None
|
|
60
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModelLimits(BaseModel):
|
|
7
|
+
model_config = ConfigDict(extra="forbid")
|
|
8
|
+
|
|
9
|
+
context: int
|
|
10
|
+
output: int
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModelModalities(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid")
|
|
15
|
+
|
|
16
|
+
input: list[str]
|
|
17
|
+
output: list[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModelVariant(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
reasoningEffort: str
|
|
24
|
+
reasoningSummary: str
|
|
25
|
+
textVerbosity: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ModelEntry(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
limit: ModelLimits
|
|
33
|
+
modalities: ModelModalities
|
|
34
|
+
variants: dict[str, ModelVariant]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
MODEL_CATALOG: dict[str, ModelEntry] = {
|
|
38
|
+
"gpt-5.2": ModelEntry(
|
|
39
|
+
name="GPT 5.2",
|
|
40
|
+
limit=ModelLimits(context=272000, output=128000),
|
|
41
|
+
modalities=ModelModalities(input=["text", "image"], output=["text"]),
|
|
42
|
+
variants={
|
|
43
|
+
"none": ModelVariant(reasoningEffort="none", reasoningSummary="auto", textVerbosity="medium"),
|
|
44
|
+
"low": ModelVariant(reasoningEffort="low", reasoningSummary="auto", textVerbosity="medium"),
|
|
45
|
+
"medium": ModelVariant(reasoningEffort="medium", reasoningSummary="auto", textVerbosity="medium"),
|
|
46
|
+
"high": ModelVariant(reasoningEffort="high", reasoningSummary="detailed", textVerbosity="medium"),
|
|
47
|
+
"xhigh": ModelVariant(reasoningEffort="xhigh", reasoningSummary="detailed", textVerbosity="medium"),
|
|
48
|
+
},
|
|
49
|
+
),
|
|
50
|
+
"gpt-5.2-codex": ModelEntry(
|
|
51
|
+
name="GPT 5.2 Codex",
|
|
52
|
+
limit=ModelLimits(context=272000, output=128000),
|
|
53
|
+
modalities=ModelModalities(input=["text", "image"], output=["text"]),
|
|
54
|
+
variants={
|
|
55
|
+
"low": ModelVariant(reasoningEffort="low", reasoningSummary="auto", textVerbosity="medium"),
|
|
56
|
+
"medium": ModelVariant(reasoningEffort="medium", reasoningSummary="auto", textVerbosity="medium"),
|
|
57
|
+
"high": ModelVariant(reasoningEffort="high", reasoningSummary="detailed", textVerbosity="medium"),
|
|
58
|
+
"xhigh": ModelVariant(reasoningEffort="xhigh", reasoningSummary="detailed", textVerbosity="medium"),
|
|
59
|
+
},
|
|
60
|
+
),
|
|
61
|
+
"gpt-5.1-codex-max": ModelEntry(
|
|
62
|
+
name="GPT 5.1 Codex Max",
|
|
63
|
+
limit=ModelLimits(context=272000, output=128000),
|
|
64
|
+
modalities=ModelModalities(input=["text", "image"], output=["text"]),
|
|
65
|
+
variants={
|
|
66
|
+
"low": ModelVariant(reasoningEffort="low", reasoningSummary="detailed", textVerbosity="medium"),
|
|
67
|
+
"medium": ModelVariant(reasoningEffort="medium", reasoningSummary="detailed", textVerbosity="medium"),
|
|
68
|
+
"high": ModelVariant(reasoningEffort="high", reasoningSummary="detailed", textVerbosity="medium"),
|
|
69
|
+
"xhigh": ModelVariant(reasoningEffort="xhigh", reasoningSummary="detailed", textVerbosity="medium"),
|
|
70
|
+
},
|
|
71
|
+
),
|
|
72
|
+
}
|
app/core/openai/requests.py
CHANGED
|
@@ -35,10 +35,10 @@ class ResponsesRequest(BaseModel):
|
|
|
35
35
|
instructions: str
|
|
36
36
|
input: list[JsonValue]
|
|
37
37
|
tools: list[JsonValue] = Field(default_factory=list)
|
|
38
|
-
tool_choice: str | None = None
|
|
38
|
+
tool_choice: str | dict[str, JsonValue] | None = None
|
|
39
39
|
parallel_tool_calls: bool | None = None
|
|
40
40
|
reasoning: ResponsesReasoning | None = None
|
|
41
|
-
store: bool
|
|
41
|
+
store: bool = False
|
|
42
42
|
stream: bool | None = None
|
|
43
43
|
include: list[str] = Field(default_factory=list)
|
|
44
44
|
prompt_cache_key: str | None = None
|
|
@@ -46,10 +46,10 @@ class ResponsesRequest(BaseModel):
|
|
|
46
46
|
|
|
47
47
|
@field_validator("store")
|
|
48
48
|
@classmethod
|
|
49
|
-
def _ensure_store_false(cls, value: bool | None) -> bool
|
|
49
|
+
def _ensure_store_false(cls, value: bool | None) -> bool:
|
|
50
50
|
if value is True:
|
|
51
51
|
raise ValueError("store must be false")
|
|
52
|
-
return value
|
|
52
|
+
return False if value is None else value
|
|
53
53
|
|
|
54
54
|
def to_payload(self) -> JsonObject:
|
|
55
55
|
payload = self.model_dump(mode="json", exclude_none=True)
|