Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/en/api-reference/openai-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,19 @@ fmt.Println(responseText)

### OpenAI Responses API

AxonHub provides partial support for the OpenAI Responses API. This API offers a simplified interface for single-turn interactions.
AxonHub provides partial support for the OpenAI Responses API, including continued conversations.

**Endpoints:**
- `POST /v1/responses` - Generate a response

**Capabilities:**
- ✅ `previous_response_id` passthrough is supported for continued Responses conversations on the same upstream channel
- ✅ When a Responses request is routed to a Chat Completions channel, AxonHub expands `previous_response_id` into explicit Chat history within the same project and API-key scope
- ✅ Basic response generation is fully functional
- ✅ Streaming responses are supported

Responses-to-Chat history expansion requires both request and response body storage to be enabled for the referenced turns. If the referenced response is missing, outside the current scope, or its bodies were not retained, AxonHub returns `400 invalid_request_error`. Storage service failures remain server errors. Previous turns' top-level `instructions` are not inherited, matching Responses API semantics.

**Example Request:**
```go
import (
Expand Down
5 changes: 4 additions & 1 deletion docs/zh/api-reference/openai-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,19 @@ fmt.Println(responseText)

### OpenAI Responses API

AxonHub 提供对 OpenAI Responses API 的部分支持。该 API 为单轮交互提供了简化的接口
AxonHub 提供对 OpenAI Responses API 的部分支持,包括连续对话

**端点:**
- `POST /v1/responses` - 生成响应

**能力:**
- ✅ 支持 `previous_response_id` 透传,可用于同一上游 channel 上的连续 Responses 对话复用
- ✅ 当 Responses 请求被路由到 Chat Completions channel 时,AxonHub 会在同一项目和 API Key 作用域内将 `previous_response_id` 展开为显式 Chat 历史
- ✅ 基本响应生成完全可用
- ✅ 支持流式响应

Responses 到 Chat 的历史展开要求被引用轮次同时启用请求体和响应体存储。引用响应不存在、超出当前作用域或正文未保留时,AxonHub 返回 `400 invalid_request_error`;存储服务故障仍按服务端错误返回。历史轮次的顶层 `instructions` 不会被继承,与 Responses API 语义一致。

**示例请求:**
```go
import (
Expand Down
2 changes: 1 addition & 1 deletion internal/ent/internal/schema.go

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions internal/ent/migrate/schema.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions internal/ent/schema/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ func (Request) Indexes() []ent.Index {
StorageKey("requests_by_channel_id_created_at"),
index.Fields("trace_id", "created_at").
StorageKey("requests_by_trace_id_created_at"),
index.Fields("project_id", "external_id").
StorageKey("requests_by_project_id_external_id"),
// Performance indexes for dashboard queries
index.Fields("created_at").
StorageKey("requests_by_created_at"),
Expand Down
103 changes: 103 additions & 0 deletions internal/server/biz/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/looplj/axonhub/internal/authz"
"github.com/looplj/axonhub/internal/contexts"
"github.com/looplj/axonhub/internal/ent"
"github.com/looplj/axonhub/internal/ent/predicate"
"github.com/looplj/axonhub/internal/ent/request"
"github.com/looplj/axonhub/internal/ent/requestexecution"
"github.com/looplj/axonhub/internal/log"
Expand All @@ -37,6 +39,47 @@ type RequestService struct {
previousChannelCache xcache.Cache[int]
}

// StoredResponseExchange contains one persisted client request and its completed response.
type StoredResponseExchange struct {
RequestBody objects.JSONRawMessage
ResponseBody objects.JSONRawMessage
}

// loadStoredResponseExchangeBody loads one JSON body without hiding external
// storage failures. A missing object represents unavailable retained content;
// other storage errors remain server errors so callers can retry them.
func (s *RequestService) loadStoredResponseExchangeBody(
ctx context.Context,
stored *ent.Request,
databaseBody objects.JSONRawMessage,
externalKey string,
bodyName string,
) (objects.JSONRawMessage, error) {
dataStorage, err := s.getDataStorage(ctx, stored.DataStorageID)
if err != nil {
return nil, fmt.Errorf("failed to resolve %s storage: %w", bodyName, err)
}
if !s.shouldUseExternalStorage(ctx, dataStorage) {
if databaseBody == nil {
return xjson.EmptyJSONRawMessage, nil
}
return databaseBody, nil
}

data, err := s.DataStorageService.LoadData(ctx, dataStorage, externalKey)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return xjson.EmptyJSONRawMessage, nil
}
return nil, fmt.Errorf("failed to load %s from external storage: %w", bodyName, err)
}
if !json.Valid(data) {
return nil, fmt.Errorf("stored %s contains invalid JSON", bodyName)
}

return objects.JSONRawMessage(data), nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// NewRequestService creates a new RequestService.
func NewRequestService(
ent *ent.Client,
Expand Down Expand Up @@ -1212,6 +1255,66 @@ func (s *RequestService) LoadResponseBody(ctx context.Context, req *ent.Request)
return xjson.EmptyJSONRawMessage, nil
}

// LoadCompletedResponseExchange finds one Responses request by response ID within
// the caller's project and API-key scope, then loads both persisted bodies.
func (s *RequestService) LoadCompletedResponseExchange(
ctx context.Context,
externalID string,
projectID int,
apiKeyID *int,
) (*StoredResponseExchange, error) {
if strings.TrimSpace(externalID) == "" {
return nil, nil
}

predicates := []predicate.Request{
request.ProjectIDEQ(projectID),
request.ExternalIDEQ(externalID),
request.FormatEQ(llm.APIFormatOpenAIResponse.String()),
request.StatusEQ(request.StatusCompleted),
}
if apiKeyID != nil {
predicates = append(predicates, request.APIKeyIDEQ(*apiKeyID))
} else {
predicates = append(predicates, request.APIKeyIDIsNil())
}

stored, err := s.entFromContext(ctx).Request.Query().
Where(predicates...).
Order(ent.Desc(request.FieldCreatedAt)).
First(ctx)
if err != nil {
if ent.IsNotFound(err) {
return nil, nil
}

return nil, fmt.Errorf("failed to find previous response %q: %w", externalID, err)
}

requestBody, err := s.loadStoredResponseExchangeBody(
ctx,
stored,
stored.RequestBody,
GenerateRequestBodyKey(stored.ProjectID, stored.ID),
"previous response request body",
)
if err != nil {
return nil, fmt.Errorf("failed to load previous response request %q: %w", externalID, err)
}
responseBody, err := s.loadStoredResponseExchangeBody(
ctx,
stored,
stored.ResponseBody,
GenerateResponseBodyKey(stored.ProjectID, stored.ID),
"previous response body",
)
if err != nil {
return nil, fmt.Errorf("failed to load previous response body %q: %w", externalID, err)
}

return &StoredResponseExchange{RequestBody: requestBody, ResponseBody: responseBody}, nil
}

// LoadResponseChunks returns the request response chunks, loading from external storage when necessary.
func (s *RequestService) LoadResponseChunks(ctx context.Context, req *ent.Request) ([]objects.JSONRawMessage, error) {
if req == nil {
Expand Down
15 changes: 14 additions & 1 deletion internal/server/orchestrator/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,16 @@ func (p *PersistentOutboundTransformer) TransformRequest(ctx context.Context, ll

llmRequest.Model = entry.ActualModel

if isResponsesFormat(llmRequest.APIFormat) &&
p.wrapped.APIFormat() == llm.APIFormatOpenAIChatCompletion &&
llmRequest.PreviousResponseID != nil {
hydrated, err := hydratePreviousResponsesForChat(ctx, llmRequest, p.state)
if err != nil {
return nil, err
}
llmRequest = hydrated
}

// Apply channel transform options to create a new request
llmRequest = applyTransformOptions(llmRequest, candidate.Channel.Settings)
llmRequest = filterResponseCustomToolMessagesForNonResponsesOutbound(llmRequest, p.wrapped.APIFormat())
Expand Down Expand Up @@ -415,7 +425,10 @@ func filterResponseCustomToolMessagesForNonResponsesOutbound(
return nil
}

if !isResponsesFormat(llmRequest.APIFormat) || isResponsesFormat(outboundFormat) || !containsResponseCustomToolMessages(llmRequest.Messages) {
if !isResponsesFormat(llmRequest.APIFormat) ||
isResponsesFormat(outboundFormat) ||
outboundFormat == llm.APIFormatOpenAIChatCompletion ||
!containsResponseCustomToolMessages(llmRequest.Messages) {
return llmRequest
}

Expand Down
9 changes: 8 additions & 1 deletion internal/server/orchestrator/outbound_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ type mockTransformer struct {
aggregatedMeta llm.ResponseMeta
aggregatedErr error
apiFormat llm.APIFormat
capturedRequest *llm.Request
}

func (m *mockTransformer) TransformRequest(ctx context.Context, req *llm.Request) (*httpclient.Request, error) {
m.capturedRequest = req
body, err := json.Marshal(map[string]any{
"model": req.Model,
"messages": req.Messages,
Expand Down Expand Up @@ -981,8 +983,13 @@ func TestFilterResponseCustomToolMessagesForNonResponsesOutbound(t *testing.T) {
},
}

t.Run("filters when inbound is responses and outbound is not", func(t *testing.T) {
t.Run("preserves when outbound Chat adapter supports custom lifecycle", func(t *testing.T) {
got := filterResponseCustomToolMessagesForNonResponsesOutbound(baseRequest, llm.APIFormatOpenAIChatCompletion)
require.Same(t, baseRequest, got)
})

t.Run("filters when outbound has no custom lifecycle adapter", func(t *testing.T) {
got := filterResponseCustomToolMessagesForNonResponsesOutbound(baseRequest, llm.APIFormatAnthropicMessage)
require.NotSame(t, baseRequest, got)
require.Len(t, got.Messages, 2)
require.Len(t, got.Messages[0].ToolCalls, 1)
Expand Down
Loading