Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
84 changes: 84 additions & 0 deletions internal/server/biz/data_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
Expand Down Expand Up @@ -39,6 +40,8 @@ import (
"github.com/looplj/axonhub/internal/server/scheduler"
)

var errDataExceedsLimit = errors.New("stored data exceeds byte limit")

// DataStorageService handles data storage operations.
type DataStorageService struct {
*AbstractService
Expand Down Expand Up @@ -732,6 +735,87 @@ func (s *DataStorageService) LoadData(ctx context.Context, ds *ent.DataStorage,
}
}

// LoadDataLimited loads at most maxBytes without materializing an oversized
// external object. It checks advertised size first and also caps the reader so
// incorrect or missing size metadata cannot bypass the limit.
func (s *DataStorageService) LoadDataLimited(
ctx context.Context,
ds *ent.DataStorage,
key string,
maxBytes int64,
) ([]byte, error) {
if maxBytes < 0 {
return nil, errDataExceedsLimit
}

switch ds.Type {
case datastorage.TypeDatabase:
if int64(len(key)) > maxBytes {
return nil, errDataExceedsLimit
}
return []byte(key), nil
case datastorage.TypeFs, datastorage.TypeS3, datastorage.TypeGcs, datastorage.TypeWebdav:
if store, ok, err := s.objectStoreFor(ctx, ds); err != nil {
return nil, err
} else if ok {
body, size, err := store.OpenObject(ctx, normalizeObjectKey(key))
if err != nil {
return nil, err
}
defer body.Close()

return readDataLimited(body, size, maxBytes)
}

fs, err := s.GetFileSystem(ctx, ds)
if err != nil {
return nil, fmt.Errorf("failed to get file system: %w", err)
}

if ds.Type == datastorage.TypeFs {
key = filepath.FromSlash(key)
} else if ds.Type == datastorage.TypeWebdav || isS3PathStyle(ds) {
key = strings.TrimPrefix(key, "/")
}

file, err := fs.Open(key)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}

return readDataLimited(file, info.Size(), maxBytes)
default:
return nil, fmt.Errorf("unsupported storage type: %s", ds.Type)
}
}

// readDataLimited caps allocation even when advertisedSize is absent or wrong.
func readDataLimited(reader io.Reader, advertisedSize, maxBytes int64) ([]byte, error) {
if advertisedSize > maxBytes {
return nil, errDataExceedsLimit
}

readLimit := maxBytes
if readLimit < math.MaxInt64 {
readLimit++
}
data, err := io.ReadAll(io.LimitReader(reader, readLimit))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, errDataExceedsLimit
}

return data, nil
}

// isS3Provided checks if any S3 field is provided in the input (non-empty).
func isS3Provided(s3 *objects.S3) bool {
if s3 == nil {
Expand Down
121 changes: 121 additions & 0 deletions internal/server/biz/data_storage_limited_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package biz

import (
"errors"
"path/filepath"
"testing"

"github.com/spf13/afero"
"github.com/stretchr/testify/require"

"github.com/looplj/axonhub/internal/authz"
"github.com/looplj/axonhub/internal/ent"
"github.com/looplj/axonhub/internal/ent/datastorage"
"github.com/looplj/axonhub/internal/ent/enttest"
"github.com/looplj/axonhub/internal/ent/request"
"github.com/looplj/axonhub/internal/objects"
"github.com/looplj/axonhub/internal/pkg/xcache"
"github.com/looplj/axonhub/llm"
)

type unexpectedReadReader struct{}

func (unexpectedReadReader) Read([]byte) (int, error) {
return 0, errors.New("reader should not be consumed")
}

func TestReadDataLimited_RejectsAdvertisedAndObservedOversize(t *testing.T) {
_, err := readDataLimited(unexpectedReadReader{}, 5, 4)
require.ErrorIs(t, err, errDataExceedsLimit)

_, err = readDataLimited(&repeatingReader{remaining: 5}, 1, 4)
require.ErrorIs(t, err, errDataExceedsLimit)
}

func TestDataStorageService_LoadDataLimited_FileSystem(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "body.json", []byte(`{"ok":true}`), 0o600))

service := &DataStorageService{fsCache: map[int]afero.Fs{7: fs}}
storage := &ent.DataStorage{ID: 7, Type: datastorage.TypeFs}

data, err := service.LoadDataLimited(t.Context(), storage, "body.json", int64(len(`{"ok":true}`)))
require.NoError(t, err)
require.JSONEq(t, `{"ok":true}`, string(data))

data, err = service.LoadDataLimited(t.Context(), storage, "body.json", int64(len(`{"ok":true}`)-1))
require.Nil(t, data)
require.ErrorIs(t, err, errDataExceedsLimit)
}

