From 12b8db674eeef11439fb54177ded192ae146e579 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 17 Apr 2026 16:58:52 +0200 Subject: [PATCH 1/8] docs(passthrough): add beta passthrough API page --- docs/docs.json | 1 + docs/features/passthrough-api.mdx | 150 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 docs/features/passthrough-api.mdx diff --git a/docs/docs.json b/docs/docs.json index 3ac99f1c..be5c5630 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -38,6 +38,7 @@ "pages": [ "features/aliases", "features/user-path", + "features/passthrough-api", "features/cache", "features/failover" ] diff --git a/docs/features/passthrough-api.mdx b/docs/features/passthrough-api.mdx new file mode 100644 index 00000000..94a9e0af --- /dev/null +++ b/docs/features/passthrough-api.mdx @@ -0,0 +1,150 @@ +--- +title: "Passthrough API" +description: "Use provider-native APIs through GoModel, including Anthropic SDK requests routed through the passthrough endpoint." +icon: "route" +tag: "Beta" +--- + +## Overview + + + The passthrough API is in beta. Use it when you need a provider-native API + shape, and prefer the OpenAI-compatible `/v1` routes when you need the most + stable GoModel interface. + + +The passthrough API proxies provider-native requests through GoModel without +translating the request or response body. + +Use it when a client library expects a provider's native API instead of the +OpenAI-compatible API. For example, the Anthropic SDK sends requests to +`/v1/messages`, so you can point it at GoModel's Anthropic passthrough base URL: + +```text +http://localhost:8080/p/anthropic +``` + +The SDK sends: + +```text +POST /p/anthropic/v1/messages +``` + +GoModel normalizes the optional `v1` segment and forwards the request upstream +as Anthropic's native: + +```text +POST /messages +``` + +## How it works + +Passthrough routes use this shape: + +```text +/p/{provider}/{endpoint} +``` + +For Anthropic, these two paths map to the same upstream endpoint when +`ALLOW_PASSTHROUGH_V1_ALIAS=true`, which is the default: + +```text +/p/anthropic/messages +/p/anthropic/v1/messages +``` + +GoModel handles gateway authentication first. If `GOMODEL_MASTER_KEY` or +managed auth keys are enabled, the client must send a GoModel bearer token: + +```http +Authorization: Bearer change-me +``` + +GoModel strips client `Authorization` and `X-Api-Key` headers before forwarding +the request, then applies the upstream provider credential configured on the +server. For Anthropic, GoModel uses its configured `ANTHROPIC_API_KEY`. + +Because passthrough is provider-native, the response is also provider-native. +For Anthropic messages, the response uses Anthropic's message schema, not an +OpenAI chat completion schema. + +## Configuration + +Passthrough routes are enabled by default: + +```env +ENABLE_PASSTHROUGH_ROUTES=true +ALLOW_PASSTHROUGH_V1_ALIAS=true +ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic +``` + +Set `ENABLED_PASSTHROUGH_PROVIDERS` to the provider types you want to expose. + +## Anthropic SDK example + +Set the Anthropic SDK base URL to GoModel's Anthropic passthrough route. Use the +GoModel token as Anthropic SDK bearer auth. + + + +```python Python +import os +from anthropic import Anthropic + +client = Anthropic( + base_url="http://localhost:8080/p/anthropic", + auth_token=os.environ["GOMODEL_MASTER_KEY"], +) + +message = client.messages.create( + model="claude-3-haiku-20240307", + max_tokens=16, + messages=[ + { + "role": "user", + "content": "Reply with exactly ok", + } + ], +) + +print(message.content[0].text) +``` + +```javascript JavaScript +import Anthropic from "@anthropic-ai/sdk"; + +const client = new Anthropic({ + baseURL: "http://localhost:8080/p/anthropic", + authToken: process.env.GOMODEL_MASTER_KEY, +}); + +const message = await client.messages.create({ + model: "claude-3-haiku-20240307", + max_tokens: 16, + messages: [ + { + role: "user", + content: "Reply with exactly ok", + }, + ], +}); + +console.log(message.content[0].text); +``` + + + +If your gateway does not require authentication, use a dummy SDK API key such as +`not-needed` instead of `auth_token` or `authToken`. GoModel strips `X-Api-Key` +from passthrough requests before forwarding them upstream. + +## Current limitations + +Passthrough is intentionally narrow while the API is in beta. + +- Only `openai` and `anthropic` passthrough providers are supported. +- GoModel does not translate passthrough request bodies or response bodies. +- Provider-native error bodies and status codes are proxied instead of converted + into OpenAI-compatible responses. +- Features that depend on OpenAI-compatible request or response shapes may not + apply to passthrough traffic in the same way as `/v1` traffic. From 9ff99a9d9d306a726720bd7ea29ec362d8d6bfba Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 17 Apr 2026 17:04:45 +0200 Subject: [PATCH 2/8] docs(passthrough): move environment variables section --- docs/features/passthrough-api.mdx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/features/passthrough-api.mdx b/docs/features/passthrough-api.mdx index 94a9e0af..847c4723 100644 --- a/docs/features/passthrough-api.mdx +++ b/docs/features/passthrough-api.mdx @@ -68,18 +68,6 @@ Because passthrough is provider-native, the response is also provider-native. For Anthropic messages, the response uses Anthropic's message schema, not an OpenAI chat completion schema. -## Configuration - -Passthrough routes are enabled by default: - -```env -ENABLE_PASSTHROUGH_ROUTES=true -ALLOW_PASSTHROUGH_V1_ALIAS=true -ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic -``` - -Set `ENABLED_PASSTHROUGH_PROVIDERS` to the provider types you want to expose. - ## Anthropic SDK example Set the Anthropic SDK base URL to GoModel's Anthropic passthrough route. Use the @@ -148,3 +136,15 @@ Passthrough is intentionally narrow while the API is in beta. into OpenAI-compatible responses. - Features that depend on OpenAI-compatible request or response shapes may not apply to passthrough traffic in the same way as `/v1` traffic. + +## Related environment variables + +Passthrough routes are enabled by default: + +```env +ENABLE_PASSTHROUGH_ROUTES=true +ALLOW_PASSTHROUGH_V1_ALIAS=true +ENABLED_PASSTHROUGH_PROVIDERS=openai,anthropic +``` + +Set `ENABLED_PASSTHROUGH_PROVIDERS` to the provider types you want to expose. From 86afe62acddfaab4e9d33bf8e1406ec852bba397 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 17 Apr 2026 22:21:44 +0200 Subject: [PATCH 3/8] feat(responses): add lifecycle and utility endpoints --- cmd/gomodel/docs/docs.go | 517 ++++++++++++- docs/docs.json | 1 + docs/features/responses-api.mdx | 126 ++++ docs/openapi.json | 694 +++++++++++++++++- internal/aliases/provider.go | 73 ++ internal/core/endpoints.go | 7 +- internal/core/endpoints_test.go | 9 + internal/core/interfaces.go | 38 + internal/core/responses.go | 53 ++ internal/guardrails/provider.go | 79 ++ .../providers/openai/compatible_provider.go | 135 ++++ internal/providers/registry.go | 18 + internal/providers/router.go | 212 +++++- internal/providers/router_test.go | 109 +++ internal/responsestore/store.go | 91 +++ internal/responsestore/store_memory.go | 87 +++ internal/server/handlers.go | 139 ++++ internal/server/handlers_test.go | 378 ++++++++++ internal/server/http.go | 23 +- internal/server/native_response_service.go | 602 +++++++++++++++ internal/server/response_input_items.go | 224 ++++++ .../server/translated_inference_service.go | 30 + 22 files changed, 3601 insertions(+), 44 deletions(-) create mode 100644 docs/features/responses-api.mdx create mode 100644 internal/responsestore/store.go create mode 100644 internal/responsestore/store_memory.go create mode 100644 internal/server/native_response_service.go create mode 100644 internal/server/response_input_items.go diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index 1a674262..c41ff038 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -144,7 +144,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Search across request_id/requested_model/provider/method/path/error_type", + "description": "Search across request_id/requested_model/provider/method/path/error_type/error_message", "name": "search", "in": "query" }, @@ -2140,6 +2140,422 @@ const docTemplate = `{ } ] } + }, + "/v1/responses/compact": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "responses" + ], + "summary": "Compact response input", + "parameters": [ + { + "description": "Responses API request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/core.ResponsesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/core.ResponseCompactResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "501": { + "description": "Not Implemented", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/input_tokens": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "responses" + ], + "summary": "Count response input tokens", + "parameters": [ + { + "description": "Responses API request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/core.ResponsesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/core.ResponseInputTokensResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "501": { + "description": "Not Implemented", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "responses" + ], + "summary": "Get a response", + "parameters": [ + { + "type": "string", + "description": "Response ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Provider override for native lookups", + "name": "provider", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/core.ResponsesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "501": { + "description": "Not Implemented", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "responses" + ], + "summary": "Delete a response", + "parameters": [ + { + "type": "string", + "description": "Response ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Provider override for native deletion", + "name": "provider", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/core.ResponseDeleteResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "501": { + "description": "Not Implemented", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/{id}/cancel": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "responses" + ], + "summary": "Cancel a response", + "parameters": [ + { + "type": "string", + "description": "Response ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Provider override for native cancellation", + "name": "provider", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/core.ResponsesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "501": { + "description": "Not Implemented", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/{id}/input_items": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "responses" + ], + "summary": "List response input items", + "parameters": [ + { + "type": "string", + "description": "Response ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Provider override for native lookups", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Pagination cursor", + "name": "after", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum items to return (1-100, default 20)", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Sort order: asc or desc", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/core.ResponseInputItemListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "501": { + "description": "Not Implemented", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } } }, "definitions": { @@ -2157,6 +2573,14 @@ const docTemplate = `{ } } }, + "auditlog.FailoverSnapshot": { + "type": "object", + "properties": { + "target_model": { + "type": "string" + } + } + }, "auditlog.LogData": { "type": "object", "properties": { @@ -2167,6 +2591,14 @@ const docTemplate = `{ "description": "Error details (message can be long, so kept in JSON)", "type": "string" }, + "failover": { + "description": "Failover captures runtime redirect details when translated execution\nmoved from the primary selector to a configured failover target.", + "allOf": [ + { + "$ref": "#/definitions/auditlog.FailoverSnapshot" + } + ] + }, "max_tokens": { "type": "integer" }, @@ -3151,6 +3583,89 @@ const docTemplate = `{ } } }, + "core.ResponseCompactResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "error": { + "$ref": "#/definitions/core.ResponsesError" + }, + "id": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "object": { + "type": "string" + }, + "output": { + "type": "array", + "items": { + "type": "object" + } + }, + "provider": { + "type": "string" + }, + "usage": { + "$ref": "#/definitions/core.ResponsesUsage" + } + } + }, + "core.ResponseDeleteResponse": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "object": { + "type": "string" + } + } + }, + "core.ResponseInputItemListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + } + }, + "first_id": { + "type": "string" + }, + "has_more": { + "type": "boolean" + }, + "last_id": { + "type": "string" + }, + "object": { + "type": "string" + } + } + }, + "core.ResponseInputTokensResponse": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + }, + "object": { + "type": "string" + } + } + }, "core.ResponseMessage": { "type": "object", "properties": { diff --git a/docs/docs.json b/docs/docs.json index be5c5630..ad2d327b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -39,6 +39,7 @@ "features/aliases", "features/user-path", "features/passthrough-api", + "features/responses-api", "features/cache", "features/failover" ] diff --git a/docs/features/responses-api.mdx b/docs/features/responses-api.mdx new file mode 100644 index 00000000..88e7e4b3 --- /dev/null +++ b/docs/features/responses-api.mdx @@ -0,0 +1,126 @@ +--- +title: "Responses API" +description: "Use OpenAI-compatible response creation, lifecycle, input items, and utility endpoints through GoModel." +icon: "messages-square" +tag: "Beta" +--- + +## Overview + +GoModel exposes OpenAI-compatible Responses API endpoints under `/v1/responses`. + +Create requests use the translated model routing pipeline, so aliases, +workflows, guardrails, failover, usage logging, and response caching continue to +apply. Lifecycle and utility endpoints use native provider capabilities when +available, and return explicit compatibility errors when the selected provider +does not support the requested operation. + +## Supported endpoints + +| Endpoint | Behavior | +| --- | --- | +| `POST /v1/responses` | Creates a response through translated model routing. Non-streaming responses are stored for later retrieval. | +| `GET /v1/responses/{id}` | Returns a stored gateway response when available. Otherwise, proxies to a native provider lookup when supported. | +| `GET /v1/responses/{id}/input_items` | Returns normalized input items captured from the original create request when available. Otherwise, proxies to a native provider lookup when supported. | +| `POST /v1/responses/{id}/cancel` | Cancels the response through the native provider when supported. | +| `DELETE /v1/responses/{id}` | Deletes the stored gateway response. When the provider supports native deletion, GoModel also forwards the delete request upstream. | +| `POST /v1/responses/input_tokens` | Counts input tokens through the native provider utility endpoint when supported. | +| `POST /v1/responses/compact` | Compacts a response input through the native provider utility endpoint when supported. | + +## Stored responses + +For non-streaming `POST /v1/responses` calls, GoModel stores the normalized +response body and the normalized input items from the request. + +This enables: + +- `GET /v1/responses/{id}` for responses created through GoModel. +- `GET /v1/responses/{id}/input_items` for the original normalized input. +- `DELETE /v1/responses/{id}` even when the provider does not expose native + response deletion. + +Streaming responses are not stored as response snapshots. + +## Native provider lookup + +When a response was not created through the current GoModel process or response +store, lifecycle endpoints can still use a native provider lookup. + +Specify the provider when the response ID is not stored locally: + +```http +GET /v1/responses/resp_abc123?provider=openai +``` + +Without a stored response or provider hint, GoModel checks providers that expose +native Responses lifecycle support. If lifecycle routing is unavailable, +GoModel returns a compatibility error. If lifecycle routing is available but no +provider can serve the response, GoModel returns a normal not-found error. + +## Input items + +GoModel normalizes stored input into OpenAI-compatible response input items. +String input becomes a user message with an `input_text` content item: + +```json +{ + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] +} +``` + +Structured input arrays preserve message, function call, and function call +output items where possible. + +## Compatibility errors + +Some providers do not support every Responses lifecycle or utility endpoint. +When the selected provider cannot perform an operation, GoModel returns an +OpenAI-compatible error with: + +```json +{ + "error": { + "type": "invalid_request_error", + "message": "response compaction is not supported by this provider", + "param": null, + "code": "unsupported_response_operation" + } +} +``` + +The HTTP status is `501 Not Implemented`. + +## Example + +Create a response: + +```bash +curl http://localhost:8080/v1/responses \ + -H "Authorization: Bearer $GOMODEL_MASTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5-mini", + "input": "Write one short sentence about reliable gateways." + }' +``` + +Retrieve it later: + +```bash +curl http://localhost:8080/v1/responses/resp_abc123 \ + -H "Authorization: Bearer $GOMODEL_MASTER_KEY" +``` + +List its stored input items: + +```bash +curl http://localhost:8080/v1/responses/resp_abc123/input_items \ + -H "Authorization: Bearer $GOMODEL_MASTER_KEY" +``` diff --git a/docs/openapi.json b/docs/openapi.json index c721890e..232cb87e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -167,7 +167,7 @@ } }, { - "description": "Search across request_id/requested_model/provider/method/path/error_type", + "description": "Search across request_id/requested_model/provider/method/path/error_type/error_message", "name": "search", "in": "query", "schema": { @@ -3253,29 +3253,447 @@ ], "summary": "Create a model response (Responses API)", "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.ResponsesRequest" + "$ref": "#/components/requestBodies/core.ResponsesRequest" + }, + "responses": { + "200": { + "description": "JSON response or SSE stream when stream=true", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponsesResponse" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/core.ResponsesResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/compact": { + "post": { + "tags": [ + "responses" + ], + "summary": "Compact response input", + "requestBody": { + "$ref": "#/components/requestBodies/core.ResponsesRequest" + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponseCompactResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "501": { + "description": "Not Implemented", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } } } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/input_tokens": { + "post": { + "tags": [ + "responses" + ], + "summary": "Count response input tokens", + "requestBody": { + "$ref": "#/components/requestBodies/core.ResponsesRequest" + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponseInputTokensResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "501": { + "description": "Not Implemented", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/{id}": { + "get": { + "tags": [ + "responses" + ], + "summary": "Get a response", + "parameters": [ + { + "description": "Response ID", + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Provider override for native lookups", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponsesResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "501": { + "description": "Not Implemented", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "responses" + ], + "summary": "Delete a response", + "parameters": [ + { + "description": "Response ID", + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Provider override for native deletion", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponseDeleteResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "501": { + "description": "Not Implemented", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/{id}/cancel": { + "post": { + "tags": [ + "responses" + ], + "summary": "Cancel a response", + "parameters": [ + { + "description": "Response ID", + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } }, - "description": "Responses API request", - "required": true - }, + { + "description": "Provider override for native cancellation", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "JSON response or SSE stream when stream=true", + "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/core.ResponsesResponse" } - }, - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/core.ResponsesResponse" - } } } }, @@ -3286,53 +3704,161 @@ "schema": { "$ref": "#/components/schemas/core.GatewayError" } - }, - "text/event-stream": { + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/core.GatewayError" } } } }, - "401": { - "description": "Unauthorized", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/core.GatewayError" } - }, - "text/event-stream": { + } + } + }, + "501": { + "description": "Not Implemented", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/core.GatewayError" } } } }, - "429": { - "description": "Too Many Requests", + "502": { + "description": "Bad Gateway", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/core.GatewayError" } - }, - "text/event-stream": { + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v1/responses/{id}/input_items": { + "get": { + "tags": [ + "responses" + ], + "summary": "List response input items", + "parameters": [ + { + "description": "Response ID", + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Provider override for native lookups", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Pagination cursor", + "name": "after", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Maximum items to return (1-100, default 20)", + "name": "limit", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "description": "Sort order: asc or desc", + "name": "order", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponseInputItemListResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/core.GatewayError" } } } }, - "502": { - "description": "Bad Gateway", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/core.GatewayError" } - }, - "text/event-stream": { + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "501": { + "description": "Not Implemented", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/core.GatewayError" } @@ -3355,6 +3881,19 @@ } ], "components": { + "requestBodies": { + "core.ResponsesRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponsesRequest" + } + } + }, + "description": "Responses API request", + "required": true + } + }, "securitySchemes": { "BearerAuth": { "type": "apiKey", @@ -3377,6 +3916,14 @@ } } }, + "auditlog.FailoverSnapshot": { + "type": "object", + "properties": { + "target_model": { + "type": "string" + } + } + }, "auditlog.LogData": { "type": "object", "properties": { @@ -3387,6 +3934,14 @@ "description": "Error details (message can be long, so kept in JSON)", "type": "string" }, + "failover": { + "description": "Failover captures runtime redirect details when translated execution\nmoved from the primary selector to a configured failover target.", + "allOf": [ + { + "$ref": "#/components/schemas/auditlog.FailoverSnapshot" + } + ] + }, "max_tokens": { "type": "integer" }, @@ -4371,6 +4926,89 @@ } } }, + "core.ResponseCompactResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "error": { + "$ref": "#/components/schemas/core.ResponsesError" + }, + "id": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "object": { + "type": "string" + }, + "output": { + "type": "array", + "items": { + "type": "object" + } + }, + "provider": { + "type": "string" + }, + "usage": { + "$ref": "#/components/schemas/core.ResponsesUsage" + } + } + }, + "core.ResponseDeleteResponse": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "object": { + "type": "string" + } + } + }, + "core.ResponseInputItemListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + } + }, + "first_id": { + "type": "string" + }, + "has_more": { + "type": "boolean" + }, + "last_id": { + "type": "string" + }, + "object": { + "type": "string" + } + } + }, + "core.ResponseInputTokensResponse": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + }, + "object": { + "type": "string" + } + } + }, "core.ResponseMessage": { "type": "object", "properties": { diff --git a/internal/aliases/provider.go b/internal/aliases/provider.go index 3acde6a7..d28fd0a5 100644 --- a/internal/aliases/provider.go +++ b/internal/aliases/provider.go @@ -178,6 +178,15 @@ func (p *Provider) NativeFileProviderTypes() []string { return nil } +// NativeResponseProviderTypes delegates provider capability inventory to the +// inner provider when available. +func (p *Provider) NativeResponseProviderTypes() []string { + if typed, ok := p.inner.(core.NativeResponseProviderTypeLister); ok { + return typed.NativeResponseProviderTypes() + } + return nil +} + func (p *Provider) CreateBatch(ctx context.Context, providerType string, req *core.BatchRequest) (*core.BatchResponse, error) { native, err := p.nativeBatchRouter() if err != nil { @@ -318,6 +327,54 @@ func (p *Provider) Passthrough(ctx context.Context, providerType string, req *co return passthrough.Passthrough(ctx, providerType, req) } +func (p *Provider) GetResponse(ctx context.Context, providerType, id string, params core.ResponseRetrieveParams) (*core.ResponsesResponse, error) { + responses, err := p.nativeResponseLifecycleRouter() + if err != nil { + return nil, err + } + return responses.GetResponse(ctx, providerType, id, params) +} + +func (p *Provider) ListResponseInputItems(ctx context.Context, providerType, id string, params core.ResponseInputItemsParams) (*core.ResponseInputItemListResponse, error) { + responses, err := p.nativeResponseLifecycleRouter() + if err != nil { + return nil, err + } + return responses.ListResponseInputItems(ctx, providerType, id, params) +} + +func (p *Provider) CancelResponse(ctx context.Context, providerType, id string) (*core.ResponsesResponse, error) { + responses, err := p.nativeResponseLifecycleRouter() + if err != nil { + return nil, err + } + return responses.CancelResponse(ctx, providerType, id) +} + +func (p *Provider) DeleteResponse(ctx context.Context, providerType, id string) (*core.ResponseDeleteResponse, error) { + responses, err := p.nativeResponseLifecycleRouter() + if err != nil { + return nil, err + } + return responses.DeleteResponse(ctx, providerType, id) +} + +func (p *Provider) CountResponseInputTokens(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { + responses, err := p.nativeResponseUtilityRouter() + if err != nil { + return nil, err + } + return responses.CountResponseInputTokens(ctx, providerType, req) +} + +func (p *Provider) CompactResponse(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { + responses, err := p.nativeResponseUtilityRouter() + if err != nil { + return nil, err + } + return responses.CompactResponse(ctx, providerType, req) +} + // PrepareBatchRequest resolves aliases for batch subrequests without // submitting the native batch to the wrapped provider. func (p *Provider) PrepareBatchRequest(ctx context.Context, providerType string, req *core.BatchRequest) (*core.BatchRewriteResult, error) { @@ -358,6 +415,22 @@ func (p *Provider) nativeFileRouter() (core.NativeFileRoutableProvider, error) { return files, nil } +func (p *Provider) nativeResponseLifecycleRouter() (core.NativeResponseLifecycleRoutableProvider, error) { + responses, ok := p.inner.(core.NativeResponseLifecycleRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("response lifecycle routing is not supported by the current provider router", nil) + } + return responses, nil +} + +func (p *Provider) nativeResponseUtilityRouter() (core.NativeResponseUtilityRoutableProvider, error) { + responses, ok := p.inner.(core.NativeResponseUtilityRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("response utility routing is not supported by the current provider router", nil) + } + return responses, nil +} + func (p *Provider) batchFileTransport() core.BatchFileTransport { files, err := p.nativeFileRouter() if err != nil { diff --git a/internal/core/endpoints.go b/internal/core/endpoints.go index c19ac78e..dff7c0ce 100644 --- a/internal/core/endpoints.go +++ b/internal/core/endpoints.go @@ -106,8 +106,13 @@ func bodyModeForEndpoint(method, path string, operation Operation) BodyMode { path = normalizeEndpointPath(path) switch operation { - case OperationChatCompletions, OperationResponses, OperationEmbeddings: + case OperationChatCompletions, OperationEmbeddings: return BodyModeJSON + case OperationResponses: + if method == http.MethodPost && (path == "/v1/responses" || path == "/v1/responses/input_tokens" || path == "/v1/responses/compact") { + return BodyModeJSON + } + return BodyModeNone case OperationBatches: switch method { case http.MethodPost: diff --git a/internal/core/endpoints_test.go b/internal/core/endpoints_test.go index 68a4c2d7..e27a4cc8 100644 --- a/internal/core/endpoints_test.go +++ b/internal/core/endpoints_test.go @@ -16,6 +16,8 @@ func TestDescribeEndpointPath(t *testing.T) { }{ {path: "/v1/chat/completions", managed: true, dialect: "openai_compat", operation: OperationChatCompletions, bodyMode: BodyModeJSON, interaction: true}, {path: "/v1/chat/completions/", managed: true, dialect: "openai_compat", operation: OperationChatCompletions, bodyMode: BodyModeJSON, interaction: true}, + {path: "/v1/responses/resp_1", managed: true, dialect: "openai_compat", operation: OperationResponses, bodyMode: BodyModeNone, interaction: true}, + {path: "/v1/responses/resp_1/input_items", managed: true, dialect: "openai_compat", operation: OperationResponses, bodyMode: BodyModeNone, interaction: true}, {path: "/v1/batches", managed: true, dialect: "openai_compat", operation: OperationBatches, bodyMode: BodyModeNone, interaction: true}, {path: "/v1/embeddings/", managed: true, dialect: "openai_compat", operation: OperationEmbeddings, bodyMode: BodyModeJSON, interaction: true}, {path: "/v1/files/file_1", managed: true, dialect: "openai_compat", operation: OperationFiles, bodyMode: BodyModeNone, interaction: true}, @@ -54,6 +56,13 @@ func TestDescribeEndpoint_UsesMethodForBodyMode(t *testing.T) { {method: http.MethodPost, path: "/v1/batches", bodyMode: BodyModeJSON}, {method: http.MethodGet, path: "/v1/batches", bodyMode: BodyModeNone}, {method: http.MethodPost, path: "/v1/chat/completions/", bodyMode: BodyModeJSON}, + {method: http.MethodPost, path: "/v1/responses", bodyMode: BodyModeJSON}, + {method: http.MethodGet, path: "/v1/responses/resp_1", bodyMode: BodyModeNone}, + {method: http.MethodGet, path: "/v1/responses/resp_1/input_items", bodyMode: BodyModeNone}, + {method: http.MethodPost, path: "/v1/responses/resp_1/cancel", bodyMode: BodyModeNone}, + {method: http.MethodDelete, path: "/v1/responses/resp_1", bodyMode: BodyModeNone}, + {method: http.MethodPost, path: "/v1/responses/input_tokens", bodyMode: BodyModeJSON}, + {method: http.MethodPost, path: "/v1/responses/compact", bodyMode: BodyModeJSON}, {method: http.MethodPost, path: "/v1/files", bodyMode: BodyModeMultipart}, {method: http.MethodPost, path: "/v1/files/", bodyMode: BodyModeMultipart}, {method: http.MethodGet, path: "/v1/files/file_1", bodyMode: BodyModeNone}, diff --git a/internal/core/interfaces.go b/internal/core/interfaces.go index 428fcff0..96dee16c 100644 --- a/internal/core/interfaces.go +++ b/internal/core/interfaces.go @@ -86,6 +86,38 @@ type NativeFileRoutableProvider interface { GetFileContent(ctx context.Context, providerType, id string) (*FileContentResponse, error) } +// NativeResponseLifecycleProvider is implemented by providers that support +// OpenAI-compatible Responses lifecycle operations. +type NativeResponseLifecycleProvider interface { + GetResponse(ctx context.Context, id string, params ResponseRetrieveParams) (*ResponsesResponse, error) + ListResponseInputItems(ctx context.Context, id string, params ResponseInputItemsParams) (*ResponseInputItemListResponse, error) + CancelResponse(ctx context.Context, id string) (*ResponsesResponse, error) + DeleteResponse(ctx context.Context, id string) (*ResponseDeleteResponse, error) +} + +// NativeResponseUtilityProvider is implemented by providers that support +// OpenAI-compatible Responses utility operations. +type NativeResponseUtilityProvider interface { + CountResponseInputTokens(ctx context.Context, req *ResponsesRequest) (*ResponseInputTokensResponse, error) + CompactResponse(ctx context.Context, req *ResponsesRequest) (*ResponseCompactResponse, error) +} + +// NativeResponseLifecycleRoutableProvider extends routing with provider-native +// Responses lifecycle operations. +type NativeResponseLifecycleRoutableProvider interface { + GetResponse(ctx context.Context, providerType, id string, params ResponseRetrieveParams) (*ResponsesResponse, error) + ListResponseInputItems(ctx context.Context, providerType, id string, params ResponseInputItemsParams) (*ResponseInputItemListResponse, error) + CancelResponse(ctx context.Context, providerType, id string) (*ResponsesResponse, error) + DeleteResponse(ctx context.Context, providerType, id string) (*ResponseDeleteResponse, error) +} + +// NativeResponseUtilityRoutableProvider extends routing with provider-native +// Responses utility operations. +type NativeResponseUtilityRoutableProvider interface { + CountResponseInputTokens(ctx context.Context, providerType string, req *ResponsesRequest) (*ResponseInputTokensResponse, error) + CompactResponse(ctx context.Context, providerType string, req *ResponsesRequest) (*ResponseCompactResponse, error) +} + // NativeFileProviderTypeLister exposes registered provider types that support // native file operations. This is an internal capability inventory and must not // depend on the public model catalog. @@ -93,6 +125,12 @@ type NativeFileProviderTypeLister interface { NativeFileProviderTypes() []string } +// NativeResponseProviderTypeLister exposes registered provider types that +// support native Responses lifecycle operations. +type NativeResponseProviderTypeLister interface { + NativeResponseProviderTypes() []string +} + // RoutableProvider extends Provider with routing capability. // This is implemented by the Router which uses a model registry // to determine if a model is supported. diff --git a/internal/core/responses.go b/internal/core/responses.go index 6ef20b70..7a063e69 100644 --- a/internal/core/responses.go +++ b/internal/core/responses.go @@ -123,3 +123,56 @@ type ResponsesError struct { Code string `json:"code"` Message string `json:"message"` } + +// ResponseRetrieveParams contains query parameters accepted by +// GET /v1/responses/{id}. +type ResponseRetrieveParams struct { + Include []string + IncludeObfuscation *bool + StartingAfter *int + Stream bool +} + +// ResponseInputItemsParams contains query parameters accepted by +// GET /v1/responses/{id}/input_items. +type ResponseInputItemsParams struct { + After string + Include []string + Limit int + Order string +} + +// ResponseInputItemListResponse is returned by +// GET /v1/responses/{id}/input_items. +type ResponseInputItemListResponse struct { + Object string `json:"object"` + Data []json.RawMessage `json:"data" swaggertype:"array,object"` + FirstID string `json:"first_id,omitempty"` + LastID string `json:"last_id,omitempty"` + HasMore bool `json:"has_more"` +} + +// ResponseInputTokensResponse is returned by POST /v1/responses/input_tokens. +type ResponseInputTokensResponse struct { + Object string `json:"object"` + InputTokens int `json:"input_tokens"` +} + +// ResponseCompactResponse is returned by POST /v1/responses/compact. +type ResponseCompactResponse struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Output []json.RawMessage `json:"output" swaggertype:"array,object"` + Usage *ResponsesUsage `json:"usage,omitempty"` + Error *ResponsesError `json:"error,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Provider string `json:"provider,omitempty"` +} + +// ResponseDeleteResponse is returned by DELETE /v1/responses/{id}. +type ResponseDeleteResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Deleted bool `json:"deleted"` +} diff --git a/internal/guardrails/provider.go b/internal/guardrails/provider.go index 4d7ffa20..b2d7bf5a 100644 --- a/internal/guardrails/provider.go +++ b/internal/guardrails/provider.go @@ -73,6 +73,15 @@ func (g *GuardedProvider) NativeFileProviderTypes() []string { return nil } +// NativeResponseProviderTypes delegates provider capability inventory to the +// inner provider when available. +func (g *GuardedProvider) NativeResponseProviderTypes() []string { + if typed, ok := g.inner.(core.NativeResponseProviderTypeLister); ok { + return typed.NativeResponseProviderTypes() + } + return nil +} + // ChatCompletion extracts messages, applies guardrails, then routes the request. func (g *GuardedProvider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { if g.options.DisableTranslatedRequestProcessing { @@ -155,6 +164,22 @@ func (g *GuardedProvider) nativeFileRouter() (core.NativeFileRoutableProvider, e return fp, nil } +func (g *GuardedProvider) nativeResponseLifecycleRouter() (core.NativeResponseLifecycleRoutableProvider, error) { + responses, ok := g.inner.(core.NativeResponseLifecycleRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("response lifecycle routing is not supported by the current provider router", nil) + } + return responses, nil +} + +func (g *GuardedProvider) nativeResponseUtilityRouter() (core.NativeResponseUtilityRoutableProvider, error) { + responses, ok := g.inner.(core.NativeResponseUtilityRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("response utility routing is not supported by the current provider router", nil) + } + return responses, nil +} + func (g *GuardedProvider) batchFileTransport() core.BatchFileTransport { files, err := g.nativeFileRouter() if err != nil { @@ -509,6 +534,60 @@ func (g *GuardedProvider) Passthrough(ctx context.Context, providerType string, return pp.Passthrough(ctx, providerType, req) } +// GetResponse delegates native response lookup. +func (g *GuardedProvider) GetResponse(ctx context.Context, providerType, id string, params core.ResponseRetrieveParams) (*core.ResponsesResponse, error) { + responses, err := g.nativeResponseLifecycleRouter() + if err != nil { + return nil, err + } + return responses.GetResponse(ctx, providerType, id, params) +} + +// ListResponseInputItems delegates native response input item listing. +func (g *GuardedProvider) ListResponseInputItems(ctx context.Context, providerType, id string, params core.ResponseInputItemsParams) (*core.ResponseInputItemListResponse, error) { + responses, err := g.nativeResponseLifecycleRouter() + if err != nil { + return nil, err + } + return responses.ListResponseInputItems(ctx, providerType, id, params) +} + +// CancelResponse delegates native response cancellation. +func (g *GuardedProvider) CancelResponse(ctx context.Context, providerType, id string) (*core.ResponsesResponse, error) { + responses, err := g.nativeResponseLifecycleRouter() + if err != nil { + return nil, err + } + return responses.CancelResponse(ctx, providerType, id) +} + +// DeleteResponse delegates native response deletion. +func (g *GuardedProvider) DeleteResponse(ctx context.Context, providerType, id string) (*core.ResponseDeleteResponse, error) { + responses, err := g.nativeResponseLifecycleRouter() + if err != nil { + return nil, err + } + return responses.DeleteResponse(ctx, providerType, id) +} + +// CountResponseInputTokens delegates native response token counting. +func (g *GuardedProvider) CountResponseInputTokens(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { + responses, err := g.nativeResponseUtilityRouter() + if err != nil { + return nil, err + } + return responses.CountResponseInputTokens(ctx, providerType, req) +} + +// CompactResponse delegates native response compaction. +func (g *GuardedProvider) CompactResponse(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { + responses, err := g.nativeResponseUtilityRouter() + if err != nil { + return nil, err + } + return responses.CompactResponse(ctx, providerType, req) +} + // PatchChatRequest applies guardrails to a translated chat request without // delegating to the wrapped provider. func (g *GuardedProvider) PatchChatRequest(ctx context.Context, req *core.ChatRequest) (*core.ChatRequest, error) { diff --git a/internal/providers/openai/compatible_provider.go b/internal/providers/openai/compatible_provider.go index 4c7d4142..8e9a5c6c 100644 --- a/internal/providers/openai/compatible_provider.go +++ b/internal/providers/openai/compatible_provider.go @@ -172,6 +172,98 @@ func (p *CompatibleProvider) StreamResponses(ctx context.Context, req *core.Resp return providers.EnsureResponsesDone(stream), nil } +func (p *CompatibleProvider) GetResponse(ctx context.Context, id string, params core.ResponseRetrieveParams) (*core.ResponsesResponse, error) { + var resp core.ResponsesResponse + err := p.Do(ctx, llmclient.Request{ + Method: http.MethodGet, + Endpoint: responseRetrieveEndpoint(id, params), + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (p *CompatibleProvider) ListResponseInputItems(ctx context.Context, id string, params core.ResponseInputItemsParams) (*core.ResponseInputItemListResponse, error) { + var resp core.ResponseInputItemListResponse + err := p.Do(ctx, llmclient.Request{ + Method: http.MethodGet, + Endpoint: responseInputItemsEndpoint(id, params), + }, &resp) + if err != nil { + return nil, err + } + if resp.Object == "" { + resp.Object = "list" + } + return &resp, nil +} + +func (p *CompatibleProvider) CancelResponse(ctx context.Context, id string) (*core.ResponsesResponse, error) { + var resp core.ResponsesResponse + err := p.Do(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/responses/" + url.PathEscape(id) + "/cancel", + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (p *CompatibleProvider) DeleteResponse(ctx context.Context, id string) (*core.ResponseDeleteResponse, error) { + var resp core.ResponseDeleteResponse + err := p.Do(ctx, llmclient.Request{ + Method: http.MethodDelete, + Endpoint: "/responses/" + url.PathEscape(id), + }, &resp) + if err != nil { + return nil, err + } + if resp.Object == "" { + resp.Object = "response" + } + return &resp, nil +} + +func (p *CompatibleProvider) CountResponseInputTokens(ctx context.Context, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { + if req == nil { + return nil, core.NewInvalidRequestError("responses input token request is required", nil) + } + var resp core.ResponseInputTokensResponse + err := p.Do(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/responses/input_tokens", + Body: req, + }, &resp) + if err != nil { + return nil, err + } + if resp.Object == "" { + resp.Object = "response.input_tokens" + } + return &resp, nil +} + +func (p *CompatibleProvider) CompactResponse(ctx context.Context, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { + if req == nil { + return nil, core.NewInvalidRequestError("responses compact request is required", nil) + } + var resp core.ResponseCompactResponse + err := p.Do(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/responses/compact", + Body: req, + }, &resp) + if err != nil { + return nil, err + } + if resp.Object == "" { + resp.Object = "response.compaction" + } + return &resp, nil +} + func (p *CompatibleProvider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { if req == nil { return nil, core.NewInvalidRequestError("embedding request is required", nil) @@ -331,3 +423,46 @@ func (p *CompatibleProvider) DeleteFile(ctx context.Context, id string) (*core.F func (p *CompatibleProvider) GetFileContent(ctx context.Context, id string) (*core.FileContentResponse, error) { return providers.GetOpenAICompatibleFileContentWithPreparer(ctx, p.client, id, p.prepareRequest) } + +func responseRetrieveEndpoint(id string, params core.ResponseRetrieveParams) string { + values := url.Values{} + for _, include := range params.Include { + if include != "" { + values.Add("include[]", include) + } + } + if params.IncludeObfuscation != nil { + values.Set("include_obfuscation", strconv.FormatBool(*params.IncludeObfuscation)) + } + if params.StartingAfter != nil { + values.Set("starting_after", strconv.Itoa(*params.StartingAfter)) + } + endpoint := "/responses/" + url.PathEscape(id) + if encoded := values.Encode(); encoded != "" { + endpoint += "?" + encoded + } + return endpoint +} + +func responseInputItemsEndpoint(id string, params core.ResponseInputItemsParams) string { + values := url.Values{} + for _, include := range params.Include { + if include != "" { + values.Add("include[]", include) + } + } + if params.After != "" { + values.Set("after", params.After) + } + if params.Limit > 0 { + values.Set("limit", strconv.Itoa(params.Limit)) + } + if params.Order != "" { + values.Set("order", params.Order) + } + endpoint := "/responses/" + url.PathEscape(id) + "/input_items" + if encoded := values.Encode(); encoded != "" { + endpoint += "?" + encoded + } + return endpoint +} diff --git a/internal/providers/registry.go b/internal/providers/registry.go index 0a81e043..2cde8cc9 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -843,6 +843,24 @@ func (r *ModelRegistry) ProviderByType(providerType string) core.Provider { return nil } +// ProviderByName returns the registered provider for a configured provider +// instance name. +func (r *ModelRegistry) ProviderByName(providerName string) core.Provider { + r.mu.RLock() + defer r.mu.RUnlock() + + providerName = strings.TrimSpace(providerName) + if providerName == "" { + return nil + } + for _, provider := range r.providers { + if strings.TrimSpace(r.providerNames[provider]) == providerName { + return provider + } + } + return nil +} + // ProviderTypes returns the unique registered provider types in sorted order. // This inventory is independent of discovered models. func (r *ModelRegistry) ProviderTypes() []string { diff --git a/internal/providers/router.go b/internal/providers/router.go index a9a4d99f..6b5d4415 100644 --- a/internal/providers/router.go +++ b/internal/providers/router.go @@ -27,6 +27,10 @@ type providerTypeRegistry interface { ProviderByType(providerType string) core.Provider } +type providerNameRegistry interface { + ProviderByName(providerName string) core.Provider +} + type initializedLookup interface { IsInitialized() bool } @@ -215,29 +219,57 @@ func (r *Router) resolveProvider(model, providerHint string) (core.Provider, cor } func (r *Router) resolveProviderType(providerType string) (core.Provider, error) { + if err := r.ensureProviderInventoryReady(); err != nil { + return nil, err + } + if providerType == "" { + return nil, core.NewInvalidRequestError("provider type is required", nil) + } + provider := r.providerByTypeRegistry(providerType) + if provider == nil { + return nil, core.NewInvalidRequestError(fmt.Sprintf("no provider found for provider type: %s", providerType), nil) + } + return provider, nil +} + +func (r *Router) resolveProviderSelector(providerSelector string) (core.Provider, string, error) { + if err := r.ensureProviderInventoryReady(); err != nil { + return nil, "", err + } + providerSelector = strings.TrimSpace(providerSelector) + if providerSelector == "" { + return nil, "", core.NewInvalidRequestError("provider is required", nil) + } + if provider := r.providerByTypeRegistry(providerSelector); provider != nil { + return provider, providerSelector, nil + } + if provider := r.providerByNameRegistry(providerSelector); provider != nil { + providerType := strings.TrimSpace(r.GetProviderTypeForName(providerSelector)) + if providerType == "" { + providerType = providerSelector + } + return provider, providerType, nil + } + return nil, "", core.NewInvalidRequestError(fmt.Sprintf("no provider found for provider: %s", providerSelector), nil) +} + +func (r *Router) ensureProviderInventoryReady() error { if initialized, ok := r.lookup.(initializedLookup); ok { if !initialized.IsInitialized() { if err := r.checkReady(); err != nil { if errors.Is(err, ErrRegistryNotInitialized) { - return nil, registryUnavailableError(err) + return registryUnavailableError(err) } - return nil, err + return err } } } else if err := r.checkReady(); err != nil { if errors.Is(err, ErrRegistryNotInitialized) { - return nil, registryUnavailableError(err) + return registryUnavailableError(err) } - return nil, err - } - if providerType == "" { - return nil, core.NewInvalidRequestError("provider type is required", nil) - } - provider := r.providerByTypeRegistry(providerType) - if provider == nil { - return nil, core.NewInvalidRequestError(fmt.Sprintf("no provider found for provider type: %s", providerType), nil) + return err } - return provider, nil + return nil } func (r *Router) resolveNativeBatchProvider(providerType string) (core.NativeBatchProvider, error) { @@ -264,6 +296,30 @@ func (r *Router) resolveNativeFileProvider(providerType string) (core.NativeFile return fp, nil } +func (r *Router) resolveNativeResponseLifecycleProvider(providerType string) (core.NativeResponseLifecycleProvider, string, error) { + provider, resolvedProviderType, err := r.resolveProviderSelector(providerType) + if err != nil { + return nil, "", err + } + rp, ok := provider.(core.NativeResponseLifecycleProvider) + if !ok { + return nil, "", core.NewInvalidRequestError(fmt.Sprintf("%s does not support native response lifecycle operations", providerType), nil) + } + return rp, resolvedProviderType, nil +} + +func (r *Router) resolveNativeResponseUtilityProvider(providerType string) (core.NativeResponseUtilityProvider, string, error) { + provider, resolvedProviderType, err := r.resolveProviderSelector(providerType) + if err != nil { + return nil, "", err + } + rp, ok := provider.(core.NativeResponseUtilityProvider) + if !ok { + return nil, "", core.NewInvalidRequestError(fmt.Sprintf("%s does not support native response utility operations", providerType), nil) + } + return rp, resolvedProviderType, nil +} + func (r *Router) resolvePassthroughProvider(providerType string) (core.PassthroughProvider, error) { provider, err := r.resolveProviderType(providerType) if err != nil { @@ -328,6 +384,26 @@ func routeNativeFileCall[T any](r *Router, ctx context.Context, providerType str return call(ctx, fp) } +func routeNativeResponseLifecycleCall[T any](r *Router, ctx context.Context, providerType string, call func(context.Context, core.NativeResponseLifecycleProvider) (T, error)) (T, string, error) { + rp, resolvedProviderType, err := r.resolveNativeResponseLifecycleProvider(providerType) + if err != nil { + var zero T + return zero, "", err + } + resp, err := call(ctx, rp) + return resp, resolvedProviderType, err +} + +func routeNativeResponseUtilityCall[T any](r *Router, ctx context.Context, providerType string, call func(context.Context, core.NativeResponseUtilityProvider) (T, error)) (T, string, error) { + rp, resolvedProviderType, err := r.resolveNativeResponseUtilityProvider(providerType) + if err != nil { + var zero T + return zero, "", err + } + resp, err := call(ctx, rp) + return resp, resolvedProviderType, err +} + func stampProvider[T any](resp T, providerType string) T { switch typed := any(resp).(type) { case *core.ChatResponse: @@ -350,6 +426,10 @@ func stampProvider[T any](resp T, providerType string) T { if typed != nil { typed.Provider = providerType } + case *core.ResponseCompactResponse: + if typed != nil { + typed.Provider = providerType + } } return resp } @@ -589,6 +669,39 @@ func (r *Router) providerByTypeRegistry(providerType string) core.Provider { return r.providerByType(providerType) } +func (r *Router) providerByNameRegistry(providerName string) core.Provider { + if registry, ok := r.lookup.(providerNameRegistry); ok { + if provider := registry.ProviderByName(providerName); provider != nil { + return provider + } + } + return r.providerByName(providerName) +} + +func (r *Router) providerByName(providerName string) core.Provider { + providerName = strings.TrimSpace(providerName) + if providerName == "" { + return nil + } + models, ok := r.lookup.(modelWithProviderLister) + if !ok { + return nil + } + for _, entry := range models.ListModelsWithProvider() { + if strings.TrimSpace(entry.ProviderName) != providerName { + continue + } + modelID := strings.TrimSpace(entry.Model.ID) + if modelID == "" { + continue + } + if provider := r.lookup.GetProvider(core.ModelSelector{Provider: providerName, Model: modelID}.QualifiedModel()); provider != nil { + return provider + } + } + return nil +} + func (r *Router) providerTypes() []string { if typed, ok := r.lookup.(providerTypeLister); ok { return typed.ProviderTypes() @@ -630,6 +743,24 @@ func (r *Router) NativeFileProviderTypes() []string { return result } +// NativeResponseProviderTypes returns the registered provider types that +// support native Responses lifecycle operations. +func (r *Router) NativeResponseProviderTypes() []string { + providerTypes := r.providerTypes() + result := make([]string, 0, len(providerTypes)) + for _, providerType := range providerTypes { + provider := r.providerByTypeRegistry(providerType) + if provider == nil { + continue + } + if _, ok := provider.(core.NativeResponseLifecycleProvider); !ok { + continue + } + result = append(result, providerType) + } + return result +} + // Passthrough routes an opaque provider-native request by provider type. func (r *Router) Passthrough(ctx context.Context, providerType string, req *core.PassthroughRequest) (*core.PassthroughResponse, error) { pp, err := r.resolvePassthroughProvider(providerType) @@ -777,3 +908,60 @@ func (r *Router) GetFileContent(ctx context.Context, providerType, id string) (* return fp.GetFileContent(ctx, id) }) } + +// GetResponse routes native response retrieval to a provider type. +func (r *Router) GetResponse(ctx context.Context, providerType, id string, params core.ResponseRetrieveParams) (*core.ResponsesResponse, error) { + resp, resolvedProviderType, err := routeNativeResponseLifecycleCall(r, ctx, providerType, func(ctx context.Context, rp core.NativeResponseLifecycleProvider) (*core.ResponsesResponse, error) { + return rp.GetResponse(ctx, id, params) + }) + return stampProvider(resp, resolvedProviderType), err +} + +// ListResponseInputItems routes native response input item listing to a provider type. +func (r *Router) ListResponseInputItems(ctx context.Context, providerType, id string, params core.ResponseInputItemsParams) (*core.ResponseInputItemListResponse, error) { + resp, _, err := routeNativeResponseLifecycleCall(r, ctx, providerType, func(ctx context.Context, rp core.NativeResponseLifecycleProvider) (*core.ResponseInputItemListResponse, error) { + return rp.ListResponseInputItems(ctx, id, params) + }) + return resp, err +} + +// CancelResponse routes native response cancellation to a provider type. +func (r *Router) CancelResponse(ctx context.Context, providerType, id string) (*core.ResponsesResponse, error) { + resp, resolvedProviderType, err := routeNativeResponseLifecycleCall(r, ctx, providerType, func(ctx context.Context, rp core.NativeResponseLifecycleProvider) (*core.ResponsesResponse, error) { + return rp.CancelResponse(ctx, id) + }) + return stampProvider(resp, resolvedProviderType), err +} + +// DeleteResponse routes native response deletion to a provider type. +func (r *Router) DeleteResponse(ctx context.Context, providerType, id string) (*core.ResponseDeleteResponse, error) { + resp, _, err := routeNativeResponseLifecycleCall(r, ctx, providerType, func(ctx context.Context, rp core.NativeResponseLifecycleProvider) (*core.ResponseDeleteResponse, error) { + return rp.DeleteResponse(ctx, id) + }) + return resp, err +} + +// CountResponseInputTokens routes native response input token counting to a provider type. +func (r *Router) CountResponseInputTokens(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { + resp, _, err := routeNativeResponseUtilityCall(r, ctx, providerType, func(ctx context.Context, rp core.NativeResponseUtilityProvider) (*core.ResponseInputTokensResponse, error) { + return rp.CountResponseInputTokens(ctx, forwardNativeResponseUtilityRequest(req)) + }) + return resp, err +} + +// CompactResponse routes native response compaction to a provider type. +func (r *Router) CompactResponse(ctx context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { + resp, resolvedProviderType, err := routeNativeResponseUtilityCall(r, ctx, providerType, func(ctx context.Context, rp core.NativeResponseUtilityProvider) (*core.ResponseCompactResponse, error) { + return rp.CompactResponse(ctx, forwardNativeResponseUtilityRequest(req)) + }) + return stampProvider(resp, resolvedProviderType), err +} + +func forwardNativeResponseUtilityRequest(req *core.ResponsesRequest) *core.ResponsesRequest { + if req == nil { + return nil + } + forwardReq := *req + forwardReq.Provider = "" + return &forwardReq +} diff --git a/internal/providers/router_test.go b/internal/providers/router_test.go index 4d63ff61..efad078c 100644 --- a/internal/providers/router_test.go +++ b/internal/providers/router_test.go @@ -199,6 +199,40 @@ type mockBatchProvider struct { clearedBatchHintID string } +type mockResponseProvider struct { + mockProvider + lastInputTokensReq *core.ResponsesRequest + lastCompactReq *core.ResponsesRequest + cancelledResponse string +} + +func (m *mockResponseProvider) GetResponse(_ context.Context, id string, _ core.ResponseRetrieveParams) (*core.ResponsesResponse, error) { + return &core.ResponsesResponse{ID: id, Object: "response", Status: "completed"}, nil +} + +func (m *mockResponseProvider) ListResponseInputItems(_ context.Context, _ string, _ core.ResponseInputItemsParams) (*core.ResponseInputItemListResponse, error) { + return &core.ResponseInputItemListResponse{Object: "list"}, nil +} + +func (m *mockResponseProvider) CancelResponse(_ context.Context, id string) (*core.ResponsesResponse, error) { + m.cancelledResponse = id + return &core.ResponsesResponse{ID: id, Object: "response", Status: "cancelled"}, nil +} + +func (m *mockResponseProvider) DeleteResponse(_ context.Context, id string) (*core.ResponseDeleteResponse, error) { + return &core.ResponseDeleteResponse{ID: id, Object: "response", Deleted: true}, nil +} + +func (m *mockResponseProvider) CountResponseInputTokens(_ context.Context, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { + m.lastInputTokensReq = req + return &core.ResponseInputTokensResponse{Object: "response.input_tokens", InputTokens: 42}, nil +} + +func (m *mockResponseProvider) CompactResponse(_ context.Context, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { + m.lastCompactReq = req + return &core.ResponseCompactResponse{ID: "cmp_1", Object: "response.compaction"}, nil +} + func (m *mockBatchProvider) CreateBatch(_ context.Context, _ *core.BatchRequest) (*core.BatchResponse, error) { return &core.BatchResponse{ID: "provider-batch-1", Object: "batch"}, nil } @@ -682,6 +716,81 @@ func TestRouterResponses(t *testing.T) { }) } +func TestRouterResponseUtilitiesStripProviderHint(t *testing.T) { + provider := &mockResponseProvider{} + lookup := newTestRegistryWithModels(registryModelEntry{ + provider: provider, + providerName: "openai_primary", + providerType: "openai", + modelID: "gpt-4o", + }) + router, _ := NewRouter(lookup) + + req := &core.ResponsesRequest{ + Model: "gpt-4o", + Provider: "openai_primary", + Input: "hello", + } + _, err := router.CountResponseInputTokens(context.Background(), "openai", req) + if err != nil { + t.Fatalf("CountResponseInputTokens() error = %v", err) + } + if provider.lastInputTokensReq == nil { + t.Fatal("expected input token request to be captured") + } + if provider.lastInputTokensReq.Provider != "" { + t.Fatalf("upstream provider hint = %q, want empty", provider.lastInputTokensReq.Provider) + } + if req.Provider != "openai_primary" { + t.Fatalf("original request provider mutated to %q", req.Provider) + } + + _, err = router.CompactResponse(context.Background(), "openai", req) + if err != nil { + t.Fatalf("CompactResponse() error = %v", err) + } + if provider.lastCompactReq == nil { + t.Fatal("expected compact request to be captured") + } + if provider.lastCompactReq.Provider != "" { + t.Fatalf("upstream provider hint = %q, want empty", provider.lastCompactReq.Provider) + } +} + +func TestRouterResponseLifecycleRoutesByProviderName(t *testing.T) { + primary := &mockResponseProvider{} + backup := &mockResponseProvider{} + lookup := newTestRegistryWithModels( + registryModelEntry{ + provider: primary, + providerName: "openai_primary", + providerType: "openai", + modelID: "gpt-4o", + }, + registryModelEntry{ + provider: backup, + providerName: "openai_backup", + providerType: "openai", + modelID: "gpt-4o", + }, + ) + router, _ := NewRouter(lookup) + + resp, err := router.CancelResponse(context.Background(), "openai_backup", "resp_1") + if err != nil { + t.Fatalf("CancelResponse() error = %v", err) + } + if backup.cancelledResponse != "resp_1" { + t.Fatalf("backup cancelled response = %q, want resp_1", backup.cancelledResponse) + } + if primary.cancelledResponse != "" { + t.Fatalf("primary cancelled response = %q, want empty", primary.cancelledResponse) + } + if resp.Provider != "openai" { + t.Fatalf("response provider = %q, want openai", resp.Provider) + } +} + func TestRouterListModels(t *testing.T) { lookup := newMockLookup() lookup.addModel("gpt-4o", &mockProvider{}, "openai") diff --git a/internal/responsestore/store.go b/internal/responsestore/store.go new file mode 100644 index 00000000..ec5a547e --- /dev/null +++ b/internal/responsestore/store.go @@ -0,0 +1,91 @@ +// Package responsestore provides persistence for OpenAI-compatible Responses +// lifecycle endpoints. +package responsestore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "gomodel/internal/core" +) + +// ErrNotFound indicates a requested response was not found. +var ErrNotFound = errors.New("response not found") + +// StoredResponse keeps the public response snapshot separate from gateway-only +// routing and input item metadata. +type StoredResponse struct { + Response *core.ResponsesResponse `json:"response"` + InputItems []json.RawMessage `json:"input_items,omitempty"` + Provider string `json:"provider,omitempty"` + ProviderName string `json:"provider_name,omitempty"` + ProviderResponseID string `json:"provider_response_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + UserPath string `json:"user_path,omitempty"` + WorkflowVersionID string `json:"workflow_version_id,omitempty"` +} + +// Store defines persistence operations for Responses lifecycle APIs. +type Store interface { + Create(ctx context.Context, response *StoredResponse) error + Get(ctx context.Context, id string) (*StoredResponse, error) + Update(ctx context.Context, response *StoredResponse) error + Delete(ctx context.Context, id string) error + Close() error +} + +func cloneResponse(src *StoredResponse) (*StoredResponse, error) { + if src == nil { + return nil, fmt.Errorf("response is nil") + } + normalized := normalizeStoredResponse(src) + b, err := json.Marshal(normalized) + if err != nil { + return nil, fmt.Errorf("marshal response: %w", err) + } + var dst StoredResponse + if err := json.Unmarshal(b, &dst); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + return &dst, nil +} + +func normalizeStoredResponse(src *StoredResponse) *StoredResponse { + if src == nil { + return nil + } + + normalized := *src + normalized.Provider = strings.TrimSpace(normalized.Provider) + normalized.ProviderName = strings.TrimSpace(normalized.ProviderName) + normalized.ProviderResponseID = strings.TrimSpace(normalized.ProviderResponseID) + normalized.RequestID = strings.TrimSpace(normalized.RequestID) + normalized.UserPath = strings.TrimSpace(normalized.UserPath) + normalized.WorkflowVersionID = strings.TrimSpace(normalized.WorkflowVersionID) + + if src.Response != nil { + responseCopy := *src.Response + if responseCopy.Provider == "" { + responseCopy.Provider = normalized.Provider + } + if normalized.Provider == "" { + normalized.Provider = strings.TrimSpace(responseCopy.Provider) + } + if normalized.ProviderResponseID == "" { + normalized.ProviderResponseID = strings.TrimSpace(responseCopy.ID) + } + normalized.Response = &responseCopy + } + + if len(src.InputItems) > 0 { + normalized.InputItems = make([]json.RawMessage, 0, len(src.InputItems)) + for _, item := range src.InputItems { + normalized.InputItems = append(normalized.InputItems, core.CloneRawJSON(item)) + } + } + + return &normalized +} diff --git a/internal/responsestore/store_memory.go b/internal/responsestore/store_memory.go new file mode 100644 index 00000000..50062692 --- /dev/null +++ b/internal/responsestore/store_memory.go @@ -0,0 +1,87 @@ +package responsestore + +import ( + "context" + "fmt" + "sync" +) + +// MemoryStore keeps response snapshots in process memory. +// Data survives across requests but not process restarts. +type MemoryStore struct { + mu sync.RWMutex + items map[string]*StoredResponse +} + +// NewMemoryStore creates an empty in-memory response store. +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + items: make(map[string]*StoredResponse), + } +} + +// Create stores a new response snapshot. +func (s *MemoryStore) Create(_ context.Context, response *StoredResponse) error { + if response == nil || response.Response == nil || response.Response.ID == "" { + return fmt.Errorf("response id is required") + } + + c, err := cloneResponse(response) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.items[c.Response.ID]; exists { + return fmt.Errorf("response already exists: %s", c.Response.ID) + } + s.items[c.Response.ID] = c + return nil +} + +// Get retrieves one response snapshot by id. +func (s *MemoryStore) Get(_ context.Context, id string) (*StoredResponse, error) { + s.mu.RLock() + response, ok := s.items[id] + s.mu.RUnlock() + if !ok { + return nil, ErrNotFound + } + return cloneResponse(response) +} + +// Update replaces an existing response snapshot. +func (s *MemoryStore) Update(_ context.Context, response *StoredResponse) error { + if response == nil || response.Response == nil || response.Response.ID == "" { + return fmt.Errorf("response id is required") + } + c, err := cloneResponse(response) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.items[c.Response.ID]; !exists { + return ErrNotFound + } + s.items[c.Response.ID] = c + return nil +} + +// Delete removes one response snapshot by id. +func (s *MemoryStore) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.items[id]; !exists { + return ErrNotFound + } + delete(s.items, id) + return nil +} + +// Close releases resources (no-op for memory store). +func (s *MemoryStore) Close() error { + return nil +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 244fa698..b992afae 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -11,6 +11,7 @@ import ( batchstore "gomodel/internal/batch" "gomodel/internal/core" "gomodel/internal/responsecache" + "gomodel/internal/responsestore" "gomodel/internal/usage" ) @@ -29,6 +30,7 @@ type Handler struct { usageLogger usage.LoggerInterface pricingResolver usage.PricingResolver batchStore batchstore.Store + responseStore responsestore.Store normalizePassthroughV1Prefix bool enabledPassthroughProviders map[string]struct{} responseCache *responsecache.ResponseCacheMiddleware @@ -88,6 +90,7 @@ func newHandlerWithAuthorizer( usageLogger: usageLogger, pricingResolver: pricingResolver, batchStore: batchstore.NewMemoryStore(), + responseStore: responsestore.NewMemoryStore(), normalizePassthroughV1Prefix: true, enabledPassthroughProviders: normalizeEnabledPassthroughProviders(defaultEnabledPassthroughProviders), } @@ -102,6 +105,15 @@ func (h *Handler) SetBatchStore(store batchstore.Store) { h.batchStore = store } +// SetResponseStore replaces the response snapshot store used by lifecycle endpoints. +// nil is ignored to keep an always-available fallback memory store. +func (h *Handler) SetResponseStore(store responsestore.Store) { + if store == nil { + return + } + h.responseStore = store +} + func (h *Handler) translatedInference() *translatedInferenceService { h.translatedSvcOnce.Do(func() { s := &translatedInferenceService{ @@ -116,6 +128,7 @@ func (h *Handler) translatedInference() *translatedInferenceService { pricingResolver: h.pricingResolver, responseCache: h.responseCache, guardrailsHash: h.guardrailsHash, + responseStore: h.responseStore, } s.initHandlers() h.translatedSvc = s @@ -142,6 +155,17 @@ func (h *Handler) nativeFiles() *nativeFileService { return &nativeFileService{provider: h.provider} } +func (h *Handler) nativeResponses() *nativeResponseService { + return &nativeResponseService{ + provider: h.provider, + modelResolver: h.modelResolver, + modelAuthorizer: h.modelAuthorizer, + workflowPolicyResolver: h.workflowPolicyResolver, + translatedRequestPatcher: h.translatedRequestPatcher, + responseStore: h.responseStore, + } +} + func (h *Handler) passthrough() *passthroughService { return &passthroughService{ provider: h.provider, @@ -387,6 +411,121 @@ func (h *Handler) Responses(c *echo.Context) error { return h.translatedInference().Responses(c) } +// GetResponse handles GET /v1/responses/{id}. +// +// @Summary Get a response +// @Tags responses +// @Produce json +// @Security BearerAuth +// @Param id path string true "Response ID" +// @Param provider query string false "Provider override for native lookups" +// @Success 200 {object} core.ResponsesResponse +// @Failure 400 {object} core.GatewayError +// @Failure 401 {object} core.GatewayError +// @Failure 404 {object} core.GatewayError +// @Failure 501 {object} core.GatewayError +// @Failure 502 {object} core.GatewayError +// @Router /v1/responses/{id} [get] +func (h *Handler) GetResponse(c *echo.Context) error { + return h.nativeResponses().GetResponse(c) +} + +// ListResponseInputItems handles GET /v1/responses/{id}/input_items. +// +// @Summary List response input items +// @Tags responses +// @Produce json +// @Security BearerAuth +// @Param id path string true "Response ID" +// @Param provider query string false "Provider override for native lookups" +// @Param after query string false "Pagination cursor" +// @Param limit query int false "Maximum items to return (1-100, default 20)" +// @Param order query string false "Sort order: asc or desc" +// @Success 200 {object} core.ResponseInputItemListResponse +// @Failure 400 {object} core.GatewayError +// @Failure 401 {object} core.GatewayError +// @Failure 404 {object} core.GatewayError +// @Failure 501 {object} core.GatewayError +// @Failure 502 {object} core.GatewayError +// @Router /v1/responses/{id}/input_items [get] +func (h *Handler) ListResponseInputItems(c *echo.Context) error { + return h.nativeResponses().ListResponseInputItems(c) +} + +// CancelResponse handles POST /v1/responses/{id}/cancel. +// +// @Summary Cancel a response +// @Tags responses +// @Produce json +// @Security BearerAuth +// @Param id path string true "Response ID" +// @Param provider query string false "Provider override for native cancellation" +// @Success 200 {object} core.ResponsesResponse +// @Failure 400 {object} core.GatewayError +// @Failure 401 {object} core.GatewayError +// @Failure 404 {object} core.GatewayError +// @Failure 501 {object} core.GatewayError +// @Failure 502 {object} core.GatewayError +// @Router /v1/responses/{id}/cancel [post] +func (h *Handler) CancelResponse(c *echo.Context) error { + return h.nativeResponses().CancelResponse(c) +} + +// DeleteResponse handles DELETE /v1/responses/{id}. +// +// @Summary Delete a response +// @Tags responses +// @Produce json +// @Security BearerAuth +// @Param id path string true "Response ID" +// @Param provider query string false "Provider override for native deletion" +// @Success 200 {object} core.ResponseDeleteResponse +// @Failure 400 {object} core.GatewayError +// @Failure 401 {object} core.GatewayError +// @Failure 404 {object} core.GatewayError +// @Failure 501 {object} core.GatewayError +// @Failure 502 {object} core.GatewayError +// @Router /v1/responses/{id} [delete] +func (h *Handler) DeleteResponse(c *echo.Context) error { + return h.nativeResponses().DeleteResponse(c) +} + +// ResponseInputTokens handles POST /v1/responses/input_tokens. +// +// @Summary Count response input tokens +// @Tags responses +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body core.ResponsesRequest true "Responses API request" +// @Success 200 {object} core.ResponseInputTokensResponse +// @Failure 400 {object} core.GatewayError +// @Failure 401 {object} core.GatewayError +// @Failure 501 {object} core.GatewayError +// @Failure 502 {object} core.GatewayError +// @Router /v1/responses/input_tokens [post] +func (h *Handler) ResponseInputTokens(c *echo.Context) error { + return h.nativeResponses().CountResponseInputTokens(c) +} + +// CompactResponse handles POST /v1/responses/compact. +// +// @Summary Compact response input +// @Tags responses +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body core.ResponsesRequest true "Responses API request" +// @Success 200 {object} core.ResponseCompactResponse +// @Failure 400 {object} core.GatewayError +// @Failure 401 {object} core.GatewayError +// @Failure 501 {object} core.GatewayError +// @Failure 502 {object} core.GatewayError +// @Router /v1/responses/compact [post] +func (h *Handler) CompactResponse(c *echo.Context) error { + return h.nativeResponses().CompactResponse(c) +} + // Embeddings handles POST /v1/embeddings // // @Summary Create embeddings diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 30d1d8a5..6271f205 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -27,6 +27,7 @@ import ( "gomodel/internal/core" "gomodel/internal/guardrails" provideradapter "gomodel/internal/providers" + "gomodel/internal/responsestore" "gomodel/internal/usage" ) @@ -348,6 +349,25 @@ type mockProvider struct { passthroughErr error lastPassthroughProvider string lastPassthroughReq *core.PassthroughRequest + + responseGetResponse *core.ResponsesResponse + responseInputItemsResponse *core.ResponseInputItemListResponse + responseCancelResponse *core.ResponsesResponse + responseDeleteResponse *core.ResponseDeleteResponse + responseInputTokensResponse *core.ResponseInputTokensResponse + responseCompactResponse *core.ResponseCompactResponse + responseLifecycleErr error + responseUtilityErr error + responseGetCalls []responseCall + responseInputItemsCalls []responseCall + responseCancelCalls []responseCall + responseDeleteCalls []responseCall + capturedResponseUtilityReqs []*core.ResponsesRequest +} + +type responseCall struct { + provider string + id string } type fileListCall struct { @@ -529,6 +549,10 @@ func (m *mockProvider) NativeFileProviderTypes() []string { return result } +func (m *mockProvider) NativeResponseProviderTypes() []string { + return m.NativeFileProviderTypes() +} + type providerWithoutFileInventory struct { inner *mockProvider } @@ -585,6 +609,42 @@ func (p *providerWithoutFileInventory) GetFileContent(ctx context.Context, provi return p.inner.GetFileContent(ctx, providerType, id) } +type providerWithoutResponseLifecycle struct { + inner *mockProvider +} + +func (p *providerWithoutResponseLifecycle) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + return p.inner.ChatCompletion(ctx, req) +} + +func (p *providerWithoutResponseLifecycle) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + return p.inner.StreamChatCompletion(ctx, req) +} + +func (p *providerWithoutResponseLifecycle) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + return p.inner.ListModels(ctx) +} + +func (p *providerWithoutResponseLifecycle) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return p.inner.Responses(ctx, req) +} + +func (p *providerWithoutResponseLifecycle) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + return p.inner.StreamResponses(ctx, req) +} + +func (p *providerWithoutResponseLifecycle) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return p.inner.Embeddings(ctx, req) +} + +func (p *providerWithoutResponseLifecycle) Supports(model string) bool { + return p.inner.Supports(model) +} + +func (p *providerWithoutResponseLifecycle) GetProviderType(model string) string { + return p.inner.GetProviderType(model) +} + func (m *mockProvider) ChatCompletion(_ context.Context, _ *core.ChatRequest) (*core.ChatResponse, error) { if m.err != nil { return nil, m.err @@ -639,6 +699,72 @@ func (m *mockProvider) Passthrough(_ context.Context, providerType string, req * return m.passthroughResponse, nil } +func (m *mockProvider) GetResponse(_ context.Context, providerType, id string, _ core.ResponseRetrieveParams) (*core.ResponsesResponse, error) { + m.responseGetCalls = append(m.responseGetCalls, responseCall{provider: providerType, id: id}) + if m.responseLifecycleErr != nil { + return nil, m.responseLifecycleErr + } + if m.responseGetResponse != nil { + return m.responseGetResponse, nil + } + return &core.ResponsesResponse{ID: id, Object: "response", Model: "gpt-5-mini", Provider: providerType, Status: "completed"}, nil +} + +func (m *mockProvider) ListResponseInputItems(_ context.Context, providerType, id string, _ core.ResponseInputItemsParams) (*core.ResponseInputItemListResponse, error) { + m.responseInputItemsCalls = append(m.responseInputItemsCalls, responseCall{provider: providerType, id: id}) + if m.responseLifecycleErr != nil { + return nil, m.responseLifecycleErr + } + if m.responseInputItemsResponse != nil { + return m.responseInputItemsResponse, nil + } + return &core.ResponseInputItemListResponse{Object: "list"}, nil +} + +func (m *mockProvider) CancelResponse(_ context.Context, providerType, id string) (*core.ResponsesResponse, error) { + m.responseCancelCalls = append(m.responseCancelCalls, responseCall{provider: providerType, id: id}) + if m.responseLifecycleErr != nil { + return nil, m.responseLifecycleErr + } + if m.responseCancelResponse != nil { + return m.responseCancelResponse, nil + } + return &core.ResponsesResponse{ID: id, Object: "response", Model: "gpt-5-mini", Provider: providerType, Status: "cancelled"}, nil +} + +func (m *mockProvider) DeleteResponse(_ context.Context, providerType, id string) (*core.ResponseDeleteResponse, error) { + m.responseDeleteCalls = append(m.responseDeleteCalls, responseCall{provider: providerType, id: id}) + if m.responseLifecycleErr != nil { + return nil, m.responseLifecycleErr + } + if m.responseDeleteResponse != nil { + return m.responseDeleteResponse, nil + } + return &core.ResponseDeleteResponse{ID: id, Object: "response", Deleted: true}, nil +} + +func (m *mockProvider) CountResponseInputTokens(_ context.Context, _ string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { + m.capturedResponseUtilityReqs = append(m.capturedResponseUtilityReqs, req) + if m.responseUtilityErr != nil { + return nil, m.responseUtilityErr + } + if m.responseInputTokensResponse != nil { + return m.responseInputTokensResponse, nil + } + return &core.ResponseInputTokensResponse{Object: "response.input_tokens", InputTokens: 7}, nil +} + +func (m *mockProvider) CompactResponse(_ context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { + m.capturedResponseUtilityReqs = append(m.capturedResponseUtilityReqs, req) + if m.responseUtilityErr != nil { + return nil, m.responseUtilityErr + } + if m.responseCompactResponse != nil { + return m.responseCompactResponse, nil + } + return &core.ResponseCompactResponse{ID: "cmp_1", Object: "response.compaction", Provider: providerType}, nil +} + func (m *mockProvider) CreateBatch(_ context.Context, _ string, req *core.BatchRequest) (*core.BatchResponse, error) { m.capturedBatchReq = req if m.batchErr != nil { @@ -4397,6 +4523,258 @@ func TestGetBatch_NotFound(t *testing.T) { } } +func TestResponsesLifecycle_RetrievesStoredResponseAndInputItems(t *testing.T) { + provider := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{ + "gpt-5-mini": "mock", + }, + responsesResponse: &core.ResponsesResponse{ + ID: "resp_store_1", + Object: "response", + CreatedAt: 1000, + Model: "gpt-5-mini", + Status: "completed", + Output: []core.ResponsesOutputItem{ + { + ID: "msg_1", + Type: "message", + Role: "assistant", + Status: "completed", + Content: []core.ResponsesContentItem{ + {Type: "output_text", Text: "hello"}, + }, + }, + }, + }, + } + srv := New(provider, nil) + + createReq := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + srv.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status = %d, want 200 (%s)", createRec.Code, createRec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/v1/responses/resp_store_1", nil) + getRec := httptest.NewRecorder() + srv.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("get status = %d, want 200 (%s)", getRec.Code, getRec.Body.String()) + } + var got core.ResponsesResponse + if err := json.Unmarshal(getRec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get response: %v", err) + } + if got.ID != "resp_store_1" || got.Provider != "mock" { + t.Fatalf("stored response = %+v, want id resp_store_1 provider mock", got) + } + + itemsReq := httptest.NewRequest(http.MethodGet, "/v1/responses/resp_store_1/input_items?order=asc", nil) + itemsRec := httptest.NewRecorder() + srv.ServeHTTP(itemsRec, itemsReq) + if itemsRec.Code != http.StatusOK { + t.Fatalf("input items status = %d, want 200 (%s)", itemsRec.Code, itemsRec.Body.String()) + } + var items core.ResponseInputItemListResponse + if err := json.Unmarshal(itemsRec.Body.Bytes(), &items); err != nil { + t.Fatalf("decode input items response: %v", err) + } + if items.Object != "list" || len(items.Data) != 1 || items.HasMore { + t.Fatalf("input items = %+v, want one-item list", items) + } + var first map[string]any + if err := json.Unmarshal(items.Data[0], &first); err != nil { + t.Fatalf("decode first input item: %v", err) + } + if first["type"] != "message" || first["role"] != "user" { + t.Fatalf("input item = %+v, want user message", first) + } + content, ok := first["content"].([]any) + if !ok || len(content) != 1 { + t.Fatalf("input content = %+v, want one content item", first["content"]) + } + text, ok := content[0].(map[string]any) + if !ok || text["type"] != "input_text" || text["text"] != "hello" { + t.Fatalf("input content item = %+v, want input_text hello", content[0]) + } +} + +func TestResponsesLifecycle_StoresConcreteProviderName(t *testing.T) { + store := responsestore.NewMemoryStore() + provider := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{ + "gpt-5-mini": "openai", + }, + providerNames: map[string]string{ + "gpt-5-mini": "openai_primary", + }, + responsesResponse: &core.ResponsesResponse{ + ID: "resp_provider_name_1", + Object: "response", + Model: "gpt-5-mini", + Status: "completed", + }, + } + srv := New(provider, &Config{ResponseStore: store}) + + createReq := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + srv.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status = %d, want 200 (%s)", createRec.Code, createRec.Body.String()) + } + + stored, err := store.Get(context.Background(), "resp_provider_name_1") + if err != nil { + t.Fatalf("store.Get() error = %v", err) + } + if stored.Provider != "openai" { + t.Fatalf("stored provider = %q, want openai", stored.Provider) + } + if stored.ProviderName != "openai_primary" { + t.Fatalf("stored provider name = %q, want openai_primary", stored.ProviderName) + } +} + +func TestResponsesLifecycle_CancelUnsupportedProviderReturnsCompatibilityError(t *testing.T) { + base := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{ + "gpt-5-mini": "mock", + }, + responsesResponse: &core.ResponsesResponse{ + ID: "resp_cancel_1", + Object: "response", + Model: "gpt-5-mini", + Status: "in_progress", + }, + } + srv := New(&providerWithoutResponseLifecycle{inner: base}, nil) + + createReq := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + srv.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status = %d, want 200 (%s)", createRec.Code, createRec.Body.String()) + } + + cancelReq := httptest.NewRequest(http.MethodPost, "/v1/responses/resp_cancel_1/cancel", nil) + cancelRec := httptest.NewRecorder() + srv.ServeHTTP(cancelRec, cancelReq) + if cancelRec.Code != http.StatusNotImplemented { + t.Fatalf("cancel status = %d, want 501 (%s)", cancelRec.Code, cancelRec.Body.String()) + } + var body map[string]map[string]any + if err := json.Unmarshal(cancelRec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode cancel error: %v", err) + } + if body["error"]["code"] != "unsupported_response_operation" { + t.Fatalf("error code = %v, want unsupported_response_operation", body["error"]["code"]) + } +} + +func TestResponsesLifecycle_DeleteStoredResponseWithoutNativeSupport(t *testing.T) { + base := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{ + "gpt-5-mini": "mock", + }, + responsesResponse: &core.ResponsesResponse{ + ID: "resp_delete_1", + Object: "response", + Model: "gpt-5-mini", + Status: "completed", + }, + } + srv := New(&providerWithoutResponseLifecycle{inner: base}, nil) + + createReq := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + srv.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status = %d, want 200 (%s)", createRec.Code, createRec.Body.String()) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/v1/responses/resp_delete_1", nil) + deleteRec := httptest.NewRecorder() + srv.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status = %d, want 200 (%s)", deleteRec.Code, deleteRec.Body.String()) + } + var deleted core.ResponseDeleteResponse + if err := json.Unmarshal(deleteRec.Body.Bytes(), &deleted); err != nil { + t.Fatalf("decode delete response: %v", err) + } + if deleted.ID != "resp_delete_1" || !deleted.Deleted { + t.Fatalf("delete response = %+v, want deleted resp_delete_1", deleted) + } + + getReq := httptest.NewRequest(http.MethodGet, "/v1/responses/resp_delete_1", nil) + getRec := httptest.NewRecorder() + srv.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusNotImplemented { + t.Fatalf("get after delete status = %d, want 501 (%s)", getRec.Code, getRec.Body.String()) + } +} + +func TestResponsesUtilityRoutes(t *testing.T) { + provider := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{ + "gpt-5-mini": "mock", + }, + responseInputTokensResponse: &core.ResponseInputTokensResponse{ + Object: "response.input_tokens", + InputTokens: 42, + }, + responseCompactResponse: &core.ResponseCompactResponse{ + ID: "cmp_42", + Object: "response.compaction", + }, + } + srv := New(provider, nil) + + tokensReq := httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + tokensReq.Header.Set("Content-Type", "application/json") + tokensRec := httptest.NewRecorder() + srv.ServeHTTP(tokensRec, tokensReq) + if tokensRec.Code != http.StatusOK { + t.Fatalf("input_tokens status = %d, want 200 (%s)", tokensRec.Code, tokensRec.Body.String()) + } + var tokens core.ResponseInputTokensResponse + if err := json.Unmarshal(tokensRec.Body.Bytes(), &tokens); err != nil { + t.Fatalf("decode input_tokens response: %v", err) + } + if tokens.InputTokens != 42 { + t.Fatalf("input tokens = %d, want 42", tokens.InputTokens) + } + + compactReq := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + compactReq.Header.Set("Content-Type", "application/json") + compactRec := httptest.NewRecorder() + srv.ServeHTTP(compactRec, compactReq) + if compactRec.Code != http.StatusOK { + t.Fatalf("compact status = %d, want 200 (%s)", compactRec.Code, compactRec.Body.String()) + } + var compact core.ResponseCompactResponse + if err := json.Unmarshal(compactRec.Body.Bytes(), &compact); err != nil { + t.Fatalf("decode compact response: %v", err) + } + if compact.ID != "cmp_42" || compact.Provider != "mock" { + t.Fatalf("compact response = %+v, want id cmp_42 provider mock", compact) + } + if len(provider.capturedResponseUtilityReqs) != 2 { + t.Fatalf("utility calls = %d, want 2", len(provider.capturedResponseUtilityReqs)) + } +} + // mockUsageLogger implements usage.LoggerInterface for testing. type mockUsageLogger struct { config usage.Config diff --git a/internal/server/http.go b/internal/server/http.go index 7dc8c572..a9e32198 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -23,6 +23,7 @@ import ( batchstore "gomodel/internal/batch" "gomodel/internal/core" "gomodel/internal/responsecache" + "gomodel/internal/responsestore" "gomodel/internal/usage" echoswagger "github.com/swaggo/echo-swagger" @@ -33,6 +34,7 @@ type Server struct { echo *echo.Echo handler *Handler responseCacheMiddleware *responsecache.ResponseCacheMiddleware + responseStore responsestore.Store } const ( @@ -62,6 +64,7 @@ type Config struct { KeepOnlyAliasesAtModelsEndpoint bool // Whether GET /v1/models should hide concrete provider models PassthroughSemanticEnrichers []core.PassthroughSemanticEnricher // Optional: provider-owned passthrough semantic enrichers before workflow resolution BatchStore batchstore.Store // Optional: Batch lifecycle persistence store + ResponseStore responsestore.Store // Optional: Responses lifecycle persistence store LogOnlyModelInteractions bool // Only log AI model endpoints (default: true) DisablePassthroughRoutes bool // Disable /p/{provider}/{endpoint} route registration EnabledPassthroughProviders []string // Provider types enabled on /p/{provider}/... passthrough routes @@ -128,6 +131,9 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { if cfg != nil && cfg.BatchStore != nil { handler.SetBatchStore(cfg.BatchStore) } + if cfg != nil && cfg.ResponseStore != nil { + handler.SetResponseStore(cfg.ResponseStore) + } // Build list of paths that skip authentication authSkipPaths := []string{"/health"} @@ -283,6 +289,12 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { } e.GET("/v1/models", handler.ListModels) e.POST("/v1/chat/completions", handler.ChatCompletion) + e.POST("/v1/responses/input_tokens", handler.ResponseInputTokens) + e.POST("/v1/responses/compact", handler.CompactResponse) + e.GET("/v1/responses/:id/input_items", handler.ListResponseInputItems) + e.POST("/v1/responses/:id/cancel", handler.CancelResponse) + e.GET("/v1/responses/:id", handler.GetResponse) + e.DELETE("/v1/responses/:id", handler.DeleteResponse) e.POST("/v1/responses", handler.Responses) e.POST("/v1/embeddings", handler.Embeddings) e.POST("/v1/files", handler.CreateFile) @@ -347,6 +359,7 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { echo: e, handler: handler, responseCacheMiddleware: rcm, + responseStore: handler.responseStore, } } @@ -376,10 +389,16 @@ func (s *Server) StartWithListener(ctx context.Context, listener net.Listener) e // cancelling the context passed to Start; this method drains any in-flight // response cache writes and closes the cache store. func (s *Server) Shutdown(_ context.Context) error { + var firstErr error if s.responseCacheMiddleware != nil { - return s.responseCacheMiddleware.Close() + firstErr = s.responseCacheMiddleware.Close() } - return nil + if s.responseStore != nil { + if err := s.responseStore.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr } // ServeHTTP implements the http.Handler interface, allowing Server to be used with httptest diff --git a/internal/server/native_response_service.go b/internal/server/native_response_service.go new file mode 100644 index 00000000..7b5d7904 --- /dev/null +++ b/internal/server/native_response_service.go @@ -0,0 +1,602 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/labstack/echo/v5" + + "gomodel/internal/auditlog" + "gomodel/internal/core" + "gomodel/internal/responsestore" +) + +// nativeResponseService owns Responses lifecycle and utility endpoints. Create +// still uses translatedInferenceService because it participates in fallback, +// usage, streaming, and response-cache behavior. +type nativeResponseService struct { + provider core.RoutableProvider + modelResolver RequestModelResolver + modelAuthorizer RequestModelAuthorizer + workflowPolicyResolver RequestWorkflowPolicyResolver + translatedRequestPatcher TranslatedRequestPatcher + responseStore responsestore.Store +} + +func (s *nativeResponseService) GetResponse(c *echo.Context) error { + ctx, _ := requestContextWithRequestID(c.Request()) + + id, err := responseIDFromRequest(c) + if err != nil { + return handleError(c, err) + } + params, err := responseRetrieveParamsFromRequest(c) + if err != nil { + return handleError(c, err) + } + if params.Stream { + return handleError(c, unsupportedResponseOperation("streaming response retrieval is not supported")) + } + + stored, err := s.loadStoredResponse(ctx, id) + if err == nil { + auditResponseEntry(c, storedProvider(stored)) + return c.JSON(http.StatusOK, stored.Response) + } + if err != nil && !errors.Is(err, responsestore.ErrNotFound) { + return handleError(c, err) + } + + resp, providerType, err := s.getNativeResponse(ctx, responseProviderFromRequest(c), id, params) + if err != nil { + return handleError(c, err) + } + if resp == nil { + return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) + } + auditResponseEntry(c, providerType) + return c.JSON(http.StatusOK, resp) +} + +func (s *nativeResponseService) ListResponseInputItems(c *echo.Context) error { + ctx, _ := requestContextWithRequestID(c.Request()) + + id, err := responseIDFromRequest(c) + if err != nil { + return handleError(c, err) + } + params, err := responseInputItemsParamsFromRequest(c) + if err != nil { + return handleError(c, err) + } + + stored, err := s.loadStoredResponse(ctx, id) + if err == nil { + auditResponseEntry(c, storedProvider(stored)) + return c.JSON(http.StatusOK, paginateStoredResponseInputItems(stored.InputItems, params)) + } + if err != nil && !errors.Is(err, responsestore.ErrNotFound) { + return handleError(c, err) + } + + resp, providerType, err := s.listNativeResponseInputItems(ctx, responseProviderFromRequest(c), id, params) + if err != nil { + return handleError(c, err) + } + if resp == nil { + return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) + } + if resp.Object == "" { + resp.Object = "list" + } + auditResponseEntry(c, providerType) + return c.JSON(http.StatusOK, resp) +} + +func (s *nativeResponseService) CancelResponse(c *echo.Context) error { + ctx, _ := requestContextWithRequestID(c.Request()) + + id, err := responseIDFromRequest(c) + if err != nil { + return handleError(c, err) + } + + stored, err := s.loadStoredResponse(ctx, id) + if err == nil { + providerType := storedProvider(stored) + providerRoute := storedProviderRoute(stored) + auditResponseEntry(c, providerType) + resp, err := s.cancelNativeResponse(ctx, providerRoute, firstNonEmpty(stored.ProviderResponseID, id)) + if err != nil { + if isUnsupportedNativeResponseError(err) { + return handleError(c, unsupportedResponseOperation("native response cancellation is not available for this provider")) + } + return handleError(c, err) + } + if resp == nil { + return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) + } + stored.Response = resp + stored.Provider = firstNonEmpty(resp.Provider, providerType) + if updateErr := s.responseStore.Update(ctx, stored); updateErr != nil && !errors.Is(updateErr, responsestore.ErrNotFound) { + return handleError(c, core.NewProviderError("response_store", http.StatusInternalServerError, "failed to persist cancelled response", updateErr)) + } + return c.JSON(http.StatusOK, resp) + } + if err != nil && !errors.Is(err, responsestore.ErrNotFound) { + return handleError(c, err) + } + + resp, providerType, err := s.cancelNativeResponseByRequest(ctx, responseProviderFromRequest(c), id) + if err != nil { + return handleError(c, err) + } + if resp == nil { + return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) + } + auditResponseEntry(c, providerType) + return c.JSON(http.StatusOK, resp) +} + +func (s *nativeResponseService) DeleteResponse(c *echo.Context) error { + ctx, _ := requestContextWithRequestID(c.Request()) + + id, err := responseIDFromRequest(c) + if err != nil { + return handleError(c, err) + } + + stored, err := s.loadStoredResponse(ctx, id) + if err == nil { + providerType := storedProvider(stored) + providerRoute := storedProviderRoute(stored) + auditResponseEntry(c, providerType) + deleteResp, deleteErr := s.deleteNativeResponse(ctx, providerRoute, firstNonEmpty(stored.ProviderResponseID, id)) + if deleteErr != nil && !isUnsupportedNativeResponseError(deleteErr) && !isNotFoundGatewayError(deleteErr) { + return handleError(c, deleteErr) + } + if err := s.responseStore.Delete(ctx, id); err != nil && !errors.Is(err, responsestore.ErrNotFound) { + return handleError(c, core.NewProviderError("response_store", http.StatusInternalServerError, "failed to delete response", err)) + } + if deleteResp == nil { + deleteResp = &core.ResponseDeleteResponse{ID: id, Object: "response", Deleted: true} + } + deleteResp.ID = id + if deleteResp.Object == "" { + deleteResp.Object = "response" + } + deleteResp.Deleted = true + return c.JSON(http.StatusOK, deleteResp) + } + if err != nil && !errors.Is(err, responsestore.ErrNotFound) { + return handleError(c, err) + } + + resp, providerType, err := s.deleteNativeResponseByRequest(ctx, responseProviderFromRequest(c), id) + if err != nil { + return handleError(c, err) + } + if resp == nil { + return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) + } + if resp.ID == "" { + resp.ID = id + } + if resp.Object == "" { + resp.Object = "response" + } + auditResponseEntry(c, providerType) + return c.JSON(http.StatusOK, resp) +} + +func (s *nativeResponseService) CountResponseInputTokens(c *echo.Context) error { + ctx, _ := requestContextWithRequestID(c.Request()) + c.SetRequest(c.Request().WithContext(ctx)) + + req, providerType, err := s.utilityRequest(c) + if err != nil { + return handleError(c, err) + } + auditResponseEntry(c, providerType) + + router, ok := s.provider.(core.NativeResponseUtilityRoutableProvider) + if !ok { + return handleError(c, unsupportedResponseOperation("response input token counting is not supported by the current provider router")) + } + resp, err := router.CountResponseInputTokens(ctx, providerType, req) + if err != nil { + if isUnsupportedNativeResponseError(err) { + return handleError(c, unsupportedResponseOperation("response input token counting is not supported by this provider")) + } + return handleError(c, err) + } + if resp == nil { + return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) + } + if resp.Object == "" { + resp.Object = "response.input_tokens" + } + return c.JSON(http.StatusOK, resp) +} + +func (s *nativeResponseService) CompactResponse(c *echo.Context) error { + ctx, _ := requestContextWithRequestID(c.Request()) + c.SetRequest(c.Request().WithContext(ctx)) + + req, providerType, err := s.utilityRequest(c) + if err != nil { + return handleError(c, err) + } + auditResponseEntry(c, providerType) + + router, ok := s.provider.(core.NativeResponseUtilityRoutableProvider) + if !ok { + return handleError(c, unsupportedResponseOperation("response compaction is not supported by the current provider router")) + } + resp, err := router.CompactResponse(ctx, providerType, req) + if err != nil { + if isUnsupportedNativeResponseError(err) { + return handleError(c, unsupportedResponseOperation("response compaction is not supported by this provider")) + } + return handleError(c, err) + } + if resp == nil { + return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) + } + if resp.Object == "" { + resp.Object = "response.compaction" + } + if resp.Provider == "" { + resp.Provider = providerType + } + return c.JSON(http.StatusOK, resp) +} + +func (s *nativeResponseService) utilityRequest(c *echo.Context) (*core.ResponsesRequest, string, error) { + req, err := canonicalJSONRequestFromSemantics[*core.ResponsesRequest](c, core.DecodeResponsesRequest) + if err != nil { + return nil, "", core.NewInvalidRequestError("invalid request body: "+err.Error(), err) + } + + workflow, err := ensureTranslatedRequestWorkflowWithAuthorizer(c, s.provider, s.modelResolver, s.modelAuthorizer, s.workflowPolicyResolver, &req.Model, &req.Provider) + if err != nil { + return nil, "", err + } + + if s.translatedRequestPatcher != nil { + req, err = s.translatedRequestPatcher.PatchResponsesRequest(c.Request().Context(), req) + if err != nil { + return nil, "", err + } + } + + providerType := "" + if workflow != nil { + providerType = strings.TrimSpace(workflow.ProviderType) + } + if providerType == "" { + return nil, "", unsupportedResponseOperation("unable to resolve provider for response utility operation") + } + return req, providerType, nil +} + +func (s *nativeResponseService) loadStoredResponse(ctx context.Context, id string) (*responsestore.StoredResponse, error) { + if s.responseStore == nil { + return nil, responsestore.ErrNotFound + } + stored, err := s.responseStore.Get(ctx, id) + if err != nil { + if errors.Is(err, responsestore.ErrNotFound) { + return nil, err + } + return nil, core.NewProviderError("response_store", http.StatusInternalServerError, "failed to load response", err) + } + if stored == nil || stored.Response == nil { + return nil, core.NewProviderError("response_store", http.StatusInternalServerError, "stored response payload missing", nil) + } + return stored, nil +} + +func (s *nativeResponseService) getNativeResponse(ctx context.Context, providerType, id string, params core.ResponseRetrieveParams) (*core.ResponsesResponse, string, error) { + return nativeResponseByProvider(ctx, s.provider, providerType, func(router core.NativeResponseLifecycleRoutableProvider, candidate string) (*core.ResponsesResponse, error) { + return router.GetResponse(ctx, candidate, id, params) + }) +} + +func (s *nativeResponseService) listNativeResponseInputItems(ctx context.Context, providerType, id string, params core.ResponseInputItemsParams) (*core.ResponseInputItemListResponse, string, error) { + return nativeResponseByProvider(ctx, s.provider, providerType, func(router core.NativeResponseLifecycleRoutableProvider, candidate string) (*core.ResponseInputItemListResponse, error) { + return router.ListResponseInputItems(ctx, candidate, id, params) + }) +} + +func (s *nativeResponseService) cancelNativeResponse(ctx context.Context, providerType, id string) (*core.ResponsesResponse, error) { + router, ok := s.provider.(core.NativeResponseLifecycleRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("response lifecycle routing is not supported by the current provider router", nil) + } + return router.CancelResponse(ctx, providerType, id) +} + +func (s *nativeResponseService) cancelNativeResponseByRequest(ctx context.Context, providerType, id string) (*core.ResponsesResponse, string, error) { + return nativeResponseByProvider(ctx, s.provider, providerType, func(router core.NativeResponseLifecycleRoutableProvider, candidate string) (*core.ResponsesResponse, error) { + return router.CancelResponse(ctx, candidate, id) + }) +} + +func (s *nativeResponseService) deleteNativeResponse(ctx context.Context, providerType, id string) (*core.ResponseDeleteResponse, error) { + router, ok := s.provider.(core.NativeResponseLifecycleRoutableProvider) + if !ok { + return nil, core.NewInvalidRequestError("response lifecycle routing is not supported by the current provider router", nil) + } + return router.DeleteResponse(ctx, providerType, id) +} + +func (s *nativeResponseService) deleteNativeResponseByRequest(ctx context.Context, providerType, id string) (*core.ResponseDeleteResponse, string, error) { + return nativeResponseByProvider(ctx, s.provider, providerType, func(router core.NativeResponseLifecycleRoutableProvider, candidate string) (*core.ResponseDeleteResponse, error) { + return router.DeleteResponse(ctx, candidate, id) + }) +} + +func nativeResponseByProvider[T any]( + ctx context.Context, + provider core.RoutableProvider, + providerType string, + call func(core.NativeResponseLifecycleRoutableProvider, string) (T, error), +) (T, string, error) { + var zero T + router, ok := provider.(core.NativeResponseLifecycleRoutableProvider) + if !ok { + return zero, "", unsupportedResponseOperation("response lifecycle routing is not supported by the current provider router") + } + + providerType = strings.TrimSpace(providerType) + if providerType != "" { + result, err := call(router, providerType) + return result, providerType, err + } + + providers, err := nativeResponseProviderTypes(provider) + if err != nil { + return zero, "", err + } + if len(providers) == 0 { + return zero, "", core.NewNotFoundError("response not found") + } + + var firstErr error + for _, candidate := range providers { + if err := ctx.Err(); err != nil { + return zero, "", err + } + result, err := call(router, candidate) + if err == nil { + return result, candidate, nil + } + if isNotFoundGatewayError(err) || isUnsupportedNativeResponseError(err) { + continue + } + if firstErr == nil { + firstErr = err + } + } + if firstErr != nil { + return zero, "", firstErr + } + return zero, "", core.NewNotFoundError("response not found") +} + +func nativeResponseProviderTypes(provider core.RoutableProvider) ([]string, error) { + typed, ok := provider.(core.NativeResponseProviderTypeLister) + if !ok { + return nil, unsupportedResponseOperation("response lifecycle provider inventory is unavailable") + } + return typed.NativeResponseProviderTypes(), nil +} + +func responseIDFromRequest(c *echo.Context) (string, error) { + id := strings.TrimSpace(c.Param("id")) + if id == "" { + return "", core.NewInvalidRequestError("response id is required", nil) + } + return id, nil +} + +func responseProviderFromRequest(c *echo.Context) string { + if c == nil { + return "" + } + return strings.TrimSpace(c.QueryParam("provider")) +} + +func responseRetrieveParamsFromRequest(c *echo.Context) (core.ResponseRetrieveParams, error) { + query := c.Request().URL.Query() + params := core.ResponseRetrieveParams{ + Include: appendQueryArray(query["include"], query["include[]"]), + } + if raw := strings.TrimSpace(query.Get("include_obfuscation")); raw != "" { + parsed, err := strconv.ParseBool(raw) + if err != nil { + return params, core.NewInvalidRequestError("include_obfuscation must be a boolean", err).WithParam("include_obfuscation") + } + params.IncludeObfuscation = &parsed + } + if raw := strings.TrimSpace(query.Get("starting_after")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + return params, core.NewInvalidRequestError("starting_after must be an integer", err).WithParam("starting_after") + } + params.StartingAfter = &parsed + } + if raw := strings.TrimSpace(query.Get("stream")); raw != "" { + parsed, err := strconv.ParseBool(raw) + if err != nil { + return params, core.NewInvalidRequestError("stream must be a boolean", err).WithParam("stream") + } + params.Stream = parsed + } + return params, nil +} + +func responseInputItemsParamsFromRequest(c *echo.Context) (core.ResponseInputItemsParams, error) { + query := c.Request().URL.Query() + params := core.ResponseInputItemsParams{ + After: strings.TrimSpace(query.Get("after")), + Include: appendQueryArray(query["include"], query["include[]"]), + Limit: 20, + Order: "desc", + } + if raw := strings.TrimSpace(query.Get("limit")); raw != "" { + limit, err := strconv.Atoi(raw) + if err != nil { + return params, core.NewInvalidRequestError("limit must be an integer", err).WithParam("limit") + } + switch { + case limit <= 0: + params.Limit = 20 + case limit > 100: + params.Limit = 100 + default: + params.Limit = limit + } + } + if raw := strings.TrimSpace(query.Get("order")); raw != "" { + if raw != "asc" && raw != "desc" { + return params, core.NewInvalidRequestError("order must be asc or desc", nil).WithParam("order") + } + params.Order = raw + } + return params, nil +} + +func appendQueryArray(values ...[]string) []string { + var result []string + for _, group := range values { + for _, value := range group { + value = strings.TrimSpace(value) + if value == "" { + continue + } + result = append(result, value) + } + } + return result +} + +func paginateStoredResponseInputItems(items []json.RawMessage, params core.ResponseInputItemsParams) core.ResponseInputItemListResponse { + ordered := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + ordered = append(ordered, core.CloneRawJSON(item)) + } + if params.Order != "asc" { + slices.Reverse(ordered) + } + + start := 0 + if params.After != "" { + idx := -1 + for i, item := range ordered { + if responseInputItemID(item) == params.After { + idx = i + break + } + } + if idx >= 0 { + start = idx + 1 + } else { + ordered = nil + } + } + if start > len(ordered) { + start = len(ordered) + } + + limit := params.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + remaining := ordered[start:] + hasMore := len(remaining) > limit + if hasMore { + remaining = remaining[:limit] + } + + resp := core.ResponseInputItemListResponse{ + Object: "list", + Data: remaining, + HasMore: hasMore, + } + if len(remaining) > 0 { + resp.FirstID = responseInputItemID(remaining[0]) + resp.LastID = responseInputItemID(remaining[len(remaining)-1]) + } + return resp +} + +func responseInputItemID(raw json.RawMessage) string { + var item struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &item); err != nil { + return "" + } + return strings.TrimSpace(item.ID) +} + +func storedProvider(stored *responsestore.StoredResponse) string { + if stored == nil { + return "" + } + if stored.Provider != "" { + return stored.Provider + } + if stored.Response != nil { + return stored.Response.Provider + } + return "" +} + +func storedProviderRoute(stored *responsestore.StoredResponse) string { + if stored == nil { + return "" + } + if stored.ProviderName != "" { + return stored.ProviderName + } + return storedProvider(stored) +} + +func unsupportedResponseOperation(message string) *core.GatewayError { + return core.NewInvalidRequestErrorWithStatus(http.StatusNotImplemented, message, nil).WithCode("unsupported_response_operation") +} + +func isUnsupportedNativeResponseError(err error) bool { + gatewayErr, ok := errors.AsType[*core.GatewayError](err) + if !ok { + return false + } + if gatewayErr.HTTPStatusCode() != http.StatusBadRequest && gatewayErr.HTTPStatusCode() != http.StatusNotImplemented { + return false + } + message := strings.ToLower(gatewayErr.Message) + return strings.Contains(message, "response lifecycle") || + strings.Contains(message, "response utility") || + strings.Contains(message, "native response") || + strings.Contains(message, "responses lifecycle") || + strings.Contains(message, "responses utility") +} + +func auditResponseEntry(c *echo.Context, providerType string) { + if c == nil { + return + } + auditlog.EnrichEntry(c, "response", providerType) +} diff --git a/internal/server/response_input_items.go b/internal/server/response_input_items.go new file mode 100644 index 00000000..93d019b6 --- /dev/null +++ b/internal/server/response_input_items.go @@ -0,0 +1,224 @@ +package server + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/google/uuid" + + "gomodel/internal/core" +) + +func normalizedResponseInputItems(responseID string, req *core.ResponsesRequest) []json.RawMessage { + if req == nil || req.Input == nil { + return nil + } + + switch input := req.Input.(type) { + case string: + return []json.RawMessage{mustRawJSON(map[string]any{ + "id": generatedResponseInputItemID(responseID, 0, "message", ""), + "type": "message", + "role": "user", + "content": []map[string]any{ + {"type": "input_text", "text": input}, + }, + })} + case []core.ResponsesInputElement: + items := make([]json.RawMessage, 0, len(input)) + for i, item := range input { + if normalized := normalizedResponseInputElement(responseID, i, item); len(normalized) > 0 { + items = append(items, normalized) + } + } + return items + case []any: + items := make([]json.RawMessage, 0, len(input)) + for i, item := range input { + if normalized := normalizedResponseInputAny(responseID, i, item); len(normalized) > 0 { + items = append(items, normalized) + } + } + return items + default: + return []json.RawMessage{normalizedResponseInputAny(responseID, 0, input)} + } +} + +func normalizedResponseInputElement(responseID string, index int, item core.ResponsesInputElement) json.RawMessage { + raw, err := json.Marshal(item) + if err != nil { + return nil + } + return normalizedResponseInputRaw(responseID, index, raw) +} + +func normalizedResponseInputAny(responseID string, index int, item any) json.RawMessage { + raw, err := json.Marshal(item) + if err != nil { + return nil + } + return normalizedResponseInputRaw(responseID, index, raw) +} + +func normalizedResponseInputRaw(responseID string, index int, raw json.RawMessage) json.RawMessage { + var item map[string]any + if err := json.Unmarshal(raw, &item); err != nil { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return nil + } + return mustRawJSON(map[string]any{ + "id": generatedResponseInputItemID(responseID, index, "message", ""), + "type": "message", + "role": "user", + "content": []map[string]any{ + {"type": "input_text", "text": text}, + }, + }) + } + + itemType := strings.TrimSpace(stringFromMap(item, "type")) + if itemType == "" { + itemType = "message" + item["type"] = itemType + } + + switch itemType { + case "message": + if strings.TrimSpace(stringFromMap(item, "role")) == "" { + item["role"] = "user" + } + item["content"] = normalizeResponseInputContent(item["content"]) + case "function_call", "function_call_output": + // The decoded request has already normalized call_id/id aliases. + default: + // Unknown item types are preserved with an ID attached for pagination. + } + + if strings.TrimSpace(stringFromMap(item, "id")) == "" { + item["id"] = generatedResponseInputItemID(responseID, index, itemType, stringFromMap(item, "call_id")) + } + + return mustRawJSON(item) +} + +func normalizeResponseInputContent(content any) any { + switch value := content.(type) { + case nil: + return []map[string]any{} + case string: + return []map[string]any{{"type": "input_text", "text": value}} + case []core.ContentPart: + items := make([]map[string]any, 0, len(value)) + for _, part := range value { + items = append(items, normalizeContentPart(part)) + } + return items + case []any: + items := make([]any, 0, len(value)) + for _, part := range value { + items = append(items, normalizeResponseInputContentPartAny(part)) + } + return items + default: + return content + } +} + +func normalizeContentPart(part core.ContentPart) map[string]any { + switch strings.TrimSpace(part.Type) { + case "text", "input_text": + return map[string]any{"type": "input_text", "text": part.Text} + case "image_url", "input_image": + item := map[string]any{"type": "input_image"} + if part.ImageURL != nil { + item["image_url"] = part.ImageURL.URL + if detail := strings.TrimSpace(part.ImageURL.Detail); detail != "" { + item["detail"] = detail + } + } + return item + case "input_audio": + item := map[string]any{"type": "input_audio"} + if part.InputAudio != nil { + item["input_audio"] = map[string]any{ + "data": part.InputAudio.Data, + "format": part.InputAudio.Format, + } + } + return item + default: + return map[string]any{"type": part.Type, "text": part.Text} + } +} + +func normalizeResponseInputContentPartAny(part any) any { + item, ok := part.(map[string]any) + if !ok { + return part + } + + partType := strings.TrimSpace(stringFromMap(item, "type")) + switch partType { + case "text": + normalized := cloneAnyMap(item) + normalized["type"] = "input_text" + return normalized + case "image_url": + normalized := cloneAnyMap(item) + normalized["type"] = "input_image" + if image, ok := normalized["image_url"].(map[string]any); ok { + if url, ok := image["url"]; ok { + normalized["image_url"] = url + } + if detail, ok := image["detail"]; ok { + normalized["detail"] = detail + } + } + return normalized + default: + return item + } +} + +func generatedResponseInputItemID(responseID string, index int, itemType, callID string) string { + callID = strings.TrimSpace(callID) + switch itemType { + case "function_call": + if callID != "" { + return "fc_" + callID + } + case "function_call_output": + if callID != "" { + return "fco_" + callID + } + } + seed := fmt.Sprintf("%s|%s|%d", strings.TrimSpace(responseID), strings.TrimSpace(itemType), index) + return "msg_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(seed)).String() +} + +func mustRawJSON(value any) json.RawMessage { + raw, err := json.Marshal(value) + if err != nil { + return nil + } + return raw +} + +func stringFromMap(item map[string]any, key string) string { + value, _ := item[key].(string) + return strings.TrimSpace(value) +} + +func cloneAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} diff --git a/internal/server/translated_inference_service.go b/internal/server/translated_inference_service.go index ee44f7a2..53d1a26d 100644 --- a/internal/server/translated_inference_service.go +++ b/internal/server/translated_inference_service.go @@ -14,6 +14,7 @@ import ( "gomodel/internal/auditlog" "gomodel/internal/core" "gomodel/internal/responsecache" + "gomodel/internal/responsestore" "gomodel/internal/streaming" "gomodel/internal/usage" ) @@ -32,6 +33,7 @@ type translatedInferenceService struct { pricingResolver usage.PricingResolver responseCache *responsecache.ResponseCacheMiddleware guardrailsHash string + responseStore responsestore.Store // Pre-built handlers initialized via initHandlers. chatCompletionHandler echo.HandlerFunc @@ -230,9 +232,37 @@ func (s *translatedInferenceService) dispatchResponses(c *echo.Context, req *cor return usage.ExtractFromResponsesResponse(resp, requestID, providerType, "/v1/responses", pricing) }) + if err := s.storeResponseSnapshot(ctx, workflow, req, resp, providerType, providerName, requestID); err != nil { + return handleError(c, err) + } + return c.JSON(http.StatusOK, resp) } +func (s *translatedInferenceService) storeResponseSnapshot(ctx context.Context, workflow *core.Workflow, req *core.ResponsesRequest, resp *core.ResponsesResponse, providerType, providerName, requestID string) error { + if s.responseStore == nil || resp == nil || resp.ID == "" { + return nil + } + + stored := &responsestore.StoredResponse{ + Response: resp, + InputItems: normalizedResponseInputItems(resp.ID, req), + Provider: strings.TrimSpace(providerType), + ProviderName: strings.TrimSpace(providerName), + ProviderResponseID: resp.ID, + RequestID: requestID, + UserPath: core.UserPathFromContext(ctx), + WorkflowVersionID: workflowVersionID(workflow), + } + if err := s.responseStore.Create(ctx, stored); err != nil { + if updateErr := s.responseStore.Update(ctx, stored); updateErr == nil { + return nil + } + return core.NewProviderError("response_store", http.StatusInternalServerError, "failed to persist response", err) + } + return nil +} + func (s *translatedInferenceService) tryFastPathStreamingChatPassthrough(c *echo.Context, workflow *core.Workflow, req *core.ChatRequest) (bool, error) { if !s.canFastPathStreamingChatPassthrough(workflow, req) { return false, nil From 142ba136aa62b9128a65f9745107fa108aefa9e6 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 17 Apr 2026 23:15:55 +0200 Subject: [PATCH 4/8] fix(responses): harden lifecycle edge cases --- cmd/gomodel/docs/docs.go | 4 + docs/openapi.json | 6 +- internal/observability/metrics.go | 24 +++- internal/observability/metrics_test.go | 4 + internal/responsestore/store.go | 3 + internal/responsestore/store_memory.go | 129 +++++++++++++++++- internal/responsestore/store_memory_test.go | 69 ++++++++++ internal/server/handlers.go | 5 +- internal/server/handlers_test.go | 88 ++++++++++++ internal/server/native_response_service.go | 12 +- .../server/native_response_service_test.go | 57 ++++++++ internal/server/response_input_items.go | 9 +- internal/server/response_input_items_test.go | 25 ++++ .../server/translated_inference_service.go | 27 +++- 14 files changed, 444 insertions(+), 18 deletions(-) create mode 100644 internal/responsestore/store_memory_test.go create mode 100644 internal/server/native_response_service_test.go create mode 100644 internal/server/response_input_items_test.go diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index c41ff038..b0a33bef 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -2506,6 +2506,10 @@ const docTemplate = `{ "in": "query" }, { + "enum": [ + "asc", + "desc" + ], "type": "string", "description": "Sort order: asc or desc", "name": "order", diff --git a/docs/openapi.json b/docs/openapi.json index 232cb87e..0952d431 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3800,7 +3800,11 @@ "name": "order", "in": "query", "schema": { - "type": "string" + "type": "string", + "enum": [ + "asc", + "desc" + ] } } ], diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index dfaa948d..e1bd90ad 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -42,6 +42,15 @@ var ( }, []string{"provider", "endpoint", "stream"}, ) + + // ResponseSnapshotStoreFailures counts failures while storing response snapshots. + ResponseSnapshotStoreFailures = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gomodel_response_snapshot_store_failures_total", + Help: "Total number of response snapshot store failures", + }, + []string{"provider", "provider_name", "operation"}, + ) ) // NewPrometheusHooks returns hooks that instrument LLM requests with Prometheus metrics. @@ -138,17 +147,19 @@ func NewPrometheusHooks() llmclient.Hooks { // PrometheusMetrics provides access to all registered metrics for testing type PrometheusMetrics struct { - RequestsTotal *prometheus.CounterVec - RequestDuration *prometheus.HistogramVec - InFlightRequests *prometheus.GaugeVec + RequestsTotal *prometheus.CounterVec + RequestDuration *prometheus.HistogramVec + InFlightRequests *prometheus.GaugeVec + ResponseSnapshotStoreFailures *prometheus.CounterVec } // GetMetrics returns the prometheus metrics for testing and introspection func GetMetrics() *PrometheusMetrics { return &PrometheusMetrics{ - RequestsTotal: RequestsTotal, - RequestDuration: RequestDuration, - InFlightRequests: InFlightRequests, + RequestsTotal: RequestsTotal, + RequestDuration: RequestDuration, + InFlightRequests: InFlightRequests, + ResponseSnapshotStoreFailures: ResponseSnapshotStoreFailures, } } @@ -157,6 +168,7 @@ func ResetMetrics() { RequestsTotal.Reset() RequestDuration.Reset() InFlightRequests.Reset() + ResponseSnapshotStoreFailures.Reset() } // HealthCheck verifies that metrics are being collected diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go index 954a5d9e..680db742 100644 --- a/internal/observability/metrics_test.go +++ b/internal/observability/metrics_test.go @@ -374,4 +374,8 @@ func TestGetMetrics(t *testing.T) { if metrics.InFlightRequests == nil { t.Error("InFlightRequests metric is nil") } + + if metrics.ResponseSnapshotStoreFailures == nil { + t.Error("ResponseSnapshotStoreFailures metric is nil") + } } diff --git a/internal/responsestore/store.go b/internal/responsestore/store.go index ec5a547e..f99a2ff6 100644 --- a/internal/responsestore/store.go +++ b/internal/responsestore/store.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "strings" + "time" "gomodel/internal/core" ) @@ -26,6 +27,8 @@ type StoredResponse struct { RequestID string `json:"request_id,omitempty"` UserPath string `json:"user_path,omitempty"` WorkflowVersionID string `json:"workflow_version_id,omitempty"` + StoredAt time.Time `json:"stored_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` } // Store defines persistence operations for Responses lifecycle APIs. diff --git a/internal/responsestore/store_memory.go b/internal/responsestore/store_memory.go index 50062692..b31bf508 100644 --- a/internal/responsestore/store_memory.go +++ b/internal/responsestore/store_memory.go @@ -4,20 +4,51 @@ import ( "context" "fmt" "sync" + "time" ) // MemoryStore keeps response snapshots in process memory. // Data survives across requests but not process restarts. type MemoryStore struct { - mu sync.RWMutex - items map[string]*StoredResponse + mu sync.RWMutex + items map[string]*StoredResponse + ttl time.Duration + maxEntries int +} + +// MemoryStoreOption configures bounded in-memory response retention. +type MemoryStoreOption func(*MemoryStore) + +// WithTTL expires stored responses after ttl. Non-positive values disable TTL. +func WithTTL(ttl time.Duration) MemoryStoreOption { + return func(s *MemoryStore) { + if ttl > 0 { + s.ttl = ttl + } + } +} + +// WithMaxEntries caps stored responses with FIFO eviction. Non-positive values disable the cap. +func WithMaxEntries(maxEntries int) MemoryStoreOption { + return func(s *MemoryStore) { + if maxEntries > 0 { + s.maxEntries = maxEntries + } + } } // NewMemoryStore creates an empty in-memory response store. -func NewMemoryStore() *MemoryStore { - return &MemoryStore{ +// By default retention is unbounded; pass WithTTL or WithMaxEntries to opt in. +func NewMemoryStore(options ...MemoryStoreOption) *MemoryStore { + store := &MemoryStore{ items: make(map[string]*StoredResponse), } + for _, option := range options { + if option != nil { + option(store) + } + } + return store } // Create stores a new response snapshot. @@ -31,23 +62,39 @@ func (s *MemoryStore) Create(_ context.Context, response *StoredResponse) error return err } + now := time.Now().UTC() + prepareStoredResponseForMemory(c, now, s.ttl) + s.mu.Lock() defer s.mu.Unlock() + s.cleanupExpiredLocked(now) + if responseExpired(c, now) { + return nil + } if _, exists := s.items[c.Response.ID]; exists { return fmt.Errorf("response already exists: %s", c.Response.ID) } s.items[c.Response.ID] = c + s.enforceMaxEntriesLocked() return nil } // Get retrieves one response snapshot by id. func (s *MemoryStore) Get(_ context.Context, id string) (*StoredResponse, error) { - s.mu.RLock() + now := time.Now().UTC() + s.mu.Lock() + s.cleanupExpiredLocked(now) response, ok := s.items[id] - s.mu.RUnlock() if !ok { + s.mu.Unlock() + return nil, ErrNotFound + } + if responseExpired(response, now) { + delete(s.items, id) + s.mu.Unlock() return nil, ErrNotFound } + s.mu.Unlock() return cloneResponse(response) } @@ -61,12 +108,27 @@ func (s *MemoryStore) Update(_ context.Context, response *StoredResponse) error return err } + now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.items[c.Response.ID]; !exists { + s.cleanupExpiredLocked(now) + existing, exists := s.items[c.Response.ID] + if !exists { + return ErrNotFound + } + if c.StoredAt.IsZero() { + c.StoredAt = existing.StoredAt + } + if c.ExpiresAt.IsZero() { + c.ExpiresAt = existing.ExpiresAt + } + prepareStoredResponseForMemory(c, now, s.ttl) + if responseExpired(c, now) { + delete(s.items, c.Response.ID) return ErrNotFound } s.items[c.Response.ID] = c + s.enforceMaxEntriesLocked() return nil } @@ -74,6 +136,7 @@ func (s *MemoryStore) Update(_ context.Context, response *StoredResponse) error func (s *MemoryStore) Delete(_ context.Context, id string) error { s.mu.Lock() defer s.mu.Unlock() + s.cleanupExpiredLocked(time.Now().UTC()) if _, exists := s.items[id]; !exists { return ErrNotFound } @@ -85,3 +148,55 @@ func (s *MemoryStore) Delete(_ context.Context, id string) error { func (s *MemoryStore) Close() error { return nil } + +func prepareStoredResponseForMemory(response *StoredResponse, now time.Time, ttl time.Duration) { + if response.StoredAt.IsZero() { + response.StoredAt = now + } + if ttl > 0 && response.ExpiresAt.IsZero() { + response.ExpiresAt = response.StoredAt.Add(ttl) + } +} + +func (s *MemoryStore) cleanupExpiredLocked(now time.Time) { + if s.ttl <= 0 { + return + } + for id, response := range s.items { + if responseExpired(response, now) { + delete(s.items, id) + } + } +} + +func (s *MemoryStore) enforceMaxEntriesLocked() { + if s.maxEntries <= 0 { + return + } + for len(s.items) > s.maxEntries { + oldestID := "" + var oldest time.Time + for id, response := range s.items { + storedAt := responseStoredAt(response) + if oldestID == "" || storedAt.Before(oldest) { + oldestID = id + oldest = storedAt + } + } + if oldestID == "" { + return + } + delete(s.items, oldestID) + } +} + +func responseExpired(response *StoredResponse, now time.Time) bool { + return response != nil && !response.ExpiresAt.IsZero() && !response.ExpiresAt.After(now) +} + +func responseStoredAt(response *StoredResponse) time.Time { + if response == nil || response.StoredAt.IsZero() { + return time.Time{} + } + return response.StoredAt +} diff --git a/internal/responsestore/store_memory_test.go b/internal/responsestore/store_memory_test.go new file mode 100644 index 00000000..8e9f596f --- /dev/null +++ b/internal/responsestore/store_memory_test.go @@ -0,0 +1,69 @@ +package responsestore + +import ( + "context" + "errors" + "testing" + "time" + + "gomodel/internal/core" +) + +func TestMemoryStoreExpiresResponses(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore(WithTTL(time.Second)) + + err := store.Create(ctx, &StoredResponse{ + Response: &core.ResponsesResponse{ID: "resp_old", Object: "response"}, + StoredAt: time.Now().UTC().Add(-2 * time.Second), + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if _, err := store.Get(ctx, "resp_old"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get() error = %v, want ErrNotFound", err) + } +} + +func TestMemoryStoreMaxEntriesEvictsOldest(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore(WithMaxEntries(2)) + now := time.Now().UTC() + + for _, response := range []*StoredResponse{ + {Response: &core.ResponsesResponse{ID: "resp_1", Object: "response"}, StoredAt: now.Add(-3 * time.Second)}, + {Response: &core.ResponsesResponse{ID: "resp_2", Object: "response"}, StoredAt: now.Add(-2 * time.Second)}, + {Response: &core.ResponsesResponse{ID: "resp_3", Object: "response"}, StoredAt: now.Add(-1 * time.Second)}, + } { + if err := store.Create(ctx, response); err != nil { + t.Fatalf("Create(%s) error = %v", response.Response.ID, err) + } + } + + if _, err := store.Get(ctx, "resp_1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get(resp_1) error = %v, want ErrNotFound", err) + } + for _, id := range []string{"resp_2", "resp_3"} { + if _, err := store.Get(ctx, id); err != nil { + t.Fatalf("Get(%s) error = %v", id, err) + } + } +} + +func TestMemoryStoreDefaultRetentionIsUnbounded(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + + err := store.Create(ctx, &StoredResponse{ + Response: &core.ResponsesResponse{ID: "resp_old", Object: "response"}, + StoredAt: time.Now().UTC().Add(-24 * time.Hour), + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if _, err := store.Get(ctx, "resp_old"); err != nil { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index b992afae..6629f6f8 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -112,6 +112,9 @@ func (h *Handler) SetResponseStore(store responsestore.Store) { return } h.responseStore = store + if h.translatedSvc != nil { + h.translatedSvc.responseStore = store + } } func (h *Handler) translatedInference() *translatedInferenceService { @@ -440,7 +443,7 @@ func (h *Handler) GetResponse(c *echo.Context) error { // @Param provider query string false "Provider override for native lookups" // @Param after query string false "Pagination cursor" // @Param limit query int false "Maximum items to return (1-100, default 20)" -// @Param order query string false "Sort order: asc or desc" +// @Param order query string false "Sort order: asc or desc" Enums(asc, desc) // @Success 200 {object} core.ResponseInputItemListResponse // @Failure 400 {object} core.GatewayError // @Failure 401 {object} core.GatewayError diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 6271f205..d54702f6 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -18,6 +18,7 @@ import ( "time" "github.com/labstack/echo/v5" + "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -26,6 +27,7 @@ import ( batchstore "gomodel/internal/batch" "gomodel/internal/core" "gomodel/internal/guardrails" + "gomodel/internal/observability" provideradapter "gomodel/internal/providers" "gomodel/internal/responsestore" "gomodel/internal/usage" @@ -89,6 +91,37 @@ func (s *aliasesTestStore) Close() error { return nil } +type failingResponseStore struct { + err error +} + +func (s *failingResponseStore) storeErr() error { + if s.err != nil { + return s.err + } + return errors.New("response store failed") +} + +func (s *failingResponseStore) Create(context.Context, *responsestore.StoredResponse) error { + return s.storeErr() +} + +func (s *failingResponseStore) Get(context.Context, string) (*responsestore.StoredResponse, error) { + return nil, responsestore.ErrNotFound +} + +func (s *failingResponseStore) Update(context.Context, *responsestore.StoredResponse) error { + return s.storeErr() +} + +func (s *failingResponseStore) Delete(context.Context, string) error { + return responsestore.ErrNotFound +} + +func (s *failingResponseStore) Close() error { + return nil +} + type aliasesTestCatalog struct { supported map[string]bool providerTypes map[string]string @@ -4641,6 +4674,61 @@ func TestResponsesLifecycle_StoresConcreteProviderName(t *testing.T) { } } +func TestResponsesLifecycle_ReturnsSuccessWhenSnapshotStoreFails(t *testing.T) { + observability.ResetMetrics() + provider := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{ + "gpt-5-mini": "mock", + }, + responsesResponse: &core.ResponsesResponse{ + ID: "resp_store_failure_1", + Object: "response", + Model: "gpt-5-mini", + Status: "completed", + }, + } + srv := New(provider, &Config{ResponseStore: &failingResponseStore{err: errors.New("write failed")}}) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var resp core.ResponsesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.ID != "resp_store_failure_1" { + t.Fatalf("response id = %q, want resp_store_failure_1", resp.ID) + } + + counter := observability.ResponseSnapshotStoreFailures.WithLabelValues("mock", "", "store") + if got := testutil.ToFloat64(counter); got != 1 { + t.Fatalf("snapshot store failures = %v, want 1", got) + } +} + +func TestHandlerSetResponseStoreUpdatesCachedTranslatedInferenceService(t *testing.T) { + handler := NewHandler(&mockProvider{}, nil, nil, nil) + first := responsestore.NewMemoryStore() + second := responsestore.NewMemoryStore() + + handler.SetResponseStore(first) + service := handler.translatedInference() + if service.responseStore != first { + t.Fatal("translatedInferenceService did not capture first response store") + } + + handler.SetResponseStore(second) + if service.responseStore != second { + t.Fatal("SetResponseStore did not update cached translatedInferenceService response store") + } +} + func TestResponsesLifecycle_CancelUnsupportedProviderReturnsCompatibilityError(t *testing.T) { base := &mockProvider{ supportedModels: []string{"gpt-5-mini"}, diff --git a/internal/server/native_response_service.go b/internal/server/native_response_service.go index 7b5d7904..5153a862 100644 --- a/internal/server/native_response_service.go +++ b/internal/server/native_response_service.go @@ -262,6 +262,9 @@ func (s *nativeResponseService) utilityRequest(c *echo.Context) (*core.Responses if err != nil { return nil, "", core.NewInvalidRequestError("invalid request body: "+err.Error(), err) } + if req == nil { + return nil, "", core.NewInvalidRequestError("responses request body is required", errors.New("responses request body is null")) + } workflow, err := ensureTranslatedRequestWorkflowWithAuthorizer(c, s.provider, s.modelResolver, s.modelAuthorizer, s.workflowPolicyResolver, &req.Model, &req.Provider) if err != nil { @@ -371,7 +374,7 @@ func nativeResponseByProvider[T any]( var firstErr error for _, candidate := range providers { if err := ctx.Err(); err != nil { - return zero, "", err + return zero, "", responseLifecycleContextError(err) } result, err := call(router, candidate) if err == nil { @@ -390,6 +393,13 @@ func nativeResponseByProvider[T any]( return zero, "", core.NewNotFoundError("response not found") } +func responseLifecycleContextError(err error) *core.GatewayError { + if errors.Is(err, context.DeadlineExceeded) { + return core.NewProviderError("response_lifecycle", http.StatusGatewayTimeout, "response lifecycle request timed out", err) + } + return core.NewInvalidRequestErrorWithStatus(http.StatusRequestTimeout, "response lifecycle request canceled", err) +} + func nativeResponseProviderTypes(provider core.RoutableProvider) ([]string, error) { typed, ok := provider.(core.NativeResponseProviderTypeLister) if !ok { diff --git a/internal/server/native_response_service_test.go b/internal/server/native_response_service_test.go new file mode 100644 index 00000000..f595a316 --- /dev/null +++ b/internal/server/native_response_service_test.go @@ -0,0 +1,57 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gomodel/internal/core" +) + +func TestResponsesUtilityRoutesRejectNullBody(t *testing.T) { + provider := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{ + "gpt-5-mini": "mock", + }, + } + srv := New(provider, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", strings.NewReader("null")) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (%s)", rec.Code, rec.Body.String()) + } + if len(provider.capturedResponseUtilityReqs) != 0 { + t.Fatalf("utility calls = %d, want 0", len(provider.capturedResponseUtilityReqs)) + } +} + +func TestNativeResponseByProviderWrapsContextCancellation(t *testing.T) { + provider := &mockProvider{ + providerTypes: map[string]string{ + "gpt-5-mini": "mock", + }, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := nativeResponseByProvider[*core.ResponsesResponse](ctx, provider, "", func(core.NativeResponseLifecycleRoutableProvider, string) (*core.ResponsesResponse, error) { + t.Fatal("provider call should not run after context cancellation") + return nil, nil + }) + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T %[1]v, want *core.GatewayError", err) + } + if gatewayErr.HTTPStatusCode() != http.StatusRequestTimeout { + t.Fatalf("status = %d, want 408", gatewayErr.HTTPStatusCode()) + } +} diff --git a/internal/server/response_input_items.go b/internal/server/response_input_items.go index 93d019b6..0aadf8ba 100644 --- a/internal/server/response_input_items.go +++ b/internal/server/response_input_items.go @@ -42,7 +42,11 @@ func normalizedResponseInputItems(responseID string, req *core.ResponsesRequest) } return items default: - return []json.RawMessage{normalizedResponseInputAny(responseID, 0, input)} + normalized := normalizedResponseInputAny(responseID, 0, input) + if len(normalized) == 0 { + return nil + } + return []json.RawMessage{normalized} } } @@ -78,6 +82,9 @@ func normalizedResponseInputRaw(responseID string, index int, raw json.RawMessag }, }) } + if item == nil { + return nil + } itemType := strings.TrimSpace(stringFromMap(item, "type")) if itemType == "" { diff --git a/internal/server/response_input_items_test.go b/internal/server/response_input_items_test.go new file mode 100644 index 00000000..f1cde426 --- /dev/null +++ b/internal/server/response_input_items_test.go @@ -0,0 +1,25 @@ +package server + +import ( + "encoding/json" + "testing" + + "gomodel/internal/core" +) + +func TestNormalizedResponseInputItemsSkipsNilDefaultInput(t *testing.T) { + var input *core.ResponsesInputElement + req := &core.ResponsesRequest{Input: input} + + items := normalizedResponseInputItems("resp_1", req) + if len(items) != 0 { + t.Fatalf("len(items) = %d, want 0", len(items)) + } +} + +func TestNormalizedResponseInputRawSkipsNullObject(t *testing.T) { + item := normalizedResponseInputRaw("resp_1", 0, json.RawMessage("null")) + if len(item) != 0 { + t.Fatalf("len(item) = %d, want 0", len(item)) + } +} diff --git a/internal/server/translated_inference_service.go b/internal/server/translated_inference_service.go index 53d1a26d..069bd943 100644 --- a/internal/server/translated_inference_service.go +++ b/internal/server/translated_inference_service.go @@ -13,6 +13,7 @@ import ( "gomodel/internal/auditlog" "gomodel/internal/core" + "gomodel/internal/observability" "gomodel/internal/responsecache" "gomodel/internal/responsestore" "gomodel/internal/streaming" @@ -233,7 +234,7 @@ func (s *translatedInferenceService) dispatchResponses(c *echo.Context, req *cor }) if err := s.storeResponseSnapshot(ctx, workflow, req, resp, providerType, providerName, requestID); err != nil { - return handleError(c, err) + s.recordResponseSnapshotStoreFailure(workflow, resp, providerType, providerName, requestID, err) } return c.JSON(http.StatusOK, resp) @@ -263,6 +264,30 @@ func (s *translatedInferenceService) storeResponseSnapshot(ctx context.Context, return nil } +func (s *translatedInferenceService) recordResponseSnapshotStoreFailure(workflow *core.Workflow, resp *core.ResponsesResponse, providerType, providerName, requestID string, err error) { + observability.ResponseSnapshotStoreFailures.WithLabelValues( + strings.TrimSpace(providerType), + strings.TrimSpace(providerName), + "store", + ).Inc() + + slog.Warn("response snapshot store failed", + "request_id", requestID, + "provider_type", providerType, + "provider_name", providerName, + "workflow_version_id", workflowVersionID(workflow), + "response_id", responseIDForLog(resp), + "err", err, + ) +} + +func responseIDForLog(resp *core.ResponsesResponse) string { + if resp == nil { + return "" + } + return strings.TrimSpace(resp.ID) +} + func (s *translatedInferenceService) tryFastPathStreamingChatPassthrough(c *echo.Context, workflow *core.Workflow, req *core.ChatRequest) (bool, error) { if !s.canFastPathStreamingChatPassthrough(workflow, req) { return false, nil From af711a47a5bad3383a6368f2f67b200519a7737e Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 18 Apr 2026 21:21:20 +0200 Subject: [PATCH 5/8] fix(responses): align lifecycle docs and schemas --- cmd/gomodel/docs/docs.go | 295 ++++++++----- docs/{features => advanced}/responses-api.mdx | 0 docs/docs.json | 2 +- docs/openapi.json | 409 +++++++++++------- internal/core/errors.go | 13 + internal/responsestore/store_memory.go | 29 +- internal/responsestore/store_memory_test.go | 17 +- internal/server/handlers.go | 206 ++++----- internal/server/handlers_test.go | 29 +- internal/server/native_response_service.go | 13 +- .../server/native_response_service_test.go | 74 ++++ internal/server/response_input_items.go | 4 + internal/server/response_input_items_test.go | 23 + 13 files changed, 739 insertions(+), 375 deletions(-) rename docs/{features => advanced}/responses-api.mdx (100%) diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index b0a33bef..64e3eae9 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -768,19 +768,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -849,19 +849,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -930,19 +930,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1011,19 +1011,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1092,19 +1092,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1173,19 +1173,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1254,19 +1254,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1310,25 +1310,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1370,19 +1370,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1421,31 +1421,31 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1487,31 +1487,31 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1550,37 +1550,37 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1625,25 +1625,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "429": { "description": "Too Many Requests", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1687,25 +1687,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "429": { "description": "Too Many Requests", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1761,25 +1761,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1832,19 +1832,19 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1889,25 +1889,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -1950,25 +1950,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2013,25 +2013,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2061,13 +2061,13 @@ const docTemplate = `{ "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2112,25 +2112,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "429": { "description": "Too Many Requests", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2174,25 +2174,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "501": { "description": "Not Implemented", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2236,25 +2236,25 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "501": { "description": "Not Implemented", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2287,6 +2287,44 @@ const docTemplate = `{ "description": "Provider override for native lookups", "name": "provider", "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "Fields to include in the response", + "name": "include", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "Fields to include in the response", + "name": "include[]", + "in": "query" + }, + { + "type": "boolean", + "description": "Whether to include obfuscated response data", + "name": "include_obfuscation", + "in": "query" + }, + { + "type": "integer", + "description": "Input item offset for providers that support it", + "name": "starting_after", + "in": "query" + }, + { + "type": "boolean", + "description": "Whether to stream the retrieved response", + "name": "stream", + "in": "query" } ], "responses": { @@ -2299,31 +2337,31 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "501": { "description": "Not Implemented", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2366,31 +2404,31 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "501": { "description": "Not Implemented", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2435,31 +2473,31 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "501": { "description": "Not Implemented", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -2499,6 +2537,26 @@ const docTemplate = `{ "name": "after", "in": "query" }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "Fields to include in listed input items", + "name": "include", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "Fields to include in listed input items", + "name": "include[]", + "in": "query" + }, { "type": "integer", "description": "Maximum items to return (1-100, default 20)", @@ -2526,31 +2584,31 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "501": { "description": "Not Implemented", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } }, "502": { "description": "Bad Gateway", "schema": { - "$ref": "#/definitions/core.GatewayError" + "$ref": "#/definitions/core.OpenAIErrorEnvelope" } } }, @@ -3561,6 +3619,33 @@ const docTemplate = `{ } } }, + "core.OpenAIErrorEnvelope": { + "type": "object", + "properties": { + "error": { + "$ref": "#/definitions/core.OpenAIErrorObject" + } + } + }, + "core.OpenAIErrorObject": { + "type": "object", + "properties": { + "code": { + "type": "string", + "x-nullable": true + }, + "message": { + "type": "string" + }, + "param": { + "type": "string", + "x-nullable": true + }, + "type": { + "$ref": "#/definitions/core.ErrorType" + } + } + }, "core.PromptTokensDetails": { "type": "object", "properties": { diff --git a/docs/features/responses-api.mdx b/docs/advanced/responses-api.mdx similarity index 100% rename from docs/features/responses-api.mdx rename to docs/advanced/responses-api.mdx diff --git a/docs/docs.json b/docs/docs.json index ad2d327b..9e9dd657 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -39,7 +39,6 @@ "features/aliases", "features/user-path", "features/passthrough-api", - "features/responses-api", "features/cache", "features/failover" ] @@ -50,6 +49,7 @@ "pages": [ "advanced/configuration", "advanced/config-yaml", + "advanced/responses-api", "advanced/guardrails", "advanced/workflows", "advanced/admin-endpoints" diff --git a/docs/openapi.json b/docs/openapi.json index 0952d431..af8cabd6 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1010,17 +1010,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1030,17 +1030,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1050,17 +1050,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1193,17 +1193,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1213,17 +1213,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1233,17 +1233,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1376,17 +1376,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1396,17 +1396,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1416,17 +1416,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1559,17 +1559,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1579,17 +1579,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1599,17 +1599,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1742,17 +1742,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1762,17 +1762,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1782,17 +1782,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1925,17 +1925,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1945,17 +1945,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -1965,17 +1965,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2108,17 +2108,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2128,17 +2128,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2148,17 +2148,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2211,7 +2211,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2221,7 +2221,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2231,7 +2231,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2241,7 +2241,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2285,7 +2285,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2295,7 +2295,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2305,7 +2305,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2351,7 +2351,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2361,7 +2361,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2371,7 +2371,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2381,7 +2381,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2391,7 +2391,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2437,7 +2437,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2447,7 +2447,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2457,7 +2457,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2467,7 +2467,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2477,7 +2477,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2523,7 +2523,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2533,7 +2533,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2543,7 +2543,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2553,7 +2553,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2563,7 +2563,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2573,7 +2573,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2624,12 +2624,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2639,12 +2639,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2654,12 +2654,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2669,12 +2669,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2720,7 +2720,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2730,7 +2730,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2740,7 +2740,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2750,7 +2750,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2819,7 +2819,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2829,7 +2829,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2839,7 +2839,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2849,7 +2849,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2917,7 +2917,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2927,7 +2927,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2937,7 +2937,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -2991,7 +2991,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3001,7 +3001,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3011,7 +3011,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3021,7 +3021,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3073,7 +3073,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3083,7 +3083,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3093,7 +3093,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3103,7 +3103,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3158,7 +3158,7 @@ "content": { "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3168,7 +3168,7 @@ "content": { "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3178,7 +3178,7 @@ "content": { "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3188,7 +3188,7 @@ "content": { "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3223,7 +3223,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3233,7 +3233,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3276,12 +3276,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3291,12 +3291,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3306,12 +3306,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3321,12 +3321,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } }, "text/event-stream": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3364,7 +3364,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3374,7 +3374,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3384,7 +3384,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3394,7 +3394,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3432,7 +3432,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3442,7 +3442,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3452,7 +3452,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3462,7 +3462,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3498,6 +3498,54 @@ "schema": { "type": "string" } + }, + { + "description": "Fields to include in the response", + "name": "include", + "in": "query", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Fields to include in the response", + "name": "include[]", + "in": "query", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Whether to include obfuscated response data", + "name": "include_obfuscation", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "description": "Input item offset for providers that support it", + "name": "starting_after", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "description": "Whether to stream the retrieved response", + "name": "stream", + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -3516,7 +3564,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3526,7 +3574,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3536,7 +3584,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3546,7 +3594,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3556,7 +3604,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3608,7 +3656,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3618,7 +3666,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3628,7 +3676,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3638,7 +3686,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3648,7 +3696,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3702,7 +3750,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3712,7 +3760,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3722,7 +3770,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3732,7 +3780,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3742,7 +3790,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3787,6 +3835,30 @@ "type": "string" } }, + { + "description": "Fields to include in listed input items", + "name": "include", + "in": "query", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Fields to include in listed input items", + "name": "include[]", + "in": "query", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, { "description": "Maximum items to return (1-100, default 20)", "name": "limit", @@ -3824,7 +3896,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3834,7 +3906,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3844,7 +3916,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3854,7 +3926,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -3864,7 +3936,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/core.GatewayError" + "$ref": "#/components/schemas/core.OpenAIErrorEnvelope" } } } @@ -4904,6 +4976,33 @@ } } }, + "core.OpenAIErrorEnvelope": { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/core.OpenAIErrorObject" + } + } + }, + "core.OpenAIErrorObject": { + "type": "object", + "properties": { + "code": { + "type": "string", + "nullable": true + }, + "message": { + "type": "string" + }, + "param": { + "type": "string", + "nullable": true + }, + "type": { + "$ref": "#/components/schemas/core.ErrorType" + } + } + }, "core.PromptTokensDetails": { "type": "object", "properties": { diff --git a/internal/core/errors.go b/internal/core/errors.go index cc116e0d..b6201c7e 100644 --- a/internal/core/errors.go +++ b/internal/core/errors.go @@ -35,6 +35,19 @@ type GatewayError struct { Err error `json:"-"` } +// OpenAIErrorEnvelope documents the public OpenAI-compatible error response. +type OpenAIErrorEnvelope struct { + Error OpenAIErrorObject `json:"error"` +} + +// OpenAIErrorObject is the error object exposed in public API responses. +type OpenAIErrorObject struct { + Type ErrorType `json:"type"` + Message string `json:"message"` + Param *string `json:"param" extensions:"x-nullable"` + Code *string `json:"code" extensions:"x-nullable"` +} + // Error implements the error interface func (e *GatewayError) Error() string { if e.Provider != "" { diff --git a/internal/responsestore/store_memory.go b/internal/responsestore/store_memory.go index b31bf508..7d40c1dd 100644 --- a/internal/responsestore/store_memory.go +++ b/internal/responsestore/store_memory.go @@ -7,6 +7,13 @@ import ( "time" ) +const ( + // DefaultMemoryStoreTTL bounds in-memory response retention by age. + DefaultMemoryStoreTTL = 24 * time.Hour + // DefaultMemoryStoreMaxEntries bounds in-memory response retention by count. + DefaultMemoryStoreMaxEntries = 10000 +) + // MemoryStore keeps response snapshots in process memory. // Data survives across requests but not process restarts. type MemoryStore struct { @@ -22,26 +29,32 @@ type MemoryStoreOption func(*MemoryStore) // WithTTL expires stored responses after ttl. Non-positive values disable TTL. func WithTTL(ttl time.Duration) MemoryStoreOption { return func(s *MemoryStore) { - if ttl > 0 { - s.ttl = ttl - } + s.ttl = ttl } } // WithMaxEntries caps stored responses with FIFO eviction. Non-positive values disable the cap. func WithMaxEntries(maxEntries int) MemoryStoreOption { return func(s *MemoryStore) { - if maxEntries > 0 { - s.maxEntries = maxEntries - } + s.maxEntries = maxEntries + } +} + +// WithUnboundedRetention disables default in-memory retention bounds. +func WithUnboundedRetention() MemoryStoreOption { + return func(s *MemoryStore) { + s.ttl = 0 + s.maxEntries = 0 } } // NewMemoryStore creates an empty in-memory response store. -// By default retention is unbounded; pass WithTTL or WithMaxEntries to opt in. +// By default retention is bounded; pass WithUnboundedRetention to opt out. func NewMemoryStore(options ...MemoryStoreOption) *MemoryStore { store := &MemoryStore{ - items: make(map[string]*StoredResponse), + items: make(map[string]*StoredResponse), + ttl: DefaultMemoryStoreTTL, + maxEntries: DefaultMemoryStoreMaxEntries, } for _, option := range options { if option != nil { diff --git a/internal/responsestore/store_memory_test.go b/internal/responsestore/store_memory_test.go index 8e9f596f..8f973005 100644 --- a/internal/responsestore/store_memory_test.go +++ b/internal/responsestore/store_memory_test.go @@ -28,7 +28,7 @@ func TestMemoryStoreExpiresResponses(t *testing.T) { func TestMemoryStoreMaxEntriesEvictsOldest(t *testing.T) { ctx := context.Background() - store := NewMemoryStore(WithMaxEntries(2)) + store := NewMemoryStore(WithTTL(0), WithMaxEntries(2)) now := time.Now().UTC() for _, response := range []*StoredResponse{ @@ -51,10 +51,21 @@ func TestMemoryStoreMaxEntriesEvictsOldest(t *testing.T) { } } -func TestMemoryStoreDefaultRetentionIsUnbounded(t *testing.T) { - ctx := context.Background() +func TestMemoryStoreDefaultRetentionIsBounded(t *testing.T) { store := NewMemoryStore() + if store.ttl != DefaultMemoryStoreTTL { + t.Fatalf("ttl = %s, want %s", store.ttl, DefaultMemoryStoreTTL) + } + if store.maxEntries != DefaultMemoryStoreMaxEntries { + t.Fatalf("maxEntries = %d, want %d", store.maxEntries, DefaultMemoryStoreMaxEntries) + } +} + +func TestMemoryStoreAllowsExplicitUnboundedRetention(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore(WithUnboundedRetention()) + err := store.Create(ctx, &StoredResponse{ Response: &core.ResponsesResponse{ID: "resp_old", Object: "response"}, StoredAt: time.Now().UTC().Add(-24 * time.Hour), diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 6629f6f8..c7baff86 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -80,17 +80,20 @@ func newHandlerWithAuthorizer( translatedRequestPatcher TranslatedRequestPatcher, ) *Handler { return &Handler{ - provider: provider, - modelResolver: modelResolver, - modelAuthorizer: modelAuthorizer, - fallbackResolver: fallbackResolver, - workflowPolicyResolver: workflowPolicyResolver, - translatedRequestPatcher: translatedRequestPatcher, - logger: logger, - usageLogger: usageLogger, - pricingResolver: pricingResolver, - batchStore: batchstore.NewMemoryStore(), - responseStore: responsestore.NewMemoryStore(), + provider: provider, + modelResolver: modelResolver, + modelAuthorizer: modelAuthorizer, + fallbackResolver: fallbackResolver, + workflowPolicyResolver: workflowPolicyResolver, + translatedRequestPatcher: translatedRequestPatcher, + logger: logger, + usageLogger: usageLogger, + pricingResolver: pricingResolver, + batchStore: batchstore.NewMemoryStore(), + responseStore: responsestore.NewMemoryStore( + responsestore.WithTTL(responsestore.DefaultMemoryStoreTTL), + responsestore.WithMaxEntries(responsestore.DefaultMemoryStoreMaxEntries), + ), normalizePassthroughV1Prefix: true, enabledPassthroughProviders: normalizeEnabledPassthroughProviders(defaultEnabledPassthroughProviders), } @@ -201,9 +204,9 @@ func (h *Handler) passthrough() *passthroughService { // @Success 201 {file} file "Opaque upstream response body" // @Success 202 {file} file "Opaque upstream response body" // @Success 204 {string} string "No Content passthrough response" -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /p/{provider}/{endpoint} [get] // @Router /p/{provider}/{endpoint} [post] // @Router /p/{provider}/{endpoint} [put] @@ -225,10 +228,10 @@ func (h *Handler) ProviderPassthrough(c *echo.Context) error { // @Security BearerAuth // @Param request body core.ChatRequest true "Chat completion request" // @Success 200 {object} core.ChatResponse "JSON response or SSE stream when stream=true" -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 429 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 429 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/chat/completions [post] func (h *Handler) ChatCompletion(c *echo.Context) error { return h.translatedInference().ChatCompletion(c) @@ -252,8 +255,8 @@ func (h *Handler) Health(c *echo.Context) error { // @Produce json // @Security BearerAuth // @Success 200 {object} core.ModelsResponse -// @Failure 401 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/models [get] func (h *Handler) ListModels(c *echo.Context) error { // Create context with request ID for provider @@ -313,9 +316,9 @@ func (h *Handler) ListModels(c *echo.Context) error { // @Param purpose formData string true "File purpose" // @Param file formData file true "File to upload" // @Success 200 {object} core.FileObject -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/files [post] func (h *Handler) CreateFile(c *echo.Context) error { return h.nativeFiles().CreateFile(c) @@ -332,10 +335,10 @@ func (h *Handler) CreateFile(c *echo.Context) error { // @Param after query string false "Pagination cursor" // @Param limit query int false "Maximum items to return (1-100, default 20)" // @Success 200 {object} core.FileListResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/files [get] func (h *Handler) ListFiles(c *echo.Context) error { return h.nativeFiles().ListFiles(c) @@ -350,10 +353,10 @@ func (h *Handler) ListFiles(c *echo.Context) error { // @Param id path string true "File ID" // @Param provider query string false "Provider override" // @Success 200 {object} core.FileObject -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/files/{id} [get] func (h *Handler) GetFile(c *echo.Context) error { return h.nativeFiles().GetFile(c) @@ -368,10 +371,10 @@ func (h *Handler) GetFile(c *echo.Context) error { // @Param id path string true "File ID" // @Param provider query string false "Provider override" // @Success 200 {object} core.FileDeleteResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/files/{id} [delete] func (h *Handler) DeleteFile(c *echo.Context) error { return h.nativeFiles().DeleteFile(c) @@ -386,10 +389,10 @@ func (h *Handler) DeleteFile(c *echo.Context) error { // @Param id path string true "File ID" // @Param provider query string false "Provider override" // @Success 200 {file} file "Raw file content" -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/files/{id}/content [get] func (h *Handler) GetFileContent(c *echo.Context) error { return h.nativeFiles().GetFileContent(c) @@ -405,10 +408,10 @@ func (h *Handler) GetFileContent(c *echo.Context) error { // @Security BearerAuth // @Param request body core.ResponsesRequest true "Responses API request" // @Success 200 {object} core.ResponsesResponse "JSON response or SSE stream when stream=true" -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 429 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 429 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/responses [post] func (h *Handler) Responses(c *echo.Context) error { return h.translatedInference().Responses(c) @@ -422,12 +425,17 @@ func (h *Handler) Responses(c *echo.Context) error { // @Security BearerAuth // @Param id path string true "Response ID" // @Param provider query string false "Provider override for native lookups" +// @Param include query []string false "Fields to include in the response" collectionFormat(multi) +// @Param include[] query []string false "Fields to include in the response" collectionFormat(multi) +// @Param include_obfuscation query bool false "Whether to include obfuscated response data" +// @Param starting_after query int false "Input item offset for providers that support it" +// @Param stream query bool false "Whether to stream the retrieved response" // @Success 200 {object} core.ResponsesResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 501 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 501 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/responses/{id} [get] func (h *Handler) GetResponse(c *echo.Context) error { return h.nativeResponses().GetResponse(c) @@ -442,14 +450,16 @@ func (h *Handler) GetResponse(c *echo.Context) error { // @Param id path string true "Response ID" // @Param provider query string false "Provider override for native lookups" // @Param after query string false "Pagination cursor" +// @Param include query []string false "Fields to include in listed input items" collectionFormat(multi) +// @Param include[] query []string false "Fields to include in listed input items" collectionFormat(multi) // @Param limit query int false "Maximum items to return (1-100, default 20)" // @Param order query string false "Sort order: asc or desc" Enums(asc, desc) // @Success 200 {object} core.ResponseInputItemListResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 501 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 501 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/responses/{id}/input_items [get] func (h *Handler) ListResponseInputItems(c *echo.Context) error { return h.nativeResponses().ListResponseInputItems(c) @@ -464,11 +474,11 @@ func (h *Handler) ListResponseInputItems(c *echo.Context) error { // @Param id path string true "Response ID" // @Param provider query string false "Provider override for native cancellation" // @Success 200 {object} core.ResponsesResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 501 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 501 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/responses/{id}/cancel [post] func (h *Handler) CancelResponse(c *echo.Context) error { return h.nativeResponses().CancelResponse(c) @@ -483,11 +493,11 @@ func (h *Handler) CancelResponse(c *echo.Context) error { // @Param id path string true "Response ID" // @Param provider query string false "Provider override for native deletion" // @Success 200 {object} core.ResponseDeleteResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 501 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 501 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/responses/{id} [delete] func (h *Handler) DeleteResponse(c *echo.Context) error { return h.nativeResponses().DeleteResponse(c) @@ -502,10 +512,10 @@ func (h *Handler) DeleteResponse(c *echo.Context) error { // @Security BearerAuth // @Param request body core.ResponsesRequest true "Responses API request" // @Success 200 {object} core.ResponseInputTokensResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 501 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 501 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/responses/input_tokens [post] func (h *Handler) ResponseInputTokens(c *echo.Context) error { return h.nativeResponses().CountResponseInputTokens(c) @@ -520,10 +530,10 @@ func (h *Handler) ResponseInputTokens(c *echo.Context) error { // @Security BearerAuth // @Param request body core.ResponsesRequest true "Responses API request" // @Success 200 {object} core.ResponseCompactResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 501 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 501 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/responses/compact [post] func (h *Handler) CompactResponse(c *echo.Context) error { return h.nativeResponses().CompactResponse(c) @@ -538,10 +548,10 @@ func (h *Handler) CompactResponse(c *echo.Context) error { // @Security BearerAuth // @Param request body core.EmbeddingRequest true "Embeddings request" // @Success 200 {object} core.EmbeddingResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 429 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 429 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/embeddings [post] func (h *Handler) Embeddings(c *echo.Context) error { return h.translatedInference().Embeddings(c) @@ -559,9 +569,9 @@ func (h *Handler) Embeddings(c *echo.Context) error { // @Security BearerAuth // @Param request body core.BatchRequest true "Batch request" // @Success 200 {object} core.BatchResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/batches [post] func (h *Handler) Batches(c *echo.Context) error { return h.nativeBatch().Batches(c) @@ -575,11 +585,11 @@ func (h *Handler) Batches(c *echo.Context) error { // @Security BearerAuth // @Param id path string true "Batch ID" // @Success 200 {object} core.BatchResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 500 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 500 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/batches/{id} [get] func (h *Handler) GetBatch(c *echo.Context) error { return h.nativeBatch().GetBatch(c) @@ -594,10 +604,10 @@ func (h *Handler) GetBatch(c *echo.Context) error { // @Param after query string false "Pagination cursor" // @Param limit query int false "Maximum items to return (1-100, default 20)" // @Success 200 {object} core.BatchListResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 500 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 500 {object} core.OpenAIErrorEnvelope // @Router /v1/batches [get] func (h *Handler) ListBatches(c *echo.Context) error { return h.nativeBatch().ListBatches(c) @@ -612,11 +622,11 @@ func (h *Handler) ListBatches(c *echo.Context) error { // @Security BearerAuth // @Param id path string true "Batch ID" // @Success 200 {object} core.BatchResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 500 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 500 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/batches/{id}/cancel [post] func (h *Handler) CancelBatch(c *echo.Context) error { return h.nativeBatch().CancelBatch(c) @@ -630,12 +640,12 @@ func (h *Handler) CancelBatch(c *echo.Context) error { // @Security BearerAuth // @Param id path string true "Batch ID" // @Success 200 {object} core.BatchResultsResponse -// @Failure 400 {object} core.GatewayError -// @Failure 401 {object} core.GatewayError -// @Failure 404 {object} core.GatewayError -// @Failure 409 {object} core.GatewayError -// @Failure 500 {object} core.GatewayError -// @Failure 502 {object} core.GatewayError +// @Failure 400 {object} core.OpenAIErrorEnvelope +// @Failure 401 {object} core.OpenAIErrorEnvelope +// @Failure 404 {object} core.OpenAIErrorEnvelope +// @Failure 409 {object} core.OpenAIErrorEnvelope +// @Failure 500 {object} core.OpenAIErrorEnvelope +// @Failure 502 {object} core.OpenAIErrorEnvelope // @Router /v1/batches/{id}/results [get] func (h *Handler) BatchResults(c *echo.Context) error { return h.nativeBatch().BatchResults(c) diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index d54702f6..69b4f01e 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -396,6 +396,7 @@ type mockProvider struct { responseCancelCalls []responseCall responseDeleteCalls []responseCall capturedResponseUtilityReqs []*core.ResponsesRequest + capturedResponseUtility []responseUtilityCall } type responseCall struct { @@ -403,6 +404,11 @@ type responseCall struct { id string } +type responseUtilityCall struct { + provider string + operation string +} + type fileListCall struct { provider string purpose string @@ -776,8 +782,12 @@ func (m *mockProvider) DeleteResponse(_ context.Context, providerType, id string return &core.ResponseDeleteResponse{ID: id, Object: "response", Deleted: true}, nil } -func (m *mockProvider) CountResponseInputTokens(_ context.Context, _ string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { +func (m *mockProvider) CountResponseInputTokens(_ context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseInputTokensResponse, error) { m.capturedResponseUtilityReqs = append(m.capturedResponseUtilityReqs, req) + m.capturedResponseUtility = append(m.capturedResponseUtility, responseUtilityCall{ + provider: providerType, + operation: "CountResponseInputTokens", + }) if m.responseUtilityErr != nil { return nil, m.responseUtilityErr } @@ -789,6 +799,10 @@ func (m *mockProvider) CountResponseInputTokens(_ context.Context, _ string, req func (m *mockProvider) CompactResponse(_ context.Context, providerType string, req *core.ResponsesRequest) (*core.ResponseCompactResponse, error) { m.capturedResponseUtilityReqs = append(m.capturedResponseUtilityReqs, req) + m.capturedResponseUtility = append(m.capturedResponseUtility, responseUtilityCall{ + provider: providerType, + operation: "CompactResponse", + }) if m.responseUtilityErr != nil { return nil, m.responseUtilityErr } @@ -4636,7 +4650,7 @@ func TestResponsesLifecycle_RetrievesStoredResponseAndInputItems(t *testing.T) { } func TestResponsesLifecycle_StoresConcreteProviderName(t *testing.T) { - store := responsestore.NewMemoryStore() + store := responsestore.NewMemoryStore(responsestore.WithUnboundedRetention()) provider := &mockProvider{ supportedModels: []string{"gpt-5-mini"}, providerTypes: map[string]string{ @@ -4714,8 +4728,8 @@ func TestResponsesLifecycle_ReturnsSuccessWhenSnapshotStoreFails(t *testing.T) { func TestHandlerSetResponseStoreUpdatesCachedTranslatedInferenceService(t *testing.T) { handler := NewHandler(&mockProvider{}, nil, nil, nil) - first := responsestore.NewMemoryStore() - second := responsestore.NewMemoryStore() + first := responsestore.NewMemoryStore(responsestore.WithUnboundedRetention()) + second := responsestore.NewMemoryStore(responsestore.WithUnboundedRetention()) handler.SetResponseStore(first) service := handler.translatedInference() @@ -4861,6 +4875,13 @@ func TestResponsesUtilityRoutes(t *testing.T) { if len(provider.capturedResponseUtilityReqs) != 2 { t.Fatalf("utility calls = %d, want 2", len(provider.capturedResponseUtilityReqs)) } + wantUtility := []responseUtilityCall{ + {provider: "mock", operation: "CountResponseInputTokens"}, + {provider: "mock", operation: "CompactResponse"}, + } + if !reflect.DeepEqual(provider.capturedResponseUtility, wantUtility) { + t.Fatalf("utility routing = %+v, want %+v", provider.capturedResponseUtility, wantUtility) + } } // mockUsageLogger implements usage.LoggerInterface for testing. diff --git a/internal/server/native_response_service.go b/internal/server/native_response_service.go index 5153a862..b4148e88 100644 --- a/internal/server/native_response_service.go +++ b/internal/server/native_response_service.go @@ -121,8 +121,9 @@ func (s *nativeResponseService) CancelResponse(c *echo.Context) error { if resp == nil { return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) } + normalizeCanceledResponse(resp, id, providerType) stored.Response = resp - stored.Provider = firstNonEmpty(resp.Provider, providerType) + stored.Provider = providerType if updateErr := s.responseStore.Update(ctx, stored); updateErr != nil && !errors.Is(updateErr, responsestore.ErrNotFound) { return handleError(c, core.NewProviderError("response_store", http.StatusInternalServerError, "failed to persist cancelled response", updateErr)) } @@ -139,6 +140,7 @@ func (s *nativeResponseService) CancelResponse(c *echo.Context) error { if resp == nil { return handleError(c, core.NewProviderError(providerType, http.StatusBadGateway, "provider returned empty response", nil)) } + normalizeCanceledResponse(resp, id, providerType) auditResponseEntry(c, providerType) return c.JSON(http.StatusOK, resp) } @@ -331,6 +333,15 @@ func (s *nativeResponseService) cancelNativeResponseByRequest(ctx context.Contex }) } +func normalizeCanceledResponse(resp *core.ResponsesResponse, id, providerType string) { + if resp == nil { + return + } + resp.ID = id + resp.Object = "response" + resp.Provider = providerType +} + func (s *nativeResponseService) deleteNativeResponse(ctx context.Context, providerType, id string) (*core.ResponseDeleteResponse, error) { router, ok := s.provider.(core.NativeResponseLifecycleRoutableProvider) if !ok { diff --git a/internal/server/native_response_service_test.go b/internal/server/native_response_service_test.go index f595a316..6a788348 100644 --- a/internal/server/native_response_service_test.go +++ b/internal/server/native_response_service_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -9,6 +10,7 @@ import ( "testing" "gomodel/internal/core" + "gomodel/internal/responsestore" ) func TestResponsesUtilityRoutesRejectNullBody(t *testing.T) { @@ -33,6 +35,78 @@ func TestResponsesUtilityRoutesRejectNullBody(t *testing.T) { } } +func TestCancelResponseNormalizesNativeResponse(t *testing.T) { + provider := &mockProvider{ + providerTypes: map[string]string{ + "gpt-5-mini": "mock", + }, + responseCancelResponse: &core.ResponsesResponse{ + ID: "provider_resp", + Provider: "upstream", + Status: "cancelled", + }, + } + srv := New(provider, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses/resp_gateway/cancel?provider=mock", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var resp core.ResponsesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.ID != "resp_gateway" || resp.Object != "response" || resp.Provider != "mock" { + t.Fatalf("response = %+v, want gateway id/object/provider", resp) + } +} + +func TestCancelStoredResponseNormalizesPersistedResponse(t *testing.T) { + store := responsestore.NewMemoryStore(responsestore.WithUnboundedRetention()) + err := store.Create(context.Background(), &responsestore.StoredResponse{ + Response: &core.ResponsesResponse{ID: "resp_gateway", Object: "response", Provider: "mock"}, + Provider: "mock", + ProviderResponseID: "provider_resp", + }) + if err != nil { + t.Fatalf("store.Create() error = %v", err) + } + provider := &mockProvider{ + responseCancelResponse: &core.ResponsesResponse{ + ID: "provider_resp", + Provider: "upstream", + Status: "cancelled", + }, + } + srv := New(provider, &Config{ResponseStore: store}) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses/resp_gateway/cancel", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var resp core.ResponsesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.ID != "resp_gateway" || resp.Object != "response" || resp.Provider != "mock" { + t.Fatalf("response = %+v, want gateway id/object/provider", resp) + } + + stored, err := store.Get(context.Background(), "resp_gateway") + if err != nil { + t.Fatalf("store.Get() error = %v", err) + } + if stored.Response.ID != "resp_gateway" || stored.Response.Object != "response" || stored.Response.Provider != "mock" { + t.Fatalf("stored response = %+v, want normalized gateway id/object/provider", stored.Response) + } +} + func TestNativeResponseByProviderWrapsContextCancellation(t *testing.T) { provider := &mockProvider{ providerTypes: map[string]string{ diff --git a/internal/server/response_input_items.go b/internal/server/response_input_items.go index 0aadf8ba..c08fb425 100644 --- a/internal/server/response_input_items.go +++ b/internal/server/response_input_items.go @@ -69,7 +69,11 @@ func normalizedResponseInputAny(responseID string, index int, item any) json.Raw func normalizedResponseInputRaw(responseID string, index int, raw json.RawMessage) json.RawMessage { var item map[string]any if err := json.Unmarshal(raw, &item); err != nil { + var decoded string text := strings.TrimSpace(string(raw)) + if stringErr := json.Unmarshal(raw, &decoded); stringErr == nil { + text = strings.TrimSpace(decoded) + } if text == "" || text == "null" { return nil } diff --git a/internal/server/response_input_items_test.go b/internal/server/response_input_items_test.go index f1cde426..ea48606b 100644 --- a/internal/server/response_input_items_test.go +++ b/internal/server/response_input_items_test.go @@ -23,3 +23,26 @@ func TestNormalizedResponseInputRawSkipsNullObject(t *testing.T) { t.Fatalf("len(item) = %d, want 0", len(item)) } } + +func TestNormalizedResponseInputRawDecodesJSONStringFallback(t *testing.T) { + item := normalizedResponseInputRaw("resp_1", 0, json.RawMessage(`"hello"`)) + if len(item) == 0 { + t.Fatal("item is empty") + } + + var decoded map[string]any + if err := json.Unmarshal(item, &decoded); err != nil { + t.Fatalf("decode item: %v", err) + } + content, ok := decoded["content"].([]any) + if !ok || len(content) != 1 { + t.Fatalf("content = %+v, want one item", decoded["content"]) + } + first, ok := content[0].(map[string]any) + if !ok { + t.Fatalf("content[0] = %T, want object", content[0]) + } + if first["text"] != "hello" { + t.Fatalf("text = %q, want hello", first["text"]) + } +} From 4dec6c89b17d2988f6a445178beee43ade625525 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 18 Apr 2026 22:47:52 +0200 Subject: [PATCH 6/8] fix(responses): refine schemas and lifecycle utilities --- cmd/gomodel/docs/docs.go | 79 ++++++++++-- docs/advanced/responses-api.mdx | 4 + docs/openapi.json | 116 ++++++++++++++---- internal/core/errors.go | 10 +- internal/core/responses.go | 40 ++++-- internal/core/responses_json.go | 34 +++++ .../providers/openai/compatible_provider.go | 30 ++++- internal/providers/openai/openai_test.go | 74 +++++++++++ internal/providers/router.go | 8 +- internal/responsestore/store_memory.go | 72 +++++++---- internal/responsestore/store_memory_test.go | 21 ++++ internal/server/handlers.go | 25 +++- internal/server/handlers_test.go | 25 +++- internal/server/http.go | 2 +- internal/server/native_response_service.go | 11 +- .../server/native_response_service_test.go | 10 ++ .../server/translated_inference_service.go | 21 +++- 17 files changed, 487 insertions(+), 95 deletions(-) diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index 64e3eae9..96869a3f 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -2155,12 +2155,12 @@ const docTemplate = `{ "summary": "Compact response input", "parameters": [ { - "description": "Responses API request", + "description": "Response compact request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/core.ResponsesRequest" + "$ref": "#/definitions/core.ResponseCompactRequest" } } ], @@ -2217,12 +2217,12 @@ const docTemplate = `{ "summary": "Count response input tokens", "parameters": [ { - "description": "Responses API request", + "description": "Response input token request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/core.ResponsesRequest" + "$ref": "#/definitions/core.ResponseInputTokensRequest" } } ], @@ -2319,12 +2319,6 @@ const docTemplate = `{ "description": "Input item offset for providers that support it", "name": "starting_after", "in": "query" - }, - { - "type": "boolean", - "description": "Whether to stream the retrieved response", - "name": "stream", - "in": "query" } ], "responses": { @@ -3621,6 +3615,9 @@ const docTemplate = `{ }, "core.OpenAIErrorEnvelope": { "type": "object", + "required": [ + "error" + ], "properties": { "error": { "$ref": "#/definitions/core.OpenAIErrorObject" @@ -3629,6 +3626,12 @@ const docTemplate = `{ }, "core.OpenAIErrorObject": { "type": "object", + "required": [ + "code", + "message", + "param", + "type" + ], "properties": { "code": { "type": "string", @@ -3672,6 +3675,33 @@ const docTemplate = `{ } } }, + "core.ResponseCompactRequest": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "$ref": "#/definitions/core.ResponsesInputElement" + }, + "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + }, + "instructions": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "model": { + "type": "string" + }, + "reasoning": { + "$ref": "#/definitions/core.Reasoning" + } + } + }, "core.ResponseCompactResponse": { "type": "object", "properties": { @@ -3696,7 +3726,7 @@ const docTemplate = `{ "output": { "type": "array", "items": { - "type": "object" + "$ref": "#/definitions/core.ResponsesOutputItem" } }, "provider": { @@ -3744,6 +3774,33 @@ const docTemplate = `{ } } }, + "core.ResponseInputTokensRequest": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "$ref": "#/definitions/core.ResponsesInputElement" + }, + "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + }, + "instructions": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "model": { + "type": "string" + }, + "reasoning": { + "$ref": "#/definitions/core.Reasoning" + } + } + }, "core.ResponseInputTokensResponse": { "type": "object", "properties": { diff --git a/docs/advanced/responses-api.mdx b/docs/advanced/responses-api.mdx index 88e7e4b3..9d51b5c5 100644 --- a/docs/advanced/responses-api.mdx +++ b/docs/advanced/responses-api.mdx @@ -95,6 +95,10 @@ OpenAI-compatible error with: } ``` +GoModel uses OpenAI's `invalid_request_error` type for unsupported operations +so the public error type set stays closed. The `501` status and +`unsupported_response_operation` code identify the unsupported operation. + The HTTP status is `501 Not Implemented`. ## Example diff --git a/docs/openapi.json b/docs/openapi.json index af8cabd6..c2404de8 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3253,7 +3253,15 @@ ], "summary": "Create a model response (Responses API)", "requestBody": { - "$ref": "#/components/requestBodies/core.ResponsesRequest" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponsesRequest" + } + } + }, + "description": "Responses API request", + "required": true }, "responses": { "200": { @@ -3346,7 +3354,15 @@ ], "summary": "Compact response input", "requestBody": { - "$ref": "#/components/requestBodies/core.ResponsesRequest" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponseCompactRequest" + } + } + }, + "description": "Response compact request", + "required": true }, "responses": { "200": { @@ -3414,7 +3430,15 @@ ], "summary": "Count response input tokens", "requestBody": { - "$ref": "#/components/requestBodies/core.ResponsesRequest" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.ResponseInputTokensRequest" + } + } + }, + "description": "Response input token request", + "required": true }, "responses": { "200": { @@ -3538,14 +3562,6 @@ "schema": { "type": "integer" } - }, - { - "description": "Whether to stream the retrieved response", - "name": "stream", - "in": "query", - "schema": { - "type": "boolean" - } } ], "responses": { @@ -3957,19 +3973,6 @@ } ], "components": { - "requestBodies": { - "core.ResponsesRequest": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/core.ResponsesRequest" - } - } - }, - "description": "Responses API request", - "required": true - } - }, "securitySchemes": { "BearerAuth": { "type": "apiKey", @@ -4978,6 +4981,9 @@ }, "core.OpenAIErrorEnvelope": { "type": "object", + "required": [ + "error" + ], "properties": { "error": { "$ref": "#/components/schemas/core.OpenAIErrorObject" @@ -4986,6 +4992,12 @@ }, "core.OpenAIErrorObject": { "type": "object", + "required": [ + "code", + "message", + "param", + "type" + ], "properties": { "code": { "type": "string", @@ -5029,6 +5041,33 @@ } } }, + "core.ResponseCompactRequest": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "$ref": "#/components/schemas/core.ResponsesInputElement" + }, + "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + }, + "instructions": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "model": { + "type": "string" + }, + "reasoning": { + "$ref": "#/components/schemas/core.Reasoning" + } + } + }, "core.ResponseCompactResponse": { "type": "object", "properties": { @@ -5053,7 +5092,7 @@ "output": { "type": "array", "items": { - "type": "object" + "$ref": "#/components/schemas/core.ResponsesOutputItem" } }, "provider": { @@ -5101,6 +5140,33 @@ } } }, + "core.ResponseInputTokensRequest": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "$ref": "#/components/schemas/core.ResponsesInputElement" + }, + "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + }, + "instructions": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "model": { + "type": "string" + }, + "reasoning": { + "$ref": "#/components/schemas/core.Reasoning" + } + } + }, "core.ResponseInputTokensResponse": { "type": "object", "properties": { diff --git a/internal/core/errors.go b/internal/core/errors.go index b6201c7e..52566531 100644 --- a/internal/core/errors.go +++ b/internal/core/errors.go @@ -37,15 +37,15 @@ type GatewayError struct { // OpenAIErrorEnvelope documents the public OpenAI-compatible error response. type OpenAIErrorEnvelope struct { - Error OpenAIErrorObject `json:"error"` + Error OpenAIErrorObject `json:"error" binding:"required"` } // OpenAIErrorObject is the error object exposed in public API responses. type OpenAIErrorObject struct { - Type ErrorType `json:"type"` - Message string `json:"message"` - Param *string `json:"param" extensions:"x-nullable"` - Code *string `json:"code" extensions:"x-nullable"` + Type ErrorType `json:"type" binding:"required"` + Message string `json:"message" binding:"required"` + Param *string `json:"param" binding:"required" extensions:"x-nullable"` + Code *string `json:"code" binding:"required" extensions:"x-nullable"` } // Error implements the error interface diff --git a/internal/core/responses.go b/internal/core/responses.go index 7a063e69..bb4702b2 100644 --- a/internal/core/responses.go +++ b/internal/core/responses.go @@ -27,6 +27,30 @@ type ResponsesRequest struct { ExtraFields UnknownJSONFields `json:"-" swaggerignore:"true"` } +// ResponseInputTokensRequest documents the request body accepted by +// POST /v1/responses/input_tokens. +type ResponseInputTokensRequest struct { + Model string `json:"model,omitempty"` + Input any `json:"input,omitempty"` // string or []ResponsesInputElement — see docs for array form + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + //nolint:govet // Intentional duplicate json tag for Swagger docs: input is string OR []ResponsesInputElement. + InputSchema []ResponsesInputElement `json:"input,omitempty" extensions:"x-oneOf=[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]"` +} + +// ResponseCompactRequest documents the request body accepted by +// POST /v1/responses/compact. +type ResponseCompactRequest struct { + Model string `json:"model,omitempty"` + Input any `json:"input,omitempty"` // string or []ResponsesInputElement — see docs for array form + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + //nolint:govet // Intentional duplicate json tag for Swagger docs: input is string OR []ResponsesInputElement. + InputSchema []ResponsesInputElement `json:"input,omitempty" extensions:"x-oneOf=[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]"` +} + func (r *ResponsesRequest) semanticSelector() (string, string) { if r == nil { return "", "" @@ -160,14 +184,14 @@ type ResponseInputTokensResponse struct { // ResponseCompactResponse is returned by POST /v1/responses/compact. type ResponseCompactResponse struct { - ID string `json:"id"` - Object string `json:"object"` - CreatedAt int64 `json:"created_at"` - Output []json.RawMessage `json:"output" swaggertype:"array,object"` - Usage *ResponsesUsage `json:"usage,omitempty"` - Error *ResponsesError `json:"error,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Provider string `json:"provider,omitempty"` + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Output []ResponsesOutputItem `json:"output"` + Usage *ResponsesUsage `json:"usage,omitempty"` + Error *ResponsesError `json:"error,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Provider string `json:"provider,omitempty"` } // ResponseDeleteResponse is returned by DELETE /v1/responses/{id}. diff --git a/internal/core/responses_json.go b/internal/core/responses_json.go index f2a4a645..c860d9cf 100644 --- a/internal/core/responses_json.go +++ b/internal/core/responses_json.go @@ -112,6 +112,40 @@ func (r ResponsesRequest) MarshalJSON() ([]byte, error) { }, r.ExtraFields) } +// MarshalJSON preserves the dynamic input payload while omitting Swagger-only schema fields. +func (r ResponseInputTokensRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Model string `json:"model,omitempty"` + Input any `json:"input,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + }{ + Model: r.Model, + Input: r.Input, + Instructions: r.Instructions, + Metadata: r.Metadata, + Reasoning: r.Reasoning, + }) +} + +// MarshalJSON preserves the dynamic input payload while omitting Swagger-only schema fields. +func (r ResponseCompactRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Model string `json:"model,omitempty"` + Input any `json:"input,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + }{ + Model: r.Model, + Input: r.Input, + Instructions: r.Instructions, + Metadata: r.Metadata, + Reasoning: r.Reasoning, + }) +} + // UnmarshalJSON deserializes a ResponsesInputElement, switching on the "type" // field to populate variant-specific fields. func (e *ResponsesInputElement) UnmarshalJSON(data []byte) error { diff --git a/internal/providers/openai/compatible_provider.go b/internal/providers/openai/compatible_provider.go index 8e9a5c6c..e8e3da9a 100644 --- a/internal/providers/openai/compatible_provider.go +++ b/internal/providers/openai/compatible_provider.go @@ -234,7 +234,7 @@ func (p *CompatibleProvider) CountResponseInputTokens(ctx context.Context, req * err := p.Do(ctx, llmclient.Request{ Method: http.MethodPost, Endpoint: "/responses/input_tokens", - Body: req, + Body: responseInputTokensRequestFromResponses(req), }, &resp) if err != nil { return nil, err @@ -253,7 +253,7 @@ func (p *CompatibleProvider) CompactResponse(ctx context.Context, req *core.Resp err := p.Do(ctx, llmclient.Request{ Method: http.MethodPost, Endpoint: "/responses/compact", - Body: req, + Body: responseCompactRequestFromResponses(req), }, &resp) if err != nil { return nil, err @@ -264,6 +264,32 @@ func (p *CompatibleProvider) CompactResponse(ctx context.Context, req *core.Resp return &resp, nil } +func responseInputTokensRequestFromResponses(req *core.ResponsesRequest) *core.ResponseInputTokensRequest { + if req == nil { + return nil + } + return &core.ResponseInputTokensRequest{ + Model: req.Model, + Input: req.Input, + Instructions: req.Instructions, + Metadata: req.Metadata, + Reasoning: req.Reasoning, + } +} + +func responseCompactRequestFromResponses(req *core.ResponsesRequest) *core.ResponseCompactRequest { + if req == nil { + return nil + } + return &core.ResponseCompactRequest{ + Model: req.Model, + Input: req.Input, + Instructions: req.Instructions, + Metadata: req.Metadata, + Reasoning: req.Reasoning, + } +} + func (p *CompatibleProvider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { if req == nil { return nil, core.NewInvalidRequestError("embedding request is required", nil) diff --git a/internal/providers/openai/openai_test.go b/internal/providers/openai/openai_test.go index fa0917c6..ae8ab8d3 100644 --- a/internal/providers/openai/openai_test.go +++ b/internal/providers/openai/openai_test.go @@ -1155,6 +1155,80 @@ func TestResponses(t *testing.T) { } } +func TestResponsesUtilitiesForwardNarrowRequests(t *testing.T) { + var inputTokensBody map[string]any + var compactBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/responses/input_tokens": + inputTokensBody = body + _, _ = w.Write([]byte(`{"object":"response.input_tokens","input_tokens":10}`)) + case r.Method == http.MethodPost && r.URL.Path == "/responses/compact": + compactBody = body + _, _ = w.Write([]byte(`{"id":"cmp_1","object":"response.compaction","output":[]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + provider := NewWithHTTPClient("test-api-key", server.Client(), llmclient.Hooks{}) + provider.SetBaseURL(server.URL) + + maxOutputTokens := 128 + parallelToolCalls := true + temperature := 0.2 + req := &core.ResponsesRequest{ + Model: "gpt-4o", + Provider: "openai_primary", + Input: "hello", + Instructions: "be brief", + Tools: []map[string]any{{"type": "function"}}, + ToolChoice: "auto", + ParallelToolCalls: ¶llelToolCalls, + Temperature: &temperature, + MaxOutputTokens: &maxOutputTokens, + Stream: true, + StreamOptions: &core.StreamOptions{IncludeUsage: true}, + Metadata: map[string]string{"team": "alpha"}, + Reasoning: &core.Reasoning{Effort: "low"}, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "custom": json.RawMessage(`"value"`), + }), + } + + if _, err := provider.CountResponseInputTokens(context.Background(), req); err != nil { + t.Fatalf("CountResponseInputTokens() error = %v", err) + } + if _, err := provider.CompactResponse(context.Background(), req); err != nil { + t.Fatalf("CompactResponse() error = %v", err) + } + for name, body := range map[string]map[string]any{ + "input_tokens": inputTokensBody, + "compact": compactBody, + } { + if body["model"] != "gpt-4o" || body["input"] != "hello" || body["instructions"] != "be brief" { + t.Fatalf("%s body kept fields = %+v, want model/input/instructions", name, body) + } + if _, ok := body["metadata"]; !ok { + t.Fatalf("%s body missing metadata: %+v", name, body) + } + if _, ok := body["reasoning"]; !ok { + t.Fatalf("%s body missing reasoning: %+v", name, body) + } + for _, field := range []string{"provider", "tools", "tool_choice", "parallel_tool_calls", "temperature", "max_output_tokens", "stream", "stream_options", "custom"} { + if _, ok := body[field]; ok { + t.Fatalf("%s body includes filtered field %q: %+v", name, field, body) + } + } + } +} + func TestResponsesWithArrayInput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify request body contains array input diff --git a/internal/providers/router.go b/internal/providers/router.go index 6b5d4415..1a083d4d 100644 --- a/internal/providers/router.go +++ b/internal/providers/router.go @@ -303,7 +303,7 @@ func (r *Router) resolveNativeResponseLifecycleProvider(providerType string) (co } rp, ok := provider.(core.NativeResponseLifecycleProvider) if !ok { - return nil, "", core.NewInvalidRequestError(fmt.Sprintf("%s does not support native response lifecycle operations", providerType), nil) + return nil, "", unsupportedNativeResponseOperation(fmt.Sprintf("%s does not support native response lifecycle operations", providerType)) } return rp, resolvedProviderType, nil } @@ -315,11 +315,15 @@ func (r *Router) resolveNativeResponseUtilityProvider(providerType string) (core } rp, ok := provider.(core.NativeResponseUtilityProvider) if !ok { - return nil, "", core.NewInvalidRequestError(fmt.Sprintf("%s does not support native response utility operations", providerType), nil) + return nil, "", unsupportedNativeResponseOperation(fmt.Sprintf("%s does not support native response utility operations", providerType)) } return rp, resolvedProviderType, nil } +func unsupportedNativeResponseOperation(message string) *core.GatewayError { + return core.NewInvalidRequestErrorWithStatus(http.StatusNotImplemented, message, nil).WithCode("unsupported_response_operation") +} + func (r *Router) resolvePassthroughProvider(providerType string) (core.PassthroughProvider, error) { provider, err := r.resolveProviderType(providerType) if err != nil { diff --git a/internal/responsestore/store_memory.go b/internal/responsestore/store_memory.go index 7d40c1dd..d408e3ad 100644 --- a/internal/responsestore/store_memory.go +++ b/internal/responsestore/store_memory.go @@ -3,6 +3,7 @@ package responsestore import ( "context" "fmt" + "sort" "sync" "time" ) @@ -12,15 +13,19 @@ const ( DefaultMemoryStoreTTL = 24 * time.Hour // DefaultMemoryStoreMaxEntries bounds in-memory response retention by count. DefaultMemoryStoreMaxEntries = 10000 + // DefaultMemoryStoreCleanupInterval limits full expired-entry sweeps. + DefaultMemoryStoreCleanupInterval = time.Minute ) // MemoryStore keeps response snapshots in process memory. // Data survives across requests but not process restarts. type MemoryStore struct { - mu sync.RWMutex - items map[string]*StoredResponse - ttl time.Duration - maxEntries int + mu sync.RWMutex + items map[string]*StoredResponse + ttl time.Duration + maxEntries int + lastCleanup time.Time + cleanupInterval time.Duration } // MemoryStoreOption configures bounded in-memory response retention. @@ -52,9 +57,10 @@ func WithUnboundedRetention() MemoryStoreOption { // By default retention is bounded; pass WithUnboundedRetention to opt out. func NewMemoryStore(options ...MemoryStoreOption) *MemoryStore { store := &MemoryStore{ - items: make(map[string]*StoredResponse), - ttl: DefaultMemoryStoreTTL, - maxEntries: DefaultMemoryStoreMaxEntries, + items: make(map[string]*StoredResponse), + ttl: DefaultMemoryStoreTTL, + maxEntries: DefaultMemoryStoreMaxEntries, + cleanupInterval: DefaultMemoryStoreCleanupInterval, } for _, option := range options { if option != nil { @@ -84,8 +90,11 @@ func (s *MemoryStore) Create(_ context.Context, response *StoredResponse) error if responseExpired(c, now) { return nil } - if _, exists := s.items[c.Response.ID]; exists { - return fmt.Errorf("response already exists: %s", c.Response.ID) + if existing, exists := s.items[c.Response.ID]; exists { + if !responseExpired(existing, now) { + return fmt.Errorf("response already exists: %s", c.Response.ID) + } + delete(s.items, c.Response.ID) } s.items[c.Response.ID] = c s.enforceMaxEntriesLocked() @@ -129,6 +138,10 @@ func (s *MemoryStore) Update(_ context.Context, response *StoredResponse) error if !exists { return ErrNotFound } + if responseExpired(existing, now) { + delete(s.items, c.Response.ID) + return ErrNotFound + } if c.StoredAt.IsZero() { c.StoredAt = existing.StoredAt } @@ -175,6 +188,10 @@ func (s *MemoryStore) cleanupExpiredLocked(now time.Time) { if s.ttl <= 0 { return } + if s.cleanupInterval > 0 && !s.lastCleanup.IsZero() && now.Sub(s.lastCleanup) < s.cleanupInterval { + return + } + s.lastCleanup = now for id, response := range s.items { if responseExpired(response, now) { delete(s.items, id) @@ -186,23 +203,34 @@ func (s *MemoryStore) enforceMaxEntriesLocked() { if s.maxEntries <= 0 { return } - for len(s.items) > s.maxEntries { - oldestID := "" - var oldest time.Time - for id, response := range s.items { - storedAt := responseStoredAt(response) - if oldestID == "" || storedAt.Before(oldest) { - oldestID = id - oldest = storedAt - } - } - if oldestID == "" { - return + overLimit := len(s.items) - s.maxEntries + if overLimit <= 0 { + return + } + + entries := make([]memoryStoreEntry, 0, len(s.items)) + for id, response := range s.items { + entries = append(entries, memoryStoreEntry{ + id: id, + storedAt: responseStoredAt(response), + }) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].storedAt.Equal(entries[j].storedAt) { + return entries[i].id < entries[j].id } - delete(s.items, oldestID) + return entries[i].storedAt.Before(entries[j].storedAt) + }) + for i := 0; i < overLimit && i < len(entries); i++ { + delete(s.items, entries[i].id) } } +type memoryStoreEntry struct { + id string + storedAt time.Time +} + func responseExpired(response *StoredResponse, now time.Time) bool { return response != nil && !response.ExpiresAt.IsZero() && !response.ExpiresAt.After(now) } diff --git a/internal/responsestore/store_memory_test.go b/internal/responsestore/store_memory_test.go index 8f973005..4f220095 100644 --- a/internal/responsestore/store_memory_test.go +++ b/internal/responsestore/store_memory_test.go @@ -62,6 +62,27 @@ func TestMemoryStoreDefaultRetentionIsBounded(t *testing.T) { } } +func TestMemoryStoreCleanupExpiredRunsPeriodically(t *testing.T) { + now := time.Now().UTC() + store := NewMemoryStore(WithTTL(time.Second)) + store.items["resp_expired"] = &StoredResponse{ + Response: &core.ResponsesResponse{ID: "resp_expired", Object: "response"}, + StoredAt: now.Add(-2 * time.Second), + ExpiresAt: now.Add(-time.Second), + } + store.lastCleanup = now + + store.cleanupExpiredLocked(now.Add(time.Second / 2)) + if _, ok := store.items["resp_expired"]; !ok { + t.Fatal("expired response removed before cleanup interval elapsed") + } + + store.cleanupExpiredLocked(now.Add(DefaultMemoryStoreCleanupInterval + time.Second)) + if _, ok := store.items["resp_expired"]; ok { + t.Fatal("expired response retained after cleanup interval elapsed") + } +} + func TestMemoryStoreAllowsExplicitUnboundedRetention(t *testing.T) { ctx := context.Background() store := NewMemoryStore(WithUnboundedRetention()) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index c7baff86..41f48197 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -31,6 +31,7 @@ type Handler struct { pricingResolver usage.PricingResolver batchStore batchstore.Store responseStore responsestore.Store + responseStoreMu sync.RWMutex normalizePassthroughV1Prefix bool enabledPassthroughProviders map[string]struct{} responseCache *responsecache.ResponseCacheMiddleware @@ -114,9 +115,11 @@ func (h *Handler) SetResponseStore(store responsestore.Store) { if store == nil { return } + h.responseStoreMu.Lock() + defer h.responseStoreMu.Unlock() h.responseStore = store if h.translatedSvc != nil { - h.translatedSvc.responseStore = store + h.translatedSvc.setResponseStore(store) } } @@ -134,11 +137,16 @@ func (h *Handler) translatedInference() *translatedInferenceService { pricingResolver: h.pricingResolver, responseCache: h.responseCache, guardrailsHash: h.guardrailsHash, - responseStore: h.responseStore, + responseStore: h.currentResponseStore(), } s.initHandlers() + h.responseStoreMu.Lock() + s.setResponseStore(h.responseStore) h.translatedSvc = s + h.responseStoreMu.Unlock() }) + h.responseStoreMu.RLock() + defer h.responseStoreMu.RUnlock() return h.translatedSvc } @@ -168,10 +176,16 @@ func (h *Handler) nativeResponses() *nativeResponseService { modelAuthorizer: h.modelAuthorizer, workflowPolicyResolver: h.workflowPolicyResolver, translatedRequestPatcher: h.translatedRequestPatcher, - responseStore: h.responseStore, + responseStore: h.currentResponseStore(), } } +func (h *Handler) currentResponseStore() responsestore.Store { + h.responseStoreMu.RLock() + defer h.responseStoreMu.RUnlock() + return h.responseStore +} + func (h *Handler) passthrough() *passthroughService { return &passthroughService{ provider: h.provider, @@ -429,7 +443,6 @@ func (h *Handler) Responses(c *echo.Context) error { // @Param include[] query []string false "Fields to include in the response" collectionFormat(multi) // @Param include_obfuscation query bool false "Whether to include obfuscated response data" // @Param starting_after query int false "Input item offset for providers that support it" -// @Param stream query bool false "Whether to stream the retrieved response" // @Success 200 {object} core.ResponsesResponse // @Failure 400 {object} core.OpenAIErrorEnvelope // @Failure 401 {object} core.OpenAIErrorEnvelope @@ -510,7 +523,7 @@ func (h *Handler) DeleteResponse(c *echo.Context) error { // @Accept json // @Produce json // @Security BearerAuth -// @Param request body core.ResponsesRequest true "Responses API request" +// @Param request body core.ResponseInputTokensRequest true "Response input token request" // @Success 200 {object} core.ResponseInputTokensResponse // @Failure 400 {object} core.OpenAIErrorEnvelope // @Failure 401 {object} core.OpenAIErrorEnvelope @@ -528,7 +541,7 @@ func (h *Handler) ResponseInputTokens(c *echo.Context) error { // @Accept json // @Produce json // @Security BearerAuth -// @Param request body core.ResponsesRequest true "Responses API request" +// @Param request body core.ResponseCompactRequest true "Response compact request" // @Success 200 {object} core.ResponseCompactResponse // @Failure 400 {object} core.OpenAIErrorEnvelope // @Failure 401 {object} core.OpenAIErrorEnvelope diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 69b4f01e..e7b5ca72 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -14,6 +14,7 @@ import ( "slices" "sort" "strings" + "sync" "testing" "time" @@ -4733,16 +4734,36 @@ func TestHandlerSetResponseStoreUpdatesCachedTranslatedInferenceService(t *testi handler.SetResponseStore(first) service := handler.translatedInference() - if service.responseStore != first { + if service.currentResponseStore() != first { t.Fatal("translatedInferenceService did not capture first response store") } handler.SetResponseStore(second) - if service.responseStore != second { + if service.currentResponseStore() != second { t.Fatal("SetResponseStore did not update cached translatedInferenceService response store") } } +func TestHandlerSetResponseStoreIsConcurrentSafe(t *testing.T) { + handler := NewHandler(&mockProvider{}, nil, nil, nil) + _ = handler.translatedInference() + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(2) + go func() { + defer wg.Done() + handler.SetResponseStore(responsestore.NewMemoryStore(responsestore.WithUnboundedRetention())) + }() + go func() { + defer wg.Done() + _ = handler.translatedInference().currentResponseStore() + _ = handler.nativeResponses() + }() + } + wg.Wait() +} + func TestResponsesLifecycle_CancelUnsupportedProviderReturnsCompatibilityError(t *testing.T) { base := &mockProvider{ supportedModels: []string{"gpt-5-mini"}, diff --git a/internal/server/http.go b/internal/server/http.go index a9e32198..8103702d 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -359,7 +359,7 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { echo: e, handler: handler, responseCacheMiddleware: rcm, - responseStore: handler.responseStore, + responseStore: handler.currentResponseStore(), } } diff --git a/internal/server/native_response_service.go b/internal/server/native_response_service.go index b4148e88..7a82dccf 100644 --- a/internal/server/native_response_service.go +++ b/internal/server/native_response_service.go @@ -322,7 +322,7 @@ func (s *nativeResponseService) listNativeResponseInputItems(ctx context.Context func (s *nativeResponseService) cancelNativeResponse(ctx context.Context, providerType, id string) (*core.ResponsesResponse, error) { router, ok := s.provider.(core.NativeResponseLifecycleRoutableProvider) if !ok { - return nil, core.NewInvalidRequestError("response lifecycle routing is not supported by the current provider router", nil) + return nil, unsupportedResponseOperation("response lifecycle routing is not supported by the current provider router") } return router.CancelResponse(ctx, providerType, id) } @@ -345,7 +345,7 @@ func normalizeCanceledResponse(resp *core.ResponsesResponse, id, providerType st func (s *nativeResponseService) deleteNativeResponse(ctx context.Context, providerType, id string) (*core.ResponseDeleteResponse, error) { router, ok := s.provider.(core.NativeResponseLifecycleRoutableProvider) if !ok { - return nil, core.NewInvalidRequestError("response lifecycle routing is not supported by the current provider router", nil) + return nil, unsupportedResponseOperation("response lifecycle routing is not supported by the current provider router") } return router.DeleteResponse(ctx, providerType, id) } @@ -607,12 +607,7 @@ func isUnsupportedNativeResponseError(err error) bool { if gatewayErr.HTTPStatusCode() != http.StatusBadRequest && gatewayErr.HTTPStatusCode() != http.StatusNotImplemented { return false } - message := strings.ToLower(gatewayErr.Message) - return strings.Contains(message, "response lifecycle") || - strings.Contains(message, "response utility") || - strings.Contains(message, "native response") || - strings.Contains(message, "responses lifecycle") || - strings.Contains(message, "responses utility") + return gatewayErr.Code != nil && strings.TrimSpace(*gatewayErr.Code) == "unsupported_response_operation" } func auditResponseEntry(c *echo.Context, providerType string) { diff --git a/internal/server/native_response_service_test.go b/internal/server/native_response_service_test.go index 6a788348..29968c94 100644 --- a/internal/server/native_response_service_test.go +++ b/internal/server/native_response_service_test.go @@ -129,3 +129,13 @@ func TestNativeResponseByProviderWrapsContextCancellation(t *testing.T) { t.Fatalf("status = %d, want 408", gatewayErr.HTTPStatusCode()) } } + +func TestIsUnsupportedNativeResponseErrorUsesCode(t *testing.T) { + if !isUnsupportedNativeResponseError(unsupportedResponseOperation("response compaction is not supported")) { + t.Fatal("unsupportedResponseOperation should be recognized") + } + messageOnly := core.NewInvalidRequestErrorWithStatus(http.StatusNotImplemented, "response compaction is not supported", nil) + if isUnsupportedNativeResponseError(messageOnly) { + t.Fatal("message-only unsupported error should not be recognized") + } +} diff --git a/internal/server/translated_inference_service.go b/internal/server/translated_inference_service.go index 069bd943..a838845d 100644 --- a/internal/server/translated_inference_service.go +++ b/internal/server/translated_inference_service.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "github.com/labstack/echo/v5" @@ -35,6 +36,7 @@ type translatedInferenceService struct { responseCache *responsecache.ResponseCacheMiddleware guardrailsHash string responseStore responsestore.Store + responseStoreMu sync.RWMutex // Pre-built handlers initialized via initHandlers. chatCompletionHandler echo.HandlerFunc @@ -241,7 +243,8 @@ func (s *translatedInferenceService) dispatchResponses(c *echo.Context, req *cor } func (s *translatedInferenceService) storeResponseSnapshot(ctx context.Context, workflow *core.Workflow, req *core.ResponsesRequest, resp *core.ResponsesResponse, providerType, providerName, requestID string) error { - if s.responseStore == nil || resp == nil || resp.ID == "" { + store := s.currentResponseStore() + if store == nil || resp == nil || resp.ID == "" { return nil } @@ -255,8 +258,8 @@ func (s *translatedInferenceService) storeResponseSnapshot(ctx context.Context, UserPath: core.UserPathFromContext(ctx), WorkflowVersionID: workflowVersionID(workflow), } - if err := s.responseStore.Create(ctx, stored); err != nil { - if updateErr := s.responseStore.Update(ctx, stored); updateErr == nil { + if err := store.Create(ctx, stored); err != nil { + if updateErr := store.Update(ctx, stored); updateErr == nil { return nil } return core.NewProviderError("response_store", http.StatusInternalServerError, "failed to persist response", err) @@ -264,6 +267,18 @@ func (s *translatedInferenceService) storeResponseSnapshot(ctx context.Context, return nil } +func (s *translatedInferenceService) currentResponseStore() responsestore.Store { + s.responseStoreMu.RLock() + defer s.responseStoreMu.RUnlock() + return s.responseStore +} + +func (s *translatedInferenceService) setResponseStore(store responsestore.Store) { + s.responseStoreMu.Lock() + defer s.responseStoreMu.Unlock() + s.responseStore = store +} + func (s *translatedInferenceService) recordResponseSnapshotStoreFailure(workflow *core.Workflow, resp *core.ResponsesResponse, providerType, providerName, requestID string, err error) { observability.ResponseSnapshotStoreFailures.WithLabelValues( strings.TrimSpace(providerType), From a49eb579685493b4dd5dbf2b989024bb26c78bdc Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 19 Apr 2026 11:23:12 +0200 Subject: [PATCH 7/8] fix(responses): refine utility request handling --- cmd/gomodel/docs/docs.go | 8 +++ docs/advanced/responses-api.mdx | 6 +-- docs/openapi.json | 8 +++ internal/core/responses.go | 6 ++- internal/core/responses_json.go | 4 ++ internal/core/responses_json_test.go | 41 ++++++++++++++ internal/server/http.go | 14 +++-- internal/server/native_response_service.go | 54 +++++++++++-------- .../server/native_response_service_test.go | 34 ++++++++++++ .../server/translated_inference_service.go | 9 ++-- 10 files changed, 147 insertions(+), 37 deletions(-) diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index 96869a3f..4871fb78 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -3697,6 +3697,10 @@ const docTemplate = `{ "model": { "type": "string" }, + "provider": { + "description": "Gateway routing hint; stripped before upstream execution.", + "type": "string" + }, "reasoning": { "$ref": "#/definitions/core.Reasoning" } @@ -3796,6 +3800,10 @@ const docTemplate = `{ "model": { "type": "string" }, + "provider": { + "description": "Gateway routing hint; stripped before upstream execution.", + "type": "string" + }, "reasoning": { "$ref": "#/definitions/core.Reasoning" } diff --git a/docs/advanced/responses-api.mdx b/docs/advanced/responses-api.mdx index 9d51b5c5..c6651ef4 100644 --- a/docs/advanced/responses-api.mdx +++ b/docs/advanced/responses-api.mdx @@ -96,10 +96,8 @@ OpenAI-compatible error with: ``` GoModel uses OpenAI's `invalid_request_error` type for unsupported operations -so the public error type set stays closed. The `501` status and -`unsupported_response_operation` code identify the unsupported operation. - -The HTTP status is `501 Not Implemented`. +so the public error type set stays closed. The `unsupported_response_operation` +code identifies the unsupported operation, returned with HTTP `501 Not Implemented`. ## Example diff --git a/docs/openapi.json b/docs/openapi.json index c2404de8..44fd3640 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5063,6 +5063,10 @@ "model": { "type": "string" }, + "provider": { + "description": "Gateway routing hint; stripped before upstream execution.", + "type": "string" + }, "reasoning": { "$ref": "#/components/schemas/core.Reasoning" } @@ -5162,6 +5166,10 @@ "model": { "type": "string" }, + "provider": { + "description": "Gateway routing hint; stripped before upstream execution.", + "type": "string" + }, "reasoning": { "$ref": "#/components/schemas/core.Reasoning" } diff --git a/internal/core/responses.go b/internal/core/responses.go index bb4702b2..ea2d4494 100644 --- a/internal/core/responses.go +++ b/internal/core/responses.go @@ -31,7 +31,8 @@ type ResponsesRequest struct { // POST /v1/responses/input_tokens. type ResponseInputTokensRequest struct { Model string `json:"model,omitempty"` - Input any `json:"input,omitempty"` // string or []ResponsesInputElement — see docs for array form + Provider string `json:"provider,omitempty"` // Gateway routing hint; stripped before upstream execution. + Input any `json:"input,omitempty"` // string or []ResponsesInputElement — see docs for array form Instructions string `json:"instructions,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` @@ -43,7 +44,8 @@ type ResponseInputTokensRequest struct { // POST /v1/responses/compact. type ResponseCompactRequest struct { Model string `json:"model,omitempty"` - Input any `json:"input,omitempty"` // string or []ResponsesInputElement — see docs for array form + Provider string `json:"provider,omitempty"` // Gateway routing hint; stripped before upstream execution. + Input any `json:"input,omitempty"` // string or []ResponsesInputElement — see docs for array form Instructions string `json:"instructions,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` diff --git a/internal/core/responses_json.go b/internal/core/responses_json.go index c860d9cf..eb54a744 100644 --- a/internal/core/responses_json.go +++ b/internal/core/responses_json.go @@ -116,12 +116,14 @@ func (r ResponsesRequest) MarshalJSON() ([]byte, error) { func (r ResponseInputTokensRequest) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` Input any `json:"input,omitempty"` Instructions string `json:"instructions,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` }{ Model: r.Model, + Provider: r.Provider, Input: r.Input, Instructions: r.Instructions, Metadata: r.Metadata, @@ -133,12 +135,14 @@ func (r ResponseInputTokensRequest) MarshalJSON() ([]byte, error) { func (r ResponseCompactRequest) MarshalJSON() ([]byte, error) { return json.Marshal(struct { Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` Input any `json:"input,omitempty"` Instructions string `json:"instructions,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` }{ Model: r.Model, + Provider: r.Provider, Input: r.Input, Instructions: r.Instructions, Metadata: r.Metadata, diff --git a/internal/core/responses_json_test.go b/internal/core/responses_json_test.go index 5976c730..7a9258a0 100644 --- a/internal/core/responses_json_test.go +++ b/internal/core/responses_json_test.go @@ -160,6 +160,47 @@ func TestResponsesRequestMarshalJSON_PreservesInput(t *testing.T) { } } +func TestResponseUtilityRequestMarshalJSON_PreservesProvider(t *testing.T) { + tests := []struct { + name string + req any + }{ + { + name: "input tokens", + req: ResponseInputTokensRequest{ + Model: "gpt-4o-mini", + Provider: "openai_primary", + Input: "hello", + }, + }, + { + name: "compact", + req: ResponseCompactRequest{ + Model: "gpt-4o-mini", + Provider: "openai_primary", + Input: "hello", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, err := json.Marshal(tt.req) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if decoded["provider"] != "openai_primary" { + t.Fatalf("provider = %#v, want openai_primary in %s", decoded["provider"], string(body)) + } + }) + } +} + func TestResponsesRequestMarshalJSON_PreservesToolCallingControls(t *testing.T) { parallelToolCalls := false body, err := json.Marshal(ResponsesRequest{ diff --git a/internal/server/http.go b/internal/server/http.go index 8103702d..99c0d934 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -387,15 +387,21 @@ func (s *Server) StartWithListener(ctx context.Context, listener net.Listener) e // Shutdown releases server resources. The HTTP server itself is stopped by // cancelling the context passed to Start; this method drains any in-flight -// response cache writes and closes the cache store. +// response cache writes, closes the cache store, and closes the response store. func (s *Server) Shutdown(_ context.Context) error { var firstErr error if s.responseCacheMiddleware != nil { - firstErr = s.responseCacheMiddleware.Close() + if err := s.responseCacheMiddleware.Close(); err != nil { + firstErr = err + } } if s.responseStore != nil { - if err := s.responseStore.Close(); err != nil && firstErr == nil { - firstErr = err + if err := s.responseStore.Close(); err != nil { + if firstErr == nil { + firstErr = err + } else { + slog.Warn("response store close failed during shutdown", "error", err) + } } } return firstErr diff --git a/internal/server/native_response_service.go b/internal/server/native_response_service.go index 7a82dccf..dbcf5d81 100644 --- a/internal/server/native_response_service.go +++ b/internal/server/native_response_service.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "net/http" - "slices" "strconv" "strings" @@ -509,31 +508,24 @@ func appendQueryArray(values ...[]string) []string { } func paginateStoredResponseInputItems(items []json.RawMessage, params core.ResponseInputItemsParams) core.ResponseInputItemListResponse { - ordered := make([]json.RawMessage, 0, len(items)) - for _, item := range items { - ordered = append(ordered, core.CloneRawJSON(item)) - } - if params.Order != "asc" { - slices.Reverse(ordered) - } - + count := len(items) start := 0 if params.After != "" { idx := -1 - for i, item := range ordered { - if responseInputItemID(item) == params.After { - idx = i + for pos := 0; pos < count; pos++ { + if responseInputItemID(items[orderedInputItemIndex(count, pos, params.Order)]) == params.After { + idx = pos break } } if idx >= 0 { start = idx + 1 } else { - ordered = nil + count = 0 } } - if start > len(ordered) { - start = len(ordered) + if start > count { + start = count } limit := params.Limit @@ -544,24 +536,40 @@ func paginateStoredResponseInputItems(items []json.RawMessage, params core.Respo limit = 100 } - remaining := ordered[start:] - hasMore := len(remaining) > limit - if hasMore { - remaining = remaining[:limit] + remaining := count - start + if remaining < 0 { + remaining = 0 + } + hasMore := remaining > limit + if remaining > limit { + remaining = limit + } + + data := make([]json.RawMessage, 0, remaining) + for offset := 0; offset < remaining; offset++ { + idx := orderedInputItemIndex(len(items), start+offset, params.Order) + data = append(data, core.CloneRawJSON(items[idx])) } resp := core.ResponseInputItemListResponse{ Object: "list", - Data: remaining, + Data: data, HasMore: hasMore, } - if len(remaining) > 0 { - resp.FirstID = responseInputItemID(remaining[0]) - resp.LastID = responseInputItemID(remaining[len(remaining)-1]) + if len(data) > 0 { + resp.FirstID = responseInputItemID(data[0]) + resp.LastID = responseInputItemID(data[len(data)-1]) } return resp } +func orderedInputItemIndex(count, position int, order string) int { + if order == "asc" { + return position + } + return count - 1 - position +} + func responseInputItemID(raw json.RawMessage) string { var item struct { ID string `json:"id"` diff --git a/internal/server/native_response_service_test.go b/internal/server/native_response_service_test.go index 29968c94..a16e32de 100644 --- a/internal/server/native_response_service_test.go +++ b/internal/server/native_response_service_test.go @@ -139,3 +139,37 @@ func TestIsUnsupportedNativeResponseErrorUsesCode(t *testing.T) { t.Fatal("message-only unsupported error should not be recognized") } } + +func TestPaginateStoredResponseInputItemsSelectsOrderedWindow(t *testing.T) { + items := []json.RawMessage{ + json.RawMessage(`{"id":"item_1"}`), + json.RawMessage(`{"id":"item_2"}`), + json.RawMessage(`{"id":"item_3"}`), + json.RawMessage(`{"id":"item_4"}`), + json.RawMessage(`{"id":"item_5"}`), + } + + resp := paginateStoredResponseInputItems(items, core.ResponseInputItemsParams{ + Order: "desc", + After: "item_4", + Limit: 2, + }) + + if !resp.HasMore { + t.Fatal("HasMore = false, want true") + } + if resp.FirstID != "item_3" || resp.LastID != "item_2" { + t.Fatalf("first/last = %q/%q, want item_3/item_2", resp.FirstID, resp.LastID) + } + if got := responseInputItemID(resp.Data[0]); got != "item_3" { + t.Fatalf("data[0] id = %q, want item_3", got) + } + if got := responseInputItemID(resp.Data[1]); got != "item_2" { + t.Fatalf("data[1] id = %q, want item_2", got) + } + + items[2][len(`{"id":"item_`)] = 'x' + if len(resp.Data) != 2 || responseInputItemID(resp.Data[0]) != "item_3" { + t.Fatal("paginated data should be cloned from the source items") + } +} diff --git a/internal/server/translated_inference_service.go b/internal/server/translated_inference_service.go index a838845d..8701d7b7 100644 --- a/internal/server/translated_inference_service.go +++ b/internal/server/translated_inference_service.go @@ -258,11 +258,12 @@ func (s *translatedInferenceService) storeResponseSnapshot(ctx context.Context, UserPath: core.UserPathFromContext(ctx), WorkflowVersionID: workflowVersionID(workflow), } - if err := store.Create(ctx, stored); err != nil { - if updateErr := store.Update(ctx, stored); updateErr == nil { + if createErr := store.Create(ctx, stored); createErr != nil { + updateErr := store.Update(ctx, stored) + if updateErr == nil { return nil } - return core.NewProviderError("response_store", http.StatusInternalServerError, "failed to persist response", err) + return core.NewProviderError("response_store", http.StatusInternalServerError, "failed to persist response", errors.Join(createErr, updateErr)) } return nil } @@ -292,7 +293,7 @@ func (s *translatedInferenceService) recordResponseSnapshotStoreFailure(workflow "provider_name", providerName, "workflow_version_id", workflowVersionID(workflow), "response_id", responseIDForLog(resp), - "err", err, + "error", err, ) } From 67377f9ad68114244e45d2f2b70efa855ed98c54 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 19 Apr 2026 13:15:56 +0200 Subject: [PATCH 8/8] fix(responses): correct openapi utility schemas --- Makefile | 4 +- cmd/gomodel/docs/docs.go | 55 +------- docs/openapi.json | 138 +++++++++++++-------- internal/core/responses.go | 34 +++-- internal/core/responses_json.go | 102 ++++++++++++--- internal/core/responses_json_test.go | 27 ++++ internal/server/handlers_test.go | 22 ++++ internal/server/native_response_service.go | 2 +- tools/openapi-postprocess.mjs | 120 ++++++++++++++++++ 9 files changed, 362 insertions(+), 142 deletions(-) create mode 100644 tools/openapi-postprocess.mjs diff --git a/Makefile b/Makefile index f8eb1bd8..71257e58 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ all: build VERSION ?= $(shell git describe --tags --always --dirty) COMMIT ?= $(shell git rev-parse --short HEAD) DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") -DOCS_API_SERVERS ?= http://localhost:8080 +DOCS_API_SERVERS ?= https://gomodel.example.com,http://localhost:8080 # Linker flags to inject version info LDFLAGS := -X "gomodel/internal/version.Version=$(VERSION)" \ @@ -103,7 +103,7 @@ docs-openapi: --outputTypes json \ --parseDependency; \ npx -y swagger2openapi@7.0.8 --patch -o docs/openapi.json "$$tmp_dir/swagger.json"; \ - DOCS_API_SERVERS="$(DOCS_API_SERVERS)" node -e 'const fs = require("fs"); const file = "docs/openapi.json"; const urls = (process.env.DOCS_API_SERVERS || "").split(",").map((url) => url.trim()).filter(Boolean); if (!urls.length) throw new Error("DOCS_API_SERVERS must include at least one URL"); const spec = JSON.parse(fs.readFileSync(file, "utf8")); spec.servers = urls.map((url) => ({ url, description: /(^https?:\/\/)?(localhost|127\.0\.0\.1)(:|\/|$$)/.test(url) ? "Local GoModel" : "GoModel" })); fs.writeFileSync(file, JSON.stringify(spec, null, 2) + "\n");' + DOCS_API_SERVERS="$(DOCS_API_SERVERS)" node tools/openapi-postprocess.mjs docs/openapi.json # Run linter lint: diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index 4871fb78..d4e2e3fa 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -3679,11 +3679,7 @@ const docTemplate = `{ "type": "object", "properties": { "input": { - "type": "array", - "items": { - "$ref": "#/definitions/core.ResponsesInputElement" - }, - "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + "description": "string or []ResponsesInputElement — see docs for array form" }, "instructions": { "type": "string" @@ -3782,11 +3778,7 @@ const docTemplate = `{ "type": "object", "properties": { "input": { - "type": "array", - "items": { - "$ref": "#/definitions/core.ResponsesInputElement" - }, - "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + "description": "string or []ResponsesInputElement — see docs for array form" }, "instructions": { "type": "string" @@ -3877,43 +3869,6 @@ const docTemplate = `{ } } }, - "core.ResponsesInputElement": { - "type": "object", - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "description": "Function call fields (type=\"function_call\")", - "type": "string" - }, - "content": { - "type": "array", - "items": { - "$ref": "#/definitions/core.ContentPart" - }, - "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ContentPart\"}}]" - }, - "name": { - "type": "string" - }, - "output": { - "description": "Function call output fields (type=\"function_call_output\") — CallID shared above", - "type": "string" - }, - "role": { - "description": "Message fields (type=\"\" or \"message\")", - "type": "string" - }, - "status": { - "type": "string" - }, - "type": { - "description": "\"message\", \"function_call\", \"function_call_output\"", - "type": "string" - } - } - }, "core.ResponsesOutputItem": { "type": "object", "properties": { @@ -3951,11 +3906,7 @@ const docTemplate = `{ "type": "object", "properties": { "input": { - "type": "array", - "items": { - "$ref": "#/definitions/core.ResponsesInputElement" - }, - "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + "description": "string or []ResponsesInputElement — see docs for array form" }, "instructions": { "type": "string" diff --git a/docs/openapi.json b/docs/openapi.json index 44fd3640..507a5bc5 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3967,6 +3967,10 @@ } }, "servers": [ + { + "url": "https://gomodel.example.com", + "description": "GoModel HTTPS deployment" + }, { "url": "http://localhost:8080", "description": "Local GoModel" @@ -5045,11 +5049,18 @@ "type": "object", "properties": { "input": { - "type": "array", - "items": { - "$ref": "#/components/schemas/core.ResponsesInputElement" - }, - "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + "description": "string or []ResponsesInputElement — see docs for array form", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/core.ResponsesInputElement" + } + } + ] }, "instructions": { "type": "string" @@ -5127,7 +5138,7 @@ "data": { "type": "array", "items": { - "type": "object" + "$ref": "#/components/schemas/core.ResponsesInputElement" } }, "first_id": { @@ -5148,11 +5159,18 @@ "type": "object", "properties": { "input": { - "type": "array", - "items": { - "$ref": "#/components/schemas/core.ResponsesInputElement" - }, - "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + "description": "string or []ResponsesInputElement — see docs for array form", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/core.ResponsesInputElement" + } + } + ] }, "instructions": { "type": "string" @@ -5243,43 +5261,6 @@ } } }, - "core.ResponsesInputElement": { - "type": "object", - "properties": { - "arguments": { - "type": "string" - }, - "call_id": { - "description": "Function call fields (type=\"function_call\")", - "type": "string" - }, - "content": { - "type": "array", - "items": { - "$ref": "#/components/schemas/core.ContentPart" - }, - "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ContentPart\"}}]" - }, - "name": { - "type": "string" - }, - "output": { - "description": "Function call output fields (type=\"function_call_output\") — CallID shared above", - "type": "string" - }, - "role": { - "description": "Message fields (type=\"\" or \"message\")", - "type": "string" - }, - "status": { - "type": "string" - }, - "type": { - "description": "\"message\", \"function_call\", \"function_call_output\"", - "type": "string" - } - } - }, "core.ResponsesOutputItem": { "type": "object", "properties": { @@ -5317,11 +5298,18 @@ "type": "object", "properties": { "input": { - "type": "array", - "items": { - "$ref": "#/components/schemas/core.ResponsesInputElement" - }, - "x-oneof": "[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]" + "description": "string or []ResponsesInputElement — see docs for array form", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/core.ResponsesInputElement" + } + } + ] }, "instructions": { "type": "string" @@ -5752,6 +5740,50 @@ "type": "string" } } + }, + "core.ResponsesInputElement": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "description": "Function call fields (type=\"function_call\")", + "type": "string" + }, + "content": { + "description": "Can be string or []ContentPart", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/core.ContentPart" + } + } + ] + }, + "name": { + "type": "string" + }, + "output": { + "description": "Function call output fields (type=\"function_call_output\") - CallID shared above", + "type": "string" + }, + "role": { + "description": "Message fields (type=\"\" or \"message\")", + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "description": "\"message\", \"function_call\", \"function_call_output\"", + "type": "string" + } + } } } } diff --git a/internal/core/responses.go b/internal/core/responses.go index ea2d4494..92731985 100644 --- a/internal/core/responses.go +++ b/internal/core/responses.go @@ -9,22 +9,20 @@ import "encoding/json" // can round-trip extensions; Swagger ignores ExtraFields, and typed fields // should be preferred when available. type ResponsesRequest struct { - Model string `json:"model"` - Provider string `json:"provider,omitempty"` // Gateway routing hint; stripped before upstream execution. - Input any `json:"input"` // string or []ResponsesInputElement — see docs for array form - //nolint:govet // Intentional duplicate json tag for Swagger docs: input is string OR []ResponsesInputElement. - InputSchema []ResponsesInputElement `json:"input,omitempty" extensions:"x-oneOf=[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]"` - Instructions string `json:"instructions,omitempty"` - Tools []map[string]any `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` // string or object - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxOutputTokens *int `json:"max_output_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` - StreamOptions *StreamOptions `json:"stream_options,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Reasoning *Reasoning `json:"reasoning,omitempty"` - ExtraFields UnknownJSONFields `json:"-" swaggerignore:"true"` + Model string `json:"model"` + Provider string `json:"provider,omitempty"` // Gateway routing hint; stripped before upstream execution. + Input any `json:"input"` // string or []ResponsesInputElement — see docs for array form + Instructions string `json:"instructions,omitempty"` + Tools []map[string]any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` // string or object + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + ExtraFields UnknownJSONFields `json:"-" swaggerignore:"true"` } // ResponseInputTokensRequest documents the request body accepted by @@ -36,8 +34,6 @@ type ResponseInputTokensRequest struct { Instructions string `json:"instructions,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` - //nolint:govet // Intentional duplicate json tag for Swagger docs: input is string OR []ResponsesInputElement. - InputSchema []ResponsesInputElement `json:"input,omitempty" extensions:"x-oneOf=[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]"` } // ResponseCompactRequest documents the request body accepted by @@ -49,8 +45,6 @@ type ResponseCompactRequest struct { Instructions string `json:"instructions,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` - //nolint:govet // Intentional duplicate json tag for Swagger docs: input is string OR []ResponsesInputElement. - InputSchema []ResponsesInputElement `json:"input,omitempty" extensions:"x-oneOf=[{\"type\":\"string\"},{\"type\":\"array\",\"items\":{\"$ref\":\"#/definitions/core.ResponsesInputElement\"}}]"` } func (r *ResponsesRequest) semanticSelector() (string, string) { diff --git a/internal/core/responses_json.go b/internal/core/responses_json.go index eb54a744..e19cd284 100644 --- a/internal/core/responses_json.go +++ b/internal/core/responses_json.go @@ -46,20 +46,9 @@ func (r *ResponsesRequest) UnmarshalJSON(data []byte) error { return err } - var input any - trimmed := bytes.TrimSpace(raw.Input) - if len(trimmed) != 0 && !bytes.Equal(trimmed, []byte("null")) { - if trimmed[0] == '[' { - var elements []ResponsesInputElement - if err := json.Unmarshal(trimmed, &elements); err != nil { - return err - } - input = elements - } else { - if err := json.Unmarshal(trimmed, &input); err != nil { - return err - } - } + input, err := decodeResponsesInput(raw.Input) + if err != nil { + return err } r.Model = raw.Model @@ -79,6 +68,26 @@ func (r *ResponsesRequest) UnmarshalJSON(data []byte) error { return nil } +func decodeResponsesInput(raw json.RawMessage) (any, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, nil + } + if trimmed[0] == '[' { + var elements []ResponsesInputElement + if err := json.Unmarshal(trimmed, &elements); err != nil { + return nil, err + } + return elements, nil + } + + var input any + if err := json.Unmarshal(trimmed, &input); err != nil { + return nil, err + } + return input, nil +} + // MarshalJSON preserves dynamic input payloads while supporting Swagger-only schema fields. func (r ResponsesRequest) MarshalJSON() ([]byte, error) { return marshalWithUnknownJSONFields(struct { @@ -112,6 +121,56 @@ func (r ResponsesRequest) MarshalJSON() ([]byte, error) { }, r.ExtraFields) } +type responseUtilityRequestJSON struct { + Model string + Provider string + Input any + Instructions string + Metadata map[string]string + Reasoning *Reasoning +} + +func decodeResponseUtilityRequestJSON(data []byte) (responseUtilityRequestJSON, error) { + var raw struct { + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return responseUtilityRequestJSON{}, err + } + input, err := decodeResponsesInput(raw.Input) + if err != nil { + return responseUtilityRequestJSON{}, err + } + return responseUtilityRequestJSON{ + Model: raw.Model, + Provider: raw.Provider, + Input: input, + Instructions: raw.Instructions, + Metadata: raw.Metadata, + Reasoning: raw.Reasoning, + }, nil +} + +// UnmarshalJSON preserves the dynamic input payload for gateway utility requests. +func (r *ResponseInputTokensRequest) UnmarshalJSON(data []byte) error { + raw, err := decodeResponseUtilityRequestJSON(data) + if err != nil { + return err + } + r.Model = raw.Model + r.Provider = raw.Provider + r.Input = raw.Input + r.Instructions = raw.Instructions + r.Metadata = raw.Metadata + r.Reasoning = raw.Reasoning + return nil +} + // MarshalJSON preserves the dynamic input payload while omitting Swagger-only schema fields. func (r ResponseInputTokensRequest) MarshalJSON() ([]byte, error) { return json.Marshal(struct { @@ -131,6 +190,21 @@ func (r ResponseInputTokensRequest) MarshalJSON() ([]byte, error) { }) } +// UnmarshalJSON preserves the dynamic input payload for gateway utility requests. +func (r *ResponseCompactRequest) UnmarshalJSON(data []byte) error { + raw, err := decodeResponseUtilityRequestJSON(data) + if err != nil { + return err + } + r.Model = raw.Model + r.Provider = raw.Provider + r.Input = raw.Input + r.Instructions = raw.Instructions + r.Metadata = raw.Metadata + r.Reasoning = raw.Reasoning + return nil +} + // MarshalJSON preserves the dynamic input payload while omitting Swagger-only schema fields. func (r ResponseCompactRequest) MarshalJSON() ([]byte, error) { return json.Marshal(struct { diff --git a/internal/core/responses_json_test.go b/internal/core/responses_json_test.go index 7a9258a0..b1e4057f 100644 --- a/internal/core/responses_json_test.go +++ b/internal/core/responses_json_test.go @@ -197,6 +197,33 @@ func TestResponseUtilityRequestMarshalJSON_PreservesProvider(t *testing.T) { if decoded["provider"] != "openai_primary" { t.Fatalf("provider = %#v, want openai_primary in %s", decoded["provider"], string(body)) } + + switch original := tt.req.(type) { + case ResponseInputTokensRequest: + var roundTripped ResponseInputTokensRequest + if err := json.Unmarshal(body, &roundTripped); err != nil { + t.Fatalf("json.Unmarshal(ResponseInputTokensRequest) error = %v", err) + } + if roundTripped.Provider != original.Provider { + t.Fatalf("round-tripped provider = %q, want %q", roundTripped.Provider, original.Provider) + } + if input, ok := roundTripped.Input.(string); !ok || input != original.Input { + t.Fatalf("round-tripped input = %#v, want %#v", roundTripped.Input, original.Input) + } + case ResponseCompactRequest: + var roundTripped ResponseCompactRequest + if err := json.Unmarshal(body, &roundTripped); err != nil { + t.Fatalf("json.Unmarshal(ResponseCompactRequest) error = %v", err) + } + if roundTripped.Provider != original.Provider { + t.Fatalf("round-tripped provider = %q, want %q", roundTripped.Provider, original.Provider) + } + if input, ok := roundTripped.Input.(string); !ok || input != original.Input { + t.Fatalf("round-tripped input = %#v, want %#v", roundTripped.Input, original.Input) + } + default: + t.Fatalf("unexpected request type %T", tt.req) + } }) } } diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index e7b5ca72..dea6244f 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -4905,6 +4905,28 @@ func TestResponsesUtilityRoutes(t *testing.T) { } } +func TestResponsesUtilityRoutesReturnProviderErrorWhenProviderTypeMissing(t *testing.T) { + provider := &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{ + "gpt-5-mini": "", + }, + } + srv := New(provider, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", strings.NewReader(`{"model":"gpt-5-mini","input":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadGateway, rec.Code, rec.Body.String()) + var envelope core.OpenAIErrorEnvelope + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) + require.Equal(t, core.ErrorTypeProvider, envelope.Error.Type) + require.Equal(t, "unable to resolve provider for response utility operation", envelope.Error.Message) + require.Empty(t, provider.capturedResponseUtilityReqs) +} + // mockUsageLogger implements usage.LoggerInterface for testing. type mockUsageLogger struct { config usage.Config diff --git a/internal/server/native_response_service.go b/internal/server/native_response_service.go index dbcf5d81..b06d947c 100644 --- a/internal/server/native_response_service.go +++ b/internal/server/native_response_service.go @@ -284,7 +284,7 @@ func (s *nativeResponseService) utilityRequest(c *echo.Context) (*core.Responses providerType = strings.TrimSpace(workflow.ProviderType) } if providerType == "" { - return nil, "", unsupportedResponseOperation("unable to resolve provider for response utility operation") + return nil, "", core.NewProviderError("response_router", http.StatusBadGateway, "unable to resolve provider for response utility operation", nil) } return req, providerType, nil } diff --git a/tools/openapi-postprocess.mjs b/tools/openapi-postprocess.mjs new file mode 100644 index 00000000..5e4bb095 --- /dev/null +++ b/tools/openapi-postprocess.mjs @@ -0,0 +1,120 @@ +import fs from "node:fs"; + +const file = process.argv[2] || "docs/openapi.json"; +const spec = JSON.parse(fs.readFileSync(file, "utf8")); + +function parseServers(value) { + const urls = (value || "") + .split(",") + .map((url) => url.trim()) + .filter(Boolean); + if (urls.length === 0) { + throw new Error("DOCS_API_SERVERS must include at least one URL"); + } + return urls.map((url) => ({ + url, + description: isLocalServer(url) ? "Local GoModel" : "GoModel HTTPS deployment", + })); +} + +function isLocalServer(url) { + return /(^https?:\/\/)?(localhost|127\.0\.0\.1)(:|\/|$)/.test(url); +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function schema(name) { + const result = spec.components?.schemas?.[name]; + if (!result) { + throw new Error(`missing OpenAPI schema: ${name}`); + } + return result; +} + +function applyResponseInputOneOf(name) { + const properties = schema(name).properties; + if (!properties?.input) { + throw new Error(`missing input property on schema: ${name}`); + } + + const input = {}; + if (properties.input.description) { + input.description = properties.input.description; + } + input.oneOf = clone([ + { type: "string" }, + { + type: "array", + items: { $ref: "#/components/schemas/core.ResponsesInputElement" }, + }, + ]); + properties.input = input; +} + +function ensureResponsesInputElementSchema() { + const schemas = spec.components?.schemas; + if (!schemas) { + throw new Error("missing OpenAPI components.schemas"); + } + if (schemas["core.ResponsesInputElement"]) { + return; + } + schemas["core.ResponsesInputElement"] = { + type: "object", + properties: { + arguments: { type: "string" }, + call_id: { + description: 'Function call fields (type="function_call")', + type: "string", + }, + content: { + description: "Can be string or []ContentPart", + oneOf: [ + { type: "string" }, + { + type: "array", + items: { $ref: "#/components/schemas/core.ContentPart" }, + }, + ], + }, + name: { type: "string" }, + output: { + description: 'Function call output fields (type="function_call_output") - CallID shared above', + type: "string", + }, + role: { + description: 'Message fields (type="" or "message")', + type: "string", + }, + status: { type: "string" }, + type: { + description: '"message", "function_call", "function_call_output"', + type: "string", + }, + }, + }; +} + +spec.servers = parseServers(process.env.DOCS_API_SERVERS); +ensureResponsesInputElementSchema(); + +for (const name of [ + "core.ResponsesRequest", + "core.ResponseInputTokensRequest", + "core.ResponseCompactRequest", +]) { + applyResponseInputOneOf(name); +} + +const inputItemList = schema("core.ResponseInputItemListResponse"); +if (!inputItemList.properties?.data) { + throw new Error("missing data property on schema: core.ResponseInputItemListResponse"); +} +inputItemList.properties.data = { + type: "array", + items: { $ref: "#/components/schemas/core.ResponsesInputElement" }, +}; + +fs.writeFileSync(file, `${JSON.stringify(spec, null, 2)}\n`);