-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix: [Gap] OpenAI/OpenResponses Gateway shape: discovery/health unified app; /v1/chat only on optional RA #2091
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0b85628
ac6a8fa
113357c
afa00d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |||||||||||||||||||||||||||||||||||||||||
| - a2a: Agent-to-Agent protocol | ||||||||||||||||||||||||||||||||||||||||||
| - a2u: Agent-to-User event stream | ||||||||||||||||||||||||||||||||||||||||||
| - unified: All providers combined | ||||||||||||||||||||||||||||||||||||||||||
| - openai: OpenAI API compatibility layer | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| from typing import Optional | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -125,6 +126,7 @@ def serve_callback(ctx: typer.Context): | |||||||||||||||||||||||||||||||||||||||||
| [green]a2a[/green] Agent-to-Agent protocol (port 8001) | ||||||||||||||||||||||||||||||||||||||||||
| [green]a2u[/green] Agent-to-User events (port 8002) | ||||||||||||||||||||||||||||||||||||||||||
| [green]unified[/green] All providers combined (port 8765) | ||||||||||||||||||||||||||||||||||||||||||
| [green]openai[/green] OpenAI API compatibility layer (port 8765) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| [bold]Management:[/bold] | ||||||||||||||||||||||||||||||||||||||||||
| [yellow]start[/yellow] Start legacy API server | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -577,3 +579,140 @@ def serve_unified( | |||||||||||||||||||||||||||||||||||||||||
| output = get_output_controller() | ||||||||||||||||||||||||||||||||||||||||||
| output.print_error(f"Unified serve module not available: {e}") | ||||||||||||||||||||||||||||||||||||||||||
| raise typer.Exit(4) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @app.command("openai") | ||||||||||||||||||||||||||||||||||||||||||
| def serve_openai( | ||||||||||||||||||||||||||||||||||||||||||
| host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"), | ||||||||||||||||||||||||||||||||||||||||||
| port: int = typer.Option(8765, "--port", "-p", help="Port to bind to"), | ||||||||||||||||||||||||||||||||||||||||||
| agents_url: str = typer.Option("http://127.0.0.1:8000", "--agents-url", help="URL of the running Agents API server"), | ||||||||||||||||||||||||||||||||||||||||||
| api_key: Optional[str] = typer.Option(None, "--api-key", help="API key for authentication"), | ||||||||||||||||||||||||||||||||||||||||||
| reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"), | ||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||
| """Start OpenAI API compatibility server. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Provides OpenAI-compatible endpoints like /v1/chat/completions for | ||||||||||||||||||||||||||||||||||||||||||
| drop-in compatibility with OpenAI client libraries. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Examples: | ||||||||||||||||||||||||||||||||||||||||||
| praisonai serve openai | ||||||||||||||||||||||||||||||||||||||||||
| praisonai serve openai --port 8765 --api-key mykey | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| output = get_output_controller() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||
| import uvicorn | ||||||||||||||||||||||||||||||||||||||||||
| from fastapi import FastAPI | ||||||||||||||||||||||||||||||||||||||||||
| from praisonai.endpoints.providers import OpenAICompatProvider, AgentsAPIProvider | ||||||||||||||||||||||||||||||||||||||||||
| from praisonai.endpoints.server import create_unified_app, register_provider_to_discovery, register_endpoint_to_discovery | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| output.print_info(f"Starting OpenAI-compatible server on {host}:{port}") | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Create providers | ||||||||||||||||||||||||||||||||||||||||||
| agents_provider = AgentsAPIProvider(base_url=agents_url) | ||||||||||||||||||||||||||||||||||||||||||
| openai_provider = OpenAICompatProvider( | ||||||||||||||||||||||||||||||||||||||||||
| base_url=f"http://{host}:{port}", | ||||||||||||||||||||||||||||||||||||||||||
| api_key=api_key, | ||||||||||||||||||||||||||||||||||||||||||
| agent_provider=agents_provider | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+589
to
+617
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enforce
🛡️ Suggested direction from fastapi import Request, Response
from fastapi.responses import StreamingResponse
+ import hmac
import json
+
+ def _json_error(message: str, error_type: str, status_code: int) -> Response:
+ return Response(
+ content=json.dumps({"error": {"message": message, "type": error_type}}),
+ status_code=status_code,
+ media_type="application/json",
+ headers={"WWW-Authenticate": "Bearer"} if status_code == 401 else None,
+ )
+
+ def _require_api_key(request: Request) -> Optional[Response]:
+ if api_key is None:
+ return None
+
+ authorization = request.headers.get("authorization", "")
+ bearer_token = (
+ authorization[7:].strip()
+ if authorization.lower().startswith("bearer ")
+ else ""
+ )
+ x_api_key = request.headers.get("x-api-key", "")
+
+ if (
+ bearer_token and hmac.compare_digest(bearer_token, api_key)
+ ) or (
+ x_api_key and hmac.compare_digest(x_api_key, api_key)
+ ):
+ return None
+
+ return _json_error("Invalid API key", "authentication_error", 401)
`@app.post`("/v1/chat/completions")
async def chat_completions(request: Request):
+ auth_error = _require_api_key(request)
+ if auth_error is not None:
+ return auth_error
body = await request.json()Apply the same Also applies to: 630-695 🧰 Tools🪛 ast-grep (0.44.0)[warning] 613-613: Do not make http calls without encryption (requests-http) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Create server and register OpenAI provider | ||||||||||||||||||||||||||||||||||||||||||
| app = create_unified_app(server_name="PraisonAI OpenAI API") | ||||||||||||||||||||||||||||||||||||||||||
| register_provider_to_discovery(app, openai_provider.get_provider_info()) | ||||||||||||||||||||||||||||||||||||||||||
| for endpoint in openai_provider.list_endpoints(): | ||||||||||||||||||||||||||||||||||||||||||
| register_endpoint_to_discovery(app, endpoint) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Add OpenAI-style route mappings | ||||||||||||||||||||||||||||||||||||||||||
| from fastapi import Request, Response | ||||||||||||||||||||||||||||||||||||||||||
| from fastapi.responses import StreamingResponse | ||||||||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @app.post("/v1/chat/completions") | ||||||||||||||||||||||||||||||||||||||||||
| async def chat_completions(request: Request): | ||||||||||||||||||||||||||||||||||||||||||
| body = await request.json() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if body.get("stream", False): | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+632
to
+634
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normalize bad JSON into OpenAI-style 400 responses.
🐛 Suggested direction+ from json import JSONDecodeError
+
+ async def _read_json_object(request: Request):
+ try:
+ body = await request.json()
+ except JSONDecodeError:
+ return None, Response(
+ content=json.dumps({"error": {"message": "Invalid JSON body", "type": "invalid_request_error"}}),
+ status_code=400,
+ media_type="application/json",
+ )
+
+ if not isinstance(body, dict):
+ return None, Response(
+ content=json.dumps({"error": {"message": "Request body must be a JSON object", "type": "invalid_request_error"}}),
+ status_code=400,
+ media_type="application/json",
+ )
+
+ return body, None
+
`@app.post`("/v1/chat/completions")
async def chat_completions(request: Request):
- body = await request.json()
+ body, body_error = await _read_json_object(request)
+ if body_error is not None:
+ return body_errorApply the same helper to Also applies to: 666-667, 685-686 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| def generate(): | ||||||||||||||||||||||||||||||||||||||||||
| for chunk in openai_provider.invoke_stream("chat_completions", body): | ||||||||||||||||||||||||||||||||||||||||||
| if chunk["event"] == "data": | ||||||||||||||||||||||||||||||||||||||||||
| yield f"data: {json.dumps(chunk['data'])}\n\n" | ||||||||||||||||||||||||||||||||||||||||||
| elif chunk["event"] == "done": | ||||||||||||||||||||||||||||||||||||||||||
| yield "data: [DONE]\n\n" | ||||||||||||||||||||||||||||||||||||||||||
| elif chunk["event"] == "error": | ||||||||||||||||||||||||||||||||||||||||||
| # Send error as OpenAI-formatted SSE error chunk | ||||||||||||||||||||||||||||||||||||||||||
| error_chunk = { | ||||||||||||||||||||||||||||||||||||||||||
| "error": { | ||||||||||||||||||||||||||||||||||||||||||
| "message": chunk.get("data", {}).get("error", "Stream error occurred"), | ||||||||||||||||||||||||||||||||||||||||||
| "type": "stream_error" | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| yield f"data: {json.dumps(error_chunk)}\n\n" | ||||||||||||||||||||||||||||||||||||||||||
| yield "data: [DONE]\n\n" | ||||||||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||||||||
| return StreamingResponse(generate(), media_type="text/event-stream") | ||||||||||||||||||||||||||||||||||||||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| result = openai_provider.invoke("chat_completions", body, stream=False) | ||||||||||||||||||||||||||||||||||||||||||
| if not result.ok: | ||||||||||||||||||||||||||||||||||||||||||
| return Response( | ||||||||||||||||||||||||||||||||||||||||||
| content=json.dumps({"error": {"message": result.error, "type": "api_error"}}), | ||||||||||||||||||||||||||||||||||||||||||
| status_code=400, | ||||||||||||||||||||||||||||||||||||||||||
| media_type="application/json" | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return result.data | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+630
to
+662
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When @app.post("/v1/chat/completions")
async def chat_completions(request: Request):
body = await request.json()
if body.get("stream", False):
def generate():
for chunk in openai_provider.invoke_stream("chat_completions", body):
if chunk["event"] == "data":
yield f"data: {json.dumps(chunk['data'])}\n\n"
elif chunk["event"] == "done":
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
result = openai_provider.invoke("chat_completions", body, stream=False)
if not result.ok:
return Response(
content=json.dumps({"error": {"message": result.error, "type": "api_error"}}),
status_code=400,
media_type="application/json"
)
return result.data
greptile-apps[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @app.post("/v1/completions") | ||||||||||||||||||||||||||||||||||||||||||
| async def completions(request: Request): | ||||||||||||||||||||||||||||||||||||||||||
| body = await request.json() | ||||||||||||||||||||||||||||||||||||||||||
| result = openai_provider.invoke("completions", body) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if not result.ok: | ||||||||||||||||||||||||||||||||||||||||||
| return Response( | ||||||||||||||||||||||||||||||||||||||||||
| content=json.dumps({"error": {"message": result.error, "type": "api_error"}}), | ||||||||||||||||||||||||||||||||||||||||||
| status_code=400, | ||||||||||||||||||||||||||||||||||||||||||
| media_type="application/json" | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return result.data | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @app.get("/v1/models") | ||||||||||||||||||||||||||||||||||||||||||
| async def models(): | ||||||||||||||||||||||||||||||||||||||||||
| result = openai_provider.invoke("models") | ||||||||||||||||||||||||||||||||||||||||||
| return result.data if result.ok else {"error": result.error} | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| @app.post("/v1/tools/invoke") | ||||||||||||||||||||||||||||||||||||||||||
| async def tools_invoke(request: Request): | ||||||||||||||||||||||||||||||||||||||||||
| body = await request.json() | ||||||||||||||||||||||||||||||||||||||||||
| result = openai_provider.invoke("tools_invoke", body) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if not result.ok: | ||||||||||||||||||||||||||||||||||||||||||
| return Response( | ||||||||||||||||||||||||||||||||||||||||||
| content=json.dumps({"error": {"message": result.error, "type": "api_error"}}), | ||||||||||||||||||||||||||||||||||||||||||
| status_code=400, | ||||||||||||||||||||||||||||||||||||||||||
| media_type="application/json" | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return result.data | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| output.print("OpenAI-compatible endpoints available:") | ||||||||||||||||||||||||||||||||||||||||||
| output.print(" POST /v1/chat/completions") | ||||||||||||||||||||||||||||||||||||||||||
| output.print(" POST /v1/completions") | ||||||||||||||||||||||||||||||||||||||||||
| output.print(" GET /v1/models") | ||||||||||||||||||||||||||||||||||||||||||
| output.print(" POST /v1/tools/invoke") | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Start server | ||||||||||||||||||||||||||||||||||||||||||
| uvicorn.run( | ||||||||||||||||||||||||||||||||||||||||||
| app, | ||||||||||||||||||||||||||||||||||||||||||
| host=host, | ||||||||||||||||||||||||||||||||||||||||||
| port=port, | ||||||||||||||||||||||||||||||||||||||||||
| reload=reload, | ||||||||||||||||||||||||||||||||||||||||||
| access_log=False, | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+704
to
+710
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Comment on lines
+704
to
+710
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Uvicorn does not support the reload=True option when the ASGI application is passed as an object [1][2]. If you attempt to use reload=True with an application instance, Uvicorn will typically fail or display a warning because it requires an import string (e.g., "main:app") to properly manage the lifecycle of the reloaded processes [3][2]. The current Uvicorn documentation explicitly recommends using the import string style when running programmatically if you require features like auto-reloading or multiple workers [1][4]. When using these features, you must pass the application as a string representing the import path, and it is strongly advised to wrap the uvicorn.run call within an if name == 'main': block to prevent issues with multiprocessing [5][1][4]. Citations:
Reject The code passes an in-memory FastAPI object to Suggested fix+ if reload:
+ output.print_error(
+ "--reload is not supported for the dynamically constructed OpenAI server yet"
+ )
+ raise typer.Exit(2)
+
uvicorn.run(
app,
host=host,
port=port,
- reload=reload,
+ reload=False,
access_log=False,
)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| except ImportError as e: | ||||||||||||||||||||||||||||||||||||||||||
| output.print_error(f"OpenAI compatibility module not available: {e}") | ||||||||||||||||||||||||||||||||||||||||||
| output.print("Install with: pip install praisonai[api]") | ||||||||||||||||||||||||||||||||||||||||||
| raise typer.Exit(4) | ||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||
| output.print_error(f"Failed to start OpenAI server: {e}") | ||||||||||||||||||||||||||||||||||||||||||
| raise typer.Exit(1) | ||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add an
--agents-urloption to allow users to specify the URL of their running Agents API server. Hardcoding it to the same host and port as the OpenAI server will cause tool invocation to fail with a 404, as the agents API endpoints are not registered on this server.