Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
eb836b3
feat(responses): 完善 Chat 工具协议兼容
Zacks-Zhang Jul 30, 2026
7aa58bd
feat(responses): 完善工具调用与历史读取兼容
Zacks-Zhang Jul 31, 2026
cefac12
feat(responses): 完善历史字节预算与并行索引校验
Zacks-Zhang Jul 31, 2026
0666ce9
feat(responses): 合并连续工具调用消息
Zacks-Zhang Jul 31, 2026
0b43421
feat: 合并 upstream/unstable 并解决 Responses 冲突
Zacks-Zhang Aug 3, 2026
70ef075
feat(responses): 过滤 Chat 历史中的空助手消息
Zacks-Zhang Aug 3, 2026
d3f0ac2
feat(responses): 规范化 Chat 工具参数根类型
Zacks-Zhang Aug 3, 2026
e801edf
feat(responses): 规范化并行工具调用索引
Zacks-Zhang Aug 3, 2026
7a99fdb
feat(responses): 完善 Responses 到 Chat 工具协议兼容
Zacks-Zhang Aug 4, 2026
08dccb2
feat: 添加 test.md 占位文件
Zacks-Zhang Aug 4, 2026
142e5fc
fix(responses): 修复 Responses 到 Chat 工具调用丢失与非法参数问题
Zacks-Zhang Aug 4, 2026
5fbcafd
fix(pipeline): reasoning-only 空响应不再被当作有效内容
Zacks-Zhang Aug 4, 2026
f2f9d7a
fix(responses): 清理 Responses 历史中的空内容消息
Zacks-Zhang Aug 5, 2026
a3a60ae
fix(responses): Responses 到 Chat 的多段工具输出扁平化为字符串
Zacks-Zhang Aug 5, 2026
a687c11
fix(responses): 修复 Responses 到 Chat 工具转换的边界缺陷
Zacks-Zhang Aug 5, 2026
102382a
fix(responses): 保留 codex agent_message 任务指令避免子 agent 丢失派发
Zacks-Zhang Aug 5, 2026
4249324
fix(responses): 合并同 call_id 的多段工具输出避免 Chat 上游拒绝
Zacks-Zhang Aug 5, 2026
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