Skip to content

Commit 13700c2

Browse files
authored
Add OpenAI responses API support (#97)
1 parent 8ae20df commit 13700c2

9 files changed

Lines changed: 444 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ venv.bak/
5656
# Performance test results
5757
tests/performance/results/
5858
.coverage*
59+
coverage*
5960

6061
# Local test script
6162
tools/tmp/*

app/api/routes/proxy.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
EmbeddingsRequest,
1616
ImageEditsRequest,
1717
ImageGenerationRequest,
18+
ResponsesRequest,
1819
)
1920
from app.core.async_cache import forge_scope_cache_async, get_forge_scope_cache_async
2021
from app.core.database import get_async_db
@@ -283,3 +284,51 @@ async def create_embeddings(
283284
raise HTTPException(
284285
status_code=500, detail=f"Error processing request: {str(err)}"
285286
) from err
287+
288+
@router.post("/responses")
289+
async def create_responses(
290+
request: Request,
291+
responses_request: ResponsesRequest,
292+
user_details: dict[str, Any] = Depends(get_user_details_by_api_key),
293+
db: AsyncSession = Depends(get_async_db),
294+
) -> Any:
295+
"""
296+
Create a response (OpenAI-compatible endpoint).
297+
"""
298+
try:
299+
user = user_details["user"]
300+
api_key_id = user_details["api_key_id"]
301+
provider_service = await ProviderService.async_get_instance(user, db, api_key_id=api_key_id)
302+
allowed_provider_names = await _get_allowed_provider_names(request, db)
303+
304+
response = await provider_service.process_request(
305+
"responses",
306+
responses_request.model_dump(mode="json", exclude_unset=True),
307+
allowed_provider_names=allowed_provider_names,
308+
)
309+
310+
# Check if it's a streaming response
311+
if inspect.isasyncgen(response):
312+
headers = {
313+
"Content-Type": "text/event-stream",
314+
"Cache-Control": "no-cache",
315+
"Connection": "keep-alive",
316+
"X-Accel-Buffering": "no", # Prevent Nginx buffering
317+
}
318+
319+
return StreamingResponse(
320+
response, media_type="text/event-stream", headers=headers
321+
)
322+
323+
# Otherwise, return the JSON response directly
324+
return response
325+
except NotImplementedError as err:
326+
raise HTTPException(
327+
status_code=404, detail=f"Error processing request: {str(err)}"
328+
) from err
329+
except ValueError as err:
330+
raise HTTPException(status_code=400, detail=str(err)) from err
331+
except Exception as err:
332+
raise HTTPException(
333+
status_code=500, detail=f"Error processing request: {str(err)}"
334+
) from err

app/api/schemas/openai.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,3 +252,202 @@ class EmbeddingsRequest(BaseModel):
252252
encoding_format: str | None = 'float'
253253
# inpput_type is for cohere embeddings only
254254
input_type: str | None = 'search_document'
255+
256+
257+
# ---------------------------------------------------------------------------
258+
# OpenAI Responses Request
259+
# https://platform.openai.com/docs/api-reference/responses/create
260+
# ---------------------------------------------------------------------------
261+
class ResponsesInputTextItem(BaseModel):
262+
text: str
263+
type: str # always input_text
264+
265+
class ResponsesInputImageItem(BaseModel):
266+
detail: str | None = 'auto'
267+
type: str # always input_image
268+
file_id: str | None = None
269+
image_url: str | None = None
270+
271+
class ResponsesInputFileItem(BaseModel):
272+
type: str # always input_file
273+
file_data: str | None = None
274+
file_id: str | None = None
275+
file_url: str | None = None
276+
filename: str | None = None
277+
278+
class ResponsesInputAudioItem(BaseModel):
279+
input_audio: object
280+
type: str # always input_audio
281+
282+
class ResponsesInputMessageItem(BaseModel):
283+
role: str
284+
type: str | None = None
285+
content: str | list[ResponsesInputTextItem | ResponsesInputImageItem | ResponsesInputFileItem | ResponsesInputAudioItem]
286+
287+
288+
class ResponsesItemInputMessage(BaseModel):
289+
role: str
290+
content: list[ResponsesInputTextItem | ResponsesInputImageItem | ResponsesInputFileItem | ResponsesInputAudioItem]
291+
status: str | None = None
292+
type: str | None = None
293+
294+
class ResponsesItemOutputMessage(BaseModel):
295+
content: list[object]
296+
id: str
297+
role: str
298+
status: str
299+
type: str
300+
301+
class ResponsesItemFileSearchToolCall(BaseModel):
302+
id: str
303+
query: str
304+
status: str
305+
type: str
306+
results: list[object]
307+
308+
class ResponsesItemComputerToolCall(BaseModel):
309+
action: object
310+
call_id: str
311+
id: str
312+
pending_safety_checks: list[object]
313+
status: str
314+
type: str
315+
316+
class ResponsesItemComputerToolCallOutput(BaseModel):
317+
call_id: str
318+
output: object
319+
type: str
320+
acknowledged_safety_checks: list[object] | None = None
321+
id: str | None = None
322+
status: str | None = None
323+
324+
class ResponsesItemWebSearchToolCall(BaseModel):
325+
action: object
326+
id: str
327+
status: str
328+
type: str
329+
330+
class ResponsesItemFunctionToolCall(BaseModel):
331+
arguments: str
332+
call_id: str
333+
name: str
334+
type: str
335+
id: str | None = None
336+
status: str | None = None
337+
338+
class ResponsesItemFunctionToolCallOutput(BaseModel):
339+
call_id: str
340+
output: str | list[object]
341+
type: str
342+
id: str | None = None
343+
status: str | None = None
344+
345+
class ResponsesItemReasoning(BaseModel):
346+
id: str
347+
summary: list[object]
348+
type: str
349+
content: list[object] | None = None
350+
encrypted_content: str | None = None
351+
status: str | None = None
352+
353+
class ResponsesItemImageGenerationCall(BaseModel):
354+
id: str
355+
result: str
356+
status: str
357+
type: str
358+
359+
class ResponsesItemCodeInterpreterToolCall(BaseModel):
360+
code: str
361+
container_id: str
362+
id: str
363+
outputs: list[object]
364+
status: str
365+
type: str
366+
367+
class ResponsesItemLocalShellCall(BaseModel):
368+
action: object
369+
call_id: str
370+
id: str
371+
status: str
372+
type: str
373+
374+
class ResponsesItemLocalShellCallOutput(BaseModel):
375+
id: str
376+
output: str
377+
type: str
378+
status: str | None = None
379+
380+
class ResponsesItemMCPListTools(BaseModel):
381+
id: str
382+
server_label: str
383+
tools: list[object]
384+
type: str
385+
error: str | None = None
386+
387+
class ResponsesItemMCPApprovalRequest(BaseModel):
388+
arguments: str
389+
id: str
390+
name: str
391+
server_label: str
392+
type: str
393+
394+
class ResponsesItemMCPApprovalResponse(BaseModel):
395+
approval_request_id: str
396+
approve: bool
397+
type: str
398+
id: str | None = None
399+
reason: str | None = None
400+
401+
class ResponsesItemMCPToolCall(BaseModel):
402+
arguments: str
403+
id: str
404+
name: str
405+
server_label: str
406+
type: str
407+
error: str | None = None
408+
output: str | None = None
409+
410+
class ResponsesItemCustomToolCallOutput(BaseModel):
411+
call_id: str
412+
output: str | list[object]
413+
type: str
414+
id: str | None = None
415+
416+
class ResponsesItemCustomToolCall(BaseModel):
417+
call_id: str
418+
input: str
419+
name: str
420+
type: str
421+
id: str | None = None
422+
423+
class ResponsesItemReference(BaseModel):
424+
id: str
425+
type: str
426+
427+
class ResponsesRequest(BaseModel):
428+
background: bool | None = False
429+
conversation: str | object | None = None
430+
include: list[Any] | None = None
431+
input: str | list[ResponsesInputMessageItem | ResponsesItemReference | ResponsesItemInputMessage | ResponsesItemFileSearchToolCall | ResponsesItemComputerToolCall | ResponsesItemWebSearchToolCall | ResponsesItemFunctionToolCall | ResponsesItemReasoning | ResponsesItemImageGenerationCall | ResponsesItemCodeInterpreterToolCall | ResponsesItemLocalShellCall | ResponsesItemMCPListTools | ResponsesItemMCPApprovalRequest | ResponsesItemMCPApprovalResponse | ResponsesItemMCPToolCall | ResponsesItemCustomToolCallOutput | ResponsesItemCustomToolCall] | None = None
432+
instructions: str | None = None
433+
max_output_tokens: int | None = None
434+
max_tool_calls: int | None = None
435+
metadata: dict[Any, Any] | None = None
436+
model: str | None = None
437+
parallel_tool_calls: bool | None = True
438+
previous_response_id: str | None = None
439+
prompt: object | None = None
440+
prompt_cache_key: str | None = None
441+
reasoning: object | None = None
442+
safety_identifier: str | None = None
443+
service_tier: str | None = 'auto'
444+
store: bool | None = True
445+
stream: bool | None = False
446+
stream_options: object | None = None
447+
temperature: float | None = 1.0
448+
text: object | None = None
449+
tool_choice: str | object | None = None
450+
tools: list[Any] | None = None
451+
top_logprobs: int | None = None
452+
top_p: float | None = 1.0
453+
truncation: str | None = 'disabled'

app/services/provider_service.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,12 @@ async def process_request(
620620
payload,
621621
api_key,
622622
)
623+
elif "responses" == endpoint:
624+
result = await adapter.process_responses(
625+
endpoint,
626+
payload,
627+
api_key,
628+
)
623629
elif "images/generations" in endpoint:
624630
# TODO: we only support openai for now
625631
if provider_name != "openai":

app/services/providers/base.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,27 @@ async def process_completion(
3636
) -> Any:
3737
"""Process a completion request"""
3838
pass
39+
40+
async def process_responses(
41+
self,
42+
endpoint: str,
43+
payload: dict[str, Any],
44+
api_key: str,
45+
base_url: str | None = None,
46+
) -> Any:
47+
"""Process a response request"""
48+
# TODO: currently it's openai only
49+
raise NotImplementedError("Process response is not implemented")
50+
51+
async def process_conversations(
52+
self,
53+
endpoint: str,
54+
payload: dict[str, Any],
55+
api_key: str,
56+
base_url: str | None = None,
57+
) -> Any:
58+
"""Process a conversations request"""
59+
raise NotImplementedError("Process conversations is not implemented")
3960

4061
@abstractmethod
4162
async def process_embeddings(

app/services/providers/openai_adapter.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,68 @@ async def process_embeddings(
352352
"usage": total_usage,
353353
}
354354
return final_response
355+
356+
async def process_responses(
357+
self,
358+
endpoint: str,
359+
payload: dict[str, Any],
360+
api_key: str,
361+
base_url: str | None = None,
362+
) -> Any:
363+
"""Process a response request using OpenAI API"""
364+
headers = {
365+
"Authorization": f"Bearer {api_key}",
366+
"Content-Type": "application/json",
367+
}
368+
369+
url = f"{base_url or self._base_url}/{endpoint}"
370+
371+
# Check if streaming is requested
372+
streaming = payload.get("stream", False)
373+
if streaming:
374+
# For streaming, return a streaming generator
375+
async def stream_response() -> AsyncGenerator[bytes, None]:
376+
async with (
377+
aiohttp.ClientSession() as session,
378+
session.post(
379+
url, headers=headers, json=payload
380+
) as response,
381+
):
382+
if response.status != HTTPStatus.OK:
383+
error_text = await response.text()
384+
logger.error(
385+
f"Responses Streaming API error for {self.provider_name}: {error_text}"
386+
)
387+
raise ProviderAPIException(
388+
provider_name=self.provider_name,
389+
error_code=response.status,
390+
error_message=error_text,
391+
)
392+
393+
# Stream the response back
394+
async for chunk in response.content:
395+
if chunk:
396+
yield chunk
397+
398+
# Return the streaming generator
399+
return stream_response()
400+
else:
401+
# For non-streaming, use the regular approach
402+
async with (
403+
aiohttp.ClientSession() as session,
404+
session.post(
405+
url, headers=headers, json=payload
406+
) as response,
407+
):
408+
if response.status != HTTPStatus.OK:
409+
error_text = await response.text()
410+
logger.error(
411+
f"Responses API error for {self.provider_name}: {error_text}"
412+
)
413+
raise ProviderAPIException(
414+
provider_name=self.provider_name,
415+
error_code=response.status,
416+
error_message=error_text,
417+
)
418+
419+
return await response.json()

0 commit comments

Comments
 (0)