func TestRequestService_LoadCompletedResponseExchangeLimitsExternalBodies(t *testing.T) {
client := enttest.NewEntClient(t, "sqlite3", "file:responses_history_external_limit?mode=memory&_fk=0")
ctx := ent.NewContext(authz.WithTestBypass(t.Context()), client)
storage, err := client.DataStorage.Create().
SetName("history-fs").
SetDescription("history external storage").
SetPrimary(false).
SetType(datastorage.TypeFs).
SetSettings(&objects.DataStorageSettings{}).
Save(ctx)
require.NoError(t, err)
stored, err := client.Request.Create().
SetProjectID(1).
SetDataStorageID(storage.ID).
SetModelID("gpt-5.5").
SetFormat(llm.APIFormatOpenAIResponse.String()).
SetSource(request.SourceAPI).
SetStatus(request.StatusCompleted).
SetStream(false).
SetRequestBody(objects.JSONRawMessage(`{}`)).
SetResponseBody(objects.JSONRawMessage(`{}`)).
SetExternalID("resp_external").
Save(ctx)
require.NoError(t, err)

requestBody := []byte(`{"model":"gpt-5.5","input":"external"}`)
responseBody := []byte(`{"id":"resp_external","model":"gpt-5.5","output":[]}`)
fs := afero.NewMemMapFs()
requestKey := GenerateRequestBodyKey(stored.ProjectID, stored.ID)
responseKey := GenerateResponseBodyKey(stored.ProjectID, stored.ID)
require.NoError(t, fs.MkdirAll(filepath.Dir(requestKey), 0o755))
require.NoError(t, afero.WriteFile(fs, requestKey, requestBody, 0o600))
require.NoError(t, afero.WriteFile(fs, responseKey, responseBody, 0o600))

storageService := &DataStorageService{
AbstractService: &AbstractService{db: client},
Cache: xcache.NewFromConfig[ent.DataStorage](xcache.Config{Mode: xcache.ModeMemory}),
fsCache: map[int]afero.Fs{storage.ID: fs},
objectStoreCache: make(map[int]ObjectStore),
}
requestService := &RequestService{
AbstractService: &AbstractService{db: client},
DataStorageService: storageService,
}

exactBudget := int64(len(requestBody) + len(responseBody))
exchange, err := requestService.LoadCompletedResponseExchange(ctx, "resp_external", 1, nil, exactBudget)
require.NoError(t, err)
require.Equal(t, objects.JSONRawMessage(requestBody), exchange.RequestBody)
require.Equal(t, objects.JSONRawMessage(responseBody), exchange.ResponseBody)

exchange, err = requestService.LoadCompletedResponseExchange(ctx, "resp_external", 1, nil, exactBudget-1)
require.Nil(t, exchange)
require.ErrorIs(t, err, ErrStoredResponseExchangeTooLarge)
}

type repeatingReader struct {
remaining int
}

func (r *repeatingReader) Read(p []byte) (int, error) {
if r.remaining == 0 {
return 0, nil
}
n := min(len(p), r.remaining)
for i := 0; i < n; i++ {
p[i] = 'x'
}
r.remaining -= n
return n, nil
}
Loading