codex-as-api 0.2.1__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.
codex_as_api/server.py ADDED
@@ -0,0 +1,580 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import time
7
+ import uuid
8
+ from collections.abc import AsyncIterator
9
+ from typing import Any
10
+
11
+ from .auth import ChatGPTOAuthError, ChatGPTOAuthMissingError, is_auth_locally_available
12
+ from .messages import Message, MessageRole, ToolSchema
13
+ from .provider import ChatGPTOAuthProvider
14
+
15
+
16
+ def _env_int(name: str, default: int) -> int:
17
+ value = os.getenv(name)
18
+ if value is not None and value.isdigit():
19
+ return int(value)
20
+ return default
21
+
22
+
23
+ def _env_str(name: str, default: str) -> str:
24
+ return os.getenv(name) or default
25
+
26
+
27
+ HOST = _env_str("CODEX_AS_API_HOST", "127.0.0.1")
28
+ PORT = _env_int("CODEX_AS_API_PORT", 18080)
29
+ MODEL = _env_str("CODEX_AS_API_MODEL", "gpt-5.5")
30
+ AUTH_PATH = os.getenv("CODEX_AS_API_AUTH_PATH")
31
+
32
+ _provider: ChatGPTOAuthProvider | None = None
33
+
34
+
35
+ def _get_provider() -> ChatGPTOAuthProvider:
36
+ global _provider
37
+ if _provider is None:
38
+ _provider = ChatGPTOAuthProvider(
39
+ model=MODEL,
40
+ auth_json_path=AUTH_PATH,
41
+ )
42
+ return _provider
43
+
44
+
45
+ # FastAPI is an optional dependency; fail gracefully if missing.
46
+ try:
47
+ from fastapi import FastAPI, Request
48
+ from fastapi.responses import JSONResponse, StreamingResponse
49
+ from pydantic import BaseModel, Field
50
+
51
+ app = FastAPI(
52
+ title="codex-as-api",
53
+ description="Local OpenAI-compatible API server backed by ChatGPT/Codex OAuth.",
54
+ version="0.1.0",
55
+ )
56
+
57
+ @app.exception_handler(ChatGPTOAuthError)
58
+ async def _chatgpt_oauth_error_handler(_request: Request, exc: ChatGPTOAuthError) -> JSONResponse:
59
+ status = 401 if isinstance(exc, ChatGPTOAuthMissingError) else 500
60
+ return JSONResponse(status_code=status, content={"error": {"message": str(exc), "type": "chatgpt_oauth_error"}})
61
+
62
+ # ------------------------------------------------------------------
63
+ # Request/response schemas
64
+ # ------------------------------------------------------------------
65
+
66
+ class ChatMessage(BaseModel):
67
+ role: str
68
+ content: str | list[dict[str, Any]] | None = None
69
+ name: str | None = None
70
+ tool_calls: list[dict[str, Any]] | None = None
71
+ tool_call_id: str | None = None
72
+
73
+ class ChatCompletionRequest(BaseModel):
74
+ model: str
75
+ messages: list[ChatMessage]
76
+ stream: bool = False
77
+ temperature: float | None = None
78
+ max_tokens: int | None = None
79
+ max_completion_tokens: int | None = None
80
+ stop: str | list[str] | None = None
81
+ tools: list[dict[str, Any]] | None = None
82
+ tool_choice: str | dict[str, Any] | None = None
83
+ reasoning_effort: str | None = None
84
+ prompt_cache_key: str | None = None
85
+ top_p: float | None = None
86
+ frequency_penalty: float | None = None
87
+ presence_penalty: float | None = None
88
+ user: str | None = None
89
+ subagent: str | None = None
90
+ memgen_request: bool | None = None
91
+ previous_response_id: str | None = None
92
+ service_tier: str | None = None
93
+ text: dict[str, Any] | None = None
94
+ client_metadata: dict[str, str] | None = None
95
+
96
+ class ImageGenerationRequest(BaseModel):
97
+ model: str
98
+ prompt: str
99
+ size: str | None = "auto"
100
+ reasoning_effort: str | None = None
101
+
102
+ # ------------------------------------------------------------------
103
+ # Helpers
104
+ # ------------------------------------------------------------------
105
+
106
+ def _openai_model_id(request_model: str | None = None) -> str:
107
+ return f"codex-oauth:{request_model or MODEL}"
108
+
109
+ def _request_messages_to_internal(messages: list[ChatMessage]) -> list[Message]:
110
+ result: list[Message] = []
111
+ for msg in messages:
112
+ role = _map_role(msg.role)
113
+ content = _normalize_content(msg.content)
114
+ tool_calls = _parse_tool_calls(msg.tool_calls) if msg.tool_calls else ()
115
+ result.append(
116
+ Message(
117
+ role=role,
118
+ content=content,
119
+ tool_calls=tool_calls,
120
+ tool_call_id=msg.tool_call_id,
121
+ name=msg.name,
122
+ )
123
+ )
124
+ return result
125
+
126
+ def _map_role(role: str) -> MessageRole:
127
+ mapping = {
128
+ "system": MessageRole.SYSTEM,
129
+ "user": MessageRole.USER,
130
+ "assistant": MessageRole.ASSISTANT,
131
+ "tool": MessageRole.TOOL,
132
+ }
133
+ return mapping.get(role.lower(), MessageRole.USER)
134
+
135
+ def _normalize_content(content: str | list[dict[str, Any]] | None) -> str:
136
+ if content is None:
137
+ return ""
138
+ if isinstance(content, str):
139
+ return content
140
+ if isinstance(content, list):
141
+ parts: list[str] = []
142
+ for item in content:
143
+ if isinstance(item, dict):
144
+ text = item.get("text")
145
+ if isinstance(text, str):
146
+ parts.append(text)
147
+ return "".join(parts)
148
+ return str(content)
149
+
150
+ def _parse_tool_calls(raw: list[dict[str, Any]] | None) -> tuple[Any, ...]:
151
+ from .messages import ToolCall
152
+ if not raw:
153
+ return ()
154
+ calls = []
155
+ for item in raw:
156
+ if not isinstance(item, dict):
157
+ continue
158
+ call_id = item.get("id") or item.get("call_id") or str(uuid.uuid4().hex)
159
+ func = item.get("function") or {}
160
+ name = func.get("name") if isinstance(func, dict) else item.get("name")
161
+ args = func.get("arguments") if isinstance(func, dict) else item.get("arguments")
162
+ if isinstance(args, str):
163
+ try:
164
+ parsed = json.loads(args) if args else {}
165
+ except json.JSONDecodeError:
166
+ parsed = {"input": args}
167
+ elif isinstance(args, dict):
168
+ parsed = args
169
+ else:
170
+ parsed = {}
171
+ if name:
172
+ calls.append(ToolCall(id=str(call_id), name=str(name), arguments=parsed))
173
+ return tuple(calls)
174
+
175
+ def _parse_tools(raw: list[dict[str, Any]] | None) -> list[ToolSchema] | None:
176
+ if not raw:
177
+ return None
178
+ schemas = []
179
+ for item in raw:
180
+ if not isinstance(item, dict):
181
+ continue
182
+ func = item.get("function") or item
183
+ name = func.get("name")
184
+ desc = func.get("description") or ""
185
+ params = func.get("parameters") or {}
186
+ if name:
187
+ schemas.append(ToolSchema(name=str(name), description=str(desc), parameters=params if isinstance(params, dict) else {}))
188
+ return schemas if schemas else None
189
+
190
+ def _normalize_stop(stop: str | list[str] | None) -> list[str] | None:
191
+ if stop is None:
192
+ return None
193
+ if isinstance(stop, str):
194
+ return [stop]
195
+ return list(stop)
196
+
197
+ def _max_tokens_from_request(req: ChatCompletionRequest) -> int | None:
198
+ if req.max_completion_tokens is not None:
199
+ return req.max_completion_tokens
200
+ return req.max_tokens
201
+
202
+ # ------------------------------------------------------------------
203
+ # Endpoints
204
+ # ------------------------------------------------------------------
205
+
206
+ @app.get("/health")
207
+ async def health() -> dict[str, Any]:
208
+ return {
209
+ "status": "ok",
210
+ "auth_available": is_auth_locally_available(AUTH_PATH),
211
+ "model": MODEL,
212
+ }
213
+
214
+ @app.post("/v1/chat/completions", response_model=None)
215
+ async def chat_completions(request: ChatCompletionRequest, http_request: Request) -> JSONResponse | StreamingResponse:
216
+ provider = _get_provider()
217
+ messages = _request_messages_to_internal(request.messages)
218
+ tools = _parse_tools(request.tools)
219
+ stop = _normalize_stop(request.stop)
220
+ max_tokens = _max_tokens_from_request(request)
221
+
222
+ subagent = request.subagent or http_request.headers.get("x-openai-subagent")
223
+ memgen_request_header = http_request.headers.get("x-openai-memgen-request")
224
+ memgen_request: bool | None = request.memgen_request
225
+ if memgen_request is None and memgen_request_header is not None:
226
+ memgen_request = memgen_request_header.lower() not in ("false", "0", "")
227
+ previous_response_id = request.previous_response_id
228
+
229
+ if request.stream:
230
+ async def _stream() -> AsyncIterator[str]:
231
+ request_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
232
+ created = int(time.time())
233
+ model = _openai_model_id(request.model)
234
+
235
+ # SSE preamble
236
+ preamble = {
237
+ "id": request_id,
238
+ "object": "chat.completion.chunk",
239
+ "created": created,
240
+ "model": model,
241
+ "choices": [{
242
+ "index": 0,
243
+ "delta": {"role": "assistant"},
244
+ "finish_reason": None,
245
+ }],
246
+ }
247
+ yield f"data: {json.dumps(preamble)}\n\n"
248
+
249
+ reasoning_parts: list[str] = []
250
+ content_parts: list[str] = []
251
+ tool_calls_buffer: list[dict[str, Any]] = []
252
+ usage_dict: dict[str, Any] | None = None
253
+
254
+ for event in provider.chat_stream(
255
+ messages,
256
+ model=request.model,
257
+ tools=tools,
258
+ tool_choice=request.tool_choice,
259
+ temperature=request.temperature,
260
+ reasoning_effort=request.reasoning_effort,
261
+ max_tokens=max_tokens,
262
+ stop=stop,
263
+ prompt_cache_key=request.prompt_cache_key,
264
+ subagent=subagent,
265
+ memgen_request=memgen_request,
266
+ previous_response_id=previous_response_id,
267
+ service_tier=request.service_tier,
268
+ text=request.text,
269
+ client_metadata=request.client_metadata,
270
+ ):
271
+ typ = event.get("type")
272
+ if typ == "content":
273
+ text = str(event.get("text", ""))
274
+ content_parts.append(text)
275
+ chunk = {
276
+ "id": request_id,
277
+ "object": "chat.completion.chunk",
278
+ "created": created,
279
+ "model": model,
280
+ "choices": [{
281
+ "index": 0,
282
+ "delta": {"content": text},
283
+ "finish_reason": None,
284
+ }],
285
+ }
286
+ yield f"data: {json.dumps(chunk)}\n\n"
287
+ elif typ == "reasoning_delta":
288
+ text = str(event.get("text", ""))
289
+ reasoning_parts.append(text)
290
+ # OpenAI-compatible reasoning field
291
+ chunk = {
292
+ "id": request_id,
293
+ "object": "chat.completion.chunk",
294
+ "created": created,
295
+ "model": model,
296
+ "choices": [{
297
+ "index": 0,
298
+ "delta": {"reasoning_content": text},
299
+ "finish_reason": None,
300
+ }],
301
+ }
302
+ yield f"data: {json.dumps(chunk)}\n\n"
303
+ elif typ == "reasoning_raw_delta":
304
+ text = str(event.get("text", ""))
305
+ reasoning_parts.append(text)
306
+ chunk = {
307
+ "id": request_id,
308
+ "object": "chat.completion.chunk",
309
+ "created": created,
310
+ "model": model,
311
+ "choices": [{
312
+ "index": 0,
313
+ "delta": {"reasoning": text},
314
+ "finish_reason": None,
315
+ }],
316
+ }
317
+ yield f"data: {json.dumps(chunk)}\n\n"
318
+ elif typ == "tool_call":
319
+ tc = {
320
+ "id": event.get("id"),
321
+ "type": "function",
322
+ "function": {
323
+ "name": event.get("name"),
324
+ "arguments": json.dumps(event.get("arguments") or {}),
325
+ },
326
+ }
327
+ tool_calls_buffer.append(tc)
328
+ chunk = {
329
+ "id": request_id,
330
+ "object": "chat.completion.chunk",
331
+ "created": created,
332
+ "model": model,
333
+ "choices": [{
334
+ "index": 0,
335
+ "delta": {"tool_calls": [tc]},
336
+ "finish_reason": None,
337
+ }],
338
+ }
339
+ yield f"data: {json.dumps(chunk)}\n\n"
340
+ elif typ == "finish":
341
+ usage = event.get("usage")
342
+ if isinstance(usage, dict):
343
+ usage_dict = usage
344
+ chunk = {
345
+ "id": request_id,
346
+ "object": "chat.completion.chunk",
347
+ "created": created,
348
+ "model": model,
349
+ "choices": [{
350
+ "index": 0,
351
+ "delta": {},
352
+ "finish_reason": event.get("finish_reason") or "stop",
353
+ }],
354
+ }
355
+ yield f"data: {json.dumps(chunk)}\n\n"
356
+
357
+ # Usage summary chunk if available
358
+ if usage_dict:
359
+ u = usage_dict
360
+ finish_chunk = {
361
+ "id": request_id,
362
+ "object": "chat.completion.chunk",
363
+ "created": created,
364
+ "model": model,
365
+ "choices": [],
366
+ "usage": {
367
+ "prompt_tokens": u.get("prompt_tokens", 0),
368
+ "completion_tokens": u.get("completion_tokens", 0),
369
+ "total_tokens": u.get("total_tokens", 0),
370
+ },
371
+ }
372
+ yield f"data: {json.dumps(finish_chunk)}\n\n"
373
+
374
+ yield "data: [DONE]\n\n"
375
+
376
+ return StreamingResponse(_stream(), media_type="text/event-stream")
377
+
378
+ # Non-streaming
379
+ response = provider.chat(
380
+ messages,
381
+ model=request.model,
382
+ tools=tools,
383
+ tool_choice=request.tool_choice,
384
+ temperature=request.temperature,
385
+ reasoning_effort=request.reasoning_effort,
386
+ max_tokens=max_tokens,
387
+ stop=stop,
388
+ prompt_cache_key=request.prompt_cache_key,
389
+ subagent=subagent,
390
+ memgen_request=memgen_request,
391
+ previous_response_id=previous_response_id,
392
+ service_tier=request.service_tier,
393
+ text=request.text,
394
+ client_metadata=request.client_metadata,
395
+ )
396
+
397
+ choices: list[dict[str, Any]] = [{
398
+ "index": 0,
399
+ "message": {
400
+ "role": "assistant",
401
+ "content": response.content,
402
+ },
403
+ "finish_reason": response.finish_reason,
404
+ }]
405
+
406
+ if response.tool_calls:
407
+ choices[0]["message"]["tool_calls"] = [
408
+ {
409
+ "id": tc.id,
410
+ "type": "function",
411
+ "function": {
412
+ "name": tc.name,
413
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False),
414
+ },
415
+ }
416
+ for tc in response.tool_calls
417
+ ]
418
+
419
+ if response.reasoning_content:
420
+ choices[0]["message"]["reasoning_content"] = response.reasoning_content
421
+
422
+ result: dict[str, Any] = {
423
+ "id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
424
+ "object": "chat.completion",
425
+ "created": int(time.time()),
426
+ "model": _openai_model_id(request.model),
427
+ "choices": choices,
428
+ }
429
+
430
+ if response.usage:
431
+ result["usage"] = {
432
+ "prompt_tokens": response.usage.prompt_tokens,
433
+ "completion_tokens": response.usage.completion_tokens,
434
+ "total_tokens": response.usage.total_tokens or (response.usage.prompt_tokens + response.usage.completion_tokens),
435
+ }
436
+
437
+ return JSONResponse(content=result)
438
+
439
+ @app.post("/v1/images/generations")
440
+ async def images_generations(request: ImageGenerationRequest) -> JSONResponse:
441
+ provider = _get_provider()
442
+ images = provider.generate_image(
443
+ request.prompt,
444
+ model=request.model,
445
+ size=request.size,
446
+ reasoning_effort=request.reasoning_effort,
447
+ )
448
+ data = [
449
+ {
450
+ "url": image.get("result"),
451
+ "revised_prompt": image.get("revised_prompt") or request.prompt,
452
+ }
453
+ for image in images
454
+ if image.get("result")
455
+ ]
456
+ return JSONResponse(content={"created": int(time.time()), "data": data})
457
+
458
+ # ------------------------------------------------------------------
459
+ # Anthropic Messages API compatible endpoint
460
+ # ------------------------------------------------------------------
461
+
462
+ from .anthropic_adapter import (
463
+ anthropic_request_to_internal,
464
+ internal_response_to_anthropic,
465
+ anthropic_stream_adapter,
466
+ format_anthropic_error,
467
+ )
468
+
469
+ @app.post("/v1/messages", response_model=None)
470
+ async def anthropic_messages(http_request: Request) -> JSONResponse | StreamingResponse:
471
+ provider = _get_provider()
472
+ body = await http_request.json()
473
+
474
+ try:
475
+ messages, tools, tool_choice, stop, reasoning_effort = anthropic_request_to_internal(
476
+ model=body.get("model", MODEL),
477
+ messages=body.get("messages", []),
478
+ system=body.get("system"),
479
+ max_tokens=body.get("max_tokens", 4096),
480
+ tools=body.get("tools"),
481
+ tool_choice=body.get("tool_choice"),
482
+ stop_sequences=body.get("stop_sequences"),
483
+ thinking=body.get("thinking"),
484
+ )
485
+ except Exception as exc:
486
+ return JSONResponse(status_code=400, content=format_anthropic_error(400, str(exc)))
487
+
488
+ stream = body.get("stream", False)
489
+ request_model = body.get("model") or MODEL
490
+
491
+ if stream:
492
+ async def _stream() -> AsyncIterator[str]:
493
+ request_id = f"msg_{uuid.uuid4().hex[:24]}"
494
+ for sse_chunk in anthropic_stream_adapter(
495
+ provider.chat_stream(
496
+ messages,
497
+ model=request_model,
498
+ tools=tools,
499
+ tool_choice=tool_choice,
500
+ reasoning_effort=reasoning_effort,
501
+ stop=stop,
502
+ ),
503
+ model=request_model,
504
+ request_id=request_id,
505
+ ):
506
+ yield sse_chunk
507
+
508
+ try:
509
+ return StreamingResponse(_stream(), media_type="text/event-stream")
510
+ except ChatGPTOAuthError as exc:
511
+ status = 401 if isinstance(exc, ChatGPTOAuthMissingError) else 500
512
+ return JSONResponse(status_code=status, content=format_anthropic_error(status, str(exc)))
513
+
514
+ # Non-streaming
515
+ try:
516
+ response = provider.chat(
517
+ messages,
518
+ model=request_model,
519
+ tools=tools,
520
+ tool_choice=tool_choice,
521
+ reasoning_effort=reasoning_effort,
522
+ stop=stop,
523
+ )
524
+ except ChatGPTOAuthError as exc:
525
+ status = 401 if isinstance(exc, ChatGPTOAuthMissingError) else 500
526
+ return JSONResponse(status_code=status, content=format_anthropic_error(status, str(exc)))
527
+
528
+ request_id = f"msg_{uuid.uuid4().hex[:24]}"
529
+ result = internal_response_to_anthropic(response, request_model, request_id)
530
+ return JSONResponse(content=result)
531
+
532
+ # ------------------------------------------------------------------
533
+ # Custom endpoints (not in standard OpenAI API, but exposed for full feature routing)
534
+ # ------------------------------------------------------------------
535
+
536
+ @app.post("/v1/inspect")
537
+ async def inspect(request: Request) -> JSONResponse:
538
+ """Inspect images with a text prompt.
539
+
540
+ Body: {"prompt": str, "images": [{"image_url": "data:image/..."}, ...], "reasoning_effort": str?}
541
+ """
542
+ provider = _get_provider()
543
+ body = await request.json()
544
+ prompt = str(body.get("prompt", ""))
545
+ images = body.get("images") or []
546
+ reasoning_effort = body.get("reasoning_effort")
547
+ result = provider.inspect_images(prompt, images=images, reasoning_effort=reasoning_effort)
548
+ return JSONResponse(content={"content": result})
549
+
550
+ @app.post("/v1/compact")
551
+ async def compact(request: Request) -> JSONResponse:
552
+ """Compact a conversation into a checkpoint for continuation.
553
+
554
+ Body: {"messages": [{"role": "system|user|assistant|tool", "content": str, ...}], "reasoning_effort": str?}
555
+ """
556
+ provider = _get_provider()
557
+ body = await request.json()
558
+ raw_messages = body.get("messages") or []
559
+ messages = _request_messages_to_internal([ChatMessage.model_validate(m) for m in raw_messages])
560
+ reasoning_effort = body.get("reasoning_effort")
561
+ checkpoint = provider.compact_messages(messages, reasoning_effort=reasoning_effort)
562
+ return JSONResponse(content={"checkpoint": checkpoint})
563
+
564
+ # ------------------------------------------------------------------
565
+ # CLI entry point
566
+ # ------------------------------------------------------------------
567
+
568
+ def main() -> None:
569
+ import uvicorn
570
+ uvicorn.run("codex_as_api.server:app", host=HOST, port=PORT, log_level="info")
571
+
572
+ except ImportError as _import_exc:
573
+ # FastAPI / uvicorn not installed
574
+ app = None # type: ignore[assignment]
575
+
576
+ def main() -> None: # type: ignore[misc]
577
+ raise ImportError(
578
+ "FastAPI and uvicorn are required to run the server. "
579
+ "Install with: pip install 'codex-as-api[server]'"
580
+ ) from _import_exc