mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c00df2e01 | ||
|
|
b5298fdde8 | ||
|
|
5a350108c4 | ||
|
|
1df5c39311 | ||
|
|
b3c7038cf4 | ||
|
|
8616d4e036 | ||
|
|
93c3da03eb | ||
|
|
68c2f5ae6d | ||
|
|
ff6b85e30b | ||
|
|
79c18ec836 | ||
|
|
790723e040 | ||
|
|
1ff2bd6d95 | ||
|
|
f592b336a3 | ||
|
|
37f35b12ea | ||
|
|
e4b34446af | ||
|
|
90ae81e9f8 | ||
|
|
9375e288ba | ||
|
|
06f70a6232 | ||
|
|
6906062187 | ||
|
|
dc4874a2f1 | ||
|
|
11608c51f0 | ||
|
|
8aa7fb8c2d | ||
|
|
00ae7c38e2 | ||
|
|
51a10e42f7 | ||
|
|
6655f4ec0e | ||
|
|
be64b553f9 | ||
|
|
66f07efd26 | ||
|
|
89e9976950 | ||
|
|
99d86e41ac | ||
|
|
ec334fda2c | ||
|
|
35d2ef0cbd | ||
|
|
512aa8bc93 | ||
|
|
f45126c8ec | ||
|
|
82d2552360 | ||
|
|
f652d0ed38 | ||
|
|
6b40449c3f | ||
|
|
02088be2d1 | ||
|
|
14483c9254 | ||
|
|
fd7af1d462 | ||
|
|
8b95dc94f8 | ||
|
|
7626110d0b | ||
|
|
3c5ea297ff | ||
|
|
994048e6a4 | ||
|
|
c802c45e37 | ||
|
|
f4f8ff04db | ||
|
|
510b680fca | ||
|
|
afbe51abf8 | ||
|
|
52c2f4cb10 | ||
|
|
630639f747 | ||
|
|
11ffee5543 | ||
|
|
85810365a6 | ||
|
|
f0ebc183e1 | ||
|
|
ca2746f3bb | ||
|
|
1d126c833b | ||
|
|
9461196864 | ||
|
|
a2007db707 | ||
|
|
97bdde3135 | ||
|
|
090b2cc000 | ||
|
|
82b092ffc2 | ||
|
|
5979521309 | ||
|
|
1ca7b2a519 | ||
|
|
00f6f4e4a8 | ||
|
|
a3eb7ef302 | ||
|
|
2fc91663d2 | ||
|
|
ecba49f711 | ||
|
|
17ce21c038 | ||
|
|
b5b5f1f94b | ||
|
|
1af3269adb | ||
|
|
f867e3e2ec | ||
|
|
ef51690b5a |
@@ -1 +1 @@
|
||||
0.2.7
|
||||
0.2.8
|
||||
|
||||
@@ -2,10 +2,13 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -73,3 +76,65 @@ func TestConcurrencyErrorResponse(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleConcurrencyError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
wantGStatus string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "client cancellation is 499",
|
||||
err: context.Canceled,
|
||||
wantStatus: statusClientClosedRequest,
|
||||
wantGStatus: "CANCELLED",
|
||||
wantMessage: "context canceled",
|
||||
},
|
||||
{
|
||||
name: "full wait queue is 429",
|
||||
err: &WaitQueueFullError{SlotType: "user"},
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
wantGStatus: "RESOURCE_EXHAUSTED",
|
||||
wantMessage: "Too many pending requests, please retry later",
|
||||
},
|
||||
{
|
||||
name: "slot wait timeout is 429",
|
||||
err: &ConcurrencyError{SlotType: "user", IsTimeout: true},
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
wantGStatus: "RESOURCE_EXHAUSTED",
|
||||
wantMessage: "Concurrency limit exceeded for user, please retry later",
|
||||
},
|
||||
{
|
||||
name: "acquire backend error is 503",
|
||||
err: errors.New("redis unavailable"),
|
||||
wantStatus: http.StatusServiceUnavailable,
|
||||
wantGStatus: "INTERNAL",
|
||||
wantMessage: "Service temporarily unavailable, please retry later",
|
||||
},
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
googleConcurrencyError(c, tt.err, "user")
|
||||
|
||||
require.Equal(t, tt.wantStatus, rec.Code)
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
} `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, tt.wantStatus, body.Error.Code)
|
||||
require.Equal(t, tt.wantGStatus, body.Error.Status)
|
||||
require.Equal(t, tt.wantMessage, body.Error.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +330,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
for {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("gateway.account_select_aborted_client_disconnected", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini)
|
||||
if !cls.ModelNotFound {
|
||||
@@ -660,6 +664,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
)
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("gateway.account_select_aborted_client_disconnected", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform)
|
||||
if !cls.ModelNotFound {
|
||||
@@ -1971,10 +1979,15 @@ func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, statu
|
||||
// Writer 已被写过时(ping 已 flush)走 streamStarted 分支,
|
||||
// 让 handleStreamingAwareError 通过 SSE 发协议合规的终止事件,
|
||||
// 否则下游收到的就是 silent EOF。
|
||||
// 客户端已断开时不补写,响应未提交则标记 499(见 failoverClientGone)。
|
||||
func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarted bool) bool {
|
||||
if c == nil || c.Writer == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
|
||||
failoverClientGone(c)
|
||||
return false
|
||||
}
|
||||
if service.IsResponseCommitted(c) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ func TestGatewayHandlerPreCancelledCompatibleRequestsDoNotSelectAccount(t *testi
|
||||
require.Zero(t, schedulerCache.snapshotCalls.Load(), "a cancelled request must stop before the account selector")
|
||||
_, selected := c.Get(opsAccountIDKey)
|
||||
require.False(t, selected)
|
||||
require.Equal(t, statusClientClosedRequest, c.Writer.Status(), "an uncommitted cancelled request is marked 499")
|
||||
require.Zero(t, recorder.Body.Len())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,11 +168,15 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
|
||||
for {
|
||||
if c.Request.Context().Err() != nil {
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("gateway.cc.account_select_aborted_client_disconnected", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
|
||||
cls = classifySelectionFailureError(err, cls)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -70,6 +71,43 @@ func TestGatewayEnsureForwardErrorResponse_SkipsCommittedSSEError(t *testing.T)
|
||||
require.Equal(t, 1, strings.Count(w.Body.String(), "event: error"))
|
||||
}
|
||||
|
||||
// 客户端已断开且响应未提交:不补写 502,标记 499 供访问日志与 ops 归类。
|
||||
func TestGatewayEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil).WithContext(ctx)
|
||||
cancel()
|
||||
|
||||
h := &GatewayHandler{}
|
||||
wrote := h.ensureForwardErrorResponse(c, false)
|
||||
|
||||
require.False(t, wrote)
|
||||
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
|
||||
require.Empty(t, w.Body.String())
|
||||
}
|
||||
|
||||
// 客户端已断开但流已开始:状态码已固化为 200,不再向断开的连接追加错误帧。
|
||||
func TestGatewayEnsureForwardErrorResponse_CanceledClientAfterStreamStartedAppendsNothing(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil).WithContext(ctx)
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
_, _ = c.Writer.WriteString(":\n\n")
|
||||
cancel()
|
||||
|
||||
h := &GatewayHandler{}
|
||||
wrote := h.ensureForwardErrorResponse(c, true)
|
||||
|
||||
require.False(t, wrote)
|
||||
require.Equal(t, http.StatusOK, c.Writer.Status())
|
||||
require.Equal(t, ":\n\n", w.Body.String())
|
||||
require.Empty(t, service.GetOpsStreamErrors(c))
|
||||
}
|
||||
|
||||
// case B 回归:Anthropic-backed /responses,Writer 已被写过时
|
||||
// ensureForwardErrorResponse 仍要发 response.failed。
|
||||
func TestGatewayEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsResponseFailed(t *testing.T) {
|
||||
|
||||
@@ -170,11 +170,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
fs := NewFailoverState(h.maxAccountSwitches, false)
|
||||
|
||||
for {
|
||||
if requestCtx.Err() != nil {
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("gateway.responses.account_select_aborted_client_disconnected", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey))
|
||||
cls = classifySelectionFailureError(err, cls)
|
||||
|
||||
@@ -287,6 +287,18 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
|
||||
accounts: accounts[1:],
|
||||
want: []string{sparkModel, alias},
|
||||
},
|
||||
{
|
||||
// A passthrough account with a stale mapping behaves like an unmapped
|
||||
// one: it adds the defaults but never its own mapping keys, and it no
|
||||
// longer hides the aliases declared on ordinary accounts.
|
||||
name: "passthrough account contributes defaults without hiding mapped aliases",
|
||||
accounts: append([]service.Account{{
|
||||
ID: 5, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "stale-model"}},
|
||||
Extra: map[string]any{"openai_passthrough": true},
|
||||
}}, accounts[1:]...),
|
||||
want: append(openai.DefaultModelIDs(), alias),
|
||||
},
|
||||
{
|
||||
name: "unmapped accounts from another platform do not add defaults",
|
||||
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// clientCancelUpstream 模拟上游尚未响应时客户端断开:Do 期间取消请求 context,
|
||||
// 并按 net/http 的形态返回包裹 context.Canceled 的 *url.Error。
|
||||
type clientCancelUpstream struct {
|
||||
service.HTTPUpstream
|
||||
cancel context.CancelFunc
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (u *clientCancelUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
u.calls.Add(1)
|
||||
u.cancel()
|
||||
return nil, &url.Error{Op: "Post", URL: req.URL.String(), Err: context.Canceled}
|
||||
}
|
||||
|
||||
type geminiClientCancelFixture struct {
|
||||
handler *GatewayHandler
|
||||
group *service.Group
|
||||
apiKey *service.APIKey
|
||||
upstream *clientCancelUpstream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newGeminiClientCancelFixture(t *testing.T) *geminiClientCancelFixture {
|
||||
t.Helper()
|
||||
groupID := int64(9200)
|
||||
accountID := int64(9201)
|
||||
group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformGemini, Status: service.StatusActive}
|
||||
account := &service.Account{
|
||||
ID: accountID,
|
||||
Platform: service.PlatformGemini,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "test-key"},
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 1,
|
||||
AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
|
||||
}
|
||||
h, cleanup := newTestGatewayHandler(t, group, []*service.Account{account})
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
upstream := &clientCancelUpstream{cancel: cancel}
|
||||
h.geminiCompatService = service.NewGeminiMessagesCompatService(nil, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})
|
||||
|
||||
apiKey := &service.APIKey{
|
||||
ID: 9202, UserID: 9203, GroupID: &groupID, Group: group, Status: service.StatusActive,
|
||||
User: &service.User{ID: 9203, Concurrency: 10, Balance: 100},
|
||||
}
|
||||
return &geminiClientCancelFixture{handler: h, group: group, apiKey: apiKey, upstream: upstream, ctx: ctx}
|
||||
}
|
||||
|
||||
// serve 经 ops 错误日志中间件执行请求,返回响应与 ops 队列中的条目数。
|
||||
func (f *geminiClientCancelFixture) serve(t *testing.T, route, path, body string, call func(*gin.Context)) (*httptest.ResponseRecorder, int64) {
|
||||
t.Helper()
|
||||
setupOpsErrorLogTestQueue(t, 4)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST(route, func(c *gin.Context) {
|
||||
c.Set(string(middleware.ContextKeyAPIKey), f.apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: f.apiKey.UserID, Concurrency: 10})
|
||||
call(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
|
||||
req = req.WithContext(context.WithValue(f.ctx, ctxkey.Group, f.group))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec, OpsErrorLogQueueLength()
|
||||
}
|
||||
|
||||
// 原生入口:上游响应前客户端断开,响应未提交,标记 499;纯客户端取消不落 ops 错误日志。
|
||||
func TestGeminiV1BetaModels_ClientCancelBeforeUpstreamResponseMarks499(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
f := newGeminiClientCancelFixture(t)
|
||||
|
||||
rec, opsQueued := f.serve(t,
|
||||
"/v1beta/models/*modelAction",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`,
|
||||
f.handler.GeminiV1BetaModels,
|
||||
)
|
||||
|
||||
require.Equal(t, int32(1), f.upstream.calls.Load())
|
||||
require.Equal(t, statusClientClosedRequest, rec.Code)
|
||||
require.Zero(t, rec.Body.Len())
|
||||
require.Zero(t, opsQueued)
|
||||
}
|
||||
|
||||
// Chat Completions 兼容入口(Gemini 分组):同一场景标记 499,不再补写 502。
|
||||
func TestGatewayChatCompletions_GeminiClientCancelBeforeUpstreamResponseMarks499(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
f := newGeminiClientCancelFixture(t)
|
||||
|
||||
rec, opsQueued := f.serve(t,
|
||||
"/v1/chat/completions",
|
||||
"/v1/chat/completions",
|
||||
`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"stream":true}`,
|
||||
f.handler.ChatCompletions,
|
||||
)
|
||||
|
||||
require.Equal(t, int32(1), f.upstream.calls.Load())
|
||||
require.Equal(t, statusClientClosedRequest, rec.Code)
|
||||
require.Zero(t, rec.Body.Len())
|
||||
require.Zero(t, opsQueued)
|
||||
}
|
||||
@@ -370,7 +370,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, stream, &streamStarted)
|
||||
if err != nil {
|
||||
reqLog.Warn("gemini.user_slot_acquire_failed", zap.Error(err))
|
||||
googleError(c, http.StatusTooManyRequests, err.Error())
|
||||
googleConcurrencyError(c, err, "user")
|
||||
return
|
||||
}
|
||||
// 确保请求取消时也会释放槽位,避免长连接被动中断造成泄漏
|
||||
@@ -506,6 +506,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
for {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("gemini.account_select_aborted_client_disconnected", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
|
||||
if !cls.ModelNotFound {
|
||||
@@ -598,7 +602,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("gemini.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
googleError(c, http.StatusTooManyRequests, err.Error())
|
||||
googleConcurrencyError(c, err, "account")
|
||||
return
|
||||
}
|
||||
if accountWaitCounted {
|
||||
@@ -674,7 +678,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// ForwardNative already wrote the response
|
||||
// 转发层已写出错误响应;客户端断开时转发层不写,响应未提交则标记 499。
|
||||
failoverClientGone(c)
|
||||
reqLog.Error("gemini.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
@@ -836,6 +841,13 @@ func googleError(c *gin.Context, status int, message string) {
|
||||
})
|
||||
}
|
||||
|
||||
// googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案
|
||||
// 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。
|
||||
func googleConcurrencyError(c *gin.Context, err error, slotType string) {
|
||||
status, _, _, message := concurrencyErrorResponse(err, slotType)
|
||||
googleError(c, status, message)
|
||||
}
|
||||
|
||||
func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) {
|
||||
if res == nil {
|
||||
googleError(c, http.StatusBadGateway, "Empty upstream response")
|
||||
|
||||
@@ -92,6 +92,8 @@ type modelPlazaGroup struct {
|
||||
// 不取分组/用户专属倍率。
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
VideoRateIndependent bool `json:"video_rate_independent"`
|
||||
VideoRateMultiplier float64 `json:"video_rate_multiplier"`
|
||||
// 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
|
||||
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
@@ -211,6 +213,8 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
VideoRateIndependent: g.VideoRateIndependent,
|
||||
VideoRateMultiplier: g.VideoRateMultiplier,
|
||||
LongContextPricingEnabled: g.LongContextPricingEnabled,
|
||||
Models: models,
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
g := service.PlazaGroup{
|
||||
ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
|
||||
SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
|
||||
VideoRateIndependent: true, VideoRateMultiplier: 0.7,
|
||||
Models: []service.PlazaModel{{
|
||||
Name: "claude-sonnet",
|
||||
Platform: "anthropic",
|
||||
@@ -115,11 +116,14 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
|
||||
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
|
||||
"image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled",
|
||||
"video_rate_independent", "video_rate_multiplier",
|
||||
} {
|
||||
_, exists := decoded[key]
|
||||
require.Truef(t, exists, "plaza group DTO must expose %q", key)
|
||||
}
|
||||
require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
|
||||
require.Equal(t, true, decoded["video_rate_independent"])
|
||||
require.Equal(t, 0.7, decoded["video_rate_multiplier"])
|
||||
|
||||
// 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
|
||||
models := decoded["models"].([]any)
|
||||
|
||||
@@ -197,6 +197,10 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if !errors.As(err, &failoverErr) {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("openai_alpha_search.forward_aborted_client_disconnected", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if c.Writer.Size() == writerSizeBeforeForward {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||
}
|
||||
|
||||
@@ -1146,6 +1146,9 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
if shouldSkipOpsErrorLog(c.Request.Context(), ops, parsed.Message, string(body), c.Request.URL.Path) {
|
||||
return
|
||||
}
|
||||
if shouldSkipOpsClientClosed(c, ops, status) {
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := getOpsAPIKey(c)
|
||||
|
||||
@@ -2491,6 +2494,21 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldSkipOpsClientClosed 按 IgnoreContextCanceled 过滤纯客户端取消的 499。
|
||||
// 499 表示客户端在响应提交前断开(见 failoverClientGone),通常不带
|
||||
// "context canceled" 文案,shouldSkipOpsErrorLog 的文本过滤命中不了。
|
||||
// 本次请求未观察到上游错误时是纯客户端取消;已有上游错误的 499 表示上游失败后
|
||||
// 客户端没等到换号结果就离开,仍按上游失败落库。
|
||||
func shouldSkipOpsClientClosed(c *gin.Context, ops *service.OpsService, status int) bool {
|
||||
if status != statusClientClosedRequest || ops == nil {
|
||||
return false
|
||||
}
|
||||
if !ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled {
|
||||
return false
|
||||
}
|
||||
return !hasOpsUpstreamErrorContext(c)
|
||||
}
|
||||
|
||||
// shouldSkipOpsErrorLogForCyber:cyber_policy 命中的请求由 recordCyberPolicyIfMarked
|
||||
// 统一落一条 status=403 的错误请求,故中间件跳过自身落库,避免双写。
|
||||
func shouldSkipOpsErrorLogForCyber(c *gin.Context) bool {
|
||||
|
||||
@@ -2168,3 +2168,83 @@ func TestNormalizeOpsErrorType_KeepsGeminiInBandSignalTypes(t *testing.T) {
|
||||
require.Equal(t, errType, normalizeOpsErrorType(errType, "PROHIBITED_CONTENT"), errType)
|
||||
}
|
||||
}
|
||||
|
||||
type opsAdvancedSettingsRepoStub struct {
|
||||
service.SettingRepository
|
||||
advanced string
|
||||
}
|
||||
|
||||
func (r *opsAdvancedSettingsRepoStub) GetValue(context.Context, string) (string, error) {
|
||||
return "", service.ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (r *opsAdvancedSettingsRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
|
||||
return map[string]string{service.SettingKeyOpsAdvancedSettings: r.advanced}, nil
|
||||
}
|
||||
|
||||
func (r *opsAdvancedSettingsRepoStub) Set(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveClientClosedRequest 以已取消的请求 context 走 failoverClientGone,复现网关标记 499 的收尾路径。
|
||||
func serveClientClosedRequest(t *testing.T, ops *service.OpsService, prepare func(c *gin.Context)) {
|
||||
t.Helper()
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1/messages", func(c *gin.Context) {
|
||||
if prepare != nil {
|
||||
prepare(c)
|
||||
}
|
||||
failoverClientGone(c)
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/messages", nil).WithContext(ctx))
|
||||
require.Equal(t, statusClientClosedRequest, recorder.Code)
|
||||
require.Zero(t, recorder.Body.Len())
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_SkipsPureClientClosed(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.True(t, ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled)
|
||||
|
||||
serveClientClosedRequest(t, ops, nil)
|
||||
|
||||
require.Zero(t, OpsErrorLogQueueLength(), "纯客户端取消的 499 按 IgnoreContextCanceled 跳过")
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_RecordsClientClosedAfterUpstreamFailure(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
serveClientClosedRequest(t, ops, func(c *gin.Context) {
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
AccountID: 7, UpstreamStatusCode: 524, Kind: "failover", Message: "upstream timeout",
|
||||
}})
|
||||
})
|
||||
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength(), "上游失败后客户端离开仍按上游失败落库")
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, statusClientClosedRequest, job.entry.StatusCode)
|
||||
require.Equal(t, "upstream", job.entry.ErrorPhase)
|
||||
require.NotNil(t, job.entry.UpstreamStatusCode)
|
||||
require.Equal(t, 524, *job.entry.UpstreamStatusCode)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_RecordsClientClosedWhenIgnoreContextCanceledDisabled(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 2)
|
||||
gin.SetMode(gin.TestMode)
|
||||
settings := &opsAdvancedSettingsRepoStub{advanced: `{"ignore_context_canceled":false}`}
|
||||
ops := service.NewOpsService(nil, settings, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.False(t, ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled)
|
||||
|
||||
serveClientClosedRequest(t, ops, nil)
|
||||
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, statusClientClosedRequest, job.entry.StatusCode)
|
||||
}
|
||||
|
||||
@@ -532,8 +532,8 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
||||
}
|
||||
parts = append(parts, part)
|
||||
|
||||
case "image":
|
||||
if block.Source != nil && block.Source.Type == "base64" {
|
||||
case "image", "document":
|
||||
if block.Source != nil && block.Source.Type == "base64" && strings.TrimSpace(block.Source.Data) != "" {
|
||||
parts = append(parts, GeminiPart{
|
||||
InlineData: &GeminiInlineData{
|
||||
MimeType: block.Source.MediaType,
|
||||
|
||||
@@ -646,6 +646,20 @@ func TestGeminiToolConfig_DropsBuiltinsWhenClientFunctionsPresent(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildParts_DocumentBecomesInlineData(t *testing.T) {
|
||||
content := `[
|
||||
{"type":"text","text":"read this"},
|
||||
{"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
|
||||
]`
|
||||
parts, stripped, err := buildParts(json.RawMessage(content), map[string]string{}, true)
|
||||
require.NoError(t, err)
|
||||
require.False(t, stripped)
|
||||
require.Len(t, parts, 2)
|
||||
require.NotNil(t, parts[1].InlineData)
|
||||
require.Equal(t, "application/pdf", parts[1].InlineData.MimeType)
|
||||
require.Equal(t, "JVBERi0=", parts[1].InlineData.Data)
|
||||
}
|
||||
|
||||
// TestToolConfigAlwaysPresent ensures toolConfig is always emitted, including for
|
||||
// reasoning models without any tools: upstream rejects requests that omit it.
|
||||
func TestToolConfigAlwaysPresent(t *testing.T) {
|
||||
|
||||
@@ -258,6 +258,18 @@ func cleanJSONSchemaRecursive(value any) any {
|
||||
if constVal, exists := schemaMap["const"]; exists {
|
||||
if _, hasEnum := schemaMap["enum"]; !hasEnum {
|
||||
schemaMap["enum"] = []any{constVal}
|
||||
} else if constant, ok := constVal.(string); ok {
|
||||
if existing, ok := schemaMap["enum"].([]any); ok {
|
||||
// Both constraints apply; preserve their intersection before dropping const.
|
||||
values := []any{}
|
||||
for _, value := range existing {
|
||||
if text, ok := value.(string); ok && text == constant {
|
||||
values = append(values, constant)
|
||||
break
|
||||
}
|
||||
}
|
||||
schemaMap["enum"] = values
|
||||
}
|
||||
}
|
||||
if _, hasType := schemaMap["type"]; !hasType {
|
||||
switch constVal.(type) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildToolsPreservesStringConst(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
schema string
|
||||
want string
|
||||
}{
|
||||
{"typed", `{"type":"string","const":"browser"}`, `{"type":"string","enum":["browser"]}`},
|
||||
{"inferred", `{"const":"browser"}`, `{"type":"string","enum":["browser"]}`},
|
||||
{"empty", `{"type":"string","const":""}`, `{"type":"string","enum":[""]}`},
|
||||
{"existing enum", `{"type":"string","const":"browser","enum":["browser","shell"]}`, `{"type":"string","enum":["browser"]}`},
|
||||
{"conflicting enum", `{"type":"string","const":"browser","enum":["shell"]}`, `{"type":"string","enum":[]}`},
|
||||
{"ordinary enum", `{"type":"string","enum":["browser","shell"]}`, `{"type":"string","enum":["browser","shell"]}`},
|
||||
{"array items", `{"type":"array","items":{"type":"string","const":"browser"}}`, `{"type":"array","items":{"type":"string","enum":["browser"]}}`},
|
||||
{"nested property named const", `{"type":"object","properties":{"const":{"const":"browser"}}}`, `{"type":"object","properties":{"const":{"type":"string","enum":["browser"]}}}`},
|
||||
{"schema metadata", `{"type":"string","const":"browser","$schema":"https://json-schema.org/draft/2020-12/schema","description":"Tool kind"}`, `{"type":"string","enum":["browser"],"description":"Tool kind"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var property map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.schema), &property))
|
||||
tools := buildTools([]ClaudeTool{{
|
||||
Name: "dispatch",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"action": property},
|
||||
},
|
||||
}})
|
||||
require.Len(t, tools, 1)
|
||||
require.Len(t, tools[0].FunctionDeclarations, 1)
|
||||
properties, ok := tools[0].FunctionDeclarations[0].Parameters["properties"].(map[string]any)
|
||||
require.True(t, ok, "tool parameters must contain a properties object")
|
||||
got, err := json.Marshal(properties["action"])
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, tc.want, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type StreamingProcessor struct {
|
||||
outputTokens int
|
||||
cacheReadTokens int
|
||||
imageOutputTokens int
|
||||
hasContent bool
|
||||
}
|
||||
|
||||
// NewStreamingProcessor 创建流式响应处理器
|
||||
@@ -175,6 +176,11 @@ func (p *StreamingProcessor) MessageStartSent() bool {
|
||||
return p.messageStartSent
|
||||
}
|
||||
|
||||
// HasContent reports whether any substantive text, thinking, or tool calls were emitted.
|
||||
func (p *StreamingProcessor) HasContent() bool {
|
||||
return p.hasContent
|
||||
}
|
||||
|
||||
// emitMessageStart 发送 message_start 事件
|
||||
func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte {
|
||||
if p.messageStartSent {
|
||||
@@ -296,6 +302,7 @@ func (p *StreamingProcessor) processThinking(text, signature string) []byte {
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
p.hasContent = true
|
||||
_, _ = result.Write(p.emitDelta("thinking_delta", map[string]any{
|
||||
"thinking": text,
|
||||
}))
|
||||
@@ -350,6 +357,7 @@ func (p *StreamingProcessor) processText(text, signature string) []byte {
|
||||
}))
|
||||
}
|
||||
|
||||
p.hasContent = true
|
||||
_, _ = result.Write(p.emitDelta("text_delta", map[string]any{
|
||||
"text": text,
|
||||
}))
|
||||
@@ -362,6 +370,7 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
|
||||
var result bytes.Buffer
|
||||
|
||||
p.usedTool = true
|
||||
p.hasContent = true
|
||||
|
||||
toolID := fc.ID
|
||||
if toolID == "" {
|
||||
|
||||
@@ -1136,9 +1136,24 @@ func TestAnthropicToResponses_ThinkingDisabled(t *testing.T) {
|
||||
|
||||
resp, err := AnthropicToResponses(req)
|
||||
require.NoError(t, err)
|
||||
// Default effort applies (medium) even when thinking is disabled.
|
||||
require.NotNil(t, resp.Reasoning)
|
||||
assert.Equal(t, "medium", resp.Reasoning.Effort)
|
||||
assert.Equal(t, "none", resp.Reasoning.Effort)
|
||||
assert.Empty(t, resp.Reasoning.Summary)
|
||||
}
|
||||
|
||||
func TestAnthropicToResponses_ThinkingDisabledOverridesOutputEffort(t *testing.T) {
|
||||
req := &AnthropicRequest{
|
||||
Model: "gpt-5.6-sol",
|
||||
MaxTokens: 1024,
|
||||
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
|
||||
Thinking: &AnthropicThinking{Type: "disabled"},
|
||||
OutputConfig: &AnthropicOutputConfig{Effort: "max"},
|
||||
}
|
||||
|
||||
resp, err := AnthropicToResponses(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Reasoning)
|
||||
assert.Equal(t, "none", resp.Reasoning.Effort)
|
||||
}
|
||||
|
||||
func TestAnthropicToResponses_NoThinking(t *testing.T) {
|
||||
|
||||
@@ -57,17 +57,13 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
|
||||
out.Tools = convertAnthropicToolsToResponses(req.Tools)
|
||||
}
|
||||
|
||||
// Determine reasoning effort: only output_config.effort controls the
|
||||
// level; thinking.type is ignored. Default follows Codex CLI / airgate's
|
||||
// Anthropic bridge shape, which uses medium when unset.
|
||||
// Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh.
|
||||
effort := "medium"
|
||||
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
|
||||
effort = req.OutputConfig.Effort
|
||||
}
|
||||
// An explicit thinking disable takes precedence over output_config.effort.
|
||||
effort := anthropicReasoningEffort(req)
|
||||
out.Reasoning = &ResponsesReasoning{
|
||||
Effort: mapAnthropicEffortToResponses(effort),
|
||||
Summary: "auto",
|
||||
Effort: effort,
|
||||
}
|
||||
if effort != "none" {
|
||||
out.Reasoning.Summary = "auto"
|
||||
}
|
||||
|
||||
// Convert tool_choice
|
||||
@@ -423,17 +419,22 @@ func extractAnthropicTextFromBlocks(blocks []AnthropicContentBlock) string {
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// mapAnthropicEffortToResponses converts Anthropic reasoning effort levels to
|
||||
// OpenAI Responses API effort levels.
|
||||
//
|
||||
// Both APIs default to "high". The mapping is 1:1 for shared levels;
|
||||
// only Anthropic's "max" (Opus 4.6 exclusive) maps to OpenAI's "xhigh"
|
||||
// (GPT-5.2+ exclusive) as both represent the highest reasoning tier.
|
||||
//
|
||||
// low → low
|
||||
// medium → medium
|
||||
// high → high
|
||||
// max → xhigh
|
||||
// anthropicReasoningEffort resolves the Anthropic request preference for both
|
||||
// OpenAI bridges. Explicitly disabled thinking overrides output_config.effort;
|
||||
// otherwise the bridge keeps its medium default.
|
||||
func anthropicReasoningEffort(req *AnthropicRequest) string {
|
||||
if req.Thinking != nil && req.Thinking.Type == "disabled" {
|
||||
return "none"
|
||||
}
|
||||
effort := "medium"
|
||||
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
|
||||
effort = req.OutputConfig.Effort
|
||||
}
|
||||
return mapAnthropicEffortToResponses(effort)
|
||||
}
|
||||
|
||||
// mapAnthropicEffortToResponses maps shared effort levels directly and maps
|
||||
// Anthropic's max to OpenAI's xhigh.
|
||||
func mapAnthropicEffortToResponses(effort string) string {
|
||||
if effort == "max" {
|
||||
return "xhigh"
|
||||
@@ -469,10 +470,38 @@ func boolPtr(v bool) *bool {
|
||||
|
||||
// isReasoningModel reports whether model is a reasoning model that does not
|
||||
// support sampling parameters (temperature, top_p) via the Responses API.
|
||||
// All gpt-5.x models are reasoning-only; the Responses API returns
|
||||
// "Unsupported parameter: temperature" if these fields are present.
|
||||
// GPT-5 and every later generation are reasoning-only; the Responses API
|
||||
// returns "Unsupported parameter: temperature" if these fields are present.
|
||||
//
|
||||
// Keyed on the generation number instead of a "gpt-5" prefix: pinning the
|
||||
// prefix meant each new family (gpt-6-astra and whatever follows) silently
|
||||
// fell through to the sampling branch and failed upstream on every compat
|
||||
// request until someone edited this line.
|
||||
func isReasoningModel(model string) bool {
|
||||
return strings.HasPrefix(model, "gpt-5") || openai.IsGPT6SolOrLunaModelSpelling(model)
|
||||
major, ok := openAIModelGeneration(model)
|
||||
return (ok && major >= 5) || openai.IsGPT6SolOrLunaModelSpelling(model)
|
||||
}
|
||||
|
||||
// openAIModelGeneration extracts N from a "gpt-N[.M][-suffix]" model id.
|
||||
// ok is false for non-GPT ids and for GPT families that carry no numeric
|
||||
// generation (gpt-image-1, gpt-audio, ...).
|
||||
func openAIModelGeneration(model string) (int, bool) {
|
||||
rest, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
major, digits := 0, 0
|
||||
for _, r := range rest {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
major = major*10 + int(r-'0')
|
||||
digits++
|
||||
}
|
||||
if digits == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return major, true
|
||||
}
|
||||
|
||||
// normalizeToolParameters ensures the tool parameter schema is valid for
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
@@ -198,6 +199,12 @@ type AnthropicEventToResponsesState struct {
|
||||
CurrentThinking AnthropicContentBlock
|
||||
PreserveThinkingSignatures bool
|
||||
|
||||
// PendingToolInput holds tool arguments that arrived complete on
|
||||
// content_block_start instead of as input_json_delta. It is only consumed at
|
||||
// content_block_stop, and only when no delta ever arrived, so a canonical
|
||||
// Anthropic stream keeps its exact event sequence.
|
||||
PendingToolInput string
|
||||
|
||||
// Outputs accumulates every closed output item so that response.completed
|
||||
// can carry the full output list. The OpenAI SDK's get_final_response()
|
||||
// parses the terminal event's response directly; without this, clients see
|
||||
@@ -382,6 +389,12 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi
|
||||
state.CurrentItemType = "function_call"
|
||||
state.CurrentCallID = toResponsesCallID(evt.ContentBlock.ID)
|
||||
state.CurrentName = evt.ContentBlock.Name
|
||||
// The canonical Anthropic stream leaves input empty here and streams the
|
||||
// arguments as input_json_delta, but Anthropic-compatible relays may put
|
||||
// the complete arguments on this event and never send a delta. Keep them
|
||||
// as a seed rather than emitting now: a delta, if one follows, is
|
||||
// authoritative and must not be concatenated onto this JSON.
|
||||
state.PendingToolInput = seedToolArguments(evt.ContentBlock.Input)
|
||||
|
||||
events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
|
||||
OutputIndex: state.OutputIndex,
|
||||
@@ -432,6 +445,9 @@ func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *Anthropi
|
||||
if evt.Delta.PartialJSON == "" {
|
||||
return nil
|
||||
}
|
||||
// A real delta supersedes whatever content_block_start carried; keeping
|
||||
// both would splice two complete JSON documents together.
|
||||
state.PendingToolInput = ""
|
||||
state.CurrentArgs += evt.Delta.PartialJSON
|
||||
return []ResponsesStreamEvent{makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
|
||||
OutputIndex: state.OutputIndex,
|
||||
@@ -467,21 +483,35 @@ func anthToResHandleContentBlockStop(evt *AnthropicStreamEvent, state *Anthropic
|
||||
return events
|
||||
|
||||
case "function_call":
|
||||
var events []ResponsesStreamEvent
|
||||
// No delta ever arrived, so the arguments the upstream put on
|
||||
// content_block_start are all there is. Emit them as one delta here so
|
||||
// the done event below still repeats exactly what the deltas streamed.
|
||||
if state.CurrentArgs == "" && state.PendingToolInput != "" {
|
||||
state.CurrentArgs = state.PendingToolInput
|
||||
events = append(events, makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
|
||||
OutputIndex: state.OutputIndex,
|
||||
Delta: state.PendingToolInput,
|
||||
ItemID: state.CurrentItemID,
|
||||
CallID: state.CurrentCallID,
|
||||
Name: state.CurrentName,
|
||||
}))
|
||||
}
|
||||
state.PendingToolInput = ""
|
||||
|
||||
// Emit function_call_arguments.done + output item done.
|
||||
// arguments must repeat exactly what the deltas already streamed for this
|
||||
// item: clients reconcile the done event against the accumulated
|
||||
// function_call_arguments.delta payloads and reject the call as
|
||||
// inconsistent_tool_call when the two disagree. Omitting the field left it
|
||||
// empty while the deltas carried the whole JSON.
|
||||
events := []ResponsesStreamEvent{
|
||||
makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
|
||||
OutputIndex: state.OutputIndex,
|
||||
ItemID: state.CurrentItemID,
|
||||
CallID: state.CurrentCallID,
|
||||
Name: state.CurrentName,
|
||||
Arguments: state.CurrentArgs,
|
||||
}),
|
||||
}
|
||||
events = append(events, makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
|
||||
OutputIndex: state.OutputIndex,
|
||||
ItemID: state.CurrentItemID,
|
||||
CallID: state.CurrentCallID,
|
||||
Name: state.CurrentName,
|
||||
Arguments: state.CurrentArgs,
|
||||
}))
|
||||
events = append(events, closeCurrentResponsesItem(state)...)
|
||||
return events
|
||||
|
||||
@@ -562,6 +592,19 @@ func anthropicResponsesStreamTerminalState(stopReason string) (string, *Response
|
||||
return "completed", nil
|
||||
}
|
||||
|
||||
// seedToolArguments normalizes a tool_use content block's inline input into a
|
||||
// seed for the streaming converter. Empty, absent and no-argument payloads
|
||||
// return "" so the existing "{}" fallback still applies and no empty delta is
|
||||
// synthesized.
|
||||
func seedToolArguments(input json.RawMessage) string {
|
||||
trimmed := strings.TrimSpace(string(input))
|
||||
switch trimmed {
|
||||
case "", "{}", "null":
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
|
||||
if state.CurrentItemType == "" {
|
||||
return nil
|
||||
@@ -605,6 +648,7 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response
|
||||
state.CurrentName = ""
|
||||
state.CurrentContent = nil
|
||||
state.CurrentArgs = ""
|
||||
state.PendingToolInput = ""
|
||||
state.CurrentSummary = ""
|
||||
state.CurrentThinking = AnthropicContentBlock{}
|
||||
state.TextAccum = ""
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// collectToolCallStreamEvents drives a tool_use block through the Anthropic →
|
||||
// Responses stream converter and returns every emitted event in order.
|
||||
func collectToolCallStreamEvents(t *testing.T, blockInput json.RawMessage, partials []string) []ResponsesStreamEvent {
|
||||
t.Helper()
|
||||
|
||||
state := NewAnthropicEventToResponsesState()
|
||||
var all []ResponsesStreamEvent
|
||||
|
||||
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
|
||||
Type: "message_start",
|
||||
Message: &AnthropicResponse{
|
||||
ID: "msg_tool_input",
|
||||
Model: "gemini-3.7-flash",
|
||||
Usage: AnthropicUsage{InputTokens: 12},
|
||||
},
|
||||
}, state)...)
|
||||
|
||||
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &AnthropicContentBlock{
|
||||
Type: "tool_use",
|
||||
ID: "toolu_01abc",
|
||||
Name: "eval",
|
||||
Input: blockInput,
|
||||
},
|
||||
}, state)...)
|
||||
|
||||
for _, partial := range partials {
|
||||
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
|
||||
Type: "content_block_delta",
|
||||
Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: partial},
|
||||
}, state)...)
|
||||
}
|
||||
|
||||
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "content_block_stop"}, state)...)
|
||||
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
|
||||
Type: "message_delta",
|
||||
Usage: &AnthropicUsage{OutputTokens: 9},
|
||||
}, state)...)
|
||||
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state)...)
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
func concatArgumentDeltas(events []ResponsesStreamEvent) string {
|
||||
out := ""
|
||||
for _, e := range events {
|
||||
if e.Type == "response.function_call_arguments.delta" {
|
||||
out += e.Delta
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findFunctionCallOutput(events []ResponsesStreamEvent) *ResponsesOutput {
|
||||
for i := range events {
|
||||
if events[i].Type != "response.output_item.done" || events[i].Item == nil {
|
||||
continue
|
||||
}
|
||||
if events[i].Item.Type == "function_call" {
|
||||
return events[i].Item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upstreams that are not the canonical Anthropic API (here: an Anthropic-compatible
|
||||
// relay fronting Gemini) put the complete tool arguments on content_block_start and
|
||||
// never emit an input_json_delta. The converter must not drop them: this repository
|
||||
// already reads ContentBlock.Input on the non-streaming path
|
||||
// (anthropicResponseToResponsesOutputs) and on the ChatCompletions bridge, so the
|
||||
// streaming path has to agree. Dropping it makes every client-side tool call arrive
|
||||
// with `{}` and the agent loop cannot proceed.
|
||||
func TestAnthropicEventToResponses_ToolInputOnContentBlockStart(t *testing.T) {
|
||||
const args = `{"language":"py","code":"print(1)"}`
|
||||
|
||||
events := collectToolCallStreamEvents(t, json.RawMessage(args), nil)
|
||||
|
||||
item := findFunctionCallOutput(events)
|
||||
require.NotNil(t, item, "a function_call output item must be emitted")
|
||||
assert.Equal(t, args, item.Arguments,
|
||||
"arguments carried on content_block_start must survive to output_item.done")
|
||||
assert.Equal(t, "eval", item.Name)
|
||||
|
||||
// Clients that accumulate from the event stream (rather than reading the
|
||||
// terminal item) must see the arguments too.
|
||||
assert.Equal(t, args, concatArgumentDeltas(events),
|
||||
"argument deltas must reconstruct the full arguments JSON")
|
||||
|
||||
done := findEvent(events, "response.function_call_arguments.done")
|
||||
require.NotNil(t, done, "function_call_arguments.done must be emitted")
|
||||
assert.Equal(t, args, done.Arguments,
|
||||
"function_call_arguments.done must carry the complete arguments")
|
||||
|
||||
completed := findEvent(events, "response.completed")
|
||||
require.NotNil(t, completed)
|
||||
require.NotNil(t, completed.Response)
|
||||
require.Len(t, completed.Response.Output, 1)
|
||||
assert.Equal(t, args, completed.Response.Output[0].Arguments,
|
||||
"response.completed must carry the same arguments")
|
||||
}
|
||||
|
||||
// The canonical Anthropic shape (empty input on content_block_start, arguments
|
||||
// streamed as input_json_delta) must keep its existing event sequence exactly.
|
||||
func TestAnthropicEventToResponses_ToolInputFromDeltasUnchanged(t *testing.T) {
|
||||
const args = `{"language":"py","code":"print(1)"}`
|
||||
|
||||
events := collectToolCallStreamEvents(t, json.RawMessage(`{}`),
|
||||
[]string{`{"language":"py",`, `"code":"print(1)"}`})
|
||||
|
||||
item := findFunctionCallOutput(events)
|
||||
require.NotNil(t, item)
|
||||
assert.Equal(t, args, item.Arguments)
|
||||
|
||||
// Exactly the two upstream deltas, not duplicated by the seed.
|
||||
var deltas []string
|
||||
for _, e := range events {
|
||||
if e.Type == "response.function_call_arguments.delta" {
|
||||
deltas = append(deltas, e.Delta)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []string{`{"language":"py",`, `"code":"print(1)"}`}, deltas,
|
||||
"canonical delta streaming must not gain or lose events")
|
||||
}
|
||||
|
||||
// An upstream that sends both a populated content_block_start and real deltas
|
||||
// must not have the two concatenated into malformed JSON.
|
||||
func TestAnthropicEventToResponses_ToolInputSeedNotDuplicatedByDeltas(t *testing.T) {
|
||||
const args = `{"language":"py","code":"print(1)"}`
|
||||
|
||||
events := collectToolCallStreamEvents(t, json.RawMessage(args),
|
||||
[]string{`{"language":"py",`, `"code":"print(1)"}`})
|
||||
|
||||
item := findFunctionCallOutput(events)
|
||||
require.NotNil(t, item)
|
||||
assert.Equal(t, args, item.Arguments, "deltas win over the content_block_start seed")
|
||||
assert.True(t, json.Valid([]byte(item.Arguments)), "arguments must stay valid JSON")
|
||||
assert.Equal(t, args, concatArgumentDeltas(events), "seed must not be replayed alongside deltas")
|
||||
}
|
||||
|
||||
// An empty or absent input on content_block_start with no deltas keeps the
|
||||
// existing "{}" fallback rather than emitting a spurious delta.
|
||||
func TestAnthropicEventToResponses_ToolInputEmptyStaysEmptyObject(t *testing.T) {
|
||||
for name, input := range map[string]json.RawMessage{
|
||||
"absent": nil,
|
||||
"empty": json.RawMessage(`{}`),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
events := collectToolCallStreamEvents(t, input, nil)
|
||||
|
||||
item := findFunctionCallOutput(events)
|
||||
require.NotNil(t, item)
|
||||
assert.Equal(t, "{}", item.Arguments)
|
||||
assert.Empty(t, concatArgumentDeltas(events),
|
||||
"no argument delta should be synthesized when there are no arguments")
|
||||
})
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// driveToolUseThroughRestorer runs a tool_use block through the full outbound
|
||||
// chain the gateway actually uses: the Anthropic→Responses stream converter
|
||||
// feeding ResponsesClientToolStreamRestorer. Returns the events a client sees.
|
||||
func driveToolUseThroughRestorer(
|
||||
blockInput json.RawMessage,
|
||||
partials []string,
|
||||
mapping ResponsesClientToolMapping,
|
||||
) []ResponsesStreamEvent {
|
||||
state := NewAnthropicEventToResponsesState()
|
||||
restorer := NewResponsesClientToolStreamRestorer(mapping)
|
||||
|
||||
var clientEvents []ResponsesStreamEvent
|
||||
feed := func(evt *AnthropicStreamEvent) {
|
||||
for _, converted := range AnthropicEventToResponsesEvents(evt, state) {
|
||||
clientEvents = append(clientEvents, restorer.Restore(converted)...)
|
||||
}
|
||||
}
|
||||
|
||||
feed(&AnthropicStreamEvent{
|
||||
Type: "message_start",
|
||||
Message: &AnthropicResponse{
|
||||
ID: "msg_chain",
|
||||
Model: "gemini-3.7-flash",
|
||||
Usage: AnthropicUsage{InputTokens: 11},
|
||||
},
|
||||
})
|
||||
feed(&AnthropicStreamEvent{
|
||||
Type: "content_block_start",
|
||||
ContentBlock: &AnthropicContentBlock{
|
||||
Type: "tool_use",
|
||||
ID: "toolu_chain",
|
||||
Name: "eval",
|
||||
Input: blockInput,
|
||||
},
|
||||
})
|
||||
for _, partial := range partials {
|
||||
feed(&AnthropicStreamEvent{
|
||||
Type: "content_block_delta",
|
||||
Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: partial},
|
||||
})
|
||||
}
|
||||
feed(&AnthropicStreamEvent{Type: "content_block_stop"})
|
||||
feed(&AnthropicStreamEvent{Type: "message_delta", Usage: &AnthropicUsage{OutputTokens: 6}})
|
||||
feed(&AnthropicStreamEvent{Type: "message_stop"})
|
||||
|
||||
return clientEvents
|
||||
}
|
||||
|
||||
func firstEventOfType(events []ResponsesStreamEvent, typ string) *ResponsesStreamEvent {
|
||||
for i := range events {
|
||||
if events[i].Type == typ {
|
||||
return &events[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A client-side custom tool whose arguments arrived only on content_block_start
|
||||
// must still reach the client as a populated custom_tool_call. The restorer
|
||||
// suppresses function_call_arguments.delta for adapted tools and rebuilds the
|
||||
// input from the accumulated arguments, so a converter that drops the inline
|
||||
// input yields an empty tool call even though nothing errors.
|
||||
func TestToolInputOnContentBlockStart_SurvivesClientToolRestore(t *testing.T) {
|
||||
mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"eval": true}}
|
||||
|
||||
events := driveToolUseThroughRestorer(
|
||||
json.RawMessage(`{"input":"print(1)"}`), nil, mapping)
|
||||
|
||||
inputDone := firstEventOfType(events, "response.custom_tool_call_input.done")
|
||||
require.NotNil(t, inputDone, "custom_tool_call_input.done must be emitted")
|
||||
assert.Equal(t, "print(1)", inputDone.Input,
|
||||
"inline tool input must survive conversion and client-tool restoration")
|
||||
|
||||
var itemDone *ResponsesStreamEvent
|
||||
for i := range events {
|
||||
if events[i].Type == "response.output_item.done" && events[i].Item != nil &&
|
||||
events[i].Item.Type == "custom_tool_call" {
|
||||
itemDone = &events[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, itemDone, "a custom_tool_call item must be closed")
|
||||
assert.Equal(t, "print(1)", itemDone.Item.Input)
|
||||
}
|
||||
|
||||
// The same block delivered as a plain (non-adapted) function tool must carry
|
||||
// its arguments through untouched.
|
||||
func TestToolInputOnContentBlockStart_PlainFunctionToolChain(t *testing.T) {
|
||||
const args = `{"language":"py","code":"print(1)"}`
|
||||
|
||||
events := driveToolUseThroughRestorer(
|
||||
json.RawMessage(args), nil, ResponsesClientToolMapping{})
|
||||
|
||||
done := firstEventOfType(events, "response.function_call_arguments.done")
|
||||
require.NotNil(t, done)
|
||||
assert.Equal(t, args, done.Arguments)
|
||||
|
||||
var itemDone *ResponsesStreamEvent
|
||||
for i := range events {
|
||||
if events[i].Type == "response.output_item.done" && events[i].Item != nil &&
|
||||
events[i].Item.Type == "function_call" {
|
||||
itemDone = &events[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, itemDone)
|
||||
assert.Equal(t, args, itemDone.Item.Arguments)
|
||||
}
|
||||
|
||||
// Canonical delta streaming through the same chain must keep working, so the
|
||||
// seed path cannot be mistaken for the only source of arguments.
|
||||
func TestToolInputFromDeltas_SurvivesClientToolRestore(t *testing.T) {
|
||||
mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"eval": true}}
|
||||
|
||||
events := driveToolUseThroughRestorer(
|
||||
json.RawMessage(`{}`),
|
||||
[]string{`{"input":"pri`, `nt(1)"}`},
|
||||
mapping)
|
||||
|
||||
inputDone := firstEventOfType(events, "response.custom_tool_call_input.done")
|
||||
require.NotNil(t, inputDone)
|
||||
assert.Equal(t, "print(1)", inputDone.Input)
|
||||
}
|
||||
@@ -99,13 +99,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning effort: output_config.effort maps 1:1 (max→xhigh). thinking.type
|
||||
// itself is ignored (the Responses bridge behaves identically).
|
||||
effort := "medium"
|
||||
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
|
||||
effort = req.OutputConfig.Effort
|
||||
}
|
||||
out.ReasoningEffort = mapAnthropicEffortToResponses(effort)
|
||||
// Match the Responses bridge, including an explicit thinking disable.
|
||||
out.ReasoningEffort = anthropicReasoningEffort(req)
|
||||
|
||||
parallelToolCalls := true
|
||||
out.ParallelToolCalls = ¶llelToolCalls
|
||||
|
||||
@@ -150,6 +150,20 @@ func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) {
|
||||
require.Empty(t, out.Messages[0].ReasoningContent)
|
||||
}
|
||||
|
||||
func TestAnthropicToChatCompletionsRequest_ThinkingDisabledOverridesOutputEffort(t *testing.T) {
|
||||
req := &AnthropicRequest{
|
||||
Model: "gpt-5.6-luna",
|
||||
MaxTokens: 1024,
|
||||
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
|
||||
Thinking: &AnthropicThinking{Type: "disabled"},
|
||||
OutputConfig: &AnthropicOutputConfig{Effort: "max"},
|
||||
}
|
||||
|
||||
out, err := AnthropicToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "none", out.ReasoningEffort)
|
||||
}
|
||||
|
||||
func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) {
|
||||
req := &AnthropicRequest{
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
|
||||
@@ -92,6 +92,56 @@ func TestChatCompletionsToResponses_SystemMessage(t *testing.T) {
|
||||
assert.Equal(t, "user", items[1].Role)
|
||||
}
|
||||
|
||||
func TestChatCompletionsToResponses_MessageTypesWithReasoningAndTools(t *testing.T) {
|
||||
req := &ChatCompletionsRequest{
|
||||
Model: "step-5-preview",
|
||||
Messages: []ChatMessage{
|
||||
{Role: "system", Content: json.RawMessage(`"You are helpful."`)},
|
||||
{Role: "user", Content: json.RawMessage(`"Check the directory"`)},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: json.RawMessage(`"I will check."`),
|
||||
ReasoningContent: "Need to inspect the directory.",
|
||||
ToolCalls: []ChatToolCall{{
|
||||
ID: "call_1", Type: "function",
|
||||
Function: ChatFunctionCall{Name: "bash", Arguments: `{"cmd":"pwd"}`},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_1", Content: json.RawMessage(`"/tmp"`)},
|
||||
{Role: "assistant", ReasoningContent: "The tool returned /tmp."},
|
||||
{Role: "user", Content: json.RawMessage(`"Continue"`)},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ChatCompletionsToResponses(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var items []ResponsesInputItem
|
||||
require.NoError(t, json.Unmarshal(resp.Input, &items))
|
||||
require.Len(t, items, 7)
|
||||
for i, want := range []struct{ typ, role string }{
|
||||
{"message", "system"},
|
||||
{"message", "user"},
|
||||
{"message", "assistant"},
|
||||
{"function_call", ""},
|
||||
{"function_call_output", ""},
|
||||
{"message", "assistant"},
|
||||
{"message", "user"},
|
||||
} {
|
||||
assert.Equal(t, want.typ, items[i].Type, "input item %d type", i)
|
||||
assert.Equal(t, want.role, items[i].Role, "input item %d role", i)
|
||||
}
|
||||
var assistantContent, reasoningOnlyContent []ResponsesContentPart
|
||||
require.NoError(t, json.Unmarshal(items[2].Content, &assistantContent))
|
||||
require.NoError(t, json.Unmarshal(items[5].Content, &reasoningOnlyContent))
|
||||
require.Len(t, assistantContent, 1)
|
||||
require.Len(t, reasoningOnlyContent, 1)
|
||||
assert.Equal(t, "<thinking>Need to inspect the directory.</thinking>\nI will check.", assistantContent[0].Text)
|
||||
assert.Equal(t, "<thinking>The tool returned /tmp.</thinking>", reasoningOnlyContent[0].Text)
|
||||
assert.Equal(t, "call_1", items[3].CallID)
|
||||
assert.Equal(t, "call_1", items[4].CallID)
|
||||
}
|
||||
|
||||
func TestChatCompletionsToResponses_ToolCalls(t *testing.T) {
|
||||
req := &ChatCompletionsRequest{
|
||||
Model: "gpt-4o",
|
||||
|
||||
@@ -143,7 +143,7 @@ func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []ResponsesInputItem{{Role: "system", Content: content}}, nil
|
||||
return []ResponsesInputItem{{Type: "message", Role: "system", Content: content}}, nil
|
||||
}
|
||||
|
||||
// chatUserToResponses converts a user message, handling both plain strings and
|
||||
@@ -157,7 +157,7 @@ func chatUserToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []ResponsesInputItem{{Role: "user", Content: content}}, nil
|
||||
return []ResponsesInputItem{{Type: "message", Role: "user", Content: content}}, nil
|
||||
}
|
||||
|
||||
// chatAssistantToResponses converts an assistant message. If there is both
|
||||
@@ -192,7 +192,7 @@ func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON})
|
||||
items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON})
|
||||
}
|
||||
|
||||
// Emit one function_call item per tool_call.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// GPT-5 and every later generation are reasoning-only: forwarding temperature or
|
||||
// top_p makes the Responses API reject the request with "Unsupported parameter".
|
||||
// The check is keyed on the generation number so a new family (gpt-6-astra and
|
||||
// whatever follows) does not silently fall into the sampling branch.
|
||||
func TestIsReasoningModelCoversLaterGenerations(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"gpt-6-astra", true},
|
||||
{"gpt-6", true},
|
||||
{"gpt-7-whatever", true},
|
||||
{"gpt-6-sol", true},
|
||||
{"gpt-5.5", true},
|
||||
{"gpt-5.2", true},
|
||||
{"gpt-5", true},
|
||||
{"GPT-6-Astra", true},
|
||||
{" gpt-6-astra ", true},
|
||||
{"gpt-4o", false},
|
||||
{"gpt-4.1", false},
|
||||
{"gpt-image-1", false},
|
||||
{"gpt-audio", false},
|
||||
{"claude-opus-4-6", false},
|
||||
{"gemini-3.1-pro", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := isReasoningModel(tc.model); got != tc.want {
|
||||
t.Errorf("isReasoningModel(%q) = %v, want %v", tc.model, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIModelGeneration(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
model string
|
||||
wantMajor int
|
||||
wantOK bool
|
||||
}{
|
||||
{"gpt-6-astra", 6, true},
|
||||
{"gpt-5.5", 5, true},
|
||||
{"gpt-4o", 4, true},
|
||||
{"gpt-10-future", 10, true},
|
||||
{"gpt-image-1", 0, false},
|
||||
{"claude-opus-4-6", 0, false},
|
||||
{"", 0, false},
|
||||
} {
|
||||
major, ok := openAIModelGeneration(tc.model)
|
||||
if major != tc.wantMajor || ok != tc.wantOK {
|
||||
t.Errorf("openAIModelGeneration(%q) = (%d, %v), want (%d, %v)",
|
||||
tc.model, major, ok, tc.wantMajor, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicToResponses_TemperatureStrippedForGPT6Astra(t *testing.T) {
|
||||
temp := 0.7
|
||||
req := &AnthropicRequest{
|
||||
Model: "gpt-6-astra",
|
||||
MaxTokens: 1024,
|
||||
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
|
||||
Temperature: &temp,
|
||||
TopP: &temp,
|
||||
}
|
||||
|
||||
resp, err := AnthropicToResponses(req)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resp.Temperature, "gpt-6-astra is reasoning-only: temperature must be stripped")
|
||||
require.Nil(t, resp.TopP, "gpt-6-astra is reasoning-only: top_p must be stripped")
|
||||
}
|
||||
@@ -109,7 +109,23 @@ func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T)
|
||||
require.NotContains(t, string(messages[0].Content), "drop me")
|
||||
}
|
||||
|
||||
// user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。
|
||||
// data URI 形式的 input_file 要变成 Anthropic document,供后续 Gemini inlineData 使用。
|
||||
func TestResponsesToAnthropic_InputFileDataURIBecomesDocument(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[
|
||||
{"type":"input_text","text":"read this"},
|
||||
{"type":"input_file","filename":"token.pdf","file_data":"data:application/pdf;base64,JVBERi0="}
|
||||
]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.Contains(t, string(messages[0].Content), `"type":"document"`)
|
||||
require.Contains(t, string(messages[0].Content), `"media_type":"application/pdf"`)
|
||||
require.Contains(t, string(messages[0].Content), `"data":"JVBERi0="`)
|
||||
}
|
||||
|
||||
// 只有 file_id、没有 data URI 的 input_file 仍然无法转换,整条消息丢掉。
|
||||
func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]}
|
||||
|
||||
@@ -530,6 +530,14 @@ func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessag
|
||||
Source: src,
|
||||
})
|
||||
}
|
||||
case "input_file":
|
||||
src := dataURIToAnthropicFileSource(p.FileData)
|
||||
if src != nil {
|
||||
blocks = append(blocks, AnthropicContentBlock{
|
||||
Type: "document",
|
||||
Source: src,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,6 +625,12 @@ func dataURIToAnthropicImageSource(dataURI string) *AnthropicImageSource {
|
||||
}
|
||||
}
|
||||
|
||||
// dataURIToAnthropicFileSource parses a data URI into a document source.
|
||||
// file_id-only parts are not convertible here and stay dropped.
|
||||
func dataURIToAnthropicFileSource(fileData string) *AnthropicImageSource {
|
||||
return dataURIToAnthropicImageSource(fileData)
|
||||
}
|
||||
|
||||
// mergeConsecutiveMessages merges consecutive messages with the same role
|
||||
// because Anthropic requires alternating user/assistant turns.
|
||||
func mergeConsecutiveMessages(messages []AnthropicMessage) []AnthropicMessage {
|
||||
|
||||
@@ -609,6 +609,24 @@ func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesRe
|
||||
return
|
||||
}
|
||||
|
||||
// The terminal event can carry a non-empty output array whose message has
|
||||
// no usable text. Trusting it as-is silently drops the text that already
|
||||
// streamed: the client gets an empty reply while usage still bills the
|
||||
// terminal output_tokens. Refill it from the accumulated deltas; non-empty
|
||||
// terminal text stays authoritative.
|
||||
if a.text.Len() > 0 && !responsesOutputHasText(resp.Output) {
|
||||
if !fillResponsesOutputText(resp.Output, a.text.String()) {
|
||||
resp.Output = append(resp.Output, ResponsesOutput{
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []ResponsesContentPart{{
|
||||
Type: "output_text",
|
||||
Text: a.text.String(),
|
||||
}},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for outputIndex := range resp.Output {
|
||||
item := &resp.Output[outputIndex]
|
||||
if item.Type != "function_call" || item.Arguments != "" {
|
||||
@@ -627,3 +645,46 @@ func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesRe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// responsesOutputHasText reports whether the terminal output already carries
|
||||
// usable message text. Whitespace-only text does not count.
|
||||
func responsesOutputHasText(output []ResponsesOutput) bool {
|
||||
for i := range output {
|
||||
if output[i].Type != "message" {
|
||||
continue
|
||||
}
|
||||
for _, part := range output[i].Content {
|
||||
if part.Type == "output_text" && strings.TrimSpace(part.Text) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillResponsesOutputText writes text into the first empty output_text part of
|
||||
// the first message item, adding an output_text part when that message has
|
||||
// none. It returns false when the output holds no message item, leaving the
|
||||
// caller to append one.
|
||||
func fillResponsesOutputText(output []ResponsesOutput, text string) bool {
|
||||
for i := range output {
|
||||
if output[i].Type != "message" {
|
||||
continue
|
||||
}
|
||||
for j := range output[i].Content {
|
||||
if output[i].Content[j].Type != "output_text" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(output[i].Content[j].Text) == "" {
|
||||
output[i].Content[j].Text = text
|
||||
return true
|
||||
}
|
||||
}
|
||||
output[i].Content = append(output[i].Content, ResponsesContentPart{
|
||||
Type: "output_text",
|
||||
Text: text,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// When the terminal event carries a non-empty output array whose message has
|
||||
// no text, the text that already streamed must be restored. Otherwise the
|
||||
// client gets an empty reply while usage still bills the terminal
|
||||
// output_tokens. The accumulator previously rebuilt output only when the
|
||||
// terminal output array was empty, so this upstream shape silently lost text.
|
||||
func TestSupplementResponseOutput_RecoversTextWhenTerminalMessageIsEmpty(t *testing.T) {
|
||||
acc := NewBufferedResponseAccumulator()
|
||||
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello, "})
|
||||
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "world"})
|
||||
|
||||
resp := &ResponsesResponse{
|
||||
ID: "resp_empty_msg",
|
||||
Status: "completed",
|
||||
Output: []ResponsesOutput{{
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []ResponsesContentPart{},
|
||||
}},
|
||||
Usage: &ResponsesUsage{InputTokens: 13168, OutputTokens: 3329},
|
||||
}
|
||||
|
||||
acc.SupplementResponseOutput(resp)
|
||||
|
||||
chat := ResponsesToChatCompletions(resp, "gpt-5.5")
|
||||
require.Len(t, chat.Choices, 1)
|
||||
require.NotNil(t, chat.Choices[0].Message.Content, "the client must not receive an empty reply")
|
||||
|
||||
var got string
|
||||
require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &got))
|
||||
assert.Equal(t, "Hello, world", got)
|
||||
}
|
||||
|
||||
// Whitespace-only terminal text counts as missing too.
|
||||
func TestSupplementResponseOutput_RecoversTextWhenTerminalTextIsBlank(t *testing.T) {
|
||||
acc := NewBufferedResponseAccumulator()
|
||||
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "real text"})
|
||||
|
||||
resp := &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: []ResponsesOutput{{
|
||||
Type: "message",
|
||||
Content: []ResponsesContentPart{{Type: "output_text", Text: " "}},
|
||||
}},
|
||||
}
|
||||
|
||||
acc.SupplementResponseOutput(resp)
|
||||
|
||||
chat := ResponsesToChatCompletions(resp, "m")
|
||||
var got string
|
||||
require.NotNil(t, chat.Choices[0].Message.Content)
|
||||
require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &got))
|
||||
assert.Equal(t, "real text", got)
|
||||
}
|
||||
|
||||
// A terminal output array without any message item gets one appended to carry
|
||||
// the accumulated text; existing items are kept.
|
||||
func TestSupplementResponseOutput_AppendsMessageWhenTerminalHasNoMessage(t *testing.T) {
|
||||
acc := NewBufferedResponseAccumulator()
|
||||
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "only in the stream"})
|
||||
|
||||
resp := &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: []ResponsesOutput{{
|
||||
Type: "function_call",
|
||||
CallID: "call_1",
|
||||
Name: "verify",
|
||||
}},
|
||||
}
|
||||
|
||||
acc.SupplementResponseOutput(resp)
|
||||
|
||||
var text string
|
||||
for _, item := range resp.Output {
|
||||
if item.Type != "message" {
|
||||
continue
|
||||
}
|
||||
for _, p := range item.Content {
|
||||
text += p.Text
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "only in the stream", text)
|
||||
|
||||
require.Len(t, resp.Output, 2)
|
||||
assert.Equal(t, "function_call", resp.Output[0].Type)
|
||||
assert.Equal(t, "verify", resp.Output[0].Name)
|
||||
}
|
||||
|
||||
// Non-empty terminal text stays authoritative and is never overwritten by the
|
||||
// accumulated stream.
|
||||
func TestSupplementResponseOutput_KeepsTerminalTextAuthoritative(t *testing.T) {
|
||||
acc := NewBufferedResponseAccumulator()
|
||||
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "from the stream"})
|
||||
|
||||
resp := &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: []ResponsesOutput{{
|
||||
Type: "message",
|
||||
Content: []ResponsesContentPart{{Type: "output_text", Text: "from the terminal event"}},
|
||||
}},
|
||||
}
|
||||
|
||||
acc.SupplementResponseOutput(resp)
|
||||
|
||||
require.Len(t, resp.Output, 1)
|
||||
assert.Equal(t, "from the terminal event", resp.Output[0].Content[0].Text)
|
||||
}
|
||||
|
||||
// Without streamed text no synthetic message is created.
|
||||
func TestSupplementResponseOutput_NoSyntheticMessageWithoutStreamText(t *testing.T) {
|
||||
acc := NewBufferedResponseAccumulator()
|
||||
|
||||
resp := &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: []ResponsesOutput{{
|
||||
Type: "message",
|
||||
Content: []ResponsesContentPart{},
|
||||
}},
|
||||
}
|
||||
|
||||
acc.SupplementResponseOutput(resp)
|
||||
|
||||
require.Len(t, resp.Output, 1)
|
||||
assert.Empty(t, resp.Output[0].Content)
|
||||
}
|
||||
@@ -263,7 +263,7 @@ type ResponsesText struct {
|
||||
// The Type field determines which other fields are populated.
|
||||
type ResponsesInputItem struct {
|
||||
// Common
|
||||
Type string `json:"type,omitempty"` // "" for role-based messages
|
||||
Type string `json:"type,omitempty"` // "message" for role-based messages
|
||||
|
||||
// Role-based messages (developer/system/user/assistant)
|
||||
Role string `json:"role,omitempty"`
|
||||
|
||||
@@ -18,6 +18,8 @@ const (
|
||||
BetaTokenCounting = "token-counting-2024-11-01"
|
||||
BetaContext1M = "context-1m-2025-08-07"
|
||||
BetaFastMode = "fast-mode-2026-02-01"
|
||||
// Legacy structured output compatibility; forwarded only when explicitly requested.
|
||||
BetaStructuredOutputs = "structured-outputs-2025-11-13"
|
||||
|
||||
// 新增(对齐官方 CLI 2.1.9x 以来的流量)
|
||||
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
|
||||
|
||||
@@ -53,6 +53,11 @@ func normalizeEffortModelID(model string) string {
|
||||
}
|
||||
id = strings.TrimPrefix(id, "anthropic.")
|
||||
id = strings.TrimSuffix(id, "-thinking")
|
||||
// OpenRouter uses a dotted minor version for this exact Opus 5.5 ID.
|
||||
// Normalize it before effort, thinking, and billing family lookups.
|
||||
if id == "claude-opus-5.5" {
|
||||
id = "claude-opus-5-5"
|
||||
}
|
||||
if mapped, ok := ModelIDReverseOverrides[id]; ok {
|
||||
id = mapped
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestEffortLevelsForModel(t *testing.T) {
|
||||
{model: "claude-opus-4-6", want: []string{"low", "medium", "high", "max"}},
|
||||
{model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}},
|
||||
{model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
|
||||
{model: "anthropic/claude-opus-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}},
|
||||
{model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}},
|
||||
{model: "claude-haiku-4-5-20251001", want: nil},
|
||||
{model: "gpt-5.6", want: nil},
|
||||
@@ -27,3 +28,13 @@ func TestEffortLevelsForModel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOpus55OpenRouterExactAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, model := range []string{"claude-opus-5-5", "anthropic/claude-opus-5.5"} {
|
||||
require.True(t, IsOpus55(model), model)
|
||||
}
|
||||
for _, model := range []string{"claude-opus-5", "anthropic/claude-opus-5.6", "anthropic/claude-opus-5.5-preview"} {
|
||||
require.False(t, IsOpus55(model), model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ func HTTPStatusToGoogleStatus(status int) string {
|
||||
return "NOT_FOUND"
|
||||
case http.StatusTooManyRequests:
|
||||
return "RESOURCE_EXHAUSTED"
|
||||
case 499: // client closed request
|
||||
return "CANCELLED"
|
||||
default:
|
||||
if status >= 500 {
|
||||
return "INTERNAL"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package googleapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPStatusToGoogleStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{http.StatusBadRequest, "INVALID_ARGUMENT"},
|
||||
{http.StatusUnauthorized, "UNAUTHENTICATED"},
|
||||
{http.StatusForbidden, "PERMISSION_DENIED"},
|
||||
{http.StatusNotFound, "NOT_FOUND"},
|
||||
{http.StatusTooManyRequests, "RESOURCE_EXHAUSTED"},
|
||||
{499, "CANCELLED"},
|
||||
{http.StatusBadGateway, "INTERNAL"},
|
||||
{http.StatusServiceUnavailable, "INTERNAL"},
|
||||
{http.StatusConflict, "UNKNOWN"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := HTTPStatusToGoogleStatus(tc.status); got != tc.want {
|
||||
t.Errorf("HTTPStatusToGoogleStatus(%d) = %q, want %q", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1034,6 +1034,13 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
|
||||
"auto_pause_7d_threshold",
|
||||
"auto_pause_5h_disabled",
|
||||
"auto_pause_7d_disabled",
|
||||
// 自动用卡:卡可用的 OpenAI 号在暂停阈值与用卡阈值之间继续调度。
|
||||
// 候选过滤读的是本投影,缺这几个键时放行分支永远不会生效,
|
||||
// 账号会在暂停阈值处被一刀切停调,直到窗口自然重置。
|
||||
service.OpenAIAutoResetCreditEnabledExtraKey,
|
||||
service.OpenAIAutoResetCredit5hThresholdExtraKey,
|
||||
service.OpenAIAutoResetCredit7dThresholdExtraKey,
|
||||
service.OpenAIAutoResetCreditStateExtraKey,
|
||||
"model_rate_limits",
|
||||
service.UpstreamBillingProbeExtraKey,
|
||||
service.GrokMediaEligibleExtraKey,
|
||||
|
||||
@@ -446,6 +446,47 @@ func TestBuildSchedulerMetadataAccount_KeepsQuotaAutoPauseFields(t *testing.T) {
|
||||
require.Equal(t, false, got.Extra["auto_pause_7d_disabled"])
|
||||
}
|
||||
|
||||
// 候选过滤读的是 Redis 元数据投影;自动用卡的开关、阈值和卡状态缺失时,
|
||||
// 卡可用的 OpenAI 号会在暂停阈值处被一刀切停调,放行到用卡阈值的逻辑永远不生效。
|
||||
func TestSchedulerMetadataPayload_KeepsOpenAIAutoResetCreditFields(t *testing.T) {
|
||||
checkedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
account := service.Account{
|
||||
ID: 40,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Extra: map[string]any{
|
||||
"codex_7d_used_percent": 90.0,
|
||||
service.OpenAIAutoResetCreditEnabledExtraKey: true,
|
||||
service.OpenAIAutoResetCredit5hThresholdExtraKey: 0.95,
|
||||
service.OpenAIAutoResetCredit7dThresholdExtraKey: 0.98,
|
||||
service.OpenAIAutoResetCreditStateExtraKey: map[string]any{
|
||||
"status": service.OpenAIAutoResetStatusAvailable,
|
||||
"available_count": 3,
|
||||
"checked_at": checkedAt,
|
||||
"trigger_window": "7d",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, metaPayload, err := marshalSchedulerCacheAccount(account)
|
||||
require.NoError(t, err)
|
||||
var cached service.Account
|
||||
require.NoError(t, json.Unmarshal(metaPayload, &cached))
|
||||
|
||||
config := service.ResolveOpenAIAutoResetCreditConfig(&cached)
|
||||
require.True(t, config.Enabled)
|
||||
require.Equal(t, 0.95, config.Threshold5h)
|
||||
require.Equal(t, 0.98, config.Threshold7d)
|
||||
|
||||
state, ok := cached.Extra[service.OpenAIAutoResetCreditStateExtraKey].(map[string]any)
|
||||
require.True(t, ok, "卡状态必须进入调度投影")
|
||||
require.Equal(t, service.OpenAIAutoResetStatusAvailable, state["status"])
|
||||
require.EqualValues(t, 3, state["available_count"])
|
||||
require.Equal(t, checkedAt, state["checked_at"])
|
||||
}
|
||||
|
||||
func TestBuildSchedulerMetadataAccount_KeepsQuotaStateForCachedAccounts(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
activeStart := now.Add(-time.Hour).Format(time.RFC3339)
|
||||
|
||||
@@ -271,13 +271,19 @@ func openAIThresholdCandidate(extra map[string]any, window string, now time.Time
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if openAIQuotaWindowReset(extra, window, now) || openAICodexSnapshotStaleForPause(extra, now) {
|
||||
if openAIQuotaWindowReset(extra, window, now) || (openAICodexSnapshotStaleForPause(extra, now) && !openAIQuotaWindowResetPending(extra, window, now)) {
|
||||
return nil
|
||||
}
|
||||
until := parseSchedulingResetAt(extra[resetAtKey])
|
||||
if until == nil {
|
||||
if resetAt, ok := openAICodexWindowResetAt(extra, window); ok {
|
||||
until = &resetAt
|
||||
}
|
||||
}
|
||||
return &accountSchedulingThresholdCandidate{
|
||||
window: window,
|
||||
usedPercent: schedulingPercentValue(usedPercent),
|
||||
until: parseSchedulingResetAt(extra[resetAtKey]),
|
||||
until: until,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.
|
||||
Extra: map[string]any{
|
||||
"codex_usage_updated_at": now.Add(-2 * time.Hour).Format(time.RFC3339),
|
||||
"codex_5h_used_percent": 100.0,
|
||||
"codex_5h_reset_at": now.Add(3 * time.Hour).Format(time.RFC3339),
|
||||
"codex_5h_reset_at": "invalid",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -181,6 +181,63 @@ func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.
|
||||
require.False(t, decision.ShouldPause)
|
||||
}
|
||||
|
||||
func TestOpenAIThresholdCandidate_StaleSnapshotFutureReset(t *testing.T) {
|
||||
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reset map[string]any
|
||||
want bool
|
||||
}{
|
||||
{"absolute future", map[string]any{"codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339)}, true},
|
||||
{"relative future", map[string]any{"codex_5h_reset_after_seconds": 4 * 3600}, true},
|
||||
{"missing", nil, false},
|
||||
{"invalid", map[string]any{"codex_5h_reset_at": "invalid"}, false},
|
||||
{"past", map[string]any{"codex_5h_reset_at": now.Add(-time.Minute).Format(time.RFC3339)}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
extra := map[string]any{"codex_usage_updated_at": now.Add(-3 * time.Hour).Format(time.RFC3339), "codex_5h_used_percent": 99.0}
|
||||
for key, value := range tc.reset {
|
||||
extra[key] = value
|
||||
}
|
||||
candidate := openAIThresholdCandidate(extra, "5h", now)
|
||||
require.Equal(t, tc.want, candidate != nil)
|
||||
if tc.want {
|
||||
decision := EvaluateAccountSchedulingThreshold(&Account{Platform: PlatformOpenAI, Extra: extra}, map[string]int{PlatformOpenAI: 95}, now)
|
||||
require.True(t, decision.ShouldPause)
|
||||
require.NotNil(t, decision.Until)
|
||||
require.True(t, now.Before(*decision.Until))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIQuotaUtilization_StaleSnapshotFutureReset(t *testing.T) {
|
||||
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reset map[string]any
|
||||
want bool
|
||||
}{
|
||||
{"absolute future", map[string]any{"codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339)}, true},
|
||||
{"relative future", map[string]any{"codex_5h_reset_after_seconds": 4 * 3600}, true},
|
||||
{"missing", nil, false},
|
||||
{"invalid", map[string]any{"codex_5h_reset_at": "invalid"}, false},
|
||||
{"past", map[string]any{"codex_5h_reset_at": now.Add(-time.Minute).Format(time.RFC3339)}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
extra := map[string]any{"codex_usage_updated_at": now.Add(-3 * time.Hour).Format(time.RFC3339), "codex_5h_used_percent": 99.0}
|
||||
for key, value := range tc.reset {
|
||||
extra[key] = value
|
||||
}
|
||||
utilization, ok := resolveOpenAIQuotaUtilization(extra, "5h", now)
|
||||
require.Equal(t, tc.want, ok)
|
||||
if ok {
|
||||
require.Equal(t, 0.99, utilization)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateAccountSchedulingThreshold_OpenAISkipsResetWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
// totalCost 是本次请求的客户计费(倍率前),用于优先级 2。
|
||||
// serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。
|
||||
// pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。
|
||||
// longContextPricingEnabled 表示上游是否对本次请求收取长上下文费率,用于优先级 3;
|
||||
// 由 accountStatsLongContextPricingEnabled 按账号开关得出,不受分组售价开关影响。
|
||||
// reasoningEffort 是最终转发等级;按账号统计定价中配置的等级倍率计费。
|
||||
func resolveAccountStatsCost(
|
||||
ctx context.Context,
|
||||
@@ -32,6 +34,7 @@ func resolveAccountStatsCost(
|
||||
totalCost float64,
|
||||
serviceTier string,
|
||||
pricingAt time.Time,
|
||||
longContextPricingEnabled bool,
|
||||
reasoningEfforts ...string,
|
||||
) *float64 {
|
||||
reasoningEffort := ""
|
||||
@@ -64,7 +67,7 @@ func resolveAccountStatsCost(
|
||||
|
||||
// 优先级 3:模型定价文件(LiteLLM)默认价格
|
||||
if billingService != nil {
|
||||
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort)
|
||||
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, longContextPricingEnabled, reasoningEffort)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -74,11 +77,16 @@ func resolveAccountStatsCost(
|
||||
// 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后,
|
||||
// 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 3 的
|
||||
// 语义:只取模型定价文件,不引入自定义售价。
|
||||
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, reasoningEfforts ...string) *float64 {
|
||||
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, longContextPricingEnabled bool, reasoningEfforts ...string) *float64 {
|
||||
reasoningEffort := ""
|
||||
if len(reasoningEfforts) > 0 {
|
||||
reasoningEffort = reasoningEfforts[0]
|
||||
}
|
||||
resolver := NewModelPricingResolver(nil, billingService)
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: model})
|
||||
// 无分组的解析结果默认开启长上下文,CostInput.LongContextBillingEnabled=false 无法否决,
|
||||
// 因此直接覆写解析结果。
|
||||
resolved.longContextPricingEnabled = longContextPricingEnabled
|
||||
breakdown, err := billingService.CalculateCostUnified(CostInput{
|
||||
Ctx: context.Background(),
|
||||
Model: model,
|
||||
@@ -87,7 +95,8 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us
|
||||
ServiceTier: normalizeBillingServiceTier(serviceTier),
|
||||
ReasoningEffort: reasoningEffort,
|
||||
PricingAt: pricingAt,
|
||||
Resolver: NewModelPricingResolver(nil, billingService),
|
||||
Resolver: resolver,
|
||||
Resolved: resolved,
|
||||
})
|
||||
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
|
||||
return nil
|
||||
@@ -263,6 +272,7 @@ func applyAccountStatsCost(
|
||||
tokens UsageTokens,
|
||||
totalCost float64,
|
||||
pricingAt time.Time,
|
||||
longContextPricingEnabled bool,
|
||||
) {
|
||||
model := upstreamModel
|
||||
if model == "" {
|
||||
@@ -281,6 +291,14 @@ func applyAccountStatsCost(
|
||||
reasoningEffort = *usageLog.ReasoningEffort
|
||||
}
|
||||
usageLog.AccountStatsCost = resolveAccountStatsCost(
|
||||
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, reasoningEffort,
|
||||
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, longContextPricingEnabled, reasoningEffort,
|
||||
)
|
||||
}
|
||||
|
||||
// accountStatsLongContextPricingEnabled 判断账号统计成本是否计入长上下文阶梯。
|
||||
// 账号统计成本反映上游实际成本:OpenAI 账号由开关声明上游是否收取长上下文费率;
|
||||
// 其它平台没有该开关(accountGate 为 nil),按官方阶梯计。分组开关只决定客户售价,
|
||||
// 不参与成本判断。
|
||||
func accountStatsLongContextPricingEnabled(accountGate *bool) bool {
|
||||
return accountGate == nil || *accountGate
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ func TestTryModelFilePricing_Success(t *testing.T) {
|
||||
},
|
||||
})
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
||||
require.NotNil(t, result)
|
||||
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
|
||||
require.InDelta(t, 0.2, *result, 1e-12)
|
||||
@@ -483,8 +483,8 @@ func TestTryModelFilePricing_Fable51HasNoImplicitReasoningMultiplier(t *testing.
|
||||
"claude-fable-5-1": {InputPricePerToken: 0.001},
|
||||
})
|
||||
tokens := UsageTokens{InputTokens: 100}
|
||||
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh")
|
||||
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max")
|
||||
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "xhigh")
|
||||
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "max")
|
||||
require.NotNil(t, standard)
|
||||
require.NotNil(t, max)
|
||||
require.Equal(t, *standard, *max)
|
||||
@@ -503,7 +503,7 @@ func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) {
|
||||
})
|
||||
tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5}
|
||||
|
||||
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{})
|
||||
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}, true)
|
||||
|
||||
require.NotNil(t, result)
|
||||
// Input and cache-read use the 2x input tier; output uses the 1.5x tier.
|
||||
@@ -542,7 +542,7 @@ func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{})
|
||||
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}, true)
|
||||
require.NotNil(t, result)
|
||||
require.InDelta(t, tt.want, *result, 1e-12)
|
||||
})
|
||||
@@ -572,7 +572,7 @@ func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T)
|
||||
CacheReadTokens: 5,
|
||||
}
|
||||
|
||||
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{})
|
||||
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}, true)
|
||||
|
||||
require.NotNil(t, result)
|
||||
// priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。
|
||||
@@ -583,7 +583,7 @@ func TestTryModelFilePricing_PricingNotFound(t *testing.T) {
|
||||
// "nonexistent-model" does not match any fallback pattern
|
||||
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{})
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{})
|
||||
result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}, true)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
@@ -593,7 +593,7 @@ func TestTryModelFilePricing_NilFallback(t *testing.T) {
|
||||
"claude-sonnet-4": nil,
|
||||
})
|
||||
tokens := UsageTokens{InputTokens: 100}
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
@@ -605,7 +605,7 @@ func TestTryModelFilePricing_ZeroCost(t *testing.T) {
|
||||
},
|
||||
})
|
||||
tokens := UsageTokens{} // all zero tokens → cost = 0 → nil
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
|
||||
@@ -622,7 +622,7 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) {
|
||||
OutputTokens: 50,
|
||||
ImageOutputTokens: 10,
|
||||
}
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
||||
require.NotNil(t, result)
|
||||
// ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。
|
||||
// 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28
|
||||
@@ -644,7 +644,7 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
|
||||
CacheCreationTokens: 200,
|
||||
CacheReadTokens: 300,
|
||||
}
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
||||
require.NotNil(t, result)
|
||||
// 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005
|
||||
// = 0.1 + 0.1 + 0.6 + 0.15 = 0.95
|
||||
@@ -692,7 +692,7 @@ func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) {
|
||||
{"sunday", time.Date(2026, time.August, 23, 7, 0, 0, 0, time.UTC), 1},
|
||||
} {
|
||||
t.Run(slot.name, func(t *testing.T) {
|
||||
cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at)
|
||||
cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at, true)
|
||||
require.NotNil(t, cost)
|
||||
require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12)
|
||||
})
|
||||
@@ -738,7 +738,7 @@ func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) {
|
||||
groupID = 99
|
||||
}
|
||||
cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(),
|
||||
1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak)
|
||||
1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak, true)
|
||||
if tt.noChannel {
|
||||
require.Nil(t, cost)
|
||||
return
|
||||
@@ -759,7 +759,7 @@ func TestResolveAccountStatsCost_NilChannelService(t *testing.T) {
|
||||
nil, // channelService is nil
|
||||
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
||||
1, 1, "claude-sonnet-4",
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
@@ -775,7 +775,7 @@ func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) {
|
||||
cs,
|
||||
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
||||
1, 1, "", // empty upstream model
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
@@ -792,7 +792,7 @@ func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) {
|
||||
cs,
|
||||
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
||||
1, 99, "claude-sonnet-4", // groupID 99 has no channel
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
|
||||
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
@@ -823,7 +823,7 @@ func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) {
|
||||
context.Background(),
|
||||
cs, nil, // billingService not needed when custom rule hits
|
||||
1, 10, "claude-sonnet-4",
|
||||
tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率
|
||||
tokens, 1, 999.0, "priority", time.Time{}, true, // 自定义账号价格不叠加服务层级倍率
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
// 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0
|
||||
@@ -845,7 +845,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes
|
||||
context.Background(),
|
||||
cs, nil,
|
||||
1, 10, "claude-sonnet-4",
|
||||
tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率
|
||||
tokens, 1, 0.75, "priority", time.Time{}, true, // 已完成用户计费,不再重复应用服务层级倍率
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
require.InDelta(t, 0.75, *result, 1e-12)
|
||||
@@ -863,7 +863,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_Return
|
||||
context.Background(),
|
||||
cs, nil,
|
||||
1, 10, "claude-sonnet-4",
|
||||
UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0
|
||||
UsageTokens{}, 1, 0.0, "", time.Time{}, true, // totalCost = 0
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
@@ -890,7 +890,7 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) {
|
||||
context.Background(),
|
||||
cs, bs,
|
||||
1, 10, "claude-sonnet-4",
|
||||
tokens, 1, 999.0, "", time.Time{}, // totalCost ignored
|
||||
tokens, 1, 999.0, "", time.Time{}, true, // totalCost ignored
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
|
||||
@@ -911,7 +911,7 @@ func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) {
|
||||
context.Background(), cs, bs,
|
||||
1, 10, "claude-opus-5",
|
||||
UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000},
|
||||
1, 0, "fast", time.Time{},
|
||||
1, 0, "fast", time.Time{}, true,
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
require.InDelta(t, 60, *result, 1e-12)
|
||||
@@ -930,7 +930,7 @@ func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing
|
||||
context.Background(),
|
||||
cs, bs,
|
||||
1, 10, "gemini-3.6-flash-low",
|
||||
UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{},
|
||||
UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, true,
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
require.InDelta(t, 9.15, *result, 1e-12)
|
||||
@@ -954,7 +954,7 @@ func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) {
|
||||
context.Background(),
|
||||
cs, bs,
|
||||
1, 10, "totally-unknown-model",
|
||||
tokens, 1, 0.0, "", time.Time{},
|
||||
tokens, 1, 0.0, "", time.Time{}, true,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
@@ -971,7 +971,7 @@ func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) {
|
||||
context.Background(),
|
||||
cs, nil, // billingService is nil
|
||||
1, 10, "claude-sonnet-4",
|
||||
UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{},
|
||||
UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, true,
|
||||
)
|
||||
require.Nil(t, result)
|
||||
}
|
||||
@@ -1004,7 +1004,7 @@ func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T
|
||||
context.Background(),
|
||||
cs, nil,
|
||||
1, 10, "claude-sonnet-4",
|
||||
tokens, 1, 99.0, "", time.Time{}, // totalCost = 99.0 (would be used if ApplyPricing wins)
|
||||
tokens, 1, 99.0, "", time.Time{}, true, // totalCost = 99.0 (would be used if ApplyPricing wins)
|
||||
)
|
||||
require.NotNil(t, result)
|
||||
// Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost)
|
||||
@@ -1032,13 +1032,111 @@ func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) {
|
||||
applyAccountStatsCost(
|
||||
context.Background(), usageLog, cs, bs,
|
||||
1, 10, "gpt-5.6-sol", "gpt-5.6-sol",
|
||||
UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{},
|
||||
UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, true,
|
||||
)
|
||||
|
||||
require.NotNil(t, usageLog.AccountStatsCost)
|
||||
require.InDelta(t, 0.4, *usageLog.AccountStatsCost, 1e-12)
|
||||
}
|
||||
|
||||
func TestApplyAccountStatsCost_LongContextFollowsAccountGate(t *testing.T) {
|
||||
// 渠道售价不参与优先级 3:结果只取模型定价文件。
|
||||
channel := &Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
ModelPricing: []ChannelModelPricing{{
|
||||
Models: []string{"gpt-5.6-sol"}, InputPrice: testPtrFloat64(0.01),
|
||||
}},
|
||||
}
|
||||
cs := newTestChannelServiceForStats(t, channel, 10, PlatformOpenAI)
|
||||
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
||||
"gpt-5.6-sol": {
|
||||
InputPricePerToken: 0.001,
|
||||
InputPricePerTokenPriority: 0.002,
|
||||
OutputPricePerToken: 0.002,
|
||||
OutputPricePerTokenPriority: 0.004,
|
||||
CacheReadPricePerToken: 0.0001,
|
||||
CacheReadPricePerTokenPriority: 0.0002,
|
||||
LongContextInputThreshold: 100,
|
||||
LongContextInputMultiplier: 2,
|
||||
LongContextOutputMultiplier: 1.5,
|
||||
},
|
||||
})
|
||||
tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5}
|
||||
accountOff, accountOn := false, true
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
gate *bool
|
||||
tier string
|
||||
wantCost float64
|
||||
}{
|
||||
{name: "account_off", gate: &accountOff, wantCost: 0.1215},
|
||||
{name: "account_off_priority", gate: &accountOff, tier: "priority", wantCost: 0.243},
|
||||
{name: "account_on_priority", gate: &accountOn, tier: "priority", wantCost: 0.466},
|
||||
// 非 OpenAI 平台没有账号开关,按官方阶梯计。
|
||||
{name: "no_gate", wantCost: 0.233},
|
||||
{name: "no_gate_priority", tier: "priority", wantCost: 0.466},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usageLog := &UsageLog{ServiceTier: &tt.tier}
|
||||
applyAccountStatsCost(context.Background(), usageLog, cs, bs,
|
||||
1, 10, "gpt-5.6-sol", "gpt-5.6-sol", tokens, 999, time.Time{},
|
||||
accountStatsLongContextPricingEnabled(tt.gate))
|
||||
require.NotNil(t, usageLog.AccountStatsCost)
|
||||
require.InDelta(t, tt.wantCost, *usageLog.AccountStatsCost, 1e-12)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 分组开关只决定客户售价;账号统计成本按账号开关判断上游是否收取长上下文费率。
|
||||
func TestOpenAIGatewayServiceRecordUsage_AccountStatsLongContextFollowsAccountGate(t *testing.T) {
|
||||
baseCost := 300000*2.5e-6 + 2000*15e-6
|
||||
longContextCost := 300000*2.5e-6*2 + 2000*15e-6*1.5
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
groupLongContext bool
|
||||
accountExtra map[string]any
|
||||
wantTotalCost float64
|
||||
wantAccountCost float64
|
||||
}{
|
||||
{name: "group_on_account_off", groupLongContext: true, wantTotalCost: longContextCost, wantAccountCost: baseCost},
|
||||
{name: "group_off_account_off", wantTotalCost: baseCost, wantAccountCost: baseCost},
|
||||
{
|
||||
name: "group_off_account_on",
|
||||
accountExtra: map[string]any{"openai_long_context_billing_enabled": true},
|
||||
wantTotalCost: longContextCost,
|
||||
wantAccountCost: longContextCost,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
swapInOpenAILadderCatalog(t, svc)
|
||||
svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI)
|
||||
apiKey := openAIRecordUsageAPIKeyWithGroup(svc, 1015, tt.groupLongContext)
|
||||
apiKey.GroupID = i64p(1)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
RequestID: "resp_account_stats_long_context_" + tt.name,
|
||||
Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000},
|
||||
Model: "gpt-5.4-2026-03-05",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: apiKey,
|
||||
User: &User{ID: 2015},
|
||||
Account: &Account{ID: 3015, Platform: PlatformOpenAI, Extra: tt.accountExtra},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
require.InDelta(t, tt.wantTotalCost, usageRepo.lastLog.TotalCost, 1e-10)
|
||||
require.NotNil(t, usageRepo.lastLog.AccountStatsCost)
|
||||
require.InDelta(t, tt.wantAccountCost, *usageRepo.lastLog.AccountStatsCost, 1e-10)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers for resolveAccountStatsCost tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestAdminService_CreateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T
|
||||
require.Nil(t, repo.created, "拒绝时不得落库")
|
||||
}
|
||||
|
||||
func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) {
|
||||
func TestAdminService_CreateGroup_AcceptsInteriorAllowlistWildcard(t *testing.T) {
|
||||
repo := &groupRepoStubForAdmin{createID: 51}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
@@ -40,11 +40,9 @@ func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T)
|
||||
ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
appErr := infraerrors.FromError(err)
|
||||
require.Equal(t, int32(http.StatusBadRequest), appErr.Code)
|
||||
require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason)
|
||||
require.Nil(t, repo.created, "拒绝时不得落库")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, repo.created)
|
||||
require.Equal(t, []string{"gpt-*-5.4"}, repo.created.ModelAllowlist.Models)
|
||||
}
|
||||
|
||||
func TestAdminService_CreateGroup_NormalizesModelAllowlist(t *testing.T) {
|
||||
@@ -83,7 +81,7 @@ func TestAdminService_UpdateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T
|
||||
require.Nil(t, repo.updated, "拒绝时不得落库")
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) {
|
||||
func TestAdminService_UpdateGroup_AcceptsInteriorAllowlistWildcard(t *testing.T) {
|
||||
existing := &Group{ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive}
|
||||
repo := &groupRepoStubForAdmin{getByID: existing}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
@@ -92,11 +90,9 @@ func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T)
|
||||
ModelAllowlist: &GroupModelAllowlist{Enabled: true, Models: []string{"foo-*bar"}},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
appErr := infraerrors.FromError(err)
|
||||
require.Equal(t, int32(http.StatusBadRequest), appErr.Code)
|
||||
require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason)
|
||||
require.Nil(t, repo.updated, "拒绝时不得落库")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, repo.updated)
|
||||
require.Equal(t, []string{"foo-*bar"}, repo.updated.ModelAllowlist.Models)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_NormalizesAndResetsModelAllowlist(t *testing.T) {
|
||||
|
||||
@@ -131,7 +131,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
||||
}
|
||||
// 区分客户端取消和真正的上游失败,返回更准确的错误消息
|
||||
if c.Request.Context().Err() != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response")
|
||||
return nil, s.writeClaudeError(c, antigravityStatusClientClosed, "client_disconnected", "Client disconnected before upstream response")
|
||||
}
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatTransportError(c *gin
|
||||
}
|
||||
}
|
||||
if c.Request.Context().Err() != nil {
|
||||
return s.writeAntigravityCompatError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response")
|
||||
return s.writeAntigravityCompatError(c, antigravityStatusClientClosed, "client_disconnected", "Client disconnected before upstream response")
|
||||
}
|
||||
return s.writeAntigravityCompatError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
|
||||
}
|
||||
|
||||
@@ -141,15 +141,18 @@ func (s *antigravityCompatStreamSession) consume(line string) {
|
||||
}
|
||||
|
||||
func (s *antigravityCompatStreamSession) hasMeaningfulData() bool {
|
||||
return s.meaningfulData
|
||||
return s.meaningfulData || s.processor.HasContent()
|
||||
}
|
||||
|
||||
func (s *antigravityCompatStreamSession) finish() *antigravityStreamResult {
|
||||
func (s *antigravityCompatStreamSession) finish() (*antigravityStreamResult, error) {
|
||||
finalEvents, usage := s.processor.Finish()
|
||||
mergeAntigravityCompatUsage(s.usage, usage)
|
||||
s.consumeClaudeEvents(finalEvents)
|
||||
if !s.hasMeaningfulData() && !s.writer.Disconnected() {
|
||||
return nil, antigravityCompatEmptyStreamError()
|
||||
}
|
||||
s.adapter.Finalize(s.writer)
|
||||
return s.result(s.writer.Disconnected())
|
||||
return s.result(s.writer.Disconnected()), nil
|
||||
}
|
||||
|
||||
func (s *antigravityCompatStreamSession) collectResult(clientDisconnect bool) *antigravityStreamResult {
|
||||
@@ -220,24 +223,18 @@ func isMeaningfulAntigravityCompatEvent(event *apicompat.AnthropicStreamEvent) b
|
||||
if event == nil {
|
||||
return false
|
||||
}
|
||||
if event.Type == "message_stop" {
|
||||
return true
|
||||
}
|
||||
if event.ContentBlock != nil {
|
||||
block := event.ContentBlock
|
||||
return block.Type == "tool_use" ||
|
||||
block.Text != "" ||
|
||||
block.Thinking != "" ||
|
||||
block.Signature != "" ||
|
||||
block.Source != nil
|
||||
}
|
||||
if event.Delta != nil {
|
||||
delta := event.Delta
|
||||
return delta.Text != "" ||
|
||||
delta.PartialJSON != "" ||
|
||||
delta.Thinking != "" ||
|
||||
delta.Signature != "" ||
|
||||
delta.StopReason != ""
|
||||
delta.Thinking != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -295,7 +292,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream(
|
||||
if !session.hasMeaningfulData() && !writer.Disconnected() {
|
||||
return nil, antigravityCompatEmptyStreamError()
|
||||
}
|
||||
return session.finish(), nil
|
||||
return session.finish()
|
||||
}
|
||||
if event.err != nil {
|
||||
return s.handleAntigravityCompatReadError(c, session, event.err, maxLineSize, prefix)
|
||||
|
||||
@@ -180,7 +180,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
}
|
||||
// 区分客户端取消和真正的上游失败,返回更准确的错误消息
|
||||
if c.Request.Context().Err() != nil {
|
||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Client disconnected before upstream response")
|
||||
return nil, s.writeGoogleError(c, antigravityStatusClientClosed, "Client disconnected before upstream response")
|
||||
}
|
||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries")
|
||||
}
|
||||
|
||||
@@ -686,6 +686,9 @@ func mergeTextPartsToResponse(response map[string]any, textParts []string) map[s
|
||||
return result
|
||||
}
|
||||
|
||||
// antigravityStatusClientClosed 是客户端在上游响应前断开时回写的状态码(499 client closed request)。
|
||||
const antigravityStatusClientClosed = 499
|
||||
|
||||
func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) error {
|
||||
MarkResponseCommitted(c)
|
||||
c.JSON(status, gin.H{
|
||||
@@ -790,6 +793,8 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int,
|
||||
statusStr = "NOT_FOUND"
|
||||
case 429:
|
||||
statusStr = "RESOURCE_EXHAUSTED"
|
||||
case antigravityStatusClientClosed:
|
||||
statusStr = "CANCELLED"
|
||||
case 500:
|
||||
statusStr = "INTERNAL"
|
||||
case 502, 503:
|
||||
|
||||
@@ -1350,8 +1350,8 @@ func (s *BillingService) getModelPricingAt(model string, pricingAt time.Time) (*
|
||||
return nil, fmt.Errorf("%w for model: %s", ErrModelPricingUnavailable, model)
|
||||
}
|
||||
|
||||
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值
|
||||
// 渠道存在时,未配置的图片输出价格归零(不回退到 LiteLLM)
|
||||
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值。
|
||||
// 与其他 token 字段一致,渠道留空的图片输入/输出价沿用目录价,见 applyChannelImagePriceOverrides。
|
||||
func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing *ChannelModelPricing) (*ModelPricing, error) {
|
||||
pricing, err := s.GetModelPricing(model)
|
||||
if err != nil {
|
||||
@@ -1367,13 +1367,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing
|
||||
pricing.FastMultiplier = channelPricing.FastMultiplier
|
||||
pricing.FlexMultiplier = channelPricing.FlexMultiplier
|
||||
pricing.ReasoningEffortMultipliers = maps.Clone(channelPricing.ReasoningEffortMultipliers)
|
||||
if channelPricing.ImageOutputPrice != nil {
|
||||
pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice
|
||||
} else {
|
||||
pricing.ImageOutputPricePerToken = 0
|
||||
}
|
||||
pricing.ImageOutputPriceExplicit = true
|
||||
applyChannelImageInputPrice(channelPricing, pricing)
|
||||
applyChannelImagePriceOverrides(channelPricing, pricing)
|
||||
return pricing, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1874,19 +1874,49 @@ func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "pricing not found")
|
||||
}
|
||||
|
||||
func TestGetModelPricingWithChannel_NilImageOutputPriceZerosAndMarksExplicit(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
func TestGetModelPricingWithChannel_NilImagePricesInheritCatalog(t *testing.T) {
|
||||
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
||||
"gpt-image-2": {
|
||||
Mode: "image_generation",
|
||||
InputCostPerToken: 5e-6,
|
||||
OutputCostPerToken: 10e-6,
|
||||
InputCostPerImageToken: 8e-6,
|
||||
OutputCostPerImageToken: 30e-6,
|
||||
},
|
||||
}))
|
||||
|
||||
chPricing := &ChannelModelPricing{
|
||||
InputPrice: testPtrFloat64(10e-6),
|
||||
OutputPrice: testPtrFloat64(20e-6),
|
||||
// ImageOutputPrice intentionally nil
|
||||
InputPrice: testPtrFloat64(6e-6),
|
||||
OutputPrice: testPtrFloat64(12e-6),
|
||||
// ImageInputPrice / ImageOutputPrice intentionally nil
|
||||
}
|
||||
pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
|
||||
pricing, err := svc.GetModelPricingWithChannel("gpt-image-2", chPricing)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, 6e-6, pricing.InputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 30e-6, pricing.ImageOutputPricePerToken, 1e-12)
|
||||
require.False(t, pricing.ImageOutputPriceExplicit)
|
||||
require.InDelta(t, 8e-6, pricing.ImageInputPricePerToken, 1e-12)
|
||||
}
|
||||
|
||||
func TestGetModelPricingWithChannel_ExplicitImagePricesOverrideCatalog(t *testing.T) {
|
||||
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
||||
"gpt-image-2": {
|
||||
Mode: "image_generation",
|
||||
InputCostPerToken: 5e-6,
|
||||
InputCostPerImageToken: 8e-6,
|
||||
OutputCostPerImageToken: 30e-6,
|
||||
},
|
||||
}))
|
||||
|
||||
pricing, err := svc.GetModelPricingWithChannel("gpt-image-2", &ChannelModelPricing{
|
||||
ImageInputPrice: testPtrFloat64(9e-6),
|
||||
ImageOutputPrice: testPtrFloat64(0),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
|
||||
require.True(t, pricing.ImageOutputPriceExplicit)
|
||||
require.True(t, pricing.ImageOutputPriceExplicit, "显式 0 仍表示图片输出免费")
|
||||
require.InDelta(t, 9e-6, pricing.ImageInputPricePerToken, 1e-12)
|
||||
}
|
||||
|
||||
func TestComputeTokenBreakdown_ExplicitZeroImagePrice_NoFallback(t *testing.T) {
|
||||
@@ -1976,18 +2006,20 @@ func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run(source+"/opus", func(t *testing.T) {
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600}
|
||||
for tier, mult := range map[string]float64{"": 1, "fast": 2} {
|
||||
cost, err := svc.CalculateCostWithServiceTier("claude-opus-5-5", tokens, 1, tier)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10)
|
||||
require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10)
|
||||
require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10)
|
||||
require.False(t, cost.LongContextBillingApplied)
|
||||
}
|
||||
})
|
||||
for _, model := range []string{"claude-opus-5-5", "anthropic/claude-opus-5.5"} {
|
||||
t.Run(source+"/"+model, func(t *testing.T) {
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600}
|
||||
for tier, mult := range map[string]float64{"": 1, "fast": 2} {
|
||||
cost, err := svc.CalculateCostWithServiceTier(model, tokens, 1, tier)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10)
|
||||
require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10)
|
||||
require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10)
|
||||
require.False(t, cost.LongContextBillingApplied)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// 三条传输层失败处理的共同契约:请求 context 已取消(客户端断开)时不记 Ops 上游错误事件;
|
||||
// 请求 context 仍存活时即便错误是 context.Canceled 也照常记录,不归为客户端断开。
|
||||
func TestTransportErrorHandlers_ClientDisconnectRecordsNoOpsEvent(t *testing.T) {
|
||||
account := &Account{ID: 9, Name: "acc", Platform: PlatformAnthropic}
|
||||
clientErr := &url.Error{Op: "Post", URL: "https://upstream.example/v1/messages", Err: context.Canceled}
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
handlers := []struct {
|
||||
name string
|
||||
handle func(ctx context.Context, c *gin.Context) error
|
||||
}{
|
||||
{"anthropic", func(ctx context.Context, c *gin.Context) error {
|
||||
s := &GatewayService{accountRepo: &transportTempUnschedRepoStub{}}
|
||||
return s.handleUpstreamTransportError(ctx, c, account, clientErr, OpsUpstreamErrorEvent{})
|
||||
}},
|
||||
{"gemini", func(ctx context.Context, c *gin.Context) error {
|
||||
s := &GeminiMessagesCompatService{accountRepo: &transportTempUnschedRepoStub{}}
|
||||
return s.handleUpstreamTransportError(ctx, c, account, clientErr)
|
||||
}},
|
||||
{"openai", func(ctx context.Context, c *gin.Context) error {
|
||||
s := &OpenAIGatewayService{accountRepo: &openaiTransportAccountRepoStub{}}
|
||||
return s.handleOpenAIUpstreamTransportError(ctx, c, account, clientErr, false)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, h := range handlers {
|
||||
t.Run(h.name+"/client disconnected", func(t *testing.T) {
|
||||
c := newTransportErrorTestGin(t)
|
||||
|
||||
err := h.handle(canceledCtx, c)
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
_, recorded := c.Get(OpsUpstreamErrorsKey)
|
||||
require.False(t, recorded, "客户端断开不是上游故障")
|
||||
_, messageSet := c.Get(OpsUpstreamErrorMessageKey)
|
||||
require.False(t, messageSet)
|
||||
})
|
||||
t.Run(h.name+"/request context alive", func(t *testing.T) {
|
||||
c := newTransportErrorTestGin(t)
|
||||
|
||||
err := h.handle(context.Background(), c)
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
raw, recorded := c.Get(OpsUpstreamErrorsKey)
|
||||
require.True(t, recorded)
|
||||
require.Len(t, raw.([]*OpsUpstreamErrorEvent), 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityCompatTransportError_ClientDisconnectWrites499(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
|
||||
|
||||
err := (&AntigravityGatewayService{}).handleAntigravityCompatTransportError(c, context.Canceled)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, antigravityStatusClientClosed, rec.Code)
|
||||
require.Equal(t, "client_disconnected", gjson.Get(rec.Body.String(), "error.type").String())
|
||||
}
|
||||
|
||||
func TestAntigravityWriteGoogleError_ClientClosedStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", nil)
|
||||
|
||||
_ = (&AntigravityGatewayService{}).writeGoogleError(c, antigravityStatusClientClosed, "Client disconnected before upstream response")
|
||||
|
||||
require.Equal(t, antigravityStatusClientClosed, rec.Code)
|
||||
require.Equal(t, "CANCELLED", gjson.Get(rec.Body.String(), "error.status").String())
|
||||
}
|
||||
@@ -1803,6 +1803,7 @@ func TestOpus55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) {
|
||||
func TestOpus55ThinkingDefaultPreservesSignedHistory(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"signed"},{"type":"redacted_thinking","data":"encrypted"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}],"tool_choice":{"type":"none"},"thinking":{"type":"adaptive","display":"omitted"}}`)
|
||||
require.Equal(t, string(body), string(FilterThinkingBlocks(body, "claude-opus-5-5")))
|
||||
require.Equal(t, string(body), string(FilterThinkingBlocks(body, "anthropic/claude-opus-5.5")))
|
||||
withoutThinking, _ := deleteJSONPathBytes(body, "thinking")
|
||||
require.Equal(t, string(withoutThinking), string(FilterThinkingBlocks(withoutThinking, "claude-opus-5-5")))
|
||||
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-5-5", claudeOAuthNormalizeOptions{})
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -670,7 +671,9 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) {
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "passthrough wins over ordinary account mapping",
|
||||
// The passthrough account serves the default set (its stale mapping is
|
||||
// ignored), while the ordinary account's mapping still reaches the list.
|
||||
name: "passthrough contributes defaults alongside ordinary account mapping",
|
||||
accounts: []Account{
|
||||
{
|
||||
ID: 2,
|
||||
@@ -684,7 +687,7 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) {
|
||||
Extra: map[string]any{"openai_passthrough": true},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
want: dedupeAndSortModelIDs(append([]string{"configured-model"}, openai.DefaultModelIDs()...)),
|
||||
},
|
||||
{
|
||||
name: "ordinary accounts preserve mapped whitelist",
|
||||
@@ -708,7 +711,9 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) {
|
||||
modelsListCacheTTL: time.Minute,
|
||||
}
|
||||
|
||||
require.Equal(t, tt.want, svc.GetAvailableModels(context.Background(), &groupID, PlatformOpenAI))
|
||||
got := svc.GetAvailableModels(context.Background(), &groupID, PlatformOpenAI)
|
||||
require.Equal(t, tt.want, got)
|
||||
require.NotContains(t, got, "stale-model", "passthrough mapping must never reach the public list")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1433,15 +1433,13 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
|
||||
hasAnyMapping := false
|
||||
|
||||
for _, acc := range accounts {
|
||||
// Passthrough routing accepts models independently of model_mapping. A stale
|
||||
// mapping on any eligible passthrough account therefore cannot define the
|
||||
// public whitelist; return nil so the handler uses its default model set.
|
||||
// Passthrough routing accepts models independently of model_mapping, so a
|
||||
// stale mapping on a passthrough account must not narrow the public list.
|
||||
// Treat it like an unmapped account: skip its mapping here and let
|
||||
// supplementUnmappedOpenAIModels contribute the default set. Mappings on
|
||||
// the ordinary accounts in the same group still count.
|
||||
if platform == PlatformOpenAI && acc.IsOpenAIPassthroughEnabled() {
|
||||
if s.modelsListCache != nil {
|
||||
s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL)
|
||||
modelsListCacheStoreTotal.Add(1)
|
||||
}
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
|
||||
mapping := acc.GetModelMapping()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestStructuredOutputsBetaOAuthMimic(t *testing.T) {
|
||||
const beta = "structured-outputs-2025-11-13"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
header string
|
||||
drop map[string]struct{}
|
||||
want bool
|
||||
}{
|
||||
{"explicit", beta, nil, true},
|
||||
{"mixed and duplicate", "custom-beta, " + beta + "," + beta, nil, true},
|
||||
{"absent", "custom-beta", nil, false},
|
||||
{"similar token", beta + "-other", nil, false},
|
||||
{"filtered", beta, map[string]struct{}{beta: {}}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := newTestGatewayServiceForBeta(false)
|
||||
header := http.Header{}
|
||||
header.Set("anthropic-beta", tc.header)
|
||||
body := []byte(`{"output_format":{"type":"json_schema","schema":{"type":"object"}}}`)
|
||||
got, set := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-5", header, body, tc.drop)
|
||||
require.True(t, set)
|
||||
require.Equal(t, tc.want, containsBetaToken(got, beta))
|
||||
require.False(t, containsBetaToken(got, "custom-beta"))
|
||||
require.True(t, containsBetaToken(got, "oauth-2025-04-20"))
|
||||
if tc.want {
|
||||
require.Equal(t, 1, strings.Count(got, beta))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpstreamRequestStructuredOutputsBeta(t *testing.T) {
|
||||
const beta = "structured-outputs-2025-11-13"
|
||||
for _, filtered := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "preserved", true: "policy filtered"}[filtered], func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("anthropic-beta", beta+",custom-beta")
|
||||
if filtered {
|
||||
c.Set(betaPolicyFilterSetKey, map[string]struct{}{beta: {}})
|
||||
}
|
||||
account := &Account{ID: 701, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{"access_token": "test-token"}, Status: StatusActive, Schedulable: true}
|
||||
body := []byte(`{"model":"claude-sonnet-5","max_tokens":1024,"output_format":{"type":"json_schema","schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"],"additionalProperties":false}},"messages":[{"role":"user","content":"Return JSON"}]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body,
|
||||
"test-token", "oauth", "claude-sonnet-5", false, true)
|
||||
require.NoError(t, err)
|
||||
out := readUpstreamBodyForTest(t, req)
|
||||
require.JSONEq(t, gjson.GetBytes(body, "output_format").Raw, gjson.GetBytes(out, "output_format").Raw)
|
||||
header := getHeaderRaw(req.Header, "anthropic-beta")
|
||||
require.Equal(t, !filtered, containsBetaToken(header, beta))
|
||||
require.False(t, containsBetaToken(header, "custom-beta"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
// === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
|
||||
//
|
||||
// 顺序约束:
|
||||
// 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta,
|
||||
// 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐)
|
||||
// 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径仅保留明确支持的
|
||||
// 客户端兼容性 beta,其余使用固定列表)
|
||||
// 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 →
|
||||
// strip body.context_management,与 Bedrock 路径对称)
|
||||
// 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 →
|
||||
@@ -509,9 +509,14 @@ func (s *GatewayService) computeFinalAnthropicBeta(
|
||||
|
||||
if tokenType == "oauth" {
|
||||
if mimicClaudeCode {
|
||||
// mimic 路径跳过白名单透传,incomingBeta 始终为空;所有模型都必须
|
||||
// 携带完整 Claude Code beta 集合,避免 Haiku 被识别为第三方客户端。
|
||||
return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), "", effectiveDropSet), true
|
||||
// Keep the default beta set, with a narrow opt-in for legacy structured
|
||||
// output requests. Unknown client betas remain excluded and policy drops
|
||||
// still take precedence over this compatibility token.
|
||||
incomingBeta := ""
|
||||
if containsBetaToken(clientBeta, claude.BetaStructuredOutputs) {
|
||||
incomingBeta = claude.BetaStructuredOutputs
|
||||
}
|
||||
return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), incomingBeta, effectiveDropSet), true
|
||||
}
|
||||
// 真 Claude Code 客户端透传路径
|
||||
return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true
|
||||
@@ -536,7 +541,7 @@ func (s *GatewayService) computeFinalAnthropicBeta(
|
||||
// 两条特殊规则:
|
||||
//
|
||||
// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting;
|
||||
// count_tokens 另外保留客户端 beta,而 messages mimic 会忽略客户端 beta。
|
||||
// count_tokens 另外保留客户端 beta,而 messages mimic 仅保留明确支持的兼容性 token。
|
||||
// - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader
|
||||
// - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含)
|
||||
//
|
||||
|
||||
@@ -28,7 +28,8 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst
|
||||
// 1. records the failure in Ops error logs (status 0, kind=request_error) —
|
||||
// the caller passes path-specific fields (UpstreamURL, Passthrough) via
|
||||
// event; identity, proxy attribution and classification fields are
|
||||
// filled here from the same account snapshot that built the transport;
|
||||
// filled here from the same account snapshot that built the transport.
|
||||
// A client disconnect is not recorded (see isClientCanceledTransportError);
|
||||
// 2. for durable faults (expired/rejected proxy creds, dead proxy,
|
||||
// DNS/routing) temporarily unschedules the account and logs a stable warn
|
||||
// event that alert rules can key on;
|
||||
@@ -39,6 +40,9 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst
|
||||
// It deliberately does NOT write to the response: the handler owns the
|
||||
// response (failover, or a protocol-correct error once failover is exhausted).
|
||||
func (s *GatewayService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, event OpsUpstreamErrorEvent) error {
|
||||
if isClientCanceledTransportError(ctx, err) {
|
||||
return err
|
||||
}
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
event.ProxyID, event.ProxyName = opsUpstreamProxyAttribution(account)
|
||||
|
||||
@@ -870,6 +870,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
},
|
||||
cost.TotalCost, pricingAt,
|
||||
accountStatsLongContextPricingEnabled(nil),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ var geminiTransportFailoverBody = []byte(`{"error":{"code":502,"message":"Upstre
|
||||
// Chat Completions 兼容)上的传输层失败:Do 返回非 HTTP 错误(代理 / DNS / TCP / TLS /
|
||||
// 响应头超时),没有拿到任何上游状态码。
|
||||
//
|
||||
// - 记录 Ops 错误事件(status 0,kind=request_error);
|
||||
// - 记录 Ops 错误事件(status 0,kind=request_error);客户端断开不记录
|
||||
// (见 isClientCanceledTransportError);
|
||||
// - 持久性故障(代理凭据失效、端点拒绝连接、DNS/路由不可达)临时摘除账号;
|
||||
// 瞬时故障(EOF / reset / 超时)账号保持可调度;
|
||||
// - 客户端已断开(context.Canceled)原样返回:不换号、不摘号;
|
||||
@@ -24,6 +25,9 @@ var geminiTransportFailoverBody = []byte(`{"error":{"code":502,"message":"Upstre
|
||||
//
|
||||
// 本函数不写响应:响应归 handler 所有(换号,或耗尽后按端点格式渲染错误)。
|
||||
func (s *GeminiMessagesCompatService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error) error {
|
||||
if isClientCanceledTransportError(ctx, err) {
|
||||
return err
|
||||
}
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
event := OpsUpstreamErrorEvent{
|
||||
|
||||
@@ -29,15 +29,17 @@ func GroupModelAllowlistFromDomain(cfg domain.GroupModelAllowlist) GroupModelAll
|
||||
}
|
||||
|
||||
// supplementUnmappedOpenAIModels ensures a partial mapping catalog does not
|
||||
// hide models from unmapped OpenAI accounts. An empty catalog is left unchanged
|
||||
// so callers retain their existing discovery fallback.
|
||||
// hide models from unmapped or passthrough OpenAI accounts (passthrough routing
|
||||
// ignores model_mapping, so it serves the same default set as an unmapped
|
||||
// account). An empty catalog is left unchanged so callers retain their existing
|
||||
// discovery fallback.
|
||||
func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string {
|
||||
if len(models) == 0 {
|
||||
return models
|
||||
}
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 {
|
||||
if account.Platform == PlatformOpenAI && (account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0) {
|
||||
return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs()))
|
||||
}
|
||||
}
|
||||
@@ -45,7 +47,7 @@ func supplementUnmappedOpenAIModels(accounts []Account, models []string) []strin
|
||||
}
|
||||
|
||||
// normalizeGroupModelAllowlist 归一化管理端提交的分组模型白名单:
|
||||
// 条目 TrimSpace、按小写去重保序;`*` 只允许出现在条目末尾;
|
||||
// 条目 TrimSpace、按小写去重保序;`*` 可出现在任意位置;
|
||||
// enabled=true 且列表为空视为配置错误,返回 400 而不是运行时静默放行/拒绝。
|
||||
func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, error) {
|
||||
out := GroupModelAllowlist{Enabled: cfg.Enabled}
|
||||
@@ -63,9 +65,6 @@ func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist,
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.TrimSuffix(model, "*"), "*") {
|
||||
return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", `wildcard "*" is only allowed at the end of an allowlist entry`)
|
||||
}
|
||||
key := strings.ToLower(model)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
@@ -106,18 +105,8 @@ func (a GroupModelAllowlist) Allows(model string) bool {
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
entry = strings.ToLower(entry)
|
||||
if strings.HasSuffix(entry, "*") {
|
||||
prefix := strings.TrimSuffix(entry, "*")
|
||||
for _, candidate := range candidates {
|
||||
if strings.HasPrefix(candidate, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == entry {
|
||||
if groupAllowlistPatternMatches(entry, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -125,6 +114,29 @@ func (a GroupModelAllowlist) Allows(model string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// groupAllowlistPatternMatches treats only * as a wildcard, including in the middle
|
||||
// or at either end. Matching is anchored and case-insensitive.
|
||||
func groupAllowlistPatternMatches(pattern, model string) bool {
|
||||
pattern = strings.ToLower(pattern)
|
||||
model = strings.ToLower(model)
|
||||
parts := strings.Split(pattern, "*")
|
||||
if len(parts) == 1 {
|
||||
return pattern == model
|
||||
}
|
||||
if !strings.HasPrefix(model, parts[0]) {
|
||||
return false
|
||||
}
|
||||
model = model[len(parts[0]):]
|
||||
for _, part := range parts[1 : len(parts)-1] {
|
||||
index := strings.Index(model, part)
|
||||
if index < 0 {
|
||||
return false
|
||||
}
|
||||
model = model[index+len(part):]
|
||||
}
|
||||
return strings.HasSuffix(model, parts[len(parts)-1])
|
||||
}
|
||||
|
||||
// groupModelAllowlistCandidates 返回客户端模型名在白名单匹配中的候选形式(均已小写)。
|
||||
func groupModelAllowlistCandidates(model string) []string {
|
||||
candidates := make([]string, 0, 4)
|
||||
@@ -151,6 +163,9 @@ func groupModelAllowlistCandidates(model string) []string {
|
||||
// FilterForListing 按白名单条目顺序生成模型列表输出。
|
||||
// 精确条目沿用既有规则:只有出现在 source(账号映射键 ∪ 平台默认列表)的模式
|
||||
// 集合中才输出;通配条目展开为 source 中所有匹配项并保持 source 顺序;全局去重。
|
||||
// 当 source 是前缀通配模式且白名单模式的固定前缀位于该前缀之内时,
|
||||
// 可直接输出白名单模式:它的所有实例都属于 source。其他未处理的无限
|
||||
// 交集不会被枚举为模型 ID,列表可能省略仍可请求的模型。
|
||||
func (a GroupModelAllowlist) FilterForListing(source []string) []string {
|
||||
if !a.Enabled {
|
||||
return source
|
||||
@@ -188,12 +203,14 @@ func (a GroupModelAllowlist) FilterForListing(source []string) []string {
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(entry, "*") {
|
||||
// 空 前缀(裸 `*` 条目)匹配全部来源,与 Allows 的全放行语义一致。
|
||||
prefix := strings.ToLower(strings.TrimSuffix(entry, "*"))
|
||||
if strings.Contains(entry, "*") {
|
||||
for _, pattern := range patterns {
|
||||
if strings.HasPrefix(strings.ToLower(pattern), prefix) {
|
||||
if groupAllowlistPatternMatches(entry, pattern) {
|
||||
add(pattern)
|
||||
} else if strings.HasSuffix(pattern, "*") &&
|
||||
!strings.Contains(strings.TrimSuffix(pattern, "*"), "*") &&
|
||||
strings.HasPrefix(strings.ToLower(entry), strings.ToLower(strings.TrimSuffix(pattern, "*"))) {
|
||||
add(entry)
|
||||
}
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -41,9 +41,9 @@ func TestNormalizeGroupModelAllowlist(t *testing.T) {
|
||||
want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-*"}},
|
||||
},
|
||||
{
|
||||
name: "wildcard in the middle is rejected",
|
||||
in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}},
|
||||
wantErr: "INVALID_MODEL_ALLOWLIST",
|
||||
name: "wildcards at any position are accepted",
|
||||
in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4", "*codex", "gpt-*-codex-*"}},
|
||||
want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4", "*codex", "gpt-*-codex-*"}},
|
||||
},
|
||||
{
|
||||
name: "bare wildcard is accepted as allow-all",
|
||||
@@ -52,9 +52,9 @@ func TestNormalizeGroupModelAllowlist(t *testing.T) {
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "disabled config with invalid wildcard still rejected",
|
||||
in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}},
|
||||
wantErr: "INVALID_MODEL_ALLOWLIST",
|
||||
name: "disabled config preserves interior wildcard",
|
||||
in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}},
|
||||
want: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ func TestGroupModelAllowlistAllows(t *testing.T) {
|
||||
{name: "openai reasoning suffix on unlisted model", model: "gpt-4.1-low", want: false},
|
||||
{name: "trailing wildcard prefix match", model: "grok-4.6", want: true},
|
||||
{name: "trailing wildcard requires prefix", model: "grok", want: false},
|
||||
{name: "interior and suffix wildcard", model: "gpt-5-codex-fast", want: false},
|
||||
{name: "empty model passes (handler decides required-ness)", model: "", want: true},
|
||||
}
|
||||
|
||||
@@ -117,6 +118,20 @@ func TestGroupModelAllowlistAllows(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("wildcards match at any position and remain anchored", func(t *testing.T) {
|
||||
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex-*", "*sonnet*", "gemini-?.*-pro"}}
|
||||
for _, model := range []string{"gpt-5-codex-high", "CLAUDE-SONNET-4.5", "models/claude-sonnet-4.5"} {
|
||||
if !cfg.Allows(model) {
|
||||
t.Fatalf("expected %q to match", model)
|
||||
}
|
||||
}
|
||||
for _, model := range []string{"xgpt-5-codex-high", "gpt-5-codex", "gemini-2.5-pro"} {
|
||||
if cfg.Allows(model) {
|
||||
t.Fatalf("expected %q not to match", model)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled allowlist allows everything", func(t *testing.T) {
|
||||
disabled := GroupModelAllowlist{Enabled: false, Models: []string{"only-model"}}
|
||||
if !disabled.Allows("anything") {
|
||||
@@ -191,6 +206,42 @@ func TestGroupModelAllowlistFilterForListing(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interior and leading wildcards expand source models", func(t *testing.T) {
|
||||
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex", "*sonnet*"}}
|
||||
got := cfg.FilterForListing(source)
|
||||
want := []string{"gpt-5.5-codex", "claude-sonnet-4.5"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("got %#v want %#v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("source prefix wildcard retains only a safe infinite intersection", func(t *testing.T) {
|
||||
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex"}}
|
||||
if !cfg.Allows("gpt-5-codex") || cfg.Allows("gpt-5-mini") {
|
||||
t.Fatal("request admission must distinguish allowed and denied models")
|
||||
}
|
||||
got := cfg.FilterForListing([]string{"gpt-*"})
|
||||
if strings.Join(got, ",") != "gpt-*-codex" {
|
||||
t.Fatalf("expected safe allowlist pattern, got %#v", got)
|
||||
}
|
||||
if groupAllowlistPatternMatches(got[0], "gpt-5-mini") {
|
||||
t.Fatal("listing pattern must not expose unauthorized models")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrepresentable wildcard intersections do not leak source patterns", func(t *testing.T) {
|
||||
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex"}}
|
||||
for _, source := range [][]string{{"gpt-5*"}, {"*-codex"}} {
|
||||
if got := cfg.FilterForListing(source); len(got) != 0 {
|
||||
t.Fatalf("source %#v would expose unauthorized models: %#v", source, got)
|
||||
}
|
||||
}
|
||||
leadingWildcard := GroupModelAllowlist{Enabled: true, Models: []string{"*codex"}}
|
||||
if got := leadingWildcard.FilterForListing([]string{"gpt-*"}); len(got) != 0 {
|
||||
t.Fatalf("source prefix would expose unauthorized models: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed exact and wildcard dedupes globally", func(t *testing.T) {
|
||||
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*", "gpt-5.4"}}
|
||||
got := cfg.FilterForListing(source)
|
||||
|
||||
@@ -52,6 +52,9 @@ type PlazaGroup struct {
|
||||
// = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
|
||||
ImageRateIndependent bool
|
||||
ImageRateMultiplier float64
|
||||
// 视频独立倍率与图片独立倍率分别配置,开启时覆盖分组/用户专属倍率。
|
||||
VideoRateIndependent bool
|
||||
VideoRateMultiplier float64
|
||||
// LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。
|
||||
LongContextPricingEnabled bool
|
||||
Models []PlazaModel
|
||||
@@ -132,6 +135,8 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
VideoRateIndependent: g.VideoRateIndependent,
|
||||
VideoRateMultiplier: g.VideoRateMultiplier,
|
||||
LongContextPricingEnabled: g.LongContextPricingEnabled,
|
||||
}
|
||||
groupEnt[g.ID] = g
|
||||
|
||||
@@ -265,7 +265,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
|
||||
}}
|
||||
groups := []Group{
|
||||
{ID: 10, Name: "g-media", Platform: "openai", RateMultiplier: 1,
|
||||
ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1},
|
||||
ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1,
|
||||
VideoRateIndependent: true, VideoRateMultiplier: 0.7},
|
||||
{ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1},
|
||||
}
|
||||
svc := newPlazaService(channels, groups, nil)
|
||||
@@ -279,6 +280,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
|
||||
|
||||
media := byName["g-media"]
|
||||
require.True(t, media.ImageRateIndependent)
|
||||
require.True(t, media.VideoRateIndependent)
|
||||
require.InDelta(t, 0.7, media.VideoRateMultiplier, 1e-9)
|
||||
require.InDelta(t, 1.0, media.ImageRateMultiplier, 1e-9)
|
||||
require.Len(t, media.Models, 1)
|
||||
p := media.Models[0].Pricing
|
||||
@@ -295,6 +298,7 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
|
||||
|
||||
plain := byName["g-plain"]
|
||||
require.False(t, plain.ImageRateIndependent)
|
||||
require.False(t, plain.VideoRateIndependent)
|
||||
require.Len(t, plain.Models, 1)
|
||||
pp := plain.Models[0].Pricing
|
||||
require.NotNil(t, pp)
|
||||
|
||||
@@ -249,29 +249,30 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
|
||||
resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier
|
||||
resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier
|
||||
resolved.BasePricing.ReasoningEffortMultipliers = maps.Clone(chPricing.ReasoningEffortMultipliers)
|
||||
// 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
|
||||
if chPricing.ImageOutputPrice != nil {
|
||||
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
|
||||
} else {
|
||||
resolved.BasePricing.ImageOutputPricePerToken = 0
|
||||
}
|
||||
resolved.BasePricing.ImageOutputPriceExplicit = true
|
||||
applyChannelImageInputPrice(chPricing, resolved.BasePricing)
|
||||
applyChannelImagePriceOverrides(chPricing, resolved.BasePricing)
|
||||
|
||||
// 区间未命中时回退到上面已经应用渠道覆盖的基础价。
|
||||
resolved.Intervals = filterValidIntervals(chPricing.Intervals)
|
||||
}
|
||||
|
||||
// applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值;
|
||||
// 未配置时归零,使 computeTokenBreakdown 回退到文本输入价(向后兼容,
|
||||
// 避免 commit 引入的 LiteLLM 图片输入价泄漏进渠道自定义定价)。
|
||||
// 与 image_output 不同,此处不设 Explicit 标志——图片输入未配置应回退文本价,
|
||||
// 而非硬置 0。
|
||||
func applyChannelImageInputPrice(chPricing *ChannelModelPricing, pricing *ModelPricing) {
|
||||
if chPricing != nil && chPricing.ImageInputPrice != nil {
|
||||
// applyChannelImagePriceOverrides 应用渠道图片输入/输出价,规则与其他 token 字段一致:
|
||||
// - 渠道填写了(含 0):覆盖目录价。图片输出价同时标记 ImageOutputPriceExplicit,
|
||||
// 显式 0 表示图片输出免费,computeTokenBreakdown 不再回退文本输出价。
|
||||
// - 渠道留空(nil):沿用目录价(LiteLLM 价格表或内置兜底价)。目录也没有图片价时,
|
||||
// computeTokenBreakdown 分别回退文本输入/输出价。
|
||||
//
|
||||
// 此前留空会被归零,图片输出还被标成显式 0:只建了空价卡的图片模型,图片输出
|
||||
// 一律按 0 计费,图片输入按文本价计费。
|
||||
func applyChannelImagePriceOverrides(chPricing *ChannelModelPricing, pricing *ModelPricing) {
|
||||
if chPricing == nil || pricing == nil {
|
||||
return
|
||||
}
|
||||
if chPricing.ImageOutputPrice != nil {
|
||||
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
|
||||
pricing.ImageOutputPriceExplicit = true
|
||||
}
|
||||
if chPricing.ImageInputPrice != nil {
|
||||
pricing.ImageInputPricePerToken = *chPricing.ImageInputPrice
|
||||
} else {
|
||||
pricing.ImageInputPricePerToken = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,15 +370,8 @@ func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *
|
||||
pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier)
|
||||
pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier)
|
||||
}
|
||||
// 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置
|
||||
// (区间不携带图片输入价,与 image_output 一致)。
|
||||
if chPricing != nil {
|
||||
pricing.ImageOutputPriceExplicit = true
|
||||
if chPricing.ImageOutputPrice != nil {
|
||||
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
|
||||
}
|
||||
applyChannelImageInputPrice(chPricing, pricing)
|
||||
}
|
||||
// 区间不携带图片价,沿用渠道级配置;渠道留空时保留基础价里的目录图片价。
|
||||
applyChannelImagePriceOverrides(chPricing, pricing)
|
||||
return pricing
|
||||
}
|
||||
|
||||
|
||||
@@ -722,23 +722,50 @@ func TestFilterValidIntervals(t *testing.T) {
|
||||
// 9. ImageOutputPriceExplicit tests
|
||||
// ===========================================================================
|
||||
|
||||
func TestApplyTokenOverrides_FlatSetsImageOutputPriceExplicit(t *testing.T) {
|
||||
r := newResolverWithChannel(t, []ChannelModelPricing{{
|
||||
// withCatalogImagePrices 给测试基础价补上目录图片价($8 输入 / $30 输出 per MTok)。
|
||||
func withCatalogImagePrices(r *ModelPricingResolver) *ModelPricingResolver {
|
||||
base := r.billingService.fallbackPrices["claude-sonnet-4"]
|
||||
base.ImageInputPricePerToken = 8e-6
|
||||
base.ImageOutputPricePerToken = 30e-6
|
||||
return r
|
||||
}
|
||||
|
||||
func TestApplyTokenOverrides_FlatUnsetImagePricesInheritCatalog(t *testing.T) {
|
||||
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
|
||||
Platform: "anthropic",
|
||||
Models: []string{"claude-sonnet-4"},
|
||||
BillingMode: BillingModeToken,
|
||||
InputPrice: testPtrFloat64(3e-6),
|
||||
OutputPrice: testPtrFloat64(15e-6),
|
||||
// ImageOutputPrice intentionally nil
|
||||
}})
|
||||
// ImageInputPrice / ImageOutputPrice intentionally nil
|
||||
}}))
|
||||
resolved := r.Resolve(context.Background(), PricingInput{
|
||||
Model: "claude-sonnet-4",
|
||||
GroupID: groupIDPtr(),
|
||||
})
|
||||
|
||||
require.Equal(t, PricingSourceChannel, resolved.Source)
|
||||
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit, "留空不应标记显式")
|
||||
require.InDelta(t, 30e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 8e-6, resolved.BasePricing.ImageInputPricePerToken, 1e-12)
|
||||
}
|
||||
|
||||
func TestApplyTokenOverrides_ExplicitZeroImageOutputPriceStaysZero(t *testing.T) {
|
||||
// 渠道显式填 0 仍表示图片输出免费,不回退目录价,也不回退文本输出价。
|
||||
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
|
||||
Platform: "anthropic",
|
||||
Models: []string{"claude-sonnet-4"},
|
||||
BillingMode: BillingModeToken,
|
||||
ImageOutputPrice: testPtrFloat64(0),
|
||||
}}))
|
||||
resolved := r.Resolve(context.Background(), PricingInput{
|
||||
Model: "claude-sonnet-4",
|
||||
GroupID: groupIDPtr(),
|
||||
})
|
||||
|
||||
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
|
||||
require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken)
|
||||
require.InDelta(t, 8e-6, resolved.BasePricing.ImageInputPricePerToken, 1e-12, "图片输入留空仍沿用目录价")
|
||||
}
|
||||
|
||||
func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T) {
|
||||
@@ -759,29 +786,72 @@ func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T)
|
||||
require.InDelta(t, 50e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
|
||||
}
|
||||
|
||||
func TestApplyTokenOverrides_IntervalSetsImageOutputPriceExplicit(t *testing.T) {
|
||||
r := newResolverWithChannel(t, []ChannelModelPricing{{
|
||||
func TestApplyTokenOverrides_IntervalUnsetImagePricesInheritCatalog(t *testing.T) {
|
||||
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
|
||||
Platform: "anthropic",
|
||||
Models: []string{"claude-sonnet-4"},
|
||||
BillingMode: BillingModeToken,
|
||||
// No ImageOutputPrice
|
||||
// No ImageInputPrice / ImageOutputPrice
|
||||
Intervals: []PricingInterval{
|
||||
{MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)},
|
||||
},
|
||||
}})
|
||||
}}))
|
||||
resolved := r.Resolve(context.Background(), PricingInput{
|
||||
Model: "claude-sonnet-4",
|
||||
GroupID: groupIDPtr(),
|
||||
})
|
||||
|
||||
// BasePricing should have explicit mark (for interval fallback)
|
||||
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
|
||||
require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken)
|
||||
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit)
|
||||
require.InDelta(t, 30e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
|
||||
|
||||
// 区间命中时同样沿用目录图片价
|
||||
pricing := r.GetIntervalPricing(resolved, 50000)
|
||||
require.False(t, pricing.ImageOutputPriceExplicit)
|
||||
require.InDelta(t, 30e-6, pricing.ImageOutputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 8e-6, pricing.ImageInputPricePerToken, 1e-12)
|
||||
}
|
||||
|
||||
func TestApplyTokenOverrides_IntervalExplicitImageOutputPrice(t *testing.T) {
|
||||
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
|
||||
Platform: "anthropic",
|
||||
Models: []string{"claude-sonnet-4"},
|
||||
BillingMode: BillingModeToken,
|
||||
ImageOutputPrice: testPtrFloat64(50e-6),
|
||||
Intervals: []PricingInterval{
|
||||
{MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)},
|
||||
},
|
||||
}}))
|
||||
resolved := r.Resolve(context.Background(), PricingInput{
|
||||
Model: "claude-sonnet-4",
|
||||
GroupID: groupIDPtr(),
|
||||
})
|
||||
|
||||
// intervalToModelPricing should also have explicit mark
|
||||
pricing := r.GetIntervalPricing(resolved, 50000)
|
||||
require.True(t, pricing.ImageOutputPriceExplicit)
|
||||
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
|
||||
require.InDelta(t, 50e-6, pricing.ImageOutputPricePerToken, 1e-12)
|
||||
}
|
||||
|
||||
func TestCalculateCostUnified_EmptyChannelEntryBillsCatalogImagePrices(t *testing.T) {
|
||||
// 常见配置:图片模型只建了空价卡(token 模式、价格全留空)。图片输出此前按 0 计费。
|
||||
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
|
||||
Platform: "anthropic",
|
||||
Models: []string{"claude-sonnet-4"},
|
||||
BillingMode: BillingModeToken,
|
||||
}}))
|
||||
tokens := UsageTokens{InputTokens: 1000, ImageInputTokens: 600, OutputTokens: 500, ImageOutputTokens: 500}
|
||||
cost, err := r.billingService.CalculateCostUnified(CostInput{
|
||||
Ctx: context.Background(),
|
||||
Model: "claude-sonnet-4",
|
||||
GroupID: groupIDPtr(),
|
||||
Tokens: tokens,
|
||||
RateMultiplier: 1,
|
||||
Resolver: r,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 500*30e-6, cost.ImageOutputCost, 1e-12)
|
||||
require.InDelta(t, 600*8e-6, cost.ImageInputCost, 1e-12)
|
||||
require.InDelta(t, 400*3e-6, cost.InputCost, 1e-12)
|
||||
require.InDelta(t, 0, cost.OutputCost, 1e-12, "输出全部是图片 token")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -835,7 +905,7 @@ func TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices(t *testing.T)
|
||||
})
|
||||
|
||||
require.NotNil(t, resolved)
|
||||
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
|
||||
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit, "渠道未填图片价,不应标记显式")
|
||||
|
||||
// Global fallbackPrices must NOT be polluted
|
||||
fp := r.billingService.fallbackPrices["claude-sonnet-4"]
|
||||
|
||||
@@ -1906,7 +1906,7 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_FreshUsageWind
|
||||
// inversion) gets excluded from scheduling, and a paused account never receives traffic to
|
||||
// refresh its snapshot. When the snapshot is stale (codex_usage_updated_at older than the
|
||||
// staleness bound) the account must be allowed a request so it can self-heal from the real
|
||||
// response headers — independent of the window's reset time.
|
||||
// response headers when no valid future window reset is known.
|
||||
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_StaleUsageSnapshotSkipsPause_Issue2994(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
primary := Account{
|
||||
@@ -1920,8 +1920,7 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_StaleUsageSnap
|
||||
Extra: map[string]any{
|
||||
"codex_5h_used_percent": 99.0,
|
||||
"auto_pause_5h_threshold": 0.95,
|
||||
// Window has NOT reset yet, so the reset guard stays inactive.
|
||||
"codex_5h_reset_at": time.Now().Add(time.Hour).Format(time.RFC3339),
|
||||
// No reset time is known, so stale snapshot self-heal applies.
|
||||
// Snapshot is stale: older than openAICodexAutoPauseStaleAfter (2h).
|
||||
"codex_usage_updated_at": time.Now().Add(-3 * time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
|
||||
@@ -553,7 +553,10 @@ func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool {
|
||||
}
|
||||
|
||||
func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
|
||||
output, results := parseOpenAIResponsesSSEForAlphaSearch(body)
|
||||
output, results, err := parseOpenAIResponsesSSEForAlphaSearch(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := map[string]any{
|
||||
"output": output,
|
||||
}
|
||||
@@ -563,7 +566,7 @@ func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
|
||||
return json.Marshal(resp)
|
||||
}
|
||||
|
||||
func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) {
|
||||
func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any, error) {
|
||||
text := strings.ReplaceAll(string(body), "\r\n", "\n")
|
||||
var output strings.Builder
|
||||
var completedResponse any
|
||||
@@ -579,21 +582,36 @@ func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) {
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
switch event["type"] {
|
||||
case "error", "response.failed", "response.incomplete":
|
||||
return "", nil, fmt.Errorf("alpha search responses stream ended with %s", event["type"])
|
||||
case "response.completed":
|
||||
response, ok := event["response"].(map[string]any)
|
||||
if !ok || response == nil {
|
||||
return "", nil, fmt.Errorf("alpha search responses completion is missing its response")
|
||||
}
|
||||
if status, present := response["status"]; present && status != "completed" {
|
||||
return "", nil, fmt.Errorf("alpha search responses completion has a non-success status")
|
||||
}
|
||||
completedResponse = response
|
||||
}
|
||||
if delta, _ := event["delta"].(string); delta != "" && event["type"] == "response.output_text.delta" {
|
||||
_, _ = output.WriteString(delta)
|
||||
}
|
||||
if event["type"] == "response.completed" {
|
||||
completedResponse = event["response"]
|
||||
}
|
||||
collectOpenAIAlphaSearchURLCitations(event, &results, seenURLs)
|
||||
}
|
||||
// A transport-level 200, deltas or [DONE] alone are not a successful
|
||||
// search. Do not return partial content or a billable result on EOF.
|
||||
if completedResponse == nil {
|
||||
return "", nil, fmt.Errorf("alpha search responses stream ended before completion")
|
||||
}
|
||||
|
||||
out := output.String()
|
||||
if strings.TrimSpace(out) == "" && completedResponse != nil {
|
||||
out = extractOpenAIResponsesCompletedText(completedResponse)
|
||||
collectOpenAIAlphaSearchURLCitations(completedResponse, &results, seenURLs)
|
||||
}
|
||||
return out, results
|
||||
return out, results, nil
|
||||
}
|
||||
|
||||
func openAIAlphaSearchSSEData(block string) string {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAlphaSearchFallbackRequiresSuccessfulCompletion(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
delta := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"unfinished answer\"}\n\n"
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"truncated": delta,
|
||||
"done_only": delta + "data: [DONE]\n\n",
|
||||
"failed": delta + "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\"}}\n\n",
|
||||
"incomplete": delta + "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\"}}\n\n",
|
||||
"error": delta + "data: {\"type\":\"error\",\"error\":{\"message\":\"unavailable\"}}\n\n",
|
||||
"invalid_completed": delta + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"failed\"}}\n\n",
|
||||
"missing_response": delta + "data: {\"type\":\"response.completed\"}\n\n",
|
||||
}
|
||||
for name, wire := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","commands":{"search_query":[{"q":"example"}]}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(wire)),
|
||||
}}
|
||||
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "at-test-token", "auth_mode": OpenAIAuthModePersonalAccessToken, "chatgpt_account_id": "fixture-account"}}
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result, "a failed search must not produce a billable WebSearchCalls result")
|
||||
require.False(t, c.Writer.Written(), "do not write partial content as a successful search")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlphaSearchFallbackCompletionPreservesOutputAndCitations(t *testing.T) {
|
||||
for _, wire := range []string{
|
||||
alphaSearchResponsesSSE("search result"),
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"search result\",\"annotations\":[{\"type\":\"url_citation\",\"url\":\"https://example.com/news\",\"title\":\"Example News\"}]}]}]}}\n\n",
|
||||
} {
|
||||
body, err := openAIAlphaSearchResponseFromResponsesSSE([]byte(wire))
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"output":"search result","results":[{"type":"text_result","ref_id":"turn0search0","url":"https://example.com/news","title":"Example News"}]}`, string(body))
|
||||
}
|
||||
}
|
||||
@@ -212,8 +212,8 @@ func TestForwardAlphaSearchPATBackfillsMissingChatGPTAccountMetadata(t *testing.
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"output":"search result"}`)),
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(alphaSearchResponsesSSE("search result"))),
|
||||
}}
|
||||
oauthService := NewOpenAIOAuthService(nil, nil)
|
||||
service := &OpenAIGatewayService{
|
||||
|
||||
@@ -77,7 +77,8 @@ func openaiResponsesProbePayload(modelID string) []byte {
|
||||
//
|
||||
// 工具能力探测必须用上游真实存在的模型——用占位模型(DefaultTestModel)打第三方
|
||||
// 上游只会拿到 400 model-not-found,无从判定工具能力。优先取账号 model_mapping
|
||||
// 的上游模型(值),按字典序取首个具体(非通配符)模型以保证可复现;无映射时回退
|
||||
// 的上游模型(值),其中优先通用 GPT 文本模型,同类按字典序取首个具体(非通配符)
|
||||
// 模型以保证可复现;无映射时回退
|
||||
// DefaultTestModel(适配 OpenAI 官方 APIKey 账号)。
|
||||
func selectResponsesProbeModel(account *Account) string {
|
||||
mapping := account.GetModelMapping()
|
||||
@@ -93,6 +94,13 @@ func selectResponsesProbeModel(account *Account) string {
|
||||
return openai.DefaultTestModel
|
||||
}
|
||||
sort.Strings(candidates)
|
||||
// Prefer a general text model over auxiliary entries such as
|
||||
// codex-auto-review or image-only models, which cannot prove tool support.
|
||||
for _, candidate := range candidates {
|
||||
if strings.HasPrefix(candidate, "gpt-") && !isOpenAIImageGenerationModel(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
@@ -259,9 +267,13 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
|
||||
// 其余 2xx 一律可下结论——尤其 status=completed 却只回 reasoning 的上游(火山方舟
|
||||
// coding/v3 × kimi-k2.6),仍按原逻辑判为不支持。
|
||||
//
|
||||
// 非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。
|
||||
// 明确指向探测模型不可用的 400/404(model_not_found 等)只说明模型不存在,不说明端点
|
||||
// 能力,不下结论;其余非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。
|
||||
// 缺少 status 字段的响应体(含非 JSON)也按可下结论处理,保持既有行为。
|
||||
func responsesProbeVerdictIsConclusive(status int, body []byte) bool {
|
||||
if isResponsesProbeModelUnavailable(status, body) {
|
||||
return false
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return true
|
||||
}
|
||||
@@ -296,13 +308,17 @@ func isResponsesEndpointSupportedByStatus(status int) bool {
|
||||
// decideResponsesProbeSupport 依据探测响应判定上游 /v1/responses 是否真正可用于
|
||||
// 携带工具的请求。
|
||||
//
|
||||
// - 404 / 405:端点不存在 → false
|
||||
// - 探测模型不可用的 400/404:与端点能力无关 → true(调用方同时不下结论,不写入)
|
||||
// - 其他 404 / 405:端点不存在 → false
|
||||
// - 其他非 2xx(401/403/422/5xx 等):端点存在,但本次无法判定工具能力
|
||||
// (鉴权/校验/瞬时故障)→ 保守按 true,保持既有"端点存在即支持"行为
|
||||
// - 2xx:探测以 tool_choice=required 强制工具调用,响应必须含 function_call
|
||||
// 输出项才算真正可用;否则(如火山方舟 coding/v3 × kimi-k2.6 仅回 reasoning)
|
||||
// 判为 false,使网关改走 /v1/chat/completions 直转路径。
|
||||
func decideResponsesProbeSupport(status int, body []byte) bool {
|
||||
if isResponsesProbeModelUnavailable(status, body) {
|
||||
return true // The model error says nothing negative about the endpoint.
|
||||
}
|
||||
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed {
|
||||
return false
|
||||
}
|
||||
@@ -312,6 +328,21 @@ func decideResponsesProbeSupport(status int, body []byte) bool {
|
||||
return responsesProbeBodyHasFunctionCall(body)
|
||||
}
|
||||
|
||||
// isResponsesProbeModelUnavailable 判断 400/404 是否只是在说探测模型不可用
|
||||
// (错误码/类型为 model_not_found 等,或错误文案明确说模型不存在/不可用)。
|
||||
func isResponsesProbeModelUnavailable(status int, body []byte) bool {
|
||||
if status != http.StatusBadRequest && status != http.StatusNotFound {
|
||||
return false
|
||||
}
|
||||
for _, path := range []string{"error.code", "error.type", "response.error.code", "response.error.type"} {
|
||||
switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, path).String())) {
|
||||
case "model_not_found", "model_not_available", "unsupported_model", "invalid_model":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isExplicitOpenAIModelAvailabilityMessage(extractUpstreamErrorMessage(body))
|
||||
}
|
||||
|
||||
// responsesProbeBodyHasFunctionCall 判断非流式 Responses 响应体的 output 数组里
|
||||
// 是否存在 function_call 输出项。
|
||||
func responsesProbeBodyHasFunctionCall(body []byte) bool {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A 400/404 that only says the probe model does not exist says nothing about
|
||||
// the /v1/responses endpoint: the verdict must stay inconclusive and the
|
||||
// account capability must not be overwritten with "unsupported".
|
||||
func TestResponsesProbeModelUnavailableIsInconclusive(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"error":{"type":"model_not_found","message":"Model codex-auto-review is not supported by any configured account in this group"}}`,
|
||||
`{"error":{"code":"model_not_available"}}`,
|
||||
`{"error":{"message":"The model missing does not exist"}}`,
|
||||
} {
|
||||
require.False(t, responsesProbeVerdictIsConclusive(404, []byte(body)))
|
||||
require.True(t, decideResponsesProbeSupport(404, []byte(body)))
|
||||
updates := make(chan map[string]any, 1)
|
||||
account := Account{ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test", "base_url": "https://upstream.example"}}
|
||||
repo := &snapshotUpdateAccountRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, updateExtraCalls: updates}
|
||||
svc := &AccountTestService{accountRepo: repo, cfg: &config.Config{}, httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: 404, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)),
|
||||
}}}
|
||||
svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID)
|
||||
select {
|
||||
case <-updates:
|
||||
t.Fatal("a model failure must not overwrite the account endpoint capability")
|
||||
default:
|
||||
}
|
||||
}
|
||||
// A plain 404/405 still means the endpoint does not exist.
|
||||
require.True(t, responsesProbeVerdictIsConclusive(404, []byte("404 page not found")))
|
||||
require.False(t, decideResponsesProbeSupport(404, []byte("404 page not found")))
|
||||
require.False(t, decideResponsesProbeSupport(405, nil))
|
||||
}
|
||||
|
||||
func TestSelectResponsesProbeModelPrefersGeneralTextModel(t *testing.T) {
|
||||
account := &Account{Credentials: map[string]any{"model_mapping": map[string]any{
|
||||
"codex-auto-review": "codex-auto-review", "gpt-image-2": "gpt-image-2", "gpt-5.5": "gpt-5.5",
|
||||
}}}
|
||||
require.Equal(t, "gpt-5.5", selectResponsesProbeModel(account))
|
||||
}
|
||||
@@ -107,6 +107,9 @@ func openAIReasoningEffortToClaudeOutputEffort(effort string) string {
|
||||
// normally translated to OpenAI xhigh, but GPT-5.6 accepts the original max
|
||||
// value on Responses and Chat Completions.
|
||||
func openAICompatAnthropicReasoningEffort(req *apicompat.AnthropicRequest, upstreamModel, convertedEffort string) string {
|
||||
if convertedEffort == "none" {
|
||||
return convertedEffort
|
||||
}
|
||||
if req == nil || req.OutputConfig == nil || !strings.EqualFold(strings.TrimSpace(req.OutputConfig.Effort), "max") {
|
||||
return convertedEffort
|
||||
}
|
||||
|
||||
@@ -313,6 +313,31 @@ func TestForwardAsAnthropic_PreservesMaxForFinalGPT56ResponsesModel(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_DisabledThinkingOverridesMaxForResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"sol","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"thinking":{"type":"disabled"},"output_config":{"effort":"max"},"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_no_thinking", "gpt-5.6-sol")}
|
||||
svc := &OpenAIGatewayService{
|
||||
httpUpstream: upstream,
|
||||
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||||
}
|
||||
account := rawGPT56ResponsesOAuthAccount("sol", "gpt-5.6-sol")
|
||||
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "none", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
|
||||
require.NotNil(t, result.ReasoningEffort)
|
||||
require.Equal(t, "none", *result.ReasoningEffort)
|
||||
}
|
||||
|
||||
func rawGPT56ResponsesAPIKeyAccount(requestedModel, mappedModel string) *Account {
|
||||
return &Account{
|
||||
ID: 501,
|
||||
|
||||
@@ -87,6 +87,66 @@ func TestEnsureDeepSeekChatReasoningPlaceholders(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// OpenCode Zen / Go 把 deepseek-* 模型转发给 DeepSeek,同一条 thinking-mode 400
|
||||
// 会原样回吐,占位注入必须覆盖这条链路;同账号下的非 DeepSeek 模型不受影响。
|
||||
func TestEnsureDeepSeekChatReasoningPlaceholders_OpenCodeZenUpstream(t *testing.T) {
|
||||
openCodeGo := &Account{
|
||||
ID: 24,
|
||||
Name: "opencode-go",
|
||||
Platform: PlatformOpenCodeGo,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-test", "base_url": DefaultOpenCodeGoBaseURL},
|
||||
}
|
||||
openAIViaZen := &Account{
|
||||
ID: 25,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-test", "base_url": DefaultOpenCodeZenBaseURL},
|
||||
}
|
||||
thirdPartyDeepSeekName := &Account{
|
||||
ID: 26,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"},
|
||||
}
|
||||
|
||||
missingFor := func(model string) []byte {
|
||||
return []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`)
|
||||
}
|
||||
|
||||
t.Run("opencode_go_deepseek_model_fills_empty_assistant", func(t *testing.T) {
|
||||
body := missingFor("deepseek-v4.1-flash")
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body)
|
||||
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
|
||||
require.False(t, gjson.GetBytes(got, "messages.0.reasoning_content").Exists())
|
||||
require.False(t, gjson.GetBytes(got, "messages.2.reasoning_content").Exists())
|
||||
})
|
||||
|
||||
t.Run("opencode_go_non_deepseek_model_unchanged", func(t *testing.T) {
|
||||
body := missingFor("glm-5.3")
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body)
|
||||
require.Equal(t, string(body), string(got))
|
||||
})
|
||||
|
||||
t.Run("opencode_prefixed_model_id_fills_empty_assistant", func(t *testing.T) {
|
||||
body := missingFor("opencode-go/DeepSeek-V4-Pro")
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body)
|
||||
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
|
||||
})
|
||||
|
||||
t.Run("openai_platform_pointed_at_zen_host_fills_empty_assistant", func(t *testing.T) {
|
||||
body := missingFor("deepseek-v4-flash")
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(openAIViaZen, body)
|
||||
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
|
||||
})
|
||||
|
||||
t.Run("unrelated_upstream_with_deepseek_model_name_unchanged", func(t *testing.T) {
|
||||
body := missingFor("deepseek-v4.1-flash")
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(thirdPartyDeepSeekName, body)
|
||||
require.Equal(t, string(body), string(got))
|
||||
})
|
||||
}
|
||||
|
||||
type reasoningHitCache struct {
|
||||
stubGatewayCache
|
||||
values map[string]string
|
||||
|
||||
@@ -1454,6 +1454,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
req.Header.Del("OpenAI-Beta")
|
||||
req.Header.Del("originator")
|
||||
} else {
|
||||
stripOpenAILegacyResponsesBeta(req.Header)
|
||||
req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
|
||||
}
|
||||
apiKeyID := getAPIKeyIDFromContext(c)
|
||||
|
||||
@@ -91,6 +91,13 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo
|
||||
mapped: "gpt-5.6-luna",
|
||||
wantEffort: "medium",
|
||||
},
|
||||
{
|
||||
name: "disabled thinking overrides max",
|
||||
model: "gpt-5.6-luna",
|
||||
mapped: "gpt-5.6-luna",
|
||||
effortJSON: `,"output_config":{"effort":"max"},"thinking":{"type":"disabled"}`,
|
||||
wantEffort: "none",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -3210,6 +3210,51 @@ func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastChargesStandard(t *testin
|
||||
require.InDelta(t, standardTotal*0.5, usageRepo.lastLog.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastMissingPricingRecordsZeroCostUsageLog(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
svc := newOpenAIRecordUsageServiceForTest(
|
||||
usageRepo,
|
||||
&openAIRecordUsageUserRepoStub{},
|
||||
&openAIRecordUsageSubRepoStub{},
|
||||
nil,
|
||||
)
|
||||
svc.resolver = NewModelPricingResolver(nil, svc.billingService)
|
||||
groupID := int64(78)
|
||||
serviceTier := "priority"
|
||||
apiKey := &APIKey{
|
||||
ID: 1021,
|
||||
GroupID: &groupID,
|
||||
Group: &Group{
|
||||
ID: groupID, Platform: PlatformOpenAI, Status: StatusActive,
|
||||
Hydrated: true, RateMultiplier: 1, FreeOpenAIFast: true,
|
||||
},
|
||||
}
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
RequestID: "resp_free_fast_missing_pricing",
|
||||
ServiceTier: &serviceTier,
|
||||
Usage: OpenAIUsage{InputTokens: 1200, OutputTokens: 300},
|
||||
Model: "pricing-missing-test-model",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: apiKey,
|
||||
User: &User{ID: 2021},
|
||||
Account: &Account{ID: 3021, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, usageRepo.calls)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
require.Equal(t, "resp_free_fast_missing_pricing", usageRepo.lastLog.RequestID)
|
||||
require.Equal(t, 1200, usageRepo.lastLog.InputTokens)
|
||||
require.Equal(t, 300, usageRepo.lastLog.OutputTokens)
|
||||
require.NotNil(t, usageRepo.lastLog.ServiceTier)
|
||||
require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier)
|
||||
require.Zero(t, usageRepo.lastLog.TotalCost)
|
||||
require.Zero(t, usageRepo.lastLog.ActualCost)
|
||||
}
|
||||
|
||||
func TestGroupBillsOpenAIFastAtStandardRequiresOpenAIAccount(t *testing.T) {
|
||||
apiKey := &APIKey{Group: &Group{Platform: PlatformComposite, FreeOpenAIFast: true}}
|
||||
|
||||
|
||||
@@ -431,15 +431,53 @@ func targetsDeepSeekAPIHost(account *Account) bool {
|
||||
return strings.EqualFold(u.Hostname(), ds.Hostname())
|
||||
}
|
||||
|
||||
// targetsOpenCodeZenUpstream 报告该账号上游是否是官方 OpenCode Zen / Go 网关。
|
||||
// platform=opencode_go 直接命中;其他平台按 base_url 主机判定,覆盖用 API Key
|
||||
// 直接把 openai 平台账号指向 opencode.ai 的接入方式。
|
||||
func targetsOpenCodeZenUpstream(account *Account) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if account.IsOpenCodeGo() {
|
||||
return true
|
||||
}
|
||||
return isOfficialOpenCodeHost(account.GetOpenAIBaseURL())
|
||||
}
|
||||
|
||||
// isDeepSeekCatalogModel 判定(剥掉 opencode 前缀后的)模型 ID 是否属于
|
||||
// DeepSeek 系列,例如 deepseek-v4-flash / deepseek-v4.1-flash / deepseek-chat。
|
||||
func isDeepSeekCatalogModel(model string) bool {
|
||||
return strings.HasPrefix(normalizeOpenCodeGoModelID(model), "deepseek")
|
||||
}
|
||||
|
||||
// requiresDeepSeekChatReasoning 报告该 Chat Completions 请求的上游是否按
|
||||
// DeepSeek thinking mode 语义校验 reasoning_content。
|
||||
//
|
||||
// DeepSeek 官方 API 直接命中。OpenCode Zen / Go 是转发型订阅网关:deepseek-*
|
||||
// 模型由 DeepSeek 实际承载,同一条 400 原文会被一字不差地回吐,所以按
|
||||
// 「官方 OpenCode 上游 + deepseek-* 模型」补一条判定。
|
||||
//
|
||||
// 不放宽成「任意上游的 deepseek-* 模型名」:占位符是写进请求体的额外字段,
|
||||
// 对没有这条约束的上游属于无谓改写,且可能触发未知字段校验。
|
||||
func requiresDeepSeekChatReasoning(account *Account, body []byte) bool {
|
||||
if targetsDeepSeekAPIHost(account) {
|
||||
return true
|
||||
}
|
||||
if !targetsOpenCodeZenUpstream(account) {
|
||||
return false
|
||||
}
|
||||
return isDeepSeekCatalogModel(gjson.GetBytes(body, "model").String())
|
||||
}
|
||||
|
||||
// ensureDeepSeekChatReasoningPlaceholders 给缺 reasoning_content 的 assistant
|
||||
// 消息补单个空格占位。DeepSeek thinking mode 要求历史里每条产生过思维的
|
||||
// assistant 消息都回传该字段,否则 400
|
||||
// "The `reasoning_content` in the thinking mode must be passed back to the API"。
|
||||
//
|
||||
// 桥接会从 summary / 缓存回注真实明文;这里只填仍为空的缺口,不覆盖已有内容。
|
||||
// 非 DeepSeek 上游原样返回(字节不变)。
|
||||
// 判定见 requiresDeepSeekChatReasoning,不命中的上游原样返回(字节不变)。
|
||||
func ensureDeepSeekChatReasoningPlaceholders(account *Account, body []byte) []byte {
|
||||
if !targetsDeepSeekAPIHost(account) {
|
||||
if !requiresDeepSeekChatReasoning(account, body) {
|
||||
return body
|
||||
}
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
|
||||
@@ -529,11 +529,11 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account)
|
||||
utilization5h, has5h := resolveOpenAIQuotaUtilization(account.Extra, "5h", now)
|
||||
utilization7d, has7d := resolveOpenAIQuotaUtilization(account.Extra, "7d", now)
|
||||
if has5h && utilization5h >= config.Threshold5h {
|
||||
notifyOpenAIAutoReset(account.ID)
|
||||
notifyOpenAIAutoResetFromScheduler(account.ID)
|
||||
return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: config.Threshold5h, utilization: utilization5h, reason: "quota_auto_reset_pending_5h"}
|
||||
}
|
||||
if has7d && utilization7d >= config.Threshold7d {
|
||||
notifyOpenAIAutoReset(account.ID)
|
||||
notifyOpenAIAutoResetFromScheduler(account.ID)
|
||||
return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: config.Threshold7d, utilization: utilization7d, reason: "quota_auto_reset_pending_7d"}
|
||||
}
|
||||
|
||||
@@ -547,7 +547,7 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account)
|
||||
if state != nil && state.Status == OpenAIAutoResetStatusAvailable && state.AvailableCount > 0 && !openAIAutoResetStateStale(state, now) {
|
||||
return false, openAIQuotaAutoPauseDecision{}
|
||||
}
|
||||
notifyOpenAIAutoReset(account.ID)
|
||||
notifyOpenAIAutoResetFromScheduler(account.ID)
|
||||
if pauseReached5h {
|
||||
return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: pause5h, utilization: utilization5h, reason: "quota_auto_reset_credit_check_5h"}
|
||||
}
|
||||
@@ -662,8 +662,8 @@ func resolveAccountExtraNumber(extra map[string]any, keys ...string) (float64, b
|
||||
|
||||
// resolveOpenAIQuotaUtilization returns the current utilization ratio (0..1) for the
|
||||
// given Codex usage window. ok=false means there is no usable signal to pause on:
|
||||
// either no snapshot exists, or the window has already rolled over so the cached
|
||||
// percentage is stale. The stale guard matters because a paused account stops
|
||||
// either no snapshot exists, the window has rolled over, or an old snapshot has
|
||||
// no known future reset. The stale guard matters because a paused account stops
|
||||
// receiving requests, so its snapshot is never refreshed from upstream headers —
|
||||
// without this check an old used_percent would keep the account paused forever even
|
||||
// after the real window reset.
|
||||
@@ -675,14 +675,19 @@ func resolveOpenAIQuotaUtilization(extra map[string]any, window string, now time
|
||||
if openAIQuotaWindowReset(extra, window, now) {
|
||||
return 0, false
|
||||
}
|
||||
// 快照过于陈旧(账号长期未收到流量刷新)时,不再据此暂停。放行后下一次响应头
|
||||
// 快照过于陈旧且没有明确的未来重置时间时,不再据此暂停。放行后下一次响应头
|
||||
// 会刷新快照实现自愈,避免账号在错误/过期的 used% 上被永久跳过(issue #2994)。
|
||||
if openAICodexSnapshotStaleForPause(extra, now) {
|
||||
if openAICodexSnapshotStaleForPause(extra, now) && !openAIQuotaWindowResetPending(extra, window, now) {
|
||||
return 0, false
|
||||
}
|
||||
return usedPercent / 100, true
|
||||
}
|
||||
|
||||
func openAIQuotaWindowResetPending(extra map[string]any, window string, now time.Time) bool {
|
||||
resetAt, ok := openAICodexWindowResetAt(extra, window)
|
||||
return ok && now.Before(resetAt)
|
||||
}
|
||||
|
||||
// openAICodexSnapshotStaleForPause reports whether the Codex usage snapshot is stale
|
||||
// enough that it should no longer keep an account auto-paused. It anchors on
|
||||
// codex_usage_updated_at (always written by buildCodexUsageExtraUpdates). A missing or
|
||||
|
||||
@@ -75,6 +75,7 @@ var openaiAllowedHeaders = map[string]bool{
|
||||
"accept-language": true,
|
||||
"content-type": true,
|
||||
"conversation_id": true,
|
||||
"openai-beta": true,
|
||||
"user-agent": true,
|
||||
"originator": true,
|
||||
"session_id": true,
|
||||
|
||||
@@ -3250,6 +3250,58 @@ func TestOpenAIBuildUpstreamRequestOAuthMessagesBridgeUsesSessionOnly(t *testing
|
||||
require.Empty(t, req.Header.Get("originator"))
|
||||
}
|
||||
|
||||
func TestOpenAIBuildUpstreamRequestOAuthResponsesPreservesCallerBeta(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
makeRequest := func(betaValues ...string) *gin.Context {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(`{"model":"gpt-5.5","input":"hello"}`)))
|
||||
for _, value := range betaValues {
|
||||
c.Request.Header.Add("OpenAI-Beta", value)
|
||||
}
|
||||
return c
|
||||
}
|
||||
svc := &OpenAIGatewayService{}
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-acc"}}
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-api-key"}}
|
||||
|
||||
t.Run("preserves caller multi-agent beta", func(t *testing.T) {
|
||||
c := makeRequest("responses_multi_agent=v1")
|
||||
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "responses_multi_agent=v1", req.Header.Get("OpenAI-Beta"))
|
||||
})
|
||||
|
||||
t.Run("preserves mixed beta tokens and removes legacy token", func(t *testing.T) {
|
||||
c := makeRequest("responses_multi_agent=v1, responses=experimental", "future_feature=v2")
|
||||
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"responses_multi_agent=v1", "future_feature=v2"}, req.Header.Values("OpenAI-Beta"))
|
||||
})
|
||||
|
||||
t.Run("absent beta remains absent", func(t *testing.T) {
|
||||
c := makeRequest()
|
||||
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, req.Header.Values("OpenAI-Beta"))
|
||||
})
|
||||
|
||||
t.Run("api key remains caller controlled", func(t *testing.T) {
|
||||
c := makeRequest("responses=experimental, responses_multi_agent=v1")
|
||||
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, apiKey, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"responses=experimental, responses_multi_agent=v1"}, req.Header.Values("OpenAI-Beta"))
|
||||
})
|
||||
|
||||
t.Run("compact OAuth removes legacy token and preserves independent beta", func(t *testing.T) {
|
||||
c := makeRequest("responses=experimental, future_feature=v1")
|
||||
c.Request.URL.Path = "/v1/responses/compact"
|
||||
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", false, "", false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "future_feature=v1", req.Header.Get("OpenAI-Beta"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -314,10 +314,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
longContextBillingGate,
|
||||
pricingAt,
|
||||
)
|
||||
if standardErr != nil {
|
||||
if standardErr != nil && !isUsagePricingUnavailableError(standardErr) {
|
||||
return standardErr
|
||||
}
|
||||
if cost != nil && standardCost != nil {
|
||||
// Missing pricing already fell back to a zero-cost log above; keep that
|
||||
// usage row instead of dropping it on the Standard re-evaluation.
|
||||
if standardErr == nil && cost != nil && standardCost != nil {
|
||||
cost.ActualCost = standardCost.ActualCost
|
||||
}
|
||||
}
|
||||
@@ -482,6 +484,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
|
||||
account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
|
||||
tokens, cost.TotalCost, pricingAt,
|
||||
accountStatsLongContextPricingEnabled(longContextBillingGate),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ const (
|
||||
openAIAutoResetQueueCapacity = 1024
|
||||
openAIAutoResetAttemptTTL = 8 * 24 * time.Hour
|
||||
openAIAutoResetLeaderLockKey = "jobs:openai-auto-reset-credit"
|
||||
// 查询阶段失败后的最短重试间隔。调度热路径和扫描都会触发评估,不设间隔时上游
|
||||
// 故障期间同一账号会被十几秒重查一次;用卡阶段的失败不受此限,超时后仍立即用
|
||||
// 同一幂等键重试。
|
||||
openAIAutoResetQueryFailureRetryAfter = time.Minute
|
||||
// 调度热路径每次过滤候选都会评估暂停,对同一账号的通知按此冷却合并;
|
||||
// 后台每分钟全量扫描兜底,冷却不会让账号漏检。
|
||||
openAIAutoResetSchedulerNotifyCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -288,10 +295,16 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou
|
||||
now := time.Now()
|
||||
assessment := s.assessExtra(account, config, now)
|
||||
state := openAIAutoResetStateFromExtra(account.Extra)
|
||||
needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) || assessment.resetReached
|
||||
// 达到用卡阈值本应立即查询以便用卡;但 10 分钟内已确认无卡时,重查不会改变结论,
|
||||
// 只会让调度热路径的通知把同一账号的上游额度接口打到十几秒一次。
|
||||
needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) ||
|
||||
(assessment.resetReached && !openAIAutoResetNoCreditConfirmed(state, now))
|
||||
if assessment.pauseReached && !assessment.resetReached {
|
||||
needsQuery = needsQuery || state == nil || state.Status == OpenAIAutoResetStatusChecking || state.Status == OpenAIAutoResetStatusFailed || openAIAutoResetStateStale(state, now)
|
||||
}
|
||||
if needsQuery && openAIAutoResetQueryFailureBackoffActive(state, now) {
|
||||
needsQuery = false
|
||||
}
|
||||
if !needsQuery {
|
||||
if !assessment.pauseReached && state != nil && state.TriggerWindow != "" {
|
||||
state.TriggerWindow = ""
|
||||
@@ -690,6 +703,30 @@ func openAIAutoResetStateFromExtra(extra map[string]any) *OpenAIAutoResetCreditS
|
||||
return &state
|
||||
}
|
||||
|
||||
// openAIAutoResetNoCreditConfirmed 报告账号在状态有效期内已被确认无卡。
|
||||
func openAIAutoResetNoCreditConfirmed(state *OpenAIAutoResetCreditState, now time.Time) bool {
|
||||
return state != nil && state.Status == OpenAIAutoResetStatusNoCredit && !openAIAutoResetStateStale(state, now)
|
||||
}
|
||||
|
||||
// openAIAutoResetQueryFailureBackoffActive 报告查询阶段刚失败、仍在重试间隔内。
|
||||
// 只覆盖查询、快照写入、卡明细缺失三类失败;用卡阶段的失败需要立即按原幂等键重试。
|
||||
func openAIAutoResetQueryFailureBackoffActive(state *OpenAIAutoResetCreditState, now time.Time) bool {
|
||||
if state == nil || state.Status != OpenAIAutoResetStatusFailed {
|
||||
return false
|
||||
}
|
||||
switch state.ErrorCode {
|
||||
case "RESET_CREDIT_QUERY_FAILED", "USAGE_SNAPSHOT_WRITE_FAILED", "RESET_CREDIT_DETAILS_UNAVAILABLE":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
failedAt, err := time.Parse(time.RFC3339, state.LastResultAt)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
elapsed := now.Sub(failedAt)
|
||||
return elapsed >= 0 && elapsed < openAIAutoResetQueryFailureRetryAfter
|
||||
}
|
||||
|
||||
func openAIAutoResetStateStale(state *OpenAIAutoResetCreditState, now time.Time) bool {
|
||||
if state == nil || state.CheckedAt == "" {
|
||||
return true
|
||||
@@ -808,3 +845,29 @@ func notifyOpenAIAutoReset(accountID int64) {
|
||||
func NotifyOpenAIAutoResetCredit(accountID int64) {
|
||||
notifyOpenAIAutoReset(accountID)
|
||||
}
|
||||
|
||||
// openAIAutoResetSchedulerNotifiedAt 记录调度热路径最近一次为某账号发出通知的时间。
|
||||
var openAIAutoResetSchedulerNotifiedAt sync.Map // accountID(int64) -> time.Time
|
||||
|
||||
// notifyOpenAIAutoResetFromScheduler 供调度候选过滤使用。候选过滤按请求逐账号执行,
|
||||
// 每条通知都会让后台读一次账号,不做冷却时无卡或待用卡的账号会持续占满后台协程。
|
||||
func notifyOpenAIAutoResetFromScheduler(accountID int64) {
|
||||
notifyOpenAIAutoResetFromSchedulerAt(accountID, time.Now())
|
||||
}
|
||||
|
||||
func notifyOpenAIAutoResetFromSchedulerAt(accountID int64, now time.Time) bool {
|
||||
if accountID <= 0 {
|
||||
return false
|
||||
}
|
||||
if last, ok := openAIAutoResetSchedulerNotifiedAt.Load(accountID); ok {
|
||||
if lastAt, ok := last.(time.Time); ok {
|
||||
elapsed := now.Sub(lastAt)
|
||||
if elapsed >= 0 && elapsed < openAIAutoResetSchedulerNotifyCooldown {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
openAIAutoResetSchedulerNotifiedAt.Store(accountID, now)
|
||||
notifyOpenAIAutoReset(accountID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -100,6 +101,43 @@ func TestShouldAutoPauseOpenAIAccountByQuota_AutoResetCreditStates(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifyOpenAIAutoResetFromScheduler_CoolsDownPerAccount(t *testing.T) {
|
||||
svc := &OpenAIQuotaAutoResetService{ctx: context.Background(), queue: make(chan int64, 8)}
|
||||
setOpenAIAutoResetNotifier(svc)
|
||||
t.Cleanup(func() { clearOpenAIAutoResetNotifier(svc) })
|
||||
|
||||
const accountA, accountB int64 = 9_900_001, 9_900_002
|
||||
t.Cleanup(func() {
|
||||
openAIAutoResetSchedulerNotifiedAt.Delete(accountA)
|
||||
openAIAutoResetSchedulerNotifiedAt.Delete(accountB)
|
||||
})
|
||||
drain := func() []int64 {
|
||||
var got []int64
|
||||
for {
|
||||
select {
|
||||
case id := <-svc.queue:
|
||||
svc.pending.Delete(id)
|
||||
got = append(got, id)
|
||||
default:
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base := time.Now()
|
||||
require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base))
|
||||
require.Equal(t, []int64{accountA}, drain())
|
||||
|
||||
require.False(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base.Add(10*time.Second)), "冷却期内同一账号不重复通知")
|
||||
require.Empty(t, drain())
|
||||
|
||||
require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountB, base.Add(10*time.Second)), "冷却按账号独立")
|
||||
require.Equal(t, []int64{accountB}, drain())
|
||||
|
||||
require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base.Add(openAIAutoResetSchedulerNotifyCooldown)))
|
||||
require.Equal(t, []int64{accountA}, drain())
|
||||
}
|
||||
|
||||
func TestSelectOpenAIAutoResetCandidate_FailsClosed(t *testing.T) {
|
||||
candidates := []openAIAutoResetCreditCandidate{
|
||||
{ID: "later", ExpiresAt: "2026-09-02T00:00:00Z"},
|
||||
@@ -181,9 +219,15 @@ type autoResetTestQuota struct {
|
||||
mu sync.Mutex
|
||||
resetArgs [][2]string
|
||||
failFirst bool
|
||||
queryCalls atomic.Int32
|
||||
queryErr error
|
||||
}
|
||||
|
||||
func (q *autoResetTestQuota) QueryUsage(context.Context, int64) (*OpenAIQuotaUsage, error) {
|
||||
q.queryCalls.Add(1)
|
||||
if q.queryErr != nil {
|
||||
return nil, q.queryErr
|
||||
}
|
||||
copy := *q.usage
|
||||
return ©, nil
|
||||
}
|
||||
@@ -334,3 +378,100 @@ func TestOpenAIQuotaAutoResetService_TimeoutRetryReusesRequestBody(t *testing.T)
|
||||
require.Len(t, args, 2)
|
||||
require.Equal(t, args[0], args[1], "超时重试必须复用相同 credit_id 与 redeem_request_id")
|
||||
}
|
||||
|
||||
// newResetThresholdTestFixture 构造一个 7d 用量已过用卡阈值、处于停调中的账号。
|
||||
func newResetThresholdTestFixture(t *testing.T, state *OpenAIAutoResetCreditState, availableCredits int) (*OpenAIQuotaAutoResetService, *autoResetTestAccountRepo, *autoResetTestQuota) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
extra := map[string]any{
|
||||
OpenAIAutoResetCreditEnabledExtraKey: true,
|
||||
OpenAIAutoResetCredit5hThresholdExtraKey: 0.95,
|
||||
OpenAIAutoResetCredit7dThresholdExtraKey: 0.95,
|
||||
"codex_7d_used_percent": 99.0,
|
||||
"codex_usage_updated_at": now.Format(time.RFC3339),
|
||||
"codex_7d_reset_at": now.Add(48 * time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
if state != nil {
|
||||
extra[OpenAIAutoResetCreditStateExtraKey] = state
|
||||
}
|
||||
account := &Account{ID: 501, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Extra: extra}
|
||||
repo := &autoResetTestAccountRepo{account: account}
|
||||
quota := &autoResetTestQuota{usage: &OpenAIQuotaUsage{
|
||||
FetchedAt: now.Unix(),
|
||||
RateLimit: &OpenAIRateLimit{
|
||||
SecondaryWindow: &OpenAIRateLimitWindow{UsedPercent: 99, LimitWindowSeconds: 7 * 24 * 60 * 60, ResetAfterSeconds: 48 * 3600, ResetAt: now.Add(48 * time.Hour).Unix()},
|
||||
},
|
||||
RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: availableCredits},
|
||||
}}
|
||||
idempotencyConfig := DefaultIdempotencyConfig()
|
||||
idempotencyConfig.ObserveOnly = false
|
||||
service := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{},
|
||||
NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), idempotencyConfig), nil, nil, nil)
|
||||
return service, repo, quota
|
||||
}
|
||||
|
||||
func (r *autoResetTestAccountRepo) setExtraForTest(key string, value any) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.account.Extra[key] = value
|
||||
}
|
||||
|
||||
func (r *autoResetTestAccountRepo) stateForTest() *OpenAIAutoResetCreditState {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return openAIAutoResetStateFromExtra(r.account.Extra)
|
||||
}
|
||||
|
||||
func TestOpenAIQuotaAutoResetService_ResetThresholdSkipsRequeryWhileNoCreditIsFresh(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service, repo, quota := newResetThresholdTestFixture(t, &OpenAIAutoResetCreditState{
|
||||
Status: OpenAIAutoResetStatusNoCredit, TriggerWindow: "7d", CheckedAt: now.Format(time.RFC3339), ErrorCode: "NO_RESET_CREDIT",
|
||||
}, 0)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
require.NoError(t, service.evaluateAccount(context.Background(), 501))
|
||||
}
|
||||
require.Zero(t, quota.queryCalls.Load(), "10 分钟内已确认无卡,调度通知不应再触发上游查询")
|
||||
|
||||
stale := now.Add(-openAIAutoResetSnapshotTTL - time.Minute).Format(time.RFC3339)
|
||||
repo.setExtraForTest(OpenAIAutoResetCreditStateExtraKey, &OpenAIAutoResetCreditState{
|
||||
Status: OpenAIAutoResetStatusNoCredit, TriggerWindow: "7d", CheckedAt: stale, ErrorCode: "NO_RESET_CREDIT",
|
||||
})
|
||||
repo.setExtraForTest("codex_usage_updated_at", stale)
|
||||
require.NoError(t, service.evaluateAccount(context.Background(), 501))
|
||||
require.Equal(t, int32(1), quota.queryCalls.Load(), "无卡结论过期后应重新查询,以便发现新发放的卡")
|
||||
require.Equal(t, OpenAIAutoResetStatusNoCredit, repo.stateForTest().Status)
|
||||
}
|
||||
|
||||
func TestOpenAIQuotaAutoResetService_ResetThresholdStillQueriesWhenCreditAvailable(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service, _, quota := newResetThresholdTestFixture(t, &OpenAIAutoResetCreditState{
|
||||
Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, TriggerWindow: "7d", CheckedAt: now.Format(time.RFC3339),
|
||||
}, 0)
|
||||
|
||||
require.NoError(t, service.evaluateAccount(context.Background(), 501))
|
||||
require.Equal(t, int32(1), quota.queryCalls.Load(), "有卡时到达用卡阈值必须立即查询并进入用卡流程")
|
||||
}
|
||||
|
||||
func TestOpenAIQuotaAutoResetService_QueryFailureBacksOffBeforeRetry(t *testing.T) {
|
||||
service, repo, quota := newResetThresholdTestFixture(t, nil, 0)
|
||||
quota.queryErr = errors.New("upstream EOF")
|
||||
|
||||
require.Error(t, service.evaluateAccount(context.Background(), 501))
|
||||
require.Equal(t, int32(1), quota.queryCalls.Load())
|
||||
state := repo.stateForTest()
|
||||
require.Equal(t, OpenAIAutoResetStatusFailed, state.Status)
|
||||
require.Equal(t, "RESET_CREDIT_QUERY_FAILED", state.ErrorCode)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
require.NoError(t, service.evaluateAccount(context.Background(), 501))
|
||||
}
|
||||
require.Equal(t, int32(1), quota.queryCalls.Load(), "查询失败后一分钟内不应重复打上游")
|
||||
|
||||
state.LastResultAt = time.Now().UTC().Add(-openAIAutoResetQueryFailureRetryAfter - time.Second).Format(time.RFC3339)
|
||||
repo.setExtraForTest(OpenAIAutoResetCreditStateExtraKey, state)
|
||||
quota.queryErr = nil
|
||||
require.NoError(t, service.evaluateAccount(context.Background(), 501))
|
||||
require.Equal(t, int32(2), quota.queryCalls.Load(), "重试间隔过后应恢复查询")
|
||||
require.Equal(t, OpenAIAutoResetStatusNoCredit, repo.stateForTest().Status)
|
||||
}
|
||||
|
||||
@@ -91,9 +91,18 @@ func classifyUpstreamTransportError(err error) upstreamTransportErrorClass {
|
||||
return upstreamTransportErrorClass{}
|
||||
}
|
||||
|
||||
// isClientCanceledTransportError reports whether a transport-level failure was
|
||||
// caused by the client disconnecting: the request context itself is canceled
|
||||
// and the round-trip aborted with context.Canceled. Such a failure says nothing
|
||||
// about the upstream, so it is not recorded as an Ops upstream error event.
|
||||
func isClientCanceledTransportError(ctx context.Context, err error) bool {
|
||||
return errors.Is(err, context.Canceled) && ctx != nil && errors.Is(ctx.Err(), context.Canceled)
|
||||
}
|
||||
|
||||
// handleOpenAIUpstreamTransportError handles a transport-level upstream failure
|
||||
// (Do/DoWithTLS returned a non-HTTP error: proxy/DNS/TCP/TLS). It:
|
||||
// 1. records the failure in Ops error logs (status 0, kind=request_error);
|
||||
// 1. records the failure in Ops error logs (status 0, kind=request_error),
|
||||
// except when the client disconnected (see isClientCanceledTransportError);
|
||||
// 2. for durable faults (expired/rejected proxy creds, dead proxy, DNS/routing)
|
||||
// temporarily unschedules the account (DB + in-memory) and logs a stable
|
||||
// warn event that alert rules can key on;
|
||||
@@ -106,6 +115,9 @@ func classifyUpstreamTransportError(err error) upstreamTransportErrorClass {
|
||||
//
|
||||
// passthrough tags the Ops error event for the OpenAI passthrough forward path.
|
||||
func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, passthrough bool) error {
|
||||
if isClientCanceledTransportError(ctx, err) {
|
||||
return err
|
||||
}
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
|
||||
@@ -1340,6 +1340,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
turnPrevRecoveryTried := false
|
||||
lastTurnFinishedAt := time.Time{}
|
||||
lastTurnResponseID := ""
|
||||
lastTurnWindowID := ""
|
||||
lastTurnPayload := []byte(nil)
|
||||
var lastTurnStrictState *openAIWSIngressPreviousTurnStrictState
|
||||
lastTurnReplayInput := []json.RawMessage(nil)
|
||||
@@ -1484,8 +1485,33 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
lastTurnReplayInput, _ = stripOpenAIInvalidEncryptedContentFromReplayItems(lastTurnReplayInput, invalidDigests)
|
||||
}
|
||||
}
|
||||
boundaryPayload, contextWindowBoundary, boundaryErr := normalizeOpenAIWSContextWindowBoundary(
|
||||
currentPayload,
|
||||
lastTurnWindowID,
|
||||
)
|
||||
if boundaryErr != nil {
|
||||
return fmt.Errorf("normalize Codex websocket context-window boundary: %w", boundaryErr)
|
||||
}
|
||||
if contextWindowBoundary.Changed {
|
||||
currentPayload = boundaryPayload
|
||||
currentPayloadBytes = len(boundaryPayload)
|
||||
logOpenAIWSModeInfo(
|
||||
"ingress_ws_context_window_changed account_id=%d turn=%d conn_id=%s action=break_previous_response_chain previous_window_id=%s current_window_id=%s previous_response_id_removed=%v",
|
||||
account.ID,
|
||||
turn,
|
||||
truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen),
|
||||
truncateOpenAIWSLogValue(lastTurnWindowID, openAIWSIDValueMaxLen),
|
||||
truncateOpenAIWSLogValue(contextWindowBoundary.WindowID, openAIWSIDValueMaxLen),
|
||||
contextWindowBoundary.PreviousResponseIDRemoved,
|
||||
)
|
||||
}
|
||||
currentPreviousResponseID := openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id")
|
||||
expectedPrev := strings.TrimSpace(lastTurnResponseID)
|
||||
if contextWindowBoundary.Changed {
|
||||
// A context-window rollover is a new Responses root. Do not infer a
|
||||
// continuation anchor from the response produced in the old window.
|
||||
expectedPrev = ""
|
||||
}
|
||||
toolSignals := ToolContinuationSignals{
|
||||
HasFunctionCallOutput: openAIWSRawPayloadHasToolCallOutput(currentPayload),
|
||||
}
|
||||
@@ -1822,6 +1848,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
responseID := strings.TrimSpace(result.RequestID)
|
||||
lastTurnResponseID = responseID
|
||||
if contextWindowBoundary.WindowID != "" {
|
||||
lastTurnWindowID = contextWindowBoundary.WindowID
|
||||
}
|
||||
// 正文共享:currentPayload/currentTurnReplayInput 均不可变,历史直接引用;
|
||||
// collector 增量经 combine 合并(新头数组)。
|
||||
lastTurnReplayInput = currentTurnReplayInput
|
||||
|
||||
@@ -144,6 +144,49 @@ func TestDropPreviousResponseIDFromRawPayload(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIWSContextWindowBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("same_window_keeps_previous_response_id", func(t *testing.T) {
|
||||
payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-window-id\":\"window-a\"}}")
|
||||
updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a")
|
||||
require.NoError(t, err)
|
||||
require.False(t, boundary.Changed)
|
||||
require.False(t, boundary.PreviousResponseIDRemoved)
|
||||
require.Equal(t, "window-a", boundary.WindowID)
|
||||
require.Equal(t, "resp_old", gjson.GetBytes(updated, "previous_response_id").String())
|
||||
})
|
||||
|
||||
t.Run("new_window_drops_previous_response_id", func(t *testing.T) {
|
||||
payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-window-id\":\"window-b\"}}")
|
||||
updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a")
|
||||
require.NoError(t, err)
|
||||
require.True(t, boundary.Changed)
|
||||
require.True(t, boundary.PreviousResponseIDRemoved)
|
||||
require.Equal(t, "window-b", boundary.WindowID)
|
||||
require.False(t, gjson.GetBytes(updated, "previous_response_id").Exists())
|
||||
})
|
||||
|
||||
t.Run("new_window_without_previous_response_id_still_marks_boundary", func(t *testing.T) {
|
||||
payload := []byte("{\"type\":\"response.create\",\"client_metadata\":{\"x-codex-window-id\":\"window-b\"}}")
|
||||
updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a")
|
||||
require.NoError(t, err)
|
||||
require.True(t, boundary.Changed)
|
||||
require.False(t, boundary.PreviousResponseIDRemoved)
|
||||
require.Equal(t, string(payload), string(updated))
|
||||
})
|
||||
|
||||
t.Run("embedded_turn_metadata_is_fallback", func(t *testing.T) {
|
||||
payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-turn-metadata\":\"{\\\"window_id\\\":\\\"window-b\\\"}\"}}")
|
||||
updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a")
|
||||
require.NoError(t, err)
|
||||
require.True(t, boundary.Changed)
|
||||
require.True(t, boundary.PreviousResponseIDRemoved)
|
||||
require.Equal(t, "window-b", boundary.WindowID)
|
||||
require.False(t, gjson.GetBytes(updated, "previous_response_id").Exists())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStripCodexSparkImageGenerationToolFromRawPayload(t *testing.T) {
|
||||
t.Run("strips_image_generation_for_spark", func(t *testing.T) {
|
||||
payload := []byte(`{"type":"response.create","model":"gpt-5.3-codex-spark","tools":[{"type":"function","name":"shell"},{"type":"image_generation","output_format":"png"}]}`)
|
||||
|
||||
@@ -378,6 +378,48 @@ func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string
|
||||
return rebuilt, nil
|
||||
}
|
||||
|
||||
type openAIWSContextWindowBoundary struct {
|
||||
WindowID string
|
||||
Changed bool
|
||||
PreviousResponseIDRemoved bool
|
||||
}
|
||||
|
||||
func openAIWSPayloadCodexWindowID(payload []byte) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
if windowID := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-window-id").String()); windowID != "" {
|
||||
return windowID
|
||||
}
|
||||
turnMetadata := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-turn-metadata").String())
|
||||
if turnMetadata == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(gjson.Get(turnMetadata, "window_id").String())
|
||||
}
|
||||
|
||||
// normalizeOpenAIWSContextWindowBoundary breaks a Responses continuation chain
|
||||
// when Codex moves to a new local context window. WebSocket response.create can
|
||||
// still carry the previous window's previous_response_id after new_context,
|
||||
// while HTTP starts the new window without that continuation anchor.
|
||||
func normalizeOpenAIWSContextWindowBoundary(
|
||||
payload []byte,
|
||||
previousWindowID string,
|
||||
) ([]byte, openAIWSContextWindowBoundary, error) {
|
||||
currentWindowID := openAIWSPayloadCodexWindowID(payload)
|
||||
boundary := openAIWSContextWindowBoundary{WindowID: currentWindowID}
|
||||
if previousWindowID == "" || currentWindowID == "" || currentWindowID == previousWindowID {
|
||||
return payload, boundary, nil
|
||||
}
|
||||
boundary.Changed = true
|
||||
updated, removed, err := dropPreviousResponseIDFromRawPayload(payload)
|
||||
if err != nil {
|
||||
return payload, boundary, err
|
||||
}
|
||||
boundary.PreviousResponseIDRemoved = removed
|
||||
return updated, boundary, nil
|
||||
}
|
||||
|
||||
func shouldInferIngressFunctionCallOutputPreviousResponseID(
|
||||
storeDisabled bool,
|
||||
turn int,
|
||||
|
||||
@@ -495,10 +495,6 @@ func writeConfigFile(cfg *SetupConfig) error {
|
||||
APIKeyPrefix string `yaml:"api_key_prefix"`
|
||||
RateMultiplier float64 `yaml:"rate_multiplier"`
|
||||
} `yaml:"default"`
|
||||
RateLimit struct {
|
||||
RequestsPerMinute int `yaml:"requests_per_minute"`
|
||||
BurstSize int `yaml:"burst_size"`
|
||||
} `yaml:"rate_limit"`
|
||||
Timezone string `yaml:"timezone"`
|
||||
}{
|
||||
Server: cfg.Server,
|
||||
@@ -522,13 +518,6 @@ func writeConfigFile(cfg *SetupConfig) error {
|
||||
APIKeyPrefix: "sk-",
|
||||
RateMultiplier: 1.0,
|
||||
},
|
||||
RateLimit: struct {
|
||||
RequestsPerMinute int `yaml:"requests_per_minute"`
|
||||
BurstSize int `yaml:"burst_size"`
|
||||
}{
|
||||
RequestsPerMinute: 60,
|
||||
BurstSize: 10,
|
||||
},
|
||||
Timezone: tz,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/lib/pq"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestDecideAdminBootstrap(t *testing.T) {
|
||||
@@ -231,6 +232,48 @@ func TestWriteConfigFileIncludesRedisUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfigFileOmitsObsoleteRateLimit(t *testing.T) {
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
|
||||
if err := writeConfigFile(&SetupConfig{
|
||||
Redis: RedisConfig{Host: "redis", Port: 6379, Username: "app-user"},
|
||||
Timezone: "UTC",
|
||||
}); err != nil {
|
||||
t.Fatalf("writeConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(GetConfigFilePath())
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
var config map[string]any
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
t.Fatalf("generated config is invalid YAML: %v", err)
|
||||
}
|
||||
if rateLimit, exists := config["rate_limit"]; exists {
|
||||
fields, ok := rateLimit.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("generated rate_limit must be a mapping: %#v", rateLimit)
|
||||
}
|
||||
for _, key := range []string{"requests_per_minute", "burst_size"} {
|
||||
if _, present := fields[key]; present {
|
||||
t.Fatalf("generated config contains obsolete rate_limit.%s: %s", key, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
redis, ok := config["redis"].(map[string]any)
|
||||
if !ok || redis["host"] != "redis" || redis["port"] != 6379 || redis["username"] != "app-user" {
|
||||
t.Fatalf("generated config lost Redis settings: %#v", config["redis"])
|
||||
}
|
||||
if config["timezone"] != "UTC" {
|
||||
t.Fatalf("generated config timezone = %#v, want UTC", config["timezone"])
|
||||
}
|
||||
defaults, ok := config["default"].(map[string]any)
|
||||
if !ok || defaults["user_concurrency"] != defaultUserConcurrency || defaults["api_key_prefix"] != "sk-" {
|
||||
t.Fatalf("generated config lost default settings: %#v", config["default"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConnectionDSNsUseConfiguredTargetAndLegacyBootstrapDatabase(t *testing.T) {
|
||||
cfg := &DatabaseConfig{
|
||||
Host: "db",
|
||||
|
||||
@@ -147,17 +147,17 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./redis_data:/data:Z
|
||||
# The command is one quoted script for the inner `sh -c`. Compose keeps
|
||||
# the newlines inside the quoted string, so every line needs a trailing
|
||||
# `\` — without it, `redis-server` on the first line runs with no flags
|
||||
# at all, and the --save/--appendonly/--appendfsync lines are never read.
|
||||
command: >
|
||||
sh -c '
|
||||
redis-server \
|
||||
--save 60 1 \
|
||||
--appendonly yes \
|
||||
--appendfsync everysec \
|
||||
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
|
||||
command:
|
||||
- redis-server
|
||||
- --save
|
||||
- "60"
|
||||
- "1"
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --requirepass
|
||||
- ${REDIS_PASSWORD:-}
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- REDISCLI_AUTH=${REDIS_PASSWORD:-}
|
||||
|
||||
@@ -264,17 +264,17 @@ services:
|
||||
volumes:
|
||||
# Local directory mapping for easy migration
|
||||
- ./redis_data:/data:Z
|
||||
# The command is one quoted script for the inner `sh -c`. Compose keeps
|
||||
# the newlines inside the quoted string, so every line needs a trailing
|
||||
# `\` — without it, `redis-server` on the first line runs with no flags
|
||||
# at all, and the --save/--appendonly/--appendfsync lines are never read.
|
||||
command: >
|
||||
sh -c '
|
||||
redis-server \
|
||||
--save 60 1 \
|
||||
--appendonly yes \
|
||||
--appendfsync everysec \
|
||||
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
|
||||
command:
|
||||
- redis-server
|
||||
- --save
|
||||
- "60"
|
||||
- "1"
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --requirepass
|
||||
- ${REDIS_PASSWORD:-}
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
|
||||
|
||||
+11
-11
@@ -287,17 +287,17 @@ services:
|
||||
hard: 100000
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
# The command is one quoted script for the inner `sh -c`. Compose keeps
|
||||
# the newlines inside the quoted string, so every line needs a trailing
|
||||
# `\` — without it, `redis-server` on the first line runs with no flags
|
||||
# at all, and the --save/--appendonly/--appendfsync lines are never read.
|
||||
command: >
|
||||
sh -c '
|
||||
redis-server \
|
||||
--save 60 1 \
|
||||
--appendonly yes \
|
||||
--appendfsync everysec \
|
||||
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
|
||||
command:
|
||||
- redis-server
|
||||
- --save
|
||||
- "60"
|
||||
- "1"
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --requirepass
|
||||
- ${REDIS_PASSWORD:-}
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
|
||||
|
||||
@@ -73,6 +73,9 @@ export interface ModelPlazaGroup {
|
||||
/** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */
|
||||
image_rate_independent: boolean
|
||||
image_rate_multiplier: number
|
||||
/** 视频独立倍率开启时,覆盖视频模型的分组/用户专属倍率。 */
|
||||
video_rate_independent: boolean
|
||||
video_rate_multiplier: number
|
||||
/** 分组是否启用长上下文阶梯计费;false 时实付列只展示最低档,官方阶梯仅供参考。 */
|
||||
long_context_pricing_enabled: boolean
|
||||
models: PlazaModel[]
|
||||
|
||||
@@ -188,13 +188,11 @@ const shouldShowResetTime = computed(() => {
|
||||
|
||||
// Format reset time
|
||||
const formatResetTime = computed(() => {
|
||||
// For rolling windows, when utilization is 0%, treat as immediately available.
|
||||
if (props.showNowWhenIdle && props.utilization <= 0) {
|
||||
return t('usage.resetNow')
|
||||
if (!props.resetsAt) {
|
||||
// Idle rolling window without a known reset time: treat as immediately available.
|
||||
return props.showNowWhenIdle && props.utilization <= 0 ? t('usage.resetNow') : '-'
|
||||
}
|
||||
|
||||
if (!props.resetsAt) return '-'
|
||||
|
||||
const date = new Date(props.resetsAt)
|
||||
const diffMs = date.getTime() - now.value.getTime()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('UsageProgressBar', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('showNowWhenIdle=true 且利用率为 0 时显示“现在”', () => {
|
||||
it('showNowWhenIdle=true 且利用率为 0 但有未来 resetsAt 时显示倒计时', () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: '5h',
|
||||
@@ -33,8 +33,8 @@ describe('UsageProgressBar', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('usage.resetNow')
|
||||
expect(wrapper.text()).not.toContain('2h 30m')
|
||||
expect(wrapper.text()).toContain('2h 30m')
|
||||
expect(wrapper.text()).not.toContain('usage.resetNow')
|
||||
})
|
||||
|
||||
it('showNowWhenIdle=true 但利用率大于 0 时显示倒计时', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user