Skip to content

Commit 11878e5

Browse files
enystopenhands-agentsmolpaws
authored
feat(agent-server): add OpenAI chat completions gateway (#3545)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: 🐾 smolpaws <engel@enyst.org>
1 parent fd095dd commit 11878e5

15 files changed

Lines changed: 1020 additions & 8 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Use the agent-server through an OpenAI-compatible Chat Completions client.
2+
3+
This example starts a local agent-server, stores an LLM profile, lists it through
4+
``GET /v1/models``, then calls ``POST /v1/chat/completions`` with the OpenAI
5+
Python SDK. The returned ``X-OpenHands-ServerConversation-ID`` header is passed
6+
back on a second call to continue the same OpenHands conversation.
7+
"""
8+
9+
import os
10+
from uuid import UUID
11+
12+
import httpx
13+
from openai import OpenAI
14+
from scripts.utils import ManagedAPIServer
15+
16+
17+
# The gateway runs a full OpenHands agent, but OpenAI clients still need a
18+
# normal model-like name. We create an LLM profile below and expose it as
19+
# `openhands_<profile_name>` through `/v1/models`.
20+
21+
api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
22+
assert api_key is not None, "Set LLM_API_KEY or OPENAI_API_KEY."
23+
24+
llm_model = os.getenv("LLM_MODEL", "gpt-5-nano")
25+
llm_base_url = os.getenv("LLM_BASE_URL")
26+
profile_name = "gateway_demo"
27+
gateway_model = f"openhands_{profile_name}"
28+
29+
# Start a local agent-server for the demo. `use_session_api_key=True` turns on
30+
# authentication; the same key works as both `X-Session-API-Key` for native
31+
# agent-server routes and `Authorization: Bearer ...` for OpenAI SDK calls.
32+
33+
with ManagedAPIServer(
34+
port=8770,
35+
use_session_api_key=True,
36+
extra_env={
37+
"OH_ENABLE_VNC": "0",
38+
"OH_ENABLE_VSCODE": "0",
39+
"OH_PRELOAD_TOOLS": "0",
40+
"OH_SECRET_KEY": "example-secret-key-for-demo-only-32b",
41+
"OH_WEBHOOKS": "[]",
42+
},
43+
health_request_timeout=2.0,
44+
) as server:
45+
session_api_key = (
46+
os.getenv("SESSION_API_KEY")
47+
or os.getenv("OH_SESSION_API_KEYS_0")
48+
or server.session_api_key
49+
)
50+
assert session_api_key is not None
51+
52+
# Use the native REST API once to create the profile that backs the gateway
53+
# model. After that, normal OpenAI SDK calls are enough for chat traffic.
54+
api_client = httpx.Client(
55+
base_url=server.base_url,
56+
headers={"X-Session-API-Key": session_api_key},
57+
timeout=120.0,
58+
)
59+
openai_client = OpenAI(
60+
api_key=session_api_key,
61+
base_url=f"{server.base_url}/v1",
62+
timeout=120.0,
63+
)
64+
65+
llm_config = {"model": llm_model, "api_key": api_key}
66+
if llm_base_url:
67+
llm_config["base_url"] = llm_base_url
68+
69+
# `gateway_demo` becomes visible to OpenAI clients as `openhands_gateway_demo`.
70+
profile_response = api_client.post(
71+
f"/api/profiles/{profile_name}",
72+
json={"llm": llm_config, "include_secrets": True},
73+
)
74+
assert profile_response.status_code == 201, profile_response.text
75+
76+
models = openai_client.models.list()
77+
model_ids = [model.id for model in models.data]
78+
assert gateway_model in model_ids
79+
print(f"Gateway models include: {gateway_model}")
80+
81+
# Ask through the OpenAI SDK. `with_raw_response` lets us read the custom
82+
# response header that identifies the OpenHands conversation created behind
83+
# this otherwise OpenAI-shaped request.
84+
85+
first_response = openai_client.chat.completions.with_raw_response.create(
86+
model=gateway_model,
87+
messages=[
88+
{
89+
"role": "system",
90+
"content": "Answer directly and do not use tools.",
91+
},
92+
{
93+
"role": "user",
94+
"content": (
95+
"In one sentence, explain what an OpenAI-compatible "
96+
"agent-server gateway does."
97+
),
98+
},
99+
],
100+
)
101+
first_completion = first_response.parse()
102+
conversation_id = first_response.headers.get("X-OpenHands-ServerConversation-ID")
103+
assert conversation_id is not None
104+
UUID(conversation_id)
105+
106+
first_answer = first_completion.choices[0].message.content
107+
print(f"First answer: {first_answer}")
108+
print(f"OpenHands conversation ID: {conversation_id}")
109+
110+
persisted_response = api_client.get(f"/api/conversations/{conversation_id}")
111+
assert persisted_response.status_code == 200, persisted_response.text
112+
113+
# The gateway keeps conversations by default. Passing the header back lets
114+
# another OpenAI-compatible request continue the same server-side agent
115+
# conversation instead of starting over.
116+
117+
second_completion = openai_client.chat.completions.create(
118+
model=gateway_model,
119+
messages=[
120+
{
121+
"role": "user",
122+
"content": "Now answer in five words or fewer: what did I ask about?",
123+
}
124+
],
125+
extra_headers={"X-OpenHands-ServerConversation-ID": conversation_id},
126+
)
127+
second_answer = second_completion.choices[0].message.content
128+
print(f"Second answer using same conversation: {second_answer}")
129+
130+
conversation_response = api_client.get(f"/api/conversations/{conversation_id}")
131+
assert conversation_response.status_code == 200, conversation_response.text
132+
stats = conversation_response.json().get("stats") or {}
133+
usage_to_metrics = stats.get("usage_to_metrics") or {}
134+
accumulated_cost = sum(
135+
metrics.get("accumulated_cost", 0.0) for metrics in usage_to_metrics.values()
136+
)
137+
138+
# Clean up the demo resources. Real applications can keep the conversation
139+
# ID and inspect it later through the native agent-server API.
140+
api_client.delete(f"/api/conversations/{conversation_id}")
141+
api_client.delete(f"/api/profiles/{profile_name}")
142+
api_client.close()
143+
144+
print(f"EXAMPLE_COST: {accumulated_cost}")

openhands-agent-server/openhands/agent_server/api.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
from openhands.agent_server.llm_router import llm_router
4242
from openhands.agent_server.mcp_router import mcp_router
4343
from openhands.agent_server.middleware import CORSDispatcher
44+
from openhands.agent_server.openai.router import (
45+
create_openai_api_key_dependency,
46+
openai_router,
47+
)
4448
from openhands.agent_server.profiles_router import profiles_router
4549
from openhands.agent_server.server_details_router import (
4650
get_server_info,
@@ -319,6 +323,11 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
319323
api_router.include_router(auth_router)
320324
app.include_router(api_router)
321325

326+
openai_dependencies = []
327+
if config.session_api_keys:
328+
openai_dependencies.append(Depends(create_openai_api_key_dependency(config)))
329+
app.include_router(openai_router, dependencies=openai_dependencies)
330+
322331
# Workspace static-file routes get their own auth group that accepts
323332
# EITHER the X-Session-API-Key header OR the workspace session cookie.
324333
# The cookie is required so that <iframe src> / <img src> embeds of

openhands-agent-server/openhands/agent_server/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ class Config(BaseModel):
132132
"The location of the directory where conversations and events are stored."
133133
),
134134
)
135+
workspace_path: Path = Field(
136+
default=Path("workspace/project"),
137+
description=(
138+
"Default workspace directory for conversations created by the server."
139+
),
140+
)
135141
bash_events_dir: Path = Field(
136142
default=Path("workspace/bash_events"),
137143
description=(
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# OpenAI-compatible gateway
2+
3+
This package contains the agent-server implementation for the OpenAI-compatible API surface under `/v1`.
4+
5+
- `router.py` defines the FastAPI routes and maps OpenAI-style bearer authentication to the existing session key mechanism.
6+
- `models.py` contains the small server-side request models and aliases the reusable OpenAI response models.
7+
- `service.py` translates OpenAI chat completion requests into OpenHands conversations, waits for completion, and returns OpenAI-shaped responses.
8+
9+
The gateway intentionally stays separate from the native agent-server routers so the OpenAI compatibility layer can evolve without mixing protocol translation code into the core REST API modules.

openhands-agent-server/openhands/agent_server/openai/__init__.py

Whitespace-only changes.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Models for the OpenAI-compatible agent-server gateway."""
2+
3+
from typing import Literal
4+
5+
from openai.types import CompletionUsage, Model
6+
from openai.types.chat import ChatCompletion
7+
from openai.types.chat.chat_completion import Choice
8+
from openai.types.chat.chat_completion_message import ChatCompletionMessage
9+
from pydantic import BaseModel, ConfigDict
10+
11+
12+
OpenAIChatCompletionChoice = Choice
13+
OpenAIChatCompletionResponse = ChatCompletion
14+
OpenAIModel = Model
15+
OpenAIResponseMessage = ChatCompletionMessage
16+
OpenAIUsage = CompletionUsage
17+
18+
19+
class OpenAIImageURL(BaseModel):
20+
url: str
21+
22+
23+
class OpenAIContentPart(BaseModel):
24+
type: str
25+
text: str | None = None
26+
image_url: OpenAIImageURL | str | None = None
27+
28+
model_config = ConfigDict(extra="ignore")
29+
30+
31+
class OpenAIChatMessage(BaseModel):
32+
role: Literal["system", "user", "assistant", "tool"]
33+
content: str | list[OpenAIContentPart] | None = None
34+
35+
model_config = ConfigDict(extra="ignore")
36+
37+
38+
class OpenAIChatCompletionRequest(BaseModel):
39+
model: str
40+
messages: list[OpenAIChatMessage]
41+
stream: bool = False
42+
43+
model_config = ConfigDict(extra="ignore")
44+
45+
46+
class OpenAIModelListResponse(BaseModel):
47+
object: Literal["list"] = "list"
48+
data: list[OpenAIModel]
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""OpenAI-compatible gateway routes for the agent server."""
2+
3+
from typing import Annotated
4+
from uuid import UUID
5+
6+
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status
7+
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
8+
9+
from openhands.agent_server.config import Config
10+
from openhands.agent_server.conversation_service import ConversationService
11+
from openhands.agent_server.dependencies import get_conversation_service
12+
from openhands.agent_server.openai.models import (
13+
OpenAIChatCompletionRequest,
14+
OpenAIChatCompletionResponse,
15+
OpenAIModelListResponse,
16+
)
17+
from openhands.agent_server.openai.service import (
18+
list_openai_models,
19+
run_chat_completion,
20+
)
21+
22+
23+
openai_router = APIRouter(tags=["OpenAI Compatibility"])
24+
25+
_SESSION_API_KEY_HEADER = APIKeyHeader(name="X-Session-API-Key", auto_error=False)
26+
_AUTHORIZATION_HEADER = HTTPBearer(auto_error=False)
27+
28+
29+
def create_openai_api_key_dependency(config: Config):
30+
"""Accept the same session key through OpenHands and OpenAI auth shapes.
31+
32+
``X-Session-API-Key`` preserves compatibility with existing agent-server
33+
clients, while ``Authorization: Bearer`` lets OpenAI-compatible clients use
34+
their standard API-key header. Both forms validate against
35+
``config.session_api_keys``; this does not introduce a second credential
36+
system. When no session keys are configured, the local server remains
37+
unauthenticated like the existing agent-server API.
38+
"""
39+
40+
def check_openai_api_key(
41+
session_api_key: str | None = Depends(_SESSION_API_KEY_HEADER),
42+
authorization: HTTPAuthorizationCredentials | None = Depends(
43+
_AUTHORIZATION_HEADER
44+
),
45+
) -> None:
46+
if not config.session_api_keys:
47+
return
48+
bearer_token = authorization.credentials if authorization else None
49+
if session_api_key in config.session_api_keys:
50+
return
51+
if bearer_token in config.session_api_keys:
52+
return
53+
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
54+
55+
return check_openai_api_key
56+
57+
58+
def _get_config(request: Request) -> Config:
59+
config = getattr(request.app.state, "config", None)
60+
if not isinstance(config, Config):
61+
raise HTTPException(
62+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
63+
detail="Agent server config is not available",
64+
)
65+
return config
66+
67+
68+
@openai_router.get("/v1/models", response_model=OpenAIModelListResponse)
69+
async def get_openai_models(request: Request) -> OpenAIModelListResponse:
70+
_get_config(request)
71+
return await list_openai_models()
72+
73+
74+
@openai_router.post(
75+
"/v1/chat/completions",
76+
response_model=OpenAIChatCompletionResponse,
77+
response_model_exclude_none=True,
78+
)
79+
async def create_chat_completion(
80+
body: OpenAIChatCompletionRequest,
81+
request: Request,
82+
response: Response,
83+
x_openhands_server_conversation_id: Annotated[
84+
UUID | None, Header(alias="X-OpenHands-ServerConversation-ID")
85+
] = None,
86+
conversation_service: ConversationService = Depends(get_conversation_service),
87+
) -> OpenAIChatCompletionResponse:
88+
result = await run_chat_completion(
89+
request=body,
90+
config=_get_config(request),
91+
conversation_service=conversation_service,
92+
reusable_conversation_id=x_openhands_server_conversation_id,
93+
)
94+
response.headers["X-OpenHands-ServerConversation-ID"] = str(result.conversation_id)
95+
return result.response

0 commit comments

Comments
 (0)