mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
补齐 OpenAI 与 Grok 协议兼容处理
This commit is contained in:
@@ -212,7 +212,7 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo
|
||||
if adapter.ToolSearch {
|
||||
typed["type"] = "function_call_output"
|
||||
dropInvalidLoweredFunctionItemID(typed)
|
||||
normalizeClientToolOutput(typed)
|
||||
normalizeToolSearchOutput(typed)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,20 @@ func normalizeClientToolOutput(item map[string]any) {
|
||||
item["output"] = string(encoded)
|
||||
}
|
||||
|
||||
func normalizeToolSearchOutput(item map[string]any) {
|
||||
if _, exists := item["output"]; !exists {
|
||||
if tools, hasTools := item["tools"]; hasTools {
|
||||
item["output"] = tools
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
normalizeClientToolOutput(item)
|
||||
delete(item, "tools")
|
||||
delete(item, "status")
|
||||
delete(item, "execution")
|
||||
}
|
||||
|
||||
func rewriteClientToolChoice(req map[string]any, adapter *ResponsesClientToolMapping) bool {
|
||||
choice, ok := req["tool_choice"].(map[string]any)
|
||||
if !ok {
|
||||
|
||||
@@ -66,6 +66,68 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces(
|
||||
require.Equal(t, "team__send", namespaceCall["name"])
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_LowersToolSearchDiscoveryPayloadWithoutOutput(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": []any{map[string]any{"type": "tool_search"}},
|
||||
"input": []any{map[string]any{
|
||||
"type": "tool_search_output",
|
||||
"call_id": "search_1",
|
||||
"tools": []any{map[string]any{"name": "github", "type": "namespace"}},
|
||||
"status": "completed",
|
||||
"execution": "client",
|
||||
}},
|
||||
}
|
||||
|
||||
mapping, changed, err := AdaptResponsesClientTools(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.True(t, mapping.ToolSearch)
|
||||
|
||||
item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
|
||||
require.Equal(t, "function_call_output", item["type"])
|
||||
require.Equal(t, "search_1", item["call_id"])
|
||||
require.JSONEq(t, `[{"name":"github","type":"namespace"}]`, requireResponsesClientToolValue[string](t, item["output"]))
|
||||
require.NotContains(t, item, "tools")
|
||||
require.NotContains(t, item, "status")
|
||||
require.NotContains(t, item, "execution")
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_ToolSearchExplicitOutputTakesPriority(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": []any{map[string]any{"type": "tool_search"}},
|
||||
"input": []any{map[string]any{
|
||||
"type": "tool_search_output", "call_id": "search_1",
|
||||
"output": "authoritative", "tools": []any{map[string]any{"name": "ignored"}},
|
||||
}},
|
||||
}
|
||||
|
||||
_, changed, err := AdaptResponsesClientTools(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
|
||||
require.Equal(t, "authoritative", item["output"])
|
||||
require.NotContains(t, item, "tools")
|
||||
}
|
||||
|
||||
func TestAdaptResponsesClientTools_DoesNotInventMissingToolSearchOutput(t *testing.T) {
|
||||
req := map[string]any{
|
||||
"tools": []any{map[string]any{"type": "tool_search"}},
|
||||
"input": []any{map[string]any{
|
||||
"type": "tool_search_output", "call_id": "search_1",
|
||||
"status": "incomplete", "execution": "client",
|
||||
}},
|
||||
}
|
||||
|
||||
_, changed, err := AdaptResponsesClientTools(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0])
|
||||
require.Equal(t, "function_call_output", item["type"])
|
||||
require.NotContains(t, item, "output")
|
||||
require.Equal(t, "incomplete", item["status"])
|
||||
require.Equal(t, "client", item["execution"])
|
||||
}
|
||||
|
||||
func requireResponsesClientToolValue[T any](t *testing.T, value any) T {
|
||||
t.Helper()
|
||||
typed, ok := value.(T)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const stickySessionPrefix = "sticky_session:"
|
||||
const openAIResponsesSessionWindowPrefix = "openai_responses_session_window:"
|
||||
const liveCallPrefix = "live:call:"
|
||||
|
||||
type gatewayCache struct {
|
||||
@@ -31,6 +32,10 @@ func buildSessionKey(groupID int64, sessionHash string) string {
|
||||
return fmt.Sprintf("%s%d:%s", stickySessionPrefix, groupID, sessionHash)
|
||||
}
|
||||
|
||||
func buildOpenAIResponsesSessionWindowKey(groupID int64, sessionHash string) string {
|
||||
return fmt.Sprintf("%s%d:%s", openAIResponsesSessionWindowPrefix, groupID, sessionHash)
|
||||
}
|
||||
|
||||
func (c *gatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
key := buildSessionKey(groupID, sessionHash)
|
||||
accountID, err := c.rdb.Get(ctx, key).Int64()
|
||||
@@ -65,6 +70,97 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
var claimOpenAIResponsesSessionWindowScript = redis.NewScript(`
|
||||
local previous = redis.call('GET', KEYS[1])
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
||||
return previous
|
||||
`)
|
||||
|
||||
var compareAndRefreshOpenAIResponsesSessionWindowScript = redis.NewScript(`
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if current == false or current ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
`)
|
||||
|
||||
var compareAndDeleteOpenAIResponsesSessionWindowScript = redis.NewScript(`
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if current == false or current ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 1
|
||||
`)
|
||||
|
||||
func (c *gatewayCache) ClaimOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, owner []byte, ttl time.Duration) ([]byte, error) {
|
||||
if c == nil || c.rdb == nil {
|
||||
return nil, errors.New("gateway cache unavailable")
|
||||
}
|
||||
if len(owner) == 0 || strings.TrimSpace(sessionHash) == "" || ttl <= 0 {
|
||||
return nil, errors.New("invalid OpenAI Responses session-window claim")
|
||||
}
|
||||
result, err := claimOpenAIResponsesSessionWindowScript.Run(
|
||||
ctx,
|
||||
c.rdb,
|
||||
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
|
||||
owner,
|
||||
ttl.Milliseconds(),
|
||||
).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch value := result.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
return []byte(value), nil
|
||||
case []byte:
|
||||
return append([]byte(nil), value...), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected OpenAI Responses session-window claim result %T", result)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *gatewayCache) CompareAndRefreshOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte, ttl time.Duration) (bool, error) {
|
||||
if c == nil || c.rdb == nil {
|
||||
return false, errors.New("gateway cache unavailable")
|
||||
}
|
||||
if len(expected) == 0 || strings.TrimSpace(sessionHash) == "" || ttl <= 0 {
|
||||
return false, errors.New("invalid OpenAI Responses session-window refresh")
|
||||
}
|
||||
n, err := compareAndRefreshOpenAIResponsesSessionWindowScript.Run(
|
||||
ctx,
|
||||
c.rdb,
|
||||
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
|
||||
expected,
|
||||
ttl.Milliseconds(),
|
||||
).Int()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func (c *gatewayCache) CompareAndDeleteOpenAIResponsesSessionWindow(ctx context.Context, groupID int64, sessionHash string, expected []byte) (bool, error) {
|
||||
if c == nil || c.rdb == nil {
|
||||
return false, errors.New("gateway cache unavailable")
|
||||
}
|
||||
if len(expected) == 0 || strings.TrimSpace(sessionHash) == "" {
|
||||
return false, errors.New("invalid OpenAI Responses session-window delete")
|
||||
}
|
||||
n, err := compareAndDeleteOpenAIResponsesSessionWindowScript.Run(
|
||||
ctx,
|
||||
c.rdb,
|
||||
[]string{buildOpenAIResponsesSessionWindowKey(groupID, sessionHash)},
|
||||
expected,
|
||||
).Int()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
var _ service.OpenAIWSSessionPreemptionCache = (*gatewayCache)(nil)
|
||||
|
||||
const (
|
||||
grokVideoPendingBillingPrefix = "grok_video_pending:"
|
||||
grokVideoBilledPrefix = "grok_video_billed:"
|
||||
@@ -173,23 +269,60 @@ func (c *gatewayCache) GetReasoningContent(ctx context.Context, itemID string) (
|
||||
return val, nil
|
||||
}
|
||||
|
||||
const cyberSessionBlockPrefix = "cyber_session_block:"
|
||||
const (
|
||||
cyberSessionBlockPrefix = "cyber_session_block:"
|
||||
cyberSessionScopePrefix = "cyber_session_scope:"
|
||||
)
|
||||
|
||||
// SetCyberSessionBlocked 把被 cyber_policy 命中的会话写入屏蔽表(TTL 自动过期)。
|
||||
// 存储值 "1" 作为存在标记(IsCyberSessionBlocked 只检查 key 是否存在,不读值)。
|
||||
func (c *gatewayCache) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error {
|
||||
return c.rdb.Set(ctx, cyberSessionBlockPrefix+key, "1", ttl).Err()
|
||||
// SetCyberSessionBlocked atomically writes all exact blocks and their optional
|
||||
// coarse source scope with the same TTL.
|
||||
func (c *gatewayCache) SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
pipe := c.rdb.TxPipeline()
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
pipe.Set(ctx, cyberSessionBlockPrefix+key, "1", ttl)
|
||||
}
|
||||
}
|
||||
if scopeKey != "" {
|
||||
pipe.Set(ctx, cyberSessionScopePrefix+scopeKey, "1", ttl)
|
||||
}
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsCyberSessionBlocked 查询会话是否在屏蔽表中。
|
||||
func (c *gatewayCache) IsCyberSessionBlocked(ctx context.Context, key string) (bool, error) {
|
||||
n, err := c.rdb.Exists(ctx, cyberSessionBlockPrefix+key).Result()
|
||||
func (c *gatewayCache) IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) {
|
||||
n, err := c.rdb.Exists(ctx, cyberSessionScopePrefix+scopeKey).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// FindCyberSessionBlocked checks transcript-prefix candidates in one Redis
|
||||
// round trip and returns the first blocked key in caller order.
|
||||
func (c *gatewayCache) FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) {
|
||||
if len(keys) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
redisKeys := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
redisKeys[i] = cyberSessionBlockPrefix + key
|
||||
}
|
||||
values, err := c.rdb.MGet(ctx, redisKeys...).Result()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, value := range values {
|
||||
if value != nil {
|
||||
return keys[i], nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var claimLiveControllerScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local target = ARGV[1]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGatewayCacheCyberBlockWritesScopeAndExactKeysTogether(t *testing.T) {
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
store, ok := NewGatewayCache(client).(service.CyberSessionBlockStore)
|
||||
require.True(t, ok)
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.SetCyberSessionBlocked(ctx, "scope-1", []string{"block-1", "block-2"}, time.Minute))
|
||||
active, err := store.IsCyberSessionScopeActive(ctx, "scope-1")
|
||||
require.NoError(t, err)
|
||||
require.True(t, active)
|
||||
matched, err := store.FindCyberSessionBlocked(ctx, []string{"missing", "block-1", "block-2"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "block-1", matched)
|
||||
require.Greater(t, server.TTL(cyberSessionScopePrefix+"scope-1"), time.Duration(0))
|
||||
require.Equal(t, server.TTL(cyberSessionBlockPrefix+"block-1"), server.TTL(cyberSessionBlockPrefix+"block-2"))
|
||||
}
|
||||
@@ -12,6 +12,30 @@ import (
|
||||
|
||||
const tempUnschedPrefix = "temp_unsched:account:"
|
||||
|
||||
const openAIAPIKeyHealthFailurePrefix = "openai_apikey_health:"
|
||||
|
||||
var openAIAPIKeyHealthFailureScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local sequence_key = key .. ':sequence'
|
||||
local now = redis.call('TIME')
|
||||
local now_ms = (tonumber(now[1]) * 1000) + math.floor(tonumber(now[2]) / 1000)
|
||||
local window_ms = tonumber(ARGV[1]) * 60 * 1000
|
||||
local threshold = tonumber(ARGV[2])
|
||||
local sequence = redis.call('INCR', sequence_key)
|
||||
|
||||
redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms)
|
||||
redis.call('ZADD', key, now_ms, tostring(now_ms) .. ':' .. tostring(sequence))
|
||||
local count = redis.call('ZCARD', key)
|
||||
local ttl = math.max(60, (tonumber(ARGV[1]) + 1) * 60)
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
redis.call('EXPIRE', sequence_key, ttl)
|
||||
if count >= threshold then
|
||||
redis.call('DEL', key, sequence_key)
|
||||
return {count, 1}
|
||||
end
|
||||
return {count, 0}
|
||||
`)
|
||||
|
||||
var tempUnschedSetScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local new_until = tonumber(ARGV[1])
|
||||
@@ -89,3 +113,36 @@ func (c *tempUnschedCache) DeleteTempUnsched(ctx context.Context, accountID int6
|
||||
key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID)
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func (c *tempUnschedCache) openAIAPIKeyHealthKey(accountID int64) string {
|
||||
// The hash tag keeps the rolling window and sequence key in one Redis
|
||||
// Cluster slot even though the Lua script derives the latter dynamically.
|
||||
return fmt.Sprintf("%s{%d}:failures", openAIAPIKeyHealthFailurePrefix, accountID)
|
||||
}
|
||||
|
||||
func (c *tempUnschedCache) RecordOpenAIAPIKeyHealthFailure(ctx context.Context, accountID int64, windowMinutes, threshold int) (int64, bool, error) {
|
||||
if windowMinutes < 1 {
|
||||
windowMinutes = 1
|
||||
}
|
||||
if threshold < 1 {
|
||||
threshold = 1
|
||||
}
|
||||
result, err := openAIAPIKeyHealthFailureScript.Run(ctx, c.rdb, []string{c.openAIAPIKeyHealthKey(accountID)}, windowMinutes, threshold).Slice()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("record OpenAI API key health failure: %w", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
return 0, false, fmt.Errorf("record OpenAI API key health failure: unexpected result length %d", len(result))
|
||||
}
|
||||
count, countOK := result[0].(int64)
|
||||
tripped, trippedOK := result[1].(int64)
|
||||
if !countOK || !trippedOK {
|
||||
return 0, false, fmt.Errorf("record OpenAI API key health failure: unexpected result types %T/%T", result[0], result[1])
|
||||
}
|
||||
return count, tripped == 1, nil
|
||||
}
|
||||
|
||||
func (c *tempUnschedCache) ResetOpenAIAPIKeyHealthFailures(ctx context.Context, accountID int64) error {
|
||||
key := c.openAIAPIKeyHealthKey(accountID)
|
||||
return c.rdb.Del(ctx, key, key+":sequence").Err()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIAPIKeyHealthCacheTripsAndSuccessResetsWindow(t *testing.T) {
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
store, ok := NewTempUnschedCache(client).(service.OpenAIAPIKeyHealthCache)
|
||||
require.True(t, ok)
|
||||
|
||||
ctx := context.Background()
|
||||
for attempt := 1; attempt <= 2; attempt++ {
|
||||
count, tripped, err := store.RecordOpenAIAPIKeyHealthFailure(ctx, 42, 1, 3)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, attempt, count)
|
||||
require.False(t, tripped)
|
||||
}
|
||||
require.NoError(t, store.ResetOpenAIAPIKeyHealthFailures(ctx, 42))
|
||||
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
count, tripped, err := store.RecordOpenAIAPIKeyHealthFailure(ctx, 42, 1, 3)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, attempt, count)
|
||||
require.Equal(t, attempt == 3, tripped)
|
||||
}
|
||||
}
|
||||
@@ -1279,6 +1279,19 @@ func (a *Account) IsOpenAIOAuth() bool {
|
||||
return a.IsOpenAI() && a.Type == AccountTypeOAuth
|
||||
}
|
||||
|
||||
// IsOpenAIOAuthLike reports OpenAI credentials that use the ChatGPT/Codex
|
||||
// inference protocol. Setup tokens share that forwarding contract but do not
|
||||
// participate in the refreshable OAuth credential lifecycle.
|
||||
func (a *Account) IsOpenAIOAuthLike() bool {
|
||||
return a != nil && a.IsOpenAI() && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
|
||||
}
|
||||
|
||||
// UsesOpenAICodexProtocol preserves legacy OpenAI gateway OAuth routing for
|
||||
// accounts whose platform is implicit, while adding OpenAI SetupToken.
|
||||
func (a *Account) UsesOpenAICodexProtocol() bool {
|
||||
return a != nil && (a.Type == AccountTypeOAuth || a.IsOpenAIOAuthLike())
|
||||
}
|
||||
|
||||
func (a *Account) IsOpenAIChatGPTSubscription() bool {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return false
|
||||
@@ -1595,14 +1608,14 @@ func (a *Account) GetOpenAIUserAgent() string {
|
||||
}
|
||||
|
||||
func (a *Account) GetChatGPTAccountID() string {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
if !a.IsOpenAIOAuthLike() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("chatgpt_account_id")
|
||||
}
|
||||
|
||||
func (a *Account) IsChatGPTAccountFedRAMP() bool {
|
||||
if !a.IsOpenAIOAuth() || a.Credentials == nil {
|
||||
if !a.IsOpenAIOAuthLike() || a.Credentials == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := a.Credentials["chatgpt_account_is_fedramp"]
|
||||
@@ -1814,7 +1827,7 @@ func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapabilit
|
||||
}
|
||||
switch capability {
|
||||
case OpenAIImagesCapabilityBasic, OpenAIImagesCapabilityNative:
|
||||
return a.Type == AccountTypeOAuth || a.Type == AccountTypeAPIKey
|
||||
return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken || a.Type == AccountTypeAPIKey
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -1917,7 +1930,7 @@ func (a *Account) IsOpenAIResponsesWebSocketV2Enabled() bool {
|
||||
if a == nil || !a.IsOpenAI() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if a.IsOpenAIOAuth() {
|
||||
if a.IsOpenAIOAuthLike() {
|
||||
if enabled, ok := a.Extra["openai_oauth_responses_websockets_v2_enabled"].(bool); ok {
|
||||
return enabled
|
||||
}
|
||||
@@ -2020,7 +2033,7 @@ func (a *Account) ResolveOpenAIResponsesWebSocketV2Mode(defaultMode string) stri
|
||||
return OpenAIWSIngressModeOff, true
|
||||
}
|
||||
|
||||
if a.IsOpenAIOAuth() {
|
||||
if a.IsOpenAIOAuthLike() {
|
||||
if mode, ok := resolveModeString("openai_oauth_responses_websockets_v2_mode"); ok {
|
||||
return mode
|
||||
}
|
||||
|
||||
@@ -532,6 +532,8 @@ const (
|
||||
|
||||
// SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling.
|
||||
SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings"
|
||||
// SettingKeyOpenAIAPIKeyHealthBreakerSettings stores the opt-in OpenAI pool API-key breaker config.
|
||||
SettingKeyOpenAIAPIKeyHealthBreakerSettings = "openai_apikey_health_breaker_settings"
|
||||
|
||||
// =========================
|
||||
// Stream Timeout Handling
|
||||
|
||||
@@ -66,6 +66,35 @@ func TestCheckErrorPolicy(t *testing.T) {
|
||||
body: []byte(`"error"`),
|
||||
expected: ErrorPolicySkipped,
|
||||
},
|
||||
{
|
||||
name: "global_529_bypasses_custom_error_code_filter",
|
||||
account: &Account{
|
||||
ID: 33,
|
||||
Type: AccountTypeAPIKey,
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"custom_error_codes_enabled": true,
|
||||
"custom_error_codes": []any{float64(429)},
|
||||
},
|
||||
},
|
||||
statusCode: 529,
|
||||
body: []byte(`{"error":{"message":"overloaded"}}`),
|
||||
expected: ErrorPolicyMatched,
|
||||
},
|
||||
{
|
||||
name: "global_529_bypasses_pool_mode",
|
||||
account: &Account{
|
||||
ID: 34,
|
||||
Type: AccountTypeAPIKey,
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
},
|
||||
},
|
||||
statusCode: 529,
|
||||
body: []byte(`{"error":{"message":"overloaded"}}`),
|
||||
expected: ErrorPolicyMatched,
|
||||
},
|
||||
{
|
||||
name: "temp_unschedulable_hit_returns_temp_unscheduled",
|
||||
account: &Account{
|
||||
|
||||
@@ -37,6 +37,14 @@ func (s *GatewayService) ForwardAsResponses(
|
||||
) (*ForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalized {
|
||||
body = normalizedBody
|
||||
}
|
||||
|
||||
// 1. Lower Codex client-side tools to function tools understood by Anthropic.
|
||||
adaptedBody, clientToolMapping, err := adaptResponsesClientToolsForAnthropic(body)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
type gatewayForwardErrorPolicyRepoStub struct {
|
||||
AccountRepository
|
||||
tempCalls int
|
||||
overloadCalls int
|
||||
modelRateLimitCalls []gatewayForwardModelRateLimitCall
|
||||
}
|
||||
|
||||
@@ -39,6 +40,11 @@ func (r *gatewayForwardErrorPolicyRepoStub) SetModelRateLimit(_ context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gatewayForwardErrorPolicyRepoStub) SetOverloaded(context.Context, int64, time.Time) error {
|
||||
r.overloadCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
// 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时,
|
||||
// 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同
|
||||
// 返回,供 handler 照常提交 usage 记录。
|
||||
@@ -250,9 +256,8 @@ func TestGatewayService_Forward_PreOutputSSEOverloadedErrorUsesSemantic529(t *te
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, 529, failoverErr.StatusCode)
|
||||
require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody))
|
||||
require.Len(t, repo.modelRateLimitCalls, 1, "synthetic 529 must participate in temp-unschedulable rules")
|
||||
require.Equal(t, account.ID, repo.modelRateLimitCalls[0].accountID)
|
||||
require.Equal(t, parsed.Model, repo.modelRateLimitCalls[0].scope)
|
||||
require.Equal(t, 1, repo.overloadCalls, "synthetic 529 must apply global overload cooldown")
|
||||
require.Empty(t, repo.modelRateLimitCalls, "global 529 cooldown must take precedence over custom model rules")
|
||||
require.Empty(t, rec.Body.String(), "pre-output overload must remain eligible for account failover")
|
||||
}
|
||||
|
||||
|
||||
@@ -192,8 +192,8 @@ func openAIStreamEventIsTerminalWithType(data, eventType string) bool {
|
||||
}
|
||||
|
||||
func openAIStreamEventTypeIsTerminal(eventType string) bool {
|
||||
switch eventType {
|
||||
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
||||
switch strings.TrimSpace(eventType) {
|
||||
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -672,8 +672,10 @@ type UpstreamFailoverError struct {
|
||||
ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息
|
||||
ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true
|
||||
RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换
|
||||
RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁
|
||||
SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号
|
||||
SameAccountRetryDelay time.Duration
|
||||
SameAccountRetryDeadline time.Time
|
||||
RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁
|
||||
SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号
|
||||
Stage GatewayFailureStage
|
||||
Scope GatewayFailureScope
|
||||
Reason GatewayFailureReason
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// isGrokContentPolicyRejection identifies request-scoped safety refusals from
|
||||
@@ -197,6 +199,12 @@ func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, r
|
||||
if isGrokContentPolicyRejection(statusCode, responseBody) {
|
||||
return false
|
||||
}
|
||||
// A 422 emitted by xAI's ModelInput decoder is account/runtime compatibility,
|
||||
// not quota exhaustion. Another account may run a different upstream build,
|
||||
// so fail over without applying an account cooldown.
|
||||
if isGrokDecoderCompatibilityError(statusCode, responseBody) {
|
||||
return true
|
||||
}
|
||||
decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
|
||||
switch decision.Class {
|
||||
case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity:
|
||||
@@ -205,6 +213,59 @@ func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, r
|
||||
return s.shouldFailoverUpstreamError(statusCode)
|
||||
}
|
||||
|
||||
func isGrokDecoderCompatibilityError(statusCode int, responseBody []byte) bool {
|
||||
if statusCode != http.StatusUnprocessableEntity || len(responseBody) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range grokStructuredErrorMessageCandidates(responseBody) {
|
||||
message := strings.ToLower(candidate)
|
||||
decoderSignal := strings.Contains(message, "untagged enum") ||
|
||||
strings.Contains(message, "decode") ||
|
||||
strings.Contains(message, "deserialize") ||
|
||||
strings.Contains(message, "deserializ") ||
|
||||
strings.Contains(message, "decoder")
|
||||
inputSignal := strings.Contains(message, "modelinput") ||
|
||||
strings.Contains(message, "model input") ||
|
||||
strings.Contains(message, "input[") ||
|
||||
strings.Contains(message, "input.")
|
||||
messageContentSignal := (strings.Contains(message, "messages[") ||
|
||||
strings.Contains(message, "messages.")) &&
|
||||
strings.Contains(message, "content") &&
|
||||
strings.Contains(message, "did not match any variant")
|
||||
if decoderSignal && (inputSignal || messageContentSignal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func grokStructuredErrorMessageCandidates(body []byte) []string {
|
||||
candidates := make([]string, 0, 6)
|
||||
appendCandidate := func(result gjson.Result) {
|
||||
if !result.Exists() {
|
||||
return
|
||||
}
|
||||
value := strings.TrimSpace(result.String())
|
||||
if value != "" {
|
||||
candidates = append(candidates, value)
|
||||
}
|
||||
}
|
||||
appendCandidate(gjson.GetBytes(body, "error.message"))
|
||||
appendCandidate(gjson.GetBytes(body, "error.error"))
|
||||
errorNode := gjson.GetBytes(body, "error")
|
||||
if errorNode.Type == gjson.String {
|
||||
appendCandidate(errorNode)
|
||||
}
|
||||
appendCandidate(gjson.GetBytes(body, "message"))
|
||||
appendCandidate(gjson.GetBytes(body, "detail"))
|
||||
if !json.Valid(body) {
|
||||
if plaintext := strings.TrimSpace(string(body)); plaintext != "" {
|
||||
candidates = append(candidates, plaintext)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// applyGrokForbiddenPolicy applies an administrator's existing temporary
|
||||
// unschedulable rules to a non-content 403. It reports true only when a rule
|
||||
// matched; unmatched responses retain the legacy entitlement cooldown.
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type openAIStream403AccountRepo struct {
|
||||
AccountRepository
|
||||
setErrorCalls int
|
||||
}
|
||||
|
||||
func (r *openAIStream403AccountRepo) SetError(context.Context, int64, string) error {
|
||||
r.setErrorCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOpenAIUpstreamAccessStateClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"workspace_code", `{"detail":{"code":"deactivated_workspace"}}`},
|
||||
{"disabled_account", `{"error":{"message":"Your account is disabled"}}`},
|
||||
{"suspended_workspace", `{"response":{"error":{"message":"This workspace has been suspended"}}}`},
|
||||
{"deactivated_organization", `{"detail":{"message":"The organization is deactivated"}}`},
|
||||
{"scalar_detail", `{"detail":"This workspace has been disabled"}`},
|
||||
{"suspended_org_code", `{"error":{"code":"org_suspended"}}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(tt.body)
|
||||
require.True(t, isOpenAIUpstreamAccessStateError("", body))
|
||||
require.True(t, (&OpenAIGatewayService{}).shouldFailoverOpenAIUpstreamResponse(http.StatusForbidden, "", body))
|
||||
require.True(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusForbidden, body))
|
||||
|
||||
err := newOpenAIUpstreamFailoverError(http.StatusForbidden, nil, body, "", true)
|
||||
require.True(t, err.IsCredentialFailure())
|
||||
require.Equal(t, GatewayFailureScopeAccount, err.Scope)
|
||||
require.Equal(t, OpenAIUpstreamAccessStateReason, err.Reason)
|
||||
require.Equal(t, NextAccountRetry, err.NextAccountAction)
|
||||
require.False(t, err.RetryableOnSameAccount)
|
||||
require.False(t, err.RequestScopedTransient)
|
||||
require.Equal(t, http.StatusBadGateway, err.ClientStatusCode)
|
||||
require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, err.ClientMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIUpstreamAccessStateDoesNotScanEchoedJSON(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":"invalid_request_error","message":"Invalid input"},"echo":{"prompt":"my account is disabled"}}`)
|
||||
require.False(t, isOpenAIUpstreamAccessStateError("", body))
|
||||
require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadRequest, body))
|
||||
}
|
||||
|
||||
func TestOpenAICyberPolicyWrapped5xxNeverFailsOver(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":"cyber_policy","message":"blocked"}}`)
|
||||
svc := &OpenAIGatewayService{}
|
||||
require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "wrapped upstream failure", body))
|
||||
require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadGateway, body))
|
||||
}
|
||||
|
||||
func TestOpenAICapacityFailoverCarriesSafeTerminalResponse(t *testing.T) {
|
||||
message := "Our servers are currently overloaded. Please try again later."
|
||||
body := []byte(`{"error":{"code":"server_is_overloaded","message":"` + message + `"}}`)
|
||||
err := newOpenAIUpstreamFailoverError(http.StatusBadRequest, nil, body, message, false)
|
||||
|
||||
require.True(t, err.IsOpenAICapacityShed())
|
||||
require.Equal(t, http.StatusServiceUnavailable, err.ClientStatusCode)
|
||||
require.Equal(t, message, err.ClientMessage)
|
||||
require.NotContains(t, err.ClientMessage, "server_is_overloaded")
|
||||
}
|
||||
|
||||
func TestOpenAIStreamSemanticStatusesPreservedAcrossTerminalShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
wantFailover bool
|
||||
}{
|
||||
{"unauthorized", `{"type":"error","error":{"type":"authentication_error","code":"invalid_api_key","message":"unauthorized"}}`, http.StatusUnauthorized, true},
|
||||
{"forbidden", `{"type":"response.failed","response":{"error":{"type":"permission_error","message":"forbidden"}}}`, http.StatusForbidden, false},
|
||||
{"rate_limit", `{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`, http.StatusTooManyRequests, true},
|
||||
{"overload_529", `{"type":"error","error":{"status_code":529,"code":"overloaded","message":"overloaded"}}`, 529, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := []byte(tt.body)
|
||||
message := extractOpenAISSEErrorMessage(payload)
|
||||
require.Equal(t, tt.status, openAIStreamFailureStatus(payload, message))
|
||||
require.Equal(t, tt.wantFailover, openAIStreamErrorEventShouldFailover(payload, message))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIStreamBareErrorUsesSemanticFailover(t *testing.T) {
|
||||
payload := []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`)
|
||||
require.True(t, openAIStreamErrorEventShouldFailover(payload, "slow down"))
|
||||
}
|
||||
|
||||
func TestOpenAIStream403FailoverRequiresStructuredAccountCredentialSignal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "ordinary permission error",
|
||||
payload: `{"type":"response.failed","response":{"error":{"type":"permission_error","code":"forbidden","message":"access denied for this request"}}}`,
|
||||
},
|
||||
{
|
||||
name: "explicit 403 request status",
|
||||
payload: `{"type":"error","error":{"type":"permission_error","code":"forbidden","status_code":403,"message":"forbidden content"}}`,
|
||||
},
|
||||
{
|
||||
name: "structured access state",
|
||||
payload: `{"type":"response.failed","response":{"error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "explicit credential auth code",
|
||||
payload: `{"type":"error","error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "explicit authentication type",
|
||||
payload: `{"type":"error","error":{"type":"authentication_error","status_code":403,"message":"credential rejected"}}`,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := []byte(tt.payload)
|
||||
message := extractOpenAISSEErrorMessage(payload)
|
||||
require.Equal(t, http.StatusForbidden, openAIStreamFailureStatus(payload, message))
|
||||
require.Equal(t, tt.want, openAIStreamFailedEventShouldFailover(payload, message))
|
||||
require.Equal(t, tt.want, openAIStreamErrorEventShouldFailover(payload, message))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIStream403PostOutputAccountSideEffectsIgnoreRequestPermissionErrors(t *testing.T) {
|
||||
repo := &openAIStream403AccountRepo{}
|
||||
rateLimits := &RateLimitService{accountRepo: repo}
|
||||
svc := &OpenAIGatewayService{rateLimitService: rateLimits}
|
||||
account := &Account{ID: 918, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
payload := []byte(`{"type":"error","error":{"type":"permission_error","code":"forbidden","status_code":403,"message":"access denied for this request"}}`)
|
||||
|
||||
status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "access denied for this request", nil)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
require.False(t, disabled)
|
||||
require.Zero(t, repo.setErrorCalls)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
}
|
||||
|
||||
func TestOpenAIStream403ExplicitCredentialAuthAppliesAccountSideEffects(t *testing.T) {
|
||||
repo := &openAIStream403AccountRepo{}
|
||||
rateLimits := &RateLimitService{accountRepo: repo}
|
||||
svc := &OpenAIGatewayService{rateLimitService: rateLimits}
|
||||
account := &Account{ID: 917, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
payload := []byte(`{"type":"error","error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}`)
|
||||
|
||||
status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "credential rejected", nil)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
require.True(t, disabled)
|
||||
require.Equal(t, 1, repo.setErrorCalls)
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
}
|
||||
|
||||
func TestOpenAIWSStandaloneFailedStructured403AppliesAccountSideEffectsOnce(t *testing.T) {
|
||||
repo := &openAIStream403AccountRepo{}
|
||||
svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}}
|
||||
account := &Account{ID: 923, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
failed := []byte(`{"type":"response.failed","response":{"error":{"type":"permission_error","code":"invalid_api_key","status_code":403,"message":"credential rejected"}}}`)
|
||||
|
||||
require.True(t, svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, failed))
|
||||
require.Equal(t, 1, repo.setErrorCalls)
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
}
|
||||
|
||||
func TestOpenAIWSPairedStructured403SideEffectsCanBeDeduplicated(t *testing.T) {
|
||||
repo := &openAIStream403AccountRepo{}
|
||||
svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}}
|
||||
account := &Account{ID: 924, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
errorEvent := []byte(`{"type":"error","error":{"code":"workspace_suspended","status_code":403,"message":"workspace is suspended"}}`)
|
||||
failedEvent := []byte(`{"type":"response.failed","response":{"error":{"code":"workspace_suspended","status_code":403,"message":"workspace is suspended"}}}`)
|
||||
|
||||
applied := svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, errorEvent)
|
||||
if !applied {
|
||||
applied = svc.handleOpenAIWSFailureAccountSideEffects(context.Background(), account, "gpt-5", nil, failedEvent)
|
||||
}
|
||||
|
||||
require.True(t, applied)
|
||||
require.Equal(t, 1, repo.setErrorCalls)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamAccessStateAppliesAccountHealthBeforeFailover(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
account := &Account{ID: 919, Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
|
||||
payload := []byte(`{"type":"response.failed","response":{"error":{"code":"workspace_suspended","message":"workspace is suspended"}}}`)
|
||||
|
||||
status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "workspace is suspended", nil)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
require.True(t, disabled)
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
}
|
||||
|
||||
func TestOpenAIStreamPairedFailureAppliesAccountSideEffectsOnce(t *testing.T) {
|
||||
const upstream = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" +
|
||||
"data: {\"type\":\"error\",\"error\":{\"status_code\":403,\"code\":\"workspace_suspended\",\"message\":\"workspace is suspended\"}}\n\n" +
|
||||
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_failed\",\"error\":{\"status_code\":403,\"code\":\"workspace_suspended\",\"message\":\"workspace is suspended\"}}}\n\n"
|
||||
|
||||
t.Run("native", func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &openAIStream403AccountRepo{}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{},
|
||||
toolCorrector: NewCodexToolCorrector(),
|
||||
rateLimitService: &RateLimitService{accountRepo: repo},
|
||||
}
|
||||
account := &Account{ID: 921, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
recorder := newOpenAIResponseFlushRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstream)),
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, repo.setErrorCalls)
|
||||
})
|
||||
|
||||
t.Run("passthrough", func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &openAIStream403AccountRepo{}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{},
|
||||
rateLimitService: &RateLimitService{accountRepo: repo},
|
||||
}
|
||||
account := &Account{ID: 922, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
writer := &passthroughFlushTestWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
recorder: recorder,
|
||||
failAfterWrites: -1,
|
||||
}
|
||||
c.Writer = writer
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstream)),
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponsePassthrough(
|
||||
context.Background(), resp, c, account, time.Now(), "gpt-5", "gpt-5",
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 1, repo.setErrorCalls)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIStreamOAuthLike429GetsDeadlineWithoutImmediateRuntimeBlock(t *testing.T) {
|
||||
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
|
||||
t.Run(accountType, func(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
account := &Account{ID: 920, Platform: PlatformOpenAI, Type: accountType}
|
||||
payload := []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"slow down"}}`)
|
||||
status, disabled := svc.handleOpenAIStreamTerminalAccountSideEffects(nil, account, payload, "slow down", nil)
|
||||
err := svc.newOpenAIAccountFailoverError(account, status, nil, payload, "slow down", disabled, false)
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, status)
|
||||
require.False(t, disabled)
|
||||
require.True(t, err.RetryableOnSameAccount)
|
||||
require.False(t, err.SameAccountRetryDeadline.IsZero())
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
const (
|
||||
openAIAccountStateUpdateTimeout = 5 * time.Second
|
||||
openAIOAuth429FallbackCooldown = 5 * time.Second
|
||||
openAIOAuth429RetryWindow = 2 * time.Minute
|
||||
openAIOAuth429RetryDelay = 500 * time.Millisecond
|
||||
openAIOAuth429MaxRetryDelay = 8 * time.Second
|
||||
openAIOAuth429MaxAccountAttempts = 3
|
||||
openAIStopSchedulingBridgeCooldown = 2 * time.Minute
|
||||
openAIOAuth429StormWindow = 10 * time.Second
|
||||
openAIOAuth429StormThreshold = 20
|
||||
@@ -34,7 +38,7 @@ func openAIAccountStateContext(ctx context.Context) (context.Context, context.Ca
|
||||
}
|
||||
|
||||
func isOpenAIOAuthAccount(account *Account) bool {
|
||||
return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth
|
||||
return account != nil && account.IsOpenAIOAuthLike()
|
||||
}
|
||||
|
||||
func isGrokOAuthAccount(account *Account) bool {
|
||||
@@ -62,6 +66,19 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont
|
||||
}
|
||||
stateCtx, cancel := openAIAccountStateContext(ctx)
|
||||
defer cancel()
|
||||
if account != nil && account.Platform == PlatformOpenAI && isOpenAIUpstreamAccessStateError("", responseBody) {
|
||||
message := "OpenAI upstream account or workspace is unavailable"
|
||||
if upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(responseBody)); upstreamMsg != "" {
|
||||
message = upstreamMsg
|
||||
}
|
||||
if s != nil && s.rateLimitService != nil {
|
||||
s.rateLimitService.handleAuthError(stateCtx, account, message)
|
||||
}
|
||||
if s != nil {
|
||||
s.BlockAccountScheduling(account, time.Time{}, "openai_access_state")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if account != nil && account.Platform == PlatformOpenAI && isOpenAIContextWindowError("", responseBody) {
|
||||
return false
|
||||
@@ -149,6 +166,9 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context
|
||||
return
|
||||
}
|
||||
s.recordOpenAIOAuth429()
|
||||
if s.openAIOAuth429RetryWindowActive(account) {
|
||||
return
|
||||
}
|
||||
|
||||
cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown)
|
||||
if s.rateLimitService != nil {
|
||||
@@ -163,6 +183,75 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context
|
||||
}
|
||||
}
|
||||
s.BlockAccountScheduling(account, cooldownUntil, "429")
|
||||
s.openaiOAuth429RetryStartedAt.Delete(account.ID)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) shouldRetryOpenAIOAuth429OnSameAccount(account *Account, statusCode int, shouldDisable bool) bool {
|
||||
if shouldDisable || statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) || account.IsShadow() {
|
||||
return false
|
||||
}
|
||||
// markOpenAIOAuth429RateLimited parks the account once the window expires.
|
||||
// Do not accidentally create a fresh window after that transition.
|
||||
if s.isOpenAIAccountRuntimeBlocked(account) {
|
||||
return false
|
||||
}
|
||||
return s.openAIOAuth429RetryWindowActive(account)
|
||||
}
|
||||
|
||||
// ShouldRetryOpenAIOAuth429 lets RateLimitService defer persistent account
|
||||
// cooldown until the gateway's same-account retry window is exhausted.
|
||||
func (s *OpenAIGatewayService) ShouldRetryOpenAIOAuth429(account *Account, _ http.Header, _ []byte) bool {
|
||||
if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() || s.isOpenAIAccountRuntimeBlocked(account) {
|
||||
return false
|
||||
}
|
||||
return s.openAIOAuth429RetryWindowActive(account)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIOAuth429RetryWindowActive(account *Account) bool {
|
||||
if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
value, _ := s.openaiOAuth429RetryStartedAt.LoadOrStore(account.ID, now)
|
||||
startedAt, ok := value.(time.Time)
|
||||
if !ok {
|
||||
s.openaiOAuth429RetryStartedAt.Store(account.ID, now)
|
||||
startedAt = now
|
||||
}
|
||||
return now.Before(startedAt.Add(openAIOAuth429RetryWindow))
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIOAuth429RetryDeadline(account *Account) time.Time {
|
||||
if s == nil || !isOpenAIOAuthAccount(account) || account.IsShadow() {
|
||||
return time.Time{}
|
||||
}
|
||||
value, ok := s.openaiOAuth429RetryStartedAt.Load(account.ID)
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
startedAt, ok := value.(time.Time)
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
return startedAt.Add(openAIOAuth429RetryWindow)
|
||||
}
|
||||
|
||||
func openAIOAuth429SameAccountRetryDelay(headers http.Header, deadline time.Time) time.Duration {
|
||||
delay := openAIOAuth429RetryDelay
|
||||
now := time.Now()
|
||||
if resetAt := parseRetryAfterResetTime(headers, now); resetAt != nil && resetAt.After(now) {
|
||||
delay = resetAt.Sub(now)
|
||||
}
|
||||
if delay > openAIOAuth429MaxRetryDelay {
|
||||
delay = openAIOAuth429MaxRetryDelay
|
||||
}
|
||||
if remaining := time.Until(deadline); !deadline.IsZero() && delay > remaining {
|
||||
delay = remaining
|
||||
}
|
||||
if delay < 0 {
|
||||
return 0
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) BlockAccountScheduling(account *Account, until time.Time, reason string) {
|
||||
@@ -228,6 +317,7 @@ func (s *OpenAIGatewayService) ClearAccountSchedulingBlock(accountID int64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
s.openaiAccountRuntimeBlockUntil.Delete(accountID)
|
||||
s.openaiOAuth429RetryStartedAt.Delete(accountID)
|
||||
s.openaiAccountRuntimeBlockGeneration.Store(accountID, s.openaiAccountRuntimeBlockSequence.Add(1))
|
||||
}
|
||||
|
||||
@@ -273,6 +363,9 @@ func canonicalOpenAIAccountSchedulingModel(account *Account, requestedModel stri
|
||||
if account == nil || model == "" {
|
||||
return model
|
||||
}
|
||||
if account.IsOpenAI() {
|
||||
return resolveOpenAIAccountUpstreamModelForRequest(account, model, false)
|
||||
}
|
||||
if mapped := strings.TrimSpace(account.GetMappedModel(model)); mapped != "" {
|
||||
return mapped
|
||||
}
|
||||
@@ -368,5 +461,8 @@ func (s *OpenAIGatewayService) ShouldStopOpenAIOAuth429Failover(account *Account
|
||||
if statusCode != http.StatusTooManyRequests || !isOpenAIOAuthAccount(account) {
|
||||
return false
|
||||
}
|
||||
return s.isOpenAIOAuth429Storm()
|
||||
// Each OpenAI OAuth candidate has already consumed its full same-account
|
||||
// retry window before reaching this switch point. A global storm is useful
|
||||
// telemetry, but must not prevent trying the bounded next-account budget.
|
||||
return failedSwitches >= openAIOAuth429MaxAccountAttempts
|
||||
}
|
||||
|
||||
@@ -13,18 +13,57 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAI429FastPath_MarksOAuthAccountCoolingDown(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
type oauth429RateLimitRepo struct {
|
||||
AccountRepository
|
||||
setRateLimitedCalls int
|
||||
}
|
||||
|
||||
func (r *oauth429RateLimitRepo) SetRateLimited(context.Context, int64, time.Time) error {
|
||||
r.setRateLimitedCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOpenAI429FastPath_KeepsOAuthAccountSchedulableDuringRetryWindow(t *testing.T) {
|
||||
repo := &oauth429RateLimitRepo{}
|
||||
rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
svc := &OpenAIGatewayService{rateLimitService: rateLimits}
|
||||
rateLimits.SetAccountRuntimeBlocker(svc)
|
||||
account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
apiKeyAccount := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
setupTokenAccount := &Account{ID: 44, Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
|
||||
grokOAuthAccount := &Account{ID: 45, Platform: PlatformGrok, Type: AccountTypeOAuth}
|
||||
|
||||
shouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, nil)
|
||||
apiKeyShouldDisable := svc.handleOpenAIAccountUpstreamError(context.Background(), apiKeyAccount, http.StatusTooManyRequests, http.Header{}, nil)
|
||||
|
||||
require.False(t, shouldDisable)
|
||||
require.False(t, apiKeyShouldDisable)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(apiKeyAccount), "API-key 429 keeps the existing scheduler cooldown behavior")
|
||||
require.Equal(t, 1, repo.setRateLimitedCalls, "only the API-key 429 should persist a scheduler block")
|
||||
require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
|
||||
require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(setupTokenAccount, http.StatusTooManyRequests, false))
|
||||
require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(apiKeyAccount, http.StatusTooManyRequests, false))
|
||||
require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(grokOAuthAccount, http.StatusTooManyRequests, false))
|
||||
require.WithinDuration(t, time.Now().Add(openAIOAuth429RetryWindow), svc.openAIOAuth429RetryDeadline(account), time.Second)
|
||||
require.WithinDuration(t, time.Now().Add(openAIOAuth429RetryWindow), svc.openAIOAuth429RetryDeadline(setupTokenAccount), time.Second)
|
||||
}
|
||||
|
||||
func TestOpenAI429FastPath_BlocksOAuthOnlyAfterRetryWindow(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
account := &Account{ID: 420, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second))
|
||||
|
||||
svc.markOpenAIOAuth429RateLimited(context.Background(), account, http.Header{}, nil)
|
||||
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(apiKeyAccount))
|
||||
require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
|
||||
}
|
||||
|
||||
func TestOpenAI429RetryDelayHonorsBoundedRetryAfter(t *testing.T) {
|
||||
deadline := time.Now().Add(openAIOAuth429RetryWindow)
|
||||
require.Equal(t, openAIOAuth429RetryDelay, openAIOAuth429SameAccountRetryDelay(nil, deadline))
|
||||
require.Equal(t, openAIOAuth429MaxRetryDelay, openAIOAuth429SameAccountRetryDelay(http.Header{"Retry-After": []string{"90"}}, deadline))
|
||||
}
|
||||
|
||||
// TestOpenAI429FastPath_SkipsSparkShadow 外审第8轮 P1:spark 影子被选中后若 /responses 返回 429,
|
||||
@@ -50,7 +89,7 @@ func TestOpenAI429FastPath_SkipsSparkShadow(t *testing.T) {
|
||||
svc.markOpenAIOAuth429RateLimited(context.Background(), normal, headers, nil)
|
||||
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(shadow), "spark shadow must not be runtime-blocked by /responses global 429")
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account should still be runtime-blocked")
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(normal), "normal OpenAI OAuth account stays schedulable during its retry window")
|
||||
}
|
||||
|
||||
func TestOpenAIRuntimeBlock_AppliesToOpenAIAPIKeyWhenRateLimitServiceStopsScheduling(t *testing.T) {
|
||||
@@ -347,7 +386,8 @@ func TestOpenAIOAuth429_NonmatchingModelTempRuleKeepsAccountRuntimeBlock(t *test
|
||||
)
|
||||
|
||||
require.False(t, shouldDisable)
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
}
|
||||
|
||||
@@ -398,7 +438,7 @@ func TestOpenAIRuntimeBlock_ClearAccountSchedulingBlock(t *testing.T) {
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
}
|
||||
|
||||
func TestShouldStopOpenAIOAuth429Failover_OnlyDuringStorm(t *testing.T) {
|
||||
func TestShouldStopOpenAIOAuth429Failover_AfterBoundedFullWindows(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
apiKeyAccount := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
@@ -406,11 +446,9 @@ func TestShouldStopOpenAIOAuth429Failover_OnlyDuringStorm(t *testing.T) {
|
||||
|
||||
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state))
|
||||
|
||||
for i := 0; i < openAIOAuth429StormThreshold; i++ {
|
||||
svc.recordOpenAIOAuth429()
|
||||
}
|
||||
|
||||
require.True(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state))
|
||||
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 1, &state))
|
||||
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 2, &state))
|
||||
require.True(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 3, &state))
|
||||
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(apiKeyAccount, http.StatusTooManyRequests, 1, &state))
|
||||
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusInternalServerError, 1, &state))
|
||||
require.False(t, svc.ShouldStopOpenAIOAuth429Failover(account, http.StatusTooManyRequests, 0, &state))
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestHandleOpenAITransientError_CanonicalModelIsNotMappedTwice(t *testing.T)
|
||||
}
|
||||
|
||||
require.True(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias"))
|
||||
svc.ReportOpenAIAccountScheduleResult(account.ID, canonicalModel, true, nil)
|
||||
svc.ReportOpenAIAccountScheduleResult(account, canonicalModel, true, nil)
|
||||
require.False(t, svc.isOpenAIAccountModelRuntimeBlocked(account, "public-alias"))
|
||||
}
|
||||
|
||||
|
||||
@@ -2299,15 +2299,38 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou
|
||||
return s.getOpenAIWSProtocolResolver().Resolve(account).Transport == requiredTransport
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(accountID int64, model string, success bool, firstTokenMs *int) {
|
||||
func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(account *Account, model string, success bool, firstTokenMs *int, observedErr ...error) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
accountID := account.ID
|
||||
healthTripped := false
|
||||
if s != nil && s.rateLimitService != nil {
|
||||
if success {
|
||||
s.rateLimitService.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), account)
|
||||
} else if len(observedErr) > 0 && observedErr[0] != nil {
|
||||
healthTripped = s.rateLimitService.ObserveOpenAIAPIKeyHealthFailure(context.Background(), account, observedErr[0])
|
||||
}
|
||||
}
|
||||
if success {
|
||||
s.openaiOAuth429RetryStartedAt.Delete(accountID)
|
||||
s.clearOpenAIAccountModelTransientState(accountID, normalizeOpenAIAccountModelTransientModel(model))
|
||||
}
|
||||
scheduler := s.getOpenAIAccountScheduler(context.Background())
|
||||
if scheduler == nil {
|
||||
return
|
||||
return healthTripped
|
||||
}
|
||||
scheduler.ReportResult(accountID, success, firstTokenMs)
|
||||
return healthTripped
|
||||
}
|
||||
|
||||
// ObserveOpenAIAccountHealthFailure records failures that cannot reach the
|
||||
// scheduler-result path, for example after semantic response bytes were sent.
|
||||
func (s *OpenAIGatewayService) ObserveOpenAIAccountHealthFailure(ctx context.Context, account *Account, observedErr error) bool {
|
||||
if s == nil || s.rateLimitService == nil || account == nil || observedErr == nil {
|
||||
return false
|
||||
}
|
||||
return s.rateLimitService.ObserveOpenAIAPIKeyHealthFailure(ctx, account, observedErr)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) RecordOpenAIAccountSwitch() {
|
||||
@@ -2663,7 +2686,7 @@ func openAIUpstreamCostFactors(accounts []*Account, now time.Time, oauthScheduli
|
||||
continue
|
||||
}
|
||||
factors[account.ID] = openAIUpstreamCostNeutralFactor
|
||||
if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuth() {
|
||||
if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuthLike() {
|
||||
continue
|
||||
}
|
||||
eligibleCount++
|
||||
@@ -2727,7 +2750,7 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS
|
||||
// 与 openAIUpstreamCostFactors 使用同一道平台门控:只有 OpenAI 平台账号
|
||||
// 的倍率参与 legacy 低倍率优先排序。上游自报倍率来自中转方,不能让它对
|
||||
// 其他平台的调度产生影响——否则自报低价即可吸走流量,而实际结算走本地倍率。
|
||||
if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuth() {
|
||||
if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuthLike() {
|
||||
continue
|
||||
}
|
||||
rate, ok := openAISchedulingRate(account, now, oauthSchedulingRateMultiplier)
|
||||
@@ -2745,7 +2768,7 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS
|
||||
}
|
||||
|
||||
func openAISchedulingRate(account *Account, now time.Time, oauthSchedulingRateMultiplier float64) (float64, bool) {
|
||||
if account != nil && account.IsOpenAIOAuth() {
|
||||
if account != nil && account.IsOpenAIOAuthLike() {
|
||||
return oauthSchedulingRateMultiplier, true
|
||||
}
|
||||
return openAIFreshUpstreamBillingRate(account, now)
|
||||
|
||||
@@ -1455,7 +1455,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *test
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
ttft := 120
|
||||
svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft)
|
||||
svc.ReportOpenAIAccountScheduleResult(&Account{ID: 10}, "", true, &ttft)
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
@@ -2687,7 +2687,7 @@ func TestReportOpenAIAccountScheduleResult_SuccessClearsModelTransientState(t *t
|
||||
svc.openaiModelTransient.recordFailure(21636, "gpt-5.5", now.Add(time.Millisecond))
|
||||
require.True(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond)))
|
||||
|
||||
svc.ReportOpenAIAccountScheduleResult(21636, "gpt-5.5", true, nil)
|
||||
svc.ReportOpenAIAccountScheduleResult(&Account{ID: 21636}, "gpt-5.5", true, nil)
|
||||
|
||||
require.False(t, svc.openaiModelTransient.isBlocked(21636, "gpt-5.5", now.Add(2*time.Millisecond)))
|
||||
}
|
||||
@@ -3147,7 +3147,7 @@ func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics(t *testing.T) {
|
||||
selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_metrics", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selection)
|
||||
svc.ReportOpenAIAccountScheduleResult(account.ID, "", true, intPtrForTest(120))
|
||||
svc.ReportOpenAIAccountScheduleResult(&account, "", true, intPtrForTest(120))
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
@@ -3484,7 +3484,7 @@ func TestOpenAIGatewayService_SchedulerWrappersAndDefaults(t *testing.T) {
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
ttft := 120
|
||||
svc.ReportOpenAIAccountScheduleResult(10, "", true, &ttft)
|
||||
svc.ReportOpenAIAccountScheduleResult(&Account{ID: 10}, "", true, &ttft)
|
||||
svc.RecordOpenAIAccountSwitch()
|
||||
snapshot := svc.SnapshotOpenAIAccountSchedulerMetrics()
|
||||
require.Equal(t, OpenAIAccountSchedulerMetricsSnapshot{}, snapshot)
|
||||
|
||||
@@ -58,6 +58,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
if err := s.ensureOpenAIAlphaSearchAuthMetadata(ctx, account, token, proxyURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
|
||||
// Codex Personal Access Token(at-...)目前可访问 ChatGPT Codex
|
||||
// /responses,但会被 standalone /alpha/search 的 access enforcement
|
||||
@@ -97,13 +98,16 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
|
||||
// 真正的凭据失效由普通 Responses 请求或 whoami 校验判定。
|
||||
shouldDisable := false
|
||||
if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
|
||||
shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
||||
shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel))
|
||||
}
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
|
||||
if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount)
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError(upstreamMessage, respBody) {
|
||||
return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount)
|
||||
}
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,13 +176,16 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
|
||||
// 仍按 alpha/search 工具请求处理:PAT 的工具链路失败不能直接永久置错。
|
||||
shouldDisable := false
|
||||
if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
|
||||
shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
||||
shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel))
|
||||
}
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
|
||||
if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount)
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError(upstreamMessage, respBody) {
|
||||
return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount)
|
||||
}
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +218,10 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func openAIAlphaSearchSchedulingModel(account *Account, requestedModel string) string {
|
||||
return canonicalOpenAIAccountSchedulingModel(account, requestedModel)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx context.Context, c *gin.Context, account *Account, alphaBody []byte, body []byte, token string) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -653,7 +664,7 @@ func (s *OpenAIGatewayService) openAIAlphaSearchURL(account *Account) (string, e
|
||||
return "", fmt.Errorf("account is required")
|
||||
}
|
||||
switch account.Type {
|
||||
case AccountTypeOAuth:
|
||||
case AccountTypeOAuth, AccountTypeSetupToken:
|
||||
return chatgptCodexAlphaSearchURL, nil
|
||||
case AccountTypeAPIKey:
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
@@ -300,11 +301,144 @@ func TestForwardAlphaSearchReturnsFailoverBeforeWriting(t *testing.T) {
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
|
||||
require.Empty(t, failoverErr.Stage)
|
||||
require.Empty(t, failoverErr.Scope)
|
||||
require.Empty(t, failoverErr.Reason)
|
||||
require.Zero(t, failoverErr.ClientStatusCode)
|
||||
require.Empty(t, failoverErr.ClientMessage)
|
||||
require.Nil(t, failoverErr.ResponseHeaders)
|
||||
require.Equal(t, openAIPlatformAlphaSearchURL, upstream.lastReq.URL.String())
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchSetupToken429CarriesSameAccountRetryWindow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{}}`)
|
||||
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.StatusTooManyRequests,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"Retry-After": []string{"1"},
|
||||
"X-Request-Id": []string{"req_alpha_oauth_429"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"rate limited"}}`)),
|
||||
}}
|
||||
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 81,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeSetupToken,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
},
|
||||
}
|
||||
startedAt := time.Now()
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Equal(t, time.Second, failoverErr.SameAccountRetryDelay)
|
||||
require.WithinDuration(t, startedAt.Add(openAIOAuth429RetryWindow), failoverErr.SameAccountRetryDeadline, time.Second)
|
||||
require.Equal(t, "req_alpha_oauth_429", failoverErr.ResponseHeaders.Get("x-request-id"))
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchAccessStateUsesTypedFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{}}`)
|
||||
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.StatusForbidden,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"req_alpha_access_state"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"deactivated_workspace","message":"Workspace is deactivated"}}`)),
|
||||
}}
|
||||
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 11,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.Nil(t, result)
|
||||
assertOpenAIAlphaSearchAccessStateFailover(t, err, "req_alpha_access_state")
|
||||
require.Equal(t, chatgptCodexAlphaSearchURL, upstream.lastReq.URL.String())
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchPATFallbackAccessStateUsesTypedFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
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.StatusForbidden,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"req_alpha_pat_access_state"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"account_disabled","message":"Account is disabled"}}`)),
|
||||
}}
|
||||
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 12,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "at-test-token",
|
||||
"auth_mode": OpenAIAuthModePersonalAccessToken,
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||
|
||||
require.Nil(t, result)
|
||||
assertOpenAIAlphaSearchAccessStateFailover(t, err, "req_alpha_pat_access_state")
|
||||
require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
func assertOpenAIAlphaSearchAccessStateFailover(t *testing.T, err error, requestID string) {
|
||||
t.Helper()
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
|
||||
require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage)
|
||||
require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
|
||||
require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
|
||||
require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
|
||||
require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Equal(t, requestID, failoverErr.ResponseHeaders.Get("x-request-id"))
|
||||
}
|
||||
|
||||
func TestForwardAlphaSearchUnauthorizedDoesNotMarkAccountError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
@@ -482,6 +616,14 @@ func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) {
|
||||
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests))
|
||||
}
|
||||
|
||||
func TestOpenAIAlphaSearchSchedulingModelUsesCanonicalAccountMapping(t *testing.T) {
|
||||
account := &Account{Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"client-visible": "canonical-upstream"},
|
||||
}}
|
||||
require.Equal(t, "canonical-upstream", openAIAlphaSearchSchedulingModel(account, "client-visible"))
|
||||
require.Equal(t, "unmapped", openAIAlphaSearchSchedulingModel(account, "unmapped"))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIAlphaSearchBody_RemovesResponsesOnlyFields(t *testing.T) {
|
||||
body := []byte(`{"id":"search-session","store":false,"prompt_cache_key":"cache","commands":{"search_query":[{"q":"news"}]}}`)
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const openAIAPIKeyHealthBreakerReason = "openai_apikey_health_breaker"
|
||||
|
||||
func isOpenAIAPIKeyHealthBreakerAccount(account *Account) bool {
|
||||
return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && account.IsPoolMode()
|
||||
}
|
||||
|
||||
func classifyOpenAIAPIKeyHealthFailure(err error) (int, []byte, bool) {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
var failoverErr *UpstreamFailoverError
|
||||
if errors.As(err, &failoverErr) {
|
||||
// These failures already have dedicated recovery/state handling or are not
|
||||
// attributable to the selected account.
|
||||
if failoverErr.IsCredentialFailure() ||
|
||||
failoverErr.RequestScopedTransient ||
|
||||
failoverErr.RetryableOnSameAccount ||
|
||||
failoverErr.Scope == GatewayFailureScopeRequest ||
|
||||
failoverErr.Scope == GatewayFailureScopeProvider {
|
||||
return failoverErr.StatusCode, failoverErr.ResponseBody, false
|
||||
}
|
||||
if failoverErr.StatusCode == http.StatusTooManyRequests || failoverErr.StatusCode >= http.StatusInternalServerError {
|
||||
return failoverErr.StatusCode, failoverErr.ResponseBody, true
|
||||
}
|
||||
return failoverErr.StatusCode, failoverErr.ResponseBody, false
|
||||
}
|
||||
|
||||
var imageErr *OpenAIImagesUpstreamError
|
||||
if errors.As(err, &imageErr) {
|
||||
if imageErr.StatusCode == http.StatusTooManyRequests || imageErr.StatusCode >= http.StatusInternalServerError {
|
||||
return imageErr.StatusCode, []byte(strings.TrimSpace(imageErr.Message)), true
|
||||
}
|
||||
}
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
func (s *RateLimitService) ObserveOpenAIAPIKeyHealthFailure(ctx context.Context, account *Account, upstreamErr error) bool {
|
||||
if s == nil || s.openAIAPIKeyHealth == nil || s.settingService == nil || s.accountRepo == nil || !isOpenAIAPIKeyHealthBreakerAccount(account) {
|
||||
return false
|
||||
}
|
||||
statusCode, responseBody, eligible := classifyOpenAIAPIKeyHealthFailure(upstreamErr)
|
||||
if !eligible {
|
||||
return false
|
||||
}
|
||||
settings, err := s.settingService.GetOpenAIAPIKeyHealthBreakerSettings(ctx)
|
||||
if err != nil {
|
||||
logger.L().Warn("openai.apikey_health_breaker_settings_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
if settings == nil || !settings.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
count, tripped, err := s.openAIAPIKeyHealth.RecordOpenAIAPIKeyHealthFailure(ctx, account.ID, settings.WindowMinutes, settings.FailureThreshold)
|
||||
if err != nil {
|
||||
logger.L().Warn("openai.apikey_health_breaker_record_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
if !tripped {
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
until := now.Add(time.Duration(settings.CooldownMinutes) * time.Minute)
|
||||
state := &TempUnschedState{
|
||||
UntilUnix: until.Unix(),
|
||||
TriggeredAtUnix: now.Unix(),
|
||||
StatusCode: statusCode,
|
||||
MatchedKeyword: openAIAPIKeyHealthBreakerReason,
|
||||
RuleIndex: -1,
|
||||
ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes),
|
||||
TriggerCount: count,
|
||||
TriggerThreshold: settings.FailureThreshold,
|
||||
TriggerWindowMinutes: settings.WindowMinutes,
|
||||
}
|
||||
reasonBytes, _ := json.Marshal(state)
|
||||
reason := string(reasonBytes)
|
||||
if reason == "" {
|
||||
reason = fmt.Sprintf("%s: %d failures in %d minute(s)", openAIAPIKeyHealthBreakerReason, count, settings.WindowMinutes)
|
||||
}
|
||||
|
||||
persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := s.accountRepo.SetTempUnschedulable(persistCtx, account.ID, until, reason); err != nil {
|
||||
logger.L().Warn("openai.apikey_health_breaker_persist_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
if account.TempUnschedulableUntil == nil || account.TempUnschedulableUntil.Before(until) {
|
||||
account.TempUnschedulableUntil = &until
|
||||
account.TempUnschedulableReason = reason
|
||||
}
|
||||
s.notifyAccountSchedulingBlocked(account, until, openAIAPIKeyHealthBreakerReason)
|
||||
if s.tempUnschedCache != nil {
|
||||
if err := s.tempUnschedCache.SetTempUnsched(persistCtx, account.ID, state); err != nil {
|
||||
logger.L().Warn("openai.apikey_health_breaker_cache_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
logger.L().Warn("openai.apikey_health_breaker_tripped",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int64("failure_count", count),
|
||||
zap.Int("failure_threshold", settings.FailureThreshold),
|
||||
zap.Int("window_minutes", settings.WindowMinutes),
|
||||
zap.Int("cooldown_minutes", settings.CooldownMinutes),
|
||||
zap.Int("upstream_status", statusCode),
|
||||
zap.Time("until", until),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *RateLimitService) ObserveOpenAIAPIKeyHealthSuccess(ctx context.Context, account *Account) {
|
||||
if s == nil || s.openAIAPIKeyHealth == nil || s.settingService == nil || !isOpenAIAPIKeyHealthBreakerAccount(account) {
|
||||
return
|
||||
}
|
||||
settings, err := s.settingService.GetOpenAIAPIKeyHealthBreakerSettings(ctx)
|
||||
if err != nil || settings == nil || !settings.Enabled {
|
||||
return
|
||||
}
|
||||
if err := s.openAIAPIKeyHealth.ResetOpenAIAPIKeyHealthFailures(ctx, account.ID); err != nil {
|
||||
logger.L().Warn("openai.apikey_health_breaker_reset_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type openAIAPIKeyHealthSettingRepo struct {
|
||||
SettingRepository
|
||||
value string
|
||||
}
|
||||
|
||||
func (r *openAIAPIKeyHealthSettingRepo) GetValue(context.Context, string) (string, error) {
|
||||
return r.value, nil
|
||||
}
|
||||
|
||||
type openAIAPIKeyHealthAccountRepo struct {
|
||||
AccountRepository
|
||||
setCalls int
|
||||
reason string
|
||||
}
|
||||
|
||||
func (r *openAIAPIKeyHealthAccountRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
|
||||
r.setCalls++
|
||||
r.reason = reason
|
||||
return nil
|
||||
}
|
||||
|
||||
type openAIAPIKeyHealthCacheStub struct {
|
||||
TempUnschedCache
|
||||
recordCalls int
|
||||
resetCalls int
|
||||
setCalls int
|
||||
tripped bool
|
||||
}
|
||||
|
||||
func (c *openAIAPIKeyHealthCacheStub) RecordOpenAIAPIKeyHealthFailure(context.Context, int64, int, int) (int64, bool, error) {
|
||||
c.recordCalls++
|
||||
return 3, c.tripped, nil
|
||||
}
|
||||
|
||||
func (c *openAIAPIKeyHealthCacheStub) ResetOpenAIAPIKeyHealthFailures(context.Context, int64) error {
|
||||
c.resetCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *openAIAPIKeyHealthCacheStub) SetTempUnsched(context.Context, int64, *TempUnschedState) error {
|
||||
c.setCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type openAIAPIKeyHealthRuntimeBlocker struct{ calls int }
|
||||
|
||||
func (b *openAIAPIKeyHealthRuntimeBlocker) BlockAccountScheduling(*Account, time.Time, string) {
|
||||
b.calls++
|
||||
}
|
||||
func (*openAIAPIKeyHealthRuntimeBlocker) ClearAccountSchedulingBlock(int64) {}
|
||||
|
||||
func openAIHealthPoolAccount() *Account {
|
||||
return &Account{
|
||||
ID: 42,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyOpenAIAPIKeyHealthFailureExclusions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
eligible bool
|
||||
}{
|
||||
{name: "account attributed 502", err: &UpstreamFailoverError{StatusCode: http.StatusBadGateway}, eligible: true},
|
||||
{name: "request scoped capacity", err: &UpstreamFailoverError{StatusCode: 529, RequestScopedTransient: true}},
|
||||
{name: "provider scoped overload", err: &UpstreamFailoverError{StatusCode: 529, Scope: GatewayFailureScopeProvider}},
|
||||
{name: "dedicated same account retry", err: &UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, RetryableOnSameAccount: true}},
|
||||
{name: "credential disable path", err: &UpstreamFailoverError{StatusCode: http.StatusUnauthorized, Stage: GatewayFailureStageAccountAuth, Scope: GatewayFailureScopeAccount}},
|
||||
{name: "client request", err: &UpstreamFailoverError{StatusCode: http.StatusBadRequest}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, eligible := classifyOpenAIAPIKeyHealthFailure(tt.err)
|
||||
require.Equal(t, tt.eligible, eligible)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAPIKeyHealthBreakerDefaultDisabled(t *testing.T) {
|
||||
settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{}, &config.Config{})
|
||||
cache := &openAIAPIKeyHealthCacheStub{tripped: true}
|
||||
svc := NewRateLimitService(&openAIAPIKeyHealthAccountRepo{}, nil, &config.Config{}, nil, cache)
|
||||
svc.SetSettingService(settings)
|
||||
svc.SetOpenAIAPIKeyHealthCache(cache)
|
||||
|
||||
require.False(t, svc.ObserveOpenAIAPIKeyHealthFailure(context.Background(), openAIHealthPoolAccount(), &UpstreamFailoverError{StatusCode: http.StatusBadGateway}))
|
||||
require.Zero(t, cache.recordCalls)
|
||||
}
|
||||
|
||||
func TestOpenAIAPIKeyHealthBreakerTripsPersistedAndRuntimeState(t *testing.T) {
|
||||
encoded, err := json.Marshal(OpenAIAPIKeyHealthBreakerSettings{Enabled: true, WindowMinutes: 1, FailureThreshold: 3, CooldownMinutes: 5})
|
||||
require.NoError(t, err)
|
||||
settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{value: string(encoded)}, &config.Config{})
|
||||
cache := &openAIAPIKeyHealthCacheStub{tripped: true}
|
||||
repo := &openAIAPIKeyHealthAccountRepo{}
|
||||
blocker := &openAIAPIKeyHealthRuntimeBlocker{}
|
||||
svc := NewRateLimitService(repo, nil, &config.Config{}, nil, cache)
|
||||
svc.SetSettingService(settings)
|
||||
svc.SetOpenAIAPIKeyHealthCache(cache)
|
||||
svc.SetAccountRuntimeBlocker(blocker)
|
||||
account := openAIHealthPoolAccount()
|
||||
|
||||
require.True(t, svc.ObserveOpenAIAPIKeyHealthFailure(context.Background(), account, &UpstreamFailoverError{StatusCode: http.StatusBadGateway, ResponseBody: []byte(`{"error":"upstream"}`)}))
|
||||
require.Equal(t, 1, cache.recordCalls)
|
||||
require.Equal(t, 1, cache.setCalls)
|
||||
require.Equal(t, 1, repo.setCalls)
|
||||
require.Equal(t, 1, blocker.calls)
|
||||
require.NotNil(t, account.TempUnschedulableUntil)
|
||||
require.Contains(t, repo.reason, openAIAPIKeyHealthBreakerReason)
|
||||
}
|
||||
|
||||
func TestOpenAIAPIKeyHealthSuccessResetsOnlyEligiblePoolAccount(t *testing.T) {
|
||||
encoded, err := json.Marshal(OpenAIAPIKeyHealthBreakerSettings{Enabled: true, WindowMinutes: 1, FailureThreshold: 3, CooldownMinutes: 5})
|
||||
require.NoError(t, err)
|
||||
settings := NewSettingService(&openAIAPIKeyHealthSettingRepo{value: string(encoded)}, &config.Config{})
|
||||
cache := &openAIAPIKeyHealthCacheStub{}
|
||||
svc := NewRateLimitService(&openAIAPIKeyHealthAccountRepo{}, nil, &config.Config{}, nil, cache)
|
||||
svc.SetSettingService(settings)
|
||||
svc.SetOpenAIAPIKeyHealthCache(cache)
|
||||
|
||||
svc.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), openAIHealthPoolAccount())
|
||||
svc.ObserveOpenAIAPIKeyHealthSuccess(context.Background(), &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
|
||||
require.Equal(t, 1, cache.resetCalls)
|
||||
}
|
||||
@@ -195,6 +195,8 @@ func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.True(t, failoverErr.RequestScopedTransient)
|
||||
require.Equal(t, http.StatusServiceUnavailable, failoverErr.ClientStatusCode)
|
||||
require.Contains(t, failoverErr.ClientMessage, "servers are currently overloaded")
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, rec.Body.String())
|
||||
})
|
||||
@@ -244,8 +246,8 @@ func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *test
|
||||
}
|
||||
|
||||
// 流中途(已有真实输出)降载时无法再 failover,此时必须把降载码改写为客户端
|
||||
// 可重试的 server_error 再转发——Codex 对 server_is_overloaded/slow_down 判致命
|
||||
// 并终止会话,对其余错误码执行内置退避重试。消息原样保留。
|
||||
// 可重试的 server_error 再通过唯一 response.failed 终态转发——Codex 对
|
||||
// server_is_overloaded/slow_down 判致命并终止会话,对其余错误码执行内置退避重试。
|
||||
func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
@@ -285,7 +287,9 @@ func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T)
|
||||
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, "partial")
|
||||
require.Contains(t, body, "event: response.failed")
|
||||
require.NotContains(t, body, "event: error")
|
||||
require.Equal(t, 1, strings.Count(body, "event: response.failed"))
|
||||
require.Equal(t, 1, strings.Count(body, `"code":"server_error"`))
|
||||
require.Contains(t, body, `"code":"server_error"`)
|
||||
require.NotContains(t, body, "server_is_overloaded")
|
||||
require.Contains(t, body, "Our servers are currently overloaded")
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func setOpenAIChatGPTAccountHeaders(headers http.Header, account *Account) {
|
||||
if headers == nil || account == nil || !account.IsOpenAIOAuth() {
|
||||
if headers == nil || account == nil || !account.IsOpenAIOAuthLike() {
|
||||
return
|
||||
}
|
||||
if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" {
|
||||
|
||||
@@ -32,7 +32,7 @@ func stageCodexFingerprintIDs(c *gin.Context, ids *codexFingerprintIDs) {
|
||||
}
|
||||
|
||||
func stagedCodexFingerprintIDs(c *gin.Context, account *Account) *codexFingerprintIDs {
|
||||
if c == nil || account == nil || account.Type != AccountTypeOAuth {
|
||||
if c == nil || account == nil || !account.UsesOpenAICodexProtocol() {
|
||||
return nil
|
||||
}
|
||||
value, ok := c.Get(codexFingerprintIDsContextKey)
|
||||
@@ -141,7 +141,7 @@ func codexFingerprintSeed(extra map[string]any) (string, bool) {
|
||||
|
||||
func prepareCodexFingerprintExtraForCreate(platform, accountType string, extra map[string]any) map[string]any {
|
||||
prepared := stripCodexFingerprintSeed(extra)
|
||||
if platform != PlatformOpenAI || accountType != AccountTypeOAuth || !codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) {
|
||||
if platform != PlatformOpenAI || (accountType != AccountTypeOAuth && accountType != AccountTypeSetupToken) || !codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) {
|
||||
return prepared
|
||||
}
|
||||
if prepared == nil {
|
||||
@@ -153,7 +153,7 @@ func prepareCodexFingerprintExtraForCreate(platform, accountType string, extra m
|
||||
|
||||
func prepareCodexFingerprintExtraForUpdate(account *Account, extra map[string]any) map[string]any {
|
||||
prepared := stripCodexFingerprintSeed(extra)
|
||||
if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
|
||||
if account == nil || !account.IsOpenAIOAuthLike() {
|
||||
return prepared
|
||||
}
|
||||
if seed, ok := codexFingerprintSeed(account.Extra); ok {
|
||||
@@ -203,7 +203,7 @@ func ShouldEnsureCodexFingerprintSeedForExtraUpdates(updates map[string]any) boo
|
||||
// 的 A/B 实测。上游的配额判定策略不可观测,因此这里取兼容安全的一侧:
|
||||
// 不显式 opt-in 就保持 v0.1.175 之前的客户端身份(#5610)。
|
||||
func (a *Account) GetCodexFingerprintMode() codexFingerprintMode {
|
||||
if a == nil || !a.IsOpenAIOAuth() {
|
||||
if a == nil || !a.IsOpenAIOAuthLike() {
|
||||
return codexFingerprintOff
|
||||
}
|
||||
return codexFingerprintModeFromExtra(a.Extra)
|
||||
|
||||
@@ -64,6 +64,8 @@ func TestGetCodexFingerprintMode(t *testing.T) {
|
||||
}{
|
||||
{"nil 账号", nil, codexFingerprintOff},
|
||||
{"非 OAuth 账号", &Account{Platform: PlatformOpenAI, Type: "api_key"}, codexFingerprintOff},
|
||||
{"OpenAI setup token", &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}}, codexFingerprintSession},
|
||||
{"Anthropic setup token", &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken, Extra: map[string]any{codexFingerprintModeExtraKey: "session"}}, codexFingerprintOff},
|
||||
// 收敛是显式 opt-in:缺省/空/非法一律 off(#5610)。存量账号普遍没有这个
|
||||
// extra 键,升级不得把它们静默切进收敛。
|
||||
{"无 extra 默认 off", newTestOAuthAccount(1, nil), codexFingerprintOff},
|
||||
|
||||
@@ -6,12 +6,15 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
codexReservedPythonToolName = "python"
|
||||
codexPythonToolAlias = "python__sub2api"
|
||||
codexToolNameReverseKey = "openai_codex_tool_name_reverse"
|
||||
codexToolNameSessionKey = "openai_codex_tool_name_session_reverse"
|
||||
)
|
||||
|
||||
type codexToolNameField struct {
|
||||
@@ -88,25 +91,37 @@ func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNam
|
||||
continue
|
||||
}
|
||||
toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"])))
|
||||
if toolType != "namespace" {
|
||||
if toolType == "function" {
|
||||
appendName(tool, "name")
|
||||
if function, ok := tool["function"].(map[string]any); ok {
|
||||
appendName(function, "name")
|
||||
}
|
||||
}
|
||||
if function, ok := tool["function"].(map[string]any); ok {
|
||||
appendName(function, "name")
|
||||
if toolType == "namespace" {
|
||||
collectTools(tool["tools"])
|
||||
}
|
||||
collectTools(tool["tools"])
|
||||
}
|
||||
}
|
||||
collectTools(reqBody["tools"])
|
||||
collectTools(reqBody["functions"])
|
||||
if choice, ok := reqBody["tool_choice"].(map[string]any); ok {
|
||||
if !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "namespace") {
|
||||
appendName(choice, "name")
|
||||
}
|
||||
if function, ok := choice["function"].(map[string]any); ok {
|
||||
if functions, ok := reqBody["functions"].([]any); ok {
|
||||
for _, raw := range functions {
|
||||
function, _ := raw.(map[string]any)
|
||||
appendName(function, "name")
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(reqBody["type"])), "session.update") {
|
||||
if session, ok := reqBody["session"].(map[string]any); ok {
|
||||
collectTools(session["tools"])
|
||||
}
|
||||
}
|
||||
if choice, ok := reqBody["tool_choice"].(map[string]any); ok {
|
||||
if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "function") {
|
||||
appendName(choice, "name")
|
||||
if function, ok := choice["function"].(map[string]any); ok {
|
||||
appendName(function, "name")
|
||||
}
|
||||
}
|
||||
}
|
||||
if input, ok := reqBody["input"].([]any); ok {
|
||||
for _, raw := range input {
|
||||
item, ok := raw.(map[string]any)
|
||||
@@ -117,7 +132,7 @@ func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNam
|
||||
if typ == "additional_tools" {
|
||||
collectTools(item["tools"])
|
||||
}
|
||||
if strings.HasSuffix(typ, "_call") || typ == "tool_call" {
|
||||
if typ == "function_call" {
|
||||
appendName(item, "name")
|
||||
if function, ok := item["function"].(map[string]any); ok {
|
||||
appendName(function, "name")
|
||||
@@ -133,7 +148,7 @@ func aliasOpenAIOAuthReservedToolNamesBody(body []byte) ([]byte, map[string]stri
|
||||
return body, nil, false, nil
|
||||
}
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(body, &reqBody); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
|
||||
return body, nil, false, fmt.Errorf("decode OAuth reserved tool names: %w", err)
|
||||
}
|
||||
reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody)
|
||||
@@ -174,6 +189,14 @@ func containsASCIIFold(haystack, needle []byte) bool {
|
||||
}
|
||||
|
||||
func setCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
storeCodexToolNameReverse(c, codexToolNameReverseKey, reverse)
|
||||
storeCodexToolNameReverse(c, codexToolNameSessionKey, nil)
|
||||
}
|
||||
|
||||
func storeCodexToolNameReverse(c *gin.Context, key string, reverse map[string]string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
@@ -181,14 +204,32 @@ func setCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
|
||||
for aliased, original := range reverse {
|
||||
copyMap[aliased] = original
|
||||
}
|
||||
c.Set(codexToolNameReverseKey, copyMap)
|
||||
c.Set(key, copyMap)
|
||||
}
|
||||
|
||||
func mergeCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
|
||||
if c == nil || len(reverse) == 0 {
|
||||
return
|
||||
}
|
||||
merged := make(map[string]string, len(reverse)+len(codexToolNameReverseFromContext(c)))
|
||||
for aliased, original := range codexToolNameReverseFromContext(c) {
|
||||
merged[aliased] = original
|
||||
}
|
||||
for aliased, original := range reverse {
|
||||
merged[aliased] = original
|
||||
}
|
||||
storeCodexToolNameReverse(c, codexToolNameReverseKey, merged)
|
||||
}
|
||||
|
||||
func codexToolNameReverseFromContext(c *gin.Context) map[string]string {
|
||||
return codexToolNameReverseForKey(c, codexToolNameReverseKey)
|
||||
}
|
||||
|
||||
func codexToolNameReverseForKey(c *gin.Context, key string) map[string]string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := c.Get(codexToolNameReverseKey)
|
||||
raw, ok := c.Get(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -196,12 +237,59 @@ func codexToolNameReverseFromContext(c *gin.Context) map[string]string {
|
||||
return reverse
|
||||
}
|
||||
|
||||
// updateCodexToolNameReverseForWSFrame keeps the active turn isolated from
|
||||
// session updates that may arrive while that turn is still streaming.
|
||||
func updateCodexToolNameReverseForWSFrame(c *gin.Context, frame []byte, reverse map[string]string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
eventType := strings.TrimSpace(gjson.GetBytes(frame, "type").String())
|
||||
switch eventType {
|
||||
case "session.update":
|
||||
if gjson.GetBytes(frame, "session.tools").Exists() {
|
||||
storeCodexToolNameReverse(c, codexToolNameSessionKey, reverse)
|
||||
}
|
||||
case "response.create", "":
|
||||
active := reverse
|
||||
if !openAIWSFrameHasExplicitToolDeclarations(frame) {
|
||||
active = mergeCodexToolNameReverseMaps(
|
||||
codexToolNameReverseForKey(c, codexToolNameSessionKey),
|
||||
reverse,
|
||||
)
|
||||
}
|
||||
storeCodexToolNameReverse(c, codexToolNameReverseKey, active)
|
||||
}
|
||||
}
|
||||
|
||||
func openAIWSFrameHasExplicitToolDeclarations(frame []byte) bool {
|
||||
if gjson.GetBytes(frame, "tools").Exists() {
|
||||
return true
|
||||
}
|
||||
for _, item := range gjson.GetBytes(frame, "input").Array() {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Get("type").String()), "additional_tools") && item.Get("tools").Exists() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeCodexToolNameReverseMaps(base, overlay map[string]string) map[string]string {
|
||||
merged := make(map[string]string, len(base)+len(overlay))
|
||||
for aliased, original := range base {
|
||||
merged[aliased] = original
|
||||
}
|
||||
for aliased, original := range overlay {
|
||||
merged[aliased] = original
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func restoreCodexToolNamesInJSON(data []byte, reverse map[string]string) []byte {
|
||||
if len(data) == 0 || len(reverse) == 0 || !json.Valid(data) {
|
||||
return data
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(data, &decoded); err != nil {
|
||||
return data
|
||||
}
|
||||
if !restoreCodexToolNameFields(decoded, reverse) {
|
||||
@@ -215,30 +303,153 @@ func restoreCodexToolNamesInJSON(data []byte, reverse map[string]string) []byte
|
||||
}
|
||||
|
||||
func restoreCodexToolNamesFromContext(c *gin.Context, data []byte) []byte {
|
||||
return restoreCodexToolNamesInJSON(data, codexToolNameReverseFromContext(c))
|
||||
reverse := codexToolNameReverseFromContext(c)
|
||||
switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) {
|
||||
case "session.created", "session.updated":
|
||||
reverse = codexToolNameReverseForKey(c, codexToolNameSessionKey)
|
||||
}
|
||||
return restoreCodexToolNamesInJSON(data, reverse)
|
||||
}
|
||||
|
||||
func restoreCodexToolNamesFromSSEContext(c *gin.Context, data []byte, eventType string) []byte {
|
||||
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "" || strings.TrimSpace(eventType) == "" {
|
||||
return restoreCodexToolNamesFromContext(c, data)
|
||||
}
|
||||
compat := []byte(openAICompatPayloadWithEventType(string(data), eventType))
|
||||
restored := restoreCodexToolNamesFromContext(c, compat)
|
||||
if string(restored) == string(compat) {
|
||||
return data
|
||||
}
|
||||
withoutSyntheticType, err := sjson.DeleteBytes(restored, "type")
|
||||
if err != nil {
|
||||
return restored
|
||||
}
|
||||
return withoutSyntheticType
|
||||
}
|
||||
|
||||
func restoreCodexToolNameFields(value any, reverse map[string]string) bool {
|
||||
root, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if name, ok := typed["name"].(string); ok {
|
||||
restoreItem := func(raw any) {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok || !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(item["type"])), "function_call") {
|
||||
return
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if original, exists := reverse[name]; exists {
|
||||
item["name"] = original
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
restoreOutput := func(raw any) {
|
||||
output, _ := raw.([]any)
|
||||
for _, item := range output {
|
||||
restoreItem(item)
|
||||
}
|
||||
}
|
||||
restoreResponse := func(raw any) {
|
||||
response, ok := raw.(map[string]any)
|
||||
if ok {
|
||||
restoreOutput(response["output"])
|
||||
}
|
||||
}
|
||||
restoreFunction := func(raw any) {
|
||||
function, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name, _ := function["name"].(string)
|
||||
if original, exists := reverse[name]; exists {
|
||||
function["name"] = original
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
restoreChatToolCalls := func(raw any) {
|
||||
toolCalls, _ := raw.([]any)
|
||||
for _, rawCall := range toolCalls {
|
||||
call, ok := rawCall.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
callType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(call["type"])))
|
||||
if callType == "" || callType == "function" {
|
||||
restoreFunction(call["function"])
|
||||
}
|
||||
}
|
||||
}
|
||||
restoreMessageContent := func(raw any) {
|
||||
content, _ := raw.([]any)
|
||||
for _, rawBlock := range content {
|
||||
block, ok := rawBlock.(map[string]any)
|
||||
if !ok || !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(block["type"])), "tool_use") {
|
||||
continue
|
||||
}
|
||||
name, _ := block["name"].(string)
|
||||
if original, exists := reverse[name]; exists {
|
||||
typed["name"] = original
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for _, child := range typed {
|
||||
if restoreCodexToolNameFields(child, reverse) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if restoreCodexToolNameFields(child, reverse) {
|
||||
block["name"] = original
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var restoreTools func(any)
|
||||
restoreTools = func(raw any) {
|
||||
tools, _ := raw.([]any)
|
||||
for _, rawTool := range tools {
|
||||
tool, ok := rawTool.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"])))
|
||||
if toolType == "function" {
|
||||
name, _ := tool["name"].(string)
|
||||
if original, exists := reverse[name]; exists {
|
||||
tool["name"] = original
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if toolType == "namespace" {
|
||||
restoreTools(tool["tools"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(root["type"])))
|
||||
switch eventType {
|
||||
case "response.output_item.added", "response.output_item.done":
|
||||
restoreItem(root["item"])
|
||||
case "response.created", "response.in_progress", "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
||||
restoreResponse(root["response"])
|
||||
case "session.created", "session.updated":
|
||||
if session, ok := root["session"].(map[string]any); ok {
|
||||
restoreTools(session["tools"])
|
||||
}
|
||||
}
|
||||
if _, hasOutput := root["output"]; hasOutput {
|
||||
restoreOutput(root["output"])
|
||||
}
|
||||
if choices, ok := root["choices"].([]any); ok {
|
||||
for _, rawChoice := range choices {
|
||||
choice, ok := rawChoice.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"message", "delta"} {
|
||||
if message, ok := choice[key].(map[string]any); ok {
|
||||
restoreChatToolCalls(message["tool_calls"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
restoreMessageContent(root["content"])
|
||||
if block, ok := root["content_block"].(map[string]any); ok && strings.EqualFold(strings.TrimSpace(firstNonEmptyString(block["type"])), "tool_use") {
|
||||
name, _ := block["name"].(string)
|
||||
if original, exists := reverse[name]; exists {
|
||||
block["name"] = original
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -93,3 +93,121 @@ func TestRestoreCodexToolNamesFromContext_HTTPAndWSPayloadShapes(t *testing.T) {
|
||||
string(restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.added","item":{"name":"python__sub2api"}}`))),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAliasOpenAIOAuthReservedToolNames_SessionUpdateOnlyTouchesFunctionProtocolNodes(t *testing.T) {
|
||||
body := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python"},{"type":"image_generation","name":"python"},{"type":"namespace","name":"python","tools":[{"type":"function","name":"shell"}]}]},"metadata":{"name":"python"},"sequence":900719925474099312345}`)
|
||||
|
||||
aliased, reverse, changed, err := aliasOpenAIOAuthReservedToolNamesBody(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "python", reverse[codexPythonToolAlias])
|
||||
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(aliased, "session.tools.0.name").String())
|
||||
require.Equal(t, "python", gjson.GetBytes(aliased, "session.tools.1.name").String())
|
||||
require.Equal(t, "python", gjson.GetBytes(aliased, "session.tools.2.name").String())
|
||||
require.Equal(t, "shell", gjson.GetBytes(aliased, "session.tools.2.tools.0.name").String())
|
||||
require.Equal(t, "python", gjson.GetBytes(aliased, "metadata.name").String())
|
||||
require.Equal(t, "900719925474099312345", gjson.GetBytes(aliased, "sequence").Raw)
|
||||
}
|
||||
|
||||
func TestRestoreCodexToolNamesInJSON_OnlyTouchesResponseToolCallNodesAndPreservesNumbers(t *testing.T) {
|
||||
reverse := map[string]string{codexPythonToolAlias: "python"}
|
||||
body := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"python__sub2api"},{"type":"message","name":"python__sub2api","content":[]}]},"metadata":{"name":"python__sub2api"},"sequence":900719925474099312345}`)
|
||||
|
||||
restored := restoreCodexToolNamesInJSON(body, reverse)
|
||||
require.Equal(t, "python", gjson.GetBytes(restored, "response.output.0.name").String())
|
||||
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "response.output.1.name").String())
|
||||
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
|
||||
require.Equal(t, "900719925474099312345", gjson.GetBytes(restored, "sequence").Raw)
|
||||
}
|
||||
|
||||
func TestRestoreCodexToolNamesInJSON_ExplicitHTTPAndSSEToolCallProtocols(t *testing.T) {
|
||||
reverse := map[string]string{codexPythonToolAlias: "python"}
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "chat http",
|
||||
body: `{"choices":[{"message":{"tool_calls":[{"type":"function","function":{"name":"python__sub2api"}}]}}],"metadata":{"name":"python__sub2api"}}`,
|
||||
path: "choices.0.message.tool_calls.0.function.name",
|
||||
},
|
||||
{
|
||||
name: "chat sse",
|
||||
body: `{"choices":[{"delta":{"tool_calls":[{"type":"function","function":{"name":"python__sub2api"}}]}}],"metadata":{"name":"python__sub2api"}}`,
|
||||
path: "choices.0.delta.tool_calls.0.function.name",
|
||||
},
|
||||
{
|
||||
name: "messages tool use",
|
||||
body: `{"type":"content_block_start","content":[{"type":"tool_use","name":"python__sub2api"}],"metadata":{"name":"python__sub2api"}}`,
|
||||
path: "content.0.name",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
restored := restoreCodexToolNamesInJSON([]byte(tt.body), reverse)
|
||||
require.Equal(t, "python", gjson.GetBytes(restored, tt.path).String())
|
||||
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasOpenAIOAuthReservedToolNames_PromptCompatibilityRunsFirst(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","prompt":[{"type":"function_call","name":"python","call_id":"fc_1"}],"functions":[{"name":"python"}],"function_call":{"name":"python"},"sequence":900719925474099312345}`)
|
||||
reqBody, err := getOpenAIRequestBodyMap(nil, body)
|
||||
require.NoError(t, err)
|
||||
result := applyCodexOAuthTransform(reqBody, true, false)
|
||||
require.NoError(t, result.Error)
|
||||
require.Equal(t, codexPythonToolAlias, reqBody["input"].([]any)[0].(map[string]any)["name"])
|
||||
require.Equal(t, codexPythonToolAlias, reqBody["tools"].([]any)[0].(map[string]any)["name"])
|
||||
require.Equal(t, codexPythonToolAlias, reqBody["tool_choice"].(map[string]any)["name"])
|
||||
encoded, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "900719925474099312345", gjson.GetBytes(encoded, "sequence").Raw)
|
||||
}
|
||||
|
||||
func TestCodexToolNameReverse_WSSessionReplacementDoesNotChangeActiveTurn(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
setCodexToolNameReverse(c, nil)
|
||||
first := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`)
|
||||
updateCodexToolNameReverseForWSFrame(c, first, map[string]string{codexPythonToolAlias: "python"})
|
||||
|
||||
update := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python__sub2api"}]}}`)
|
||||
updateCodexToolNameReverseForWSFrame(c, update, nil)
|
||||
currentOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
|
||||
require.Equal(t, "python", gjson.GetBytes(currentOutput, "item.name").String())
|
||||
sessionEcho := restoreCodexToolNamesFromContext(c, []byte(`{"type":"session.updated","session":{"tools":[{"type":"function","name":"python__sub2api"}]}}`))
|
||||
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(sessionEcho, "session.tools.0.name").String())
|
||||
|
||||
next := []byte(`{"type":"response.create","input":"next"}`)
|
||||
updateCodexToolNameReverseForWSFrame(c, next, nil)
|
||||
nextOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
|
||||
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(nextOutput, "item.name").String())
|
||||
|
||||
sessionPython := []byte(`{"type":"session.update","session":{"tools":[{"type":"function","name":"python"}]}}`)
|
||||
updateCodexToolNameReverseForWSFrame(c, sessionPython, map[string]string{codexPythonToolAlias: "python"})
|
||||
explicitLiteral := []byte(`{"type":"response.create","input":[{"type":"additional_tools","tools":[{"type":"function","name":"python__sub2api"}]}]}`)
|
||||
updateCodexToolNameReverseForWSFrame(c, explicitLiteral, nil)
|
||||
literalOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
|
||||
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(literalOutput, "item.name").String())
|
||||
updateCodexToolNameReverseForWSFrame(c, next, nil)
|
||||
inheritedOutput := restoreCodexToolNamesFromContext(c, []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"python__sub2api"}}`))
|
||||
require.Equal(t, "python", gjson.GetBytes(inheritedOutput, "item.name").String())
|
||||
}
|
||||
|
||||
func TestDecodeOpenAIJSONUseNumberRejectsTrailingDocument(t *testing.T) {
|
||||
var decoded map[string]any
|
||||
require.Error(t, decodeOpenAIJSONUseNumber([]byte(`{"name":"python"}{"extra":true}`), &decoded))
|
||||
}
|
||||
|
||||
func TestRestoreCodexToolNamesFromSSEContextUsesEventLineTypeWithoutAddingType(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
setCodexToolNameReverse(c, map[string]string{codexPythonToolAlias: codexReservedPythonToolName})
|
||||
payload := []byte(`{"item":{"type":"function_call","name":"python__sub2api"},"metadata":{"name":"python__sub2api"}}`)
|
||||
|
||||
restored := restoreCodexToolNamesFromSSEContext(c, payload, "response.output_item.done")
|
||||
|
||||
require.Equal(t, codexReservedPythonToolName, gjson.GetBytes(restored, "item.name").String())
|
||||
require.Equal(t, codexPythonToolAlias, gjson.GetBytes(restored, "metadata.name").String())
|
||||
require.False(t, gjson.GetBytes(restored, "type").Exists())
|
||||
}
|
||||
|
||||
@@ -173,15 +173,6 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
|
||||
|
||||
func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuthTransformOptions) codexTransformResult {
|
||||
result := codexTransformResult{}
|
||||
toolNameReverse, toolNamesChanged, err := aliasOpenAIOAuthReservedToolNames(reqBody)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
result.ToolNameReverse = toolNameReverse
|
||||
if toolNamesChanged {
|
||||
result.Modified = true
|
||||
}
|
||||
if normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody) {
|
||||
result.Modified = true
|
||||
}
|
||||
@@ -273,6 +264,18 @@ func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuth
|
||||
if normalizeCodexTools(reqBody) {
|
||||
result.Modified = true
|
||||
}
|
||||
// Collect aliases only after prompt/functions/function_call compatibility
|
||||
// has produced the final Responses protocol nodes. Otherwise references
|
||||
// introduced by those migrations can retain the reserved name.
|
||||
toolNameReverse, toolNamesChanged, err := aliasOpenAIOAuthReservedToolNames(reqBody)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
result.ToolNameReverse = toolNameReverse
|
||||
if toolNamesChanged {
|
||||
result.Modified = true
|
||||
}
|
||||
if normalizeCodexToolChoice(reqBody) {
|
||||
result.Modified = true
|
||||
}
|
||||
@@ -1218,7 +1221,7 @@ func normalizeOpenAIResponsesImageOnlyModel(reqBody map[string]any) bool {
|
||||
}
|
||||
|
||||
func normalizeOpenAIModelForUpstream(account *Account, model string) string {
|
||||
if account == nil || account.Type == AccountTypeOAuth {
|
||||
if account == nil || account.UsesOpenAICodexProtocol() {
|
||||
return normalizeCodexModel(model)
|
||||
}
|
||||
return strings.TrimSpace(model)
|
||||
@@ -1599,7 +1602,7 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an
|
||||
if typ == "reasoning" {
|
||||
newItem := make(map[string]any, len(m))
|
||||
for key, value := range m {
|
||||
if key == "id" {
|
||||
if key == "id" || key == "call_id" {
|
||||
// rs_* id replayed under store=false 404s; strip it.
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1875,6 +1875,7 @@ func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) {
|
||||
map[string]any{
|
||||
"type": "reasoning",
|
||||
"id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
|
||||
"call_id": "remove_non_pair_call_id",
|
||||
"encrypted_content": "gAAAAAB-enc-payload",
|
||||
"summary": []any{},
|
||||
},
|
||||
@@ -1897,6 +1898,8 @@ func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) {
|
||||
// PreserveReferences=true (id lookup, not the item, triggers the 404).
|
||||
_, hasID := item["id"]
|
||||
require.False(t, hasID)
|
||||
_, hasCallID := item["call_id"]
|
||||
require.False(t, hasCallID)
|
||||
// summary passed through untouched.
|
||||
summary, ok := item["summary"].([]any)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -30,7 +29,7 @@ func NormalizeCompactionTriggerInputOrder(body []byte) ([]byte, bool, error) {
|
||||
return body, false, nil
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(body, &payload); err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
input, ok := payload["input"].([]any)
|
||||
@@ -57,7 +56,7 @@ func NormalizeCompactionTriggerInputOrder(body []byte) ([]byte, bool, error) {
|
||||
}
|
||||
normalized = append(normalized, map[string]any{"type": "compaction_trigger"})
|
||||
payload["input"] = normalized
|
||||
encoded, err := json.Marshal(payload)
|
||||
encoded, err := marshalOpenAIUpstreamJSON(payload)
|
||||
if err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
@@ -143,7 +142,7 @@ func applyOpenAICodexBetaFeatures(c *gin.Context, account *Account, h http.Heade
|
||||
ensureOpenAIRemoteCompactionV2BetaFeature(h)
|
||||
return
|
||||
}
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
if account == nil || !account.IsOpenAIOAuthLike() {
|
||||
return
|
||||
}
|
||||
if hasOpenAICodexBetaFeaturesHeader(h) {
|
||||
|
||||
@@ -74,3 +74,35 @@ func TestNormalizeCompactionTriggerInputOrder_AlreadyFinalPreservesBytes(t *test
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(normalized))
|
||||
}
|
||||
|
||||
func TestNormalizeCompactionTriggerInputOrder_PreservesHistoryAndLargeNumbers(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"compaction_trigger"},{"type":"message","id":"msg_1","content":"visible"},{"type":"function_call_output","call_id":"call_1","output":"result","sequence":9007199254740993}]}`)
|
||||
|
||||
normalized, changed, err := NormalizeCompactionTriggerInputOrder(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String())
|
||||
require.Equal(t, "function_call_output", gjson.GetBytes(normalized, "input.1.type").String())
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.1.sequence").Raw)
|
||||
require.Equal(t, "compaction_trigger", gjson.GetBytes(normalized, "input.2.type").String())
|
||||
|
||||
second, changedAgain, err := NormalizeCompactionTriggerInputOrder(normalized)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, normalized, second)
|
||||
}
|
||||
|
||||
func TestWebSocketCompatibilityNormalizesTriggerAfterPairedOutputCleanup(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","model":"gpt-5.4","input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`)
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
items := gjson.GetBytes(normalized, "input").Array()
|
||||
require.Len(t, items, 4)
|
||||
require.Equal(t, "function_call", items[0].Get("type").String())
|
||||
require.Equal(t, "function_call_output", items[1].Get("type").String())
|
||||
require.Equal(t, "message", items[2].Get("type").String())
|
||||
require.Equal(t, "compaction_trigger", items[3].Get("type").String())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type openAICompactFallbackSignal struct {
|
||||
payload []byte
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *openAICompactFallbackSignal) Error() string {
|
||||
if e == nil || strings.TrimSpace(e.message) == "" {
|
||||
return "upstream compact request failed"
|
||||
}
|
||||
return e.message
|
||||
}
|
||||
|
||||
func asOpenAICompactFallbackSignal(err error) (*openAICompactFallbackSignal, bool) {
|
||||
var signal *openAICompactFallbackSignal
|
||||
return signal, errors.As(err, &signal) && signal != nil
|
||||
}
|
||||
|
||||
func isExplicitOpenAICompactContext(c *gin.Context) bool {
|
||||
return isOpenAIResponsesCompactPath(c) || isOpenAINativeCompactionV2(c)
|
||||
}
|
||||
|
||||
func newOpenAICompactFallbackSignal(c *gin.Context, payload []byte, message string) error {
|
||||
if !isExplicitOpenAICompactContext(c) ||
|
||||
!isOpenAICompactModelFailure(http.StatusBadRequest, message, payload) {
|
||||
return nil
|
||||
}
|
||||
return &openAICompactFallbackSignal{
|
||||
payload: append([]byte(nil), payload...),
|
||||
message: sanitizeUpstreamErrorMessage(strings.TrimSpace(message)),
|
||||
}
|
||||
}
|
||||
|
||||
func isExplicitOpenAICompactRequest(c *gin.Context, body []byte) bool {
|
||||
return isOpenAIResponsesCompactPath(c) || HasCompactionTriggerInInput(body)
|
||||
}
|
||||
|
||||
// resolveOpenAICompactFallbackModel prefers the account's compact-only rule
|
||||
// for the client-visible model. The process-wide fallback is used only when
|
||||
// that account has no matching compact rule.
|
||||
func (s *OpenAIGatewayService) resolveOpenAICompactFallbackModel(account *Account, requestedModel string) string {
|
||||
requestedModel = strings.TrimSpace(requestedModel)
|
||||
if account != nil {
|
||||
if mapped, matched := account.ResolveCompactMappedModel(requestedModel); matched {
|
||||
if mapped = strings.TrimSpace(mapped); mapped != "" {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
if s == nil || s.cfg == nil {
|
||||
return ""
|
||||
}
|
||||
fallback := strings.TrimSpace(s.cfg.Gateway.OpenAICompactModel)
|
||||
if fallback == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(resolveOpenAIAccountUpstreamModelForRequest(account, fallback, false))
|
||||
}
|
||||
|
||||
func isOpenAICompactModelFailure(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
|
||||
if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
|
||||
return true
|
||||
}
|
||||
if statusCode != http.StatusBadRequest && statusCode != http.StatusNotFound {
|
||||
return false
|
||||
}
|
||||
|
||||
values := []string{
|
||||
extractUpstreamErrorCode(upstreamBody),
|
||||
upstreamMsg,
|
||||
gjson.GetBytes(upstreamBody, "error.type").String(),
|
||||
gjson.GetBytes(upstreamBody, "response.error.code").String(),
|
||||
gjson.GetBytes(upstreamBody, "response.error.type").String(),
|
||||
}
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "model_not_found", "model_not_available", "unsupported_model", "invalid_model":
|
||||
return true
|
||||
}
|
||||
if strings.Contains(value, "model") && (strings.Contains(value, "not found") ||
|
||||
strings.Contains(value, "does not exist") ||
|
||||
strings.Contains(value, "unavailable") ||
|
||||
strings.Contains(value, "unsupported") ||
|
||||
strings.Contains(value, "not supported")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Some compact providers return only a failed response shell. It is safe to
|
||||
// retry that shape for an explicit compact request, but a populated error is
|
||||
// left untouched so business and policy failures keep their original wire.
|
||||
if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "response.status").String()), "failed") ||
|
||||
strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "status").String()), "failed") {
|
||||
for _, path := range []string{
|
||||
"error.message", "error.code", "error.type",
|
||||
"response.error.message", "response.error.code", "response.error.type",
|
||||
} {
|
||||
if strings.TrimSpace(gjson.GetBytes(upstreamBody, path).String()) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(upstreamMsg) == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// prepareOpenAICompactFallbackRetry returns a body for one safe, same-account
|
||||
// retry. Callers invoke it only before any downstream response has been
|
||||
// written; it changes the model and deliberately leaves path, trigger, and
|
||||
// native-v2 context state untouched.
|
||||
func (s *OpenAIGatewayService) prepareOpenAICompactFallbackRetry(
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
requestedModel string,
|
||||
currentBody []byte,
|
||||
statusCode int,
|
||||
upstreamMsg string,
|
||||
upstreamBody []byte,
|
||||
alreadyRetried bool,
|
||||
) ([]byte, string, bool) {
|
||||
if alreadyRetried || !isExplicitOpenAICompactRequest(c, currentBody) ||
|
||||
!isOpenAICompactModelFailure(statusCode, upstreamMsg, upstreamBody) {
|
||||
return currentBody, "", false
|
||||
}
|
||||
fallbackModel := s.resolveOpenAICompactFallbackModel(account, requestedModel)
|
||||
currentModel := strings.TrimSpace(gjson.GetBytes(currentBody, "model").String())
|
||||
if fallbackModel == "" || strings.EqualFold(fallbackModel, currentModel) {
|
||||
return currentBody, "", false
|
||||
}
|
||||
retryBody := ReplaceModelInBody(currentBody, fallbackModel)
|
||||
if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(retryBody, "model").String()), currentModel) {
|
||||
return currentBody, "", false
|
||||
}
|
||||
return retryBody, fallbackModel, true
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func newOpenAICompactFallbackTestContext(t *testing.T, path string) *gin.Context {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestPrepareOpenAICompactFallbackRetryRequiresExplicitCompact(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
|
||||
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
|
||||
body := []byte(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":"hello"}]}`)
|
||||
errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"maximum context length exceeded"}}`)
|
||||
|
||||
retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
|
||||
c, nil, "gpt-5.5", body, http.StatusBadRequest, "maximum context length exceeded", errorBody, false,
|
||||
)
|
||||
|
||||
require.False(t, retry)
|
||||
require.Empty(t, fallbackModel)
|
||||
require.Equal(t, body, retryBody)
|
||||
}
|
||||
|
||||
func TestPrepareOpenAICompactFallbackRetryPreservesNativeTriggerAndContext(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
|
||||
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
|
||||
MarkOpenAINativeCompactionV2(c)
|
||||
body := []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
|
||||
errorBody := []byte(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)
|
||||
pathBefore := openAIResponsesRequestPathSuffix(c)
|
||||
|
||||
retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
|
||||
c, nil, "gpt-5.5", body, http.StatusBadRequest, "context window exceeded", errorBody, false,
|
||||
)
|
||||
|
||||
require.True(t, retry)
|
||||
require.Equal(t, "gpt-5.4", fallbackModel)
|
||||
require.Equal(t, "gpt-5.4", gjson.GetBytes(retryBody, "model").String())
|
||||
require.True(t, HasCompactionTriggerInInput(retryBody))
|
||||
require.True(t, isOpenAINativeCompactionV2(c))
|
||||
require.Equal(t, pathBefore, openAIResponsesRequestPathSuffix(c))
|
||||
}
|
||||
|
||||
func TestResolveOpenAICompactFallbackModelPrefersAccountMapping(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}}}
|
||||
account := &Account{Credentials: map[string]any{
|
||||
"compact_model_mapping": map[string]any{"gpt-5.5": "account-compact"},
|
||||
}}
|
||||
|
||||
require.Equal(t, "account-compact", svc.resolveOpenAICompactFallbackModel(account, "gpt-5.5"))
|
||||
require.Equal(t, "global-compact", svc.resolveOpenAICompactFallbackModel(account, "unmapped-model"))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayForwardUsesGlobalCompactModelOnInitialLegacyRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[]}`)
|
||||
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"global-compact","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "global-compact"}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
|
||||
Status: StatusActive, Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
require.Equal(t, "global-compact", gjson.GetBytes(upstream.bodies[0], "model").String())
|
||||
require.Contains(t, upstream.requests[0].URL.Path, "/compact")
|
||||
}
|
||||
|
||||
func TestPrepareOpenAICompactFallbackRetryLegacyPathAndSingleAttemptGuard(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
|
||||
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
|
||||
body := []byte(`{"model":"gpt-5.5","input":[]}`)
|
||||
errorBody := []byte(`{"response":{"status":"failed","error":null}}`)
|
||||
|
||||
retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
|
||||
c, nil, "gpt-5.5", body, http.StatusBadRequest, "", errorBody, false,
|
||||
)
|
||||
require.True(t, retry)
|
||||
require.Equal(t, "gpt-5.4", fallbackModel)
|
||||
require.Equal(t, "/compact", openAIResponsesRequestPathSuffix(c))
|
||||
|
||||
secondBody, secondModel, secondRetry := svc.prepareOpenAICompactFallbackRetry(
|
||||
c, nil, "gpt-5.5", retryBody, http.StatusBadRequest, "", errorBody, true,
|
||||
)
|
||||
require.False(t, secondRetry)
|
||||
require.Empty(t, secondModel)
|
||||
require.Equal(t, retryBody, secondBody)
|
||||
}
|
||||
|
||||
func TestPrepareOpenAICompactFallbackRetryDoesNotHideSpecificBusinessFailure(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}}}
|
||||
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
|
||||
body := []byte(`{"model":"gpt-5.5","input":[]}`)
|
||||
errorBody := []byte(`{"response":{"status":"failed","error":{"type":"permission_error","message":"workspace denied"}}}`)
|
||||
|
||||
retryBody, fallbackModel, retry := svc.prepareOpenAICompactFallbackRetry(
|
||||
c, nil, "gpt-5.5", body, http.StatusBadRequest, "workspace denied", errorBody, false,
|
||||
)
|
||||
|
||||
require.False(t, retry)
|
||||
require.Empty(t, fallbackModel)
|
||||
require.Equal(t, body, retryBody)
|
||||
}
|
||||
|
||||
func TestPrepareOpenAICompactFallbackRetrySkipsSameModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.5"}}}
|
||||
c := newOpenAICompactFallbackTestContext(t, "/v1/responses/compact")
|
||||
body := []byte(`{"model":"gpt-5.5","input":[]}`)
|
||||
errorBody := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`)
|
||||
|
||||
_, _, retry := svc.prepareOpenAICompactFallbackRetry(
|
||||
c, nil, "gpt-5.5", body, http.StatusNotFound, "model not found", errorBody, false,
|
||||
)
|
||||
require.False(t, retry)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayForwardRetriesExplicitNativeCompactHTTPFailureOnce(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
|
||||
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
MarkOpenAINativeCompactionV2(c)
|
||||
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"context_length_exceeded","message":"context window exceeded"}}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
},
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
|
||||
Status: StatusActive, Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
|
||||
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
|
||||
require.True(t, HasCompactionTriggerInInput(upstream.bodies[1]))
|
||||
require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path)
|
||||
require.NotContains(t, upstream.requests[1].URL.Path, "/compact")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayForwardRetriesExplicitNativeCompactSSEFailureBeforeOutput(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
|
||||
c := newOpenAICompactFallbackTestContext(t, "/v1/responses")
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
MarkOpenAINativeCompactionV2(c)
|
||||
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader("event: response.failed\n" +
|
||||
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n")),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
},
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
|
||||
Status: StatusActive, Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.bodies[0], "model").String())
|
||||
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
|
||||
require.Equal(t, upstream.requests[0].URL.Path, upstream.requests[1].URL.Path)
|
||||
require.NotContains(t, upstream.requests[1].URL.Path, "/compact")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayForwardRetriesStreamingCompactFailureBeforeOutput(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"compact-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}]}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
MarkOpenAINativeCompactionV2(c)
|
||||
|
||||
failed := "event: response.failed\n" +
|
||||
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"context_length_exceeded","message":"context window exceeded"}}}` + "\n\n"
|
||||
completed := "event: response.completed\n" +
|
||||
`data: {"type":"response.completed","response":{"id":"resp_compact","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}` + "\n\n"
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(failed))},
|
||||
{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(completed))},
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICompactModel: "gpt-5.4"}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
|
||||
Status: StatusActive, Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.bodies[1], "model").String())
|
||||
require.NotContains(t, recorder.Body.String(), "context_length_exceeded")
|
||||
require.Contains(t, recorder.Body.String(), "response.completed")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -39,6 +40,7 @@ func TestOpenAIGatewayService_Forward_CompactOnlyModelMappingOverridesOAuthUpstr
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
"model_mapping": map[string]any{"gpt-5.4": "gpt-5.3-codex"},
|
||||
"compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"},
|
||||
},
|
||||
Status: StatusActive,
|
||||
@@ -51,6 +53,80 @@ func TestOpenAIGatewayService_Forward_CompactOnlyModelMappingOverridesOAuthUpstr
|
||||
require.Equal(t, "gpt-5.4", result.Model)
|
||||
require.Equal(t, "gpt-5.4-openai-compact", result.UpstreamModel)
|
||||
require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
opsModel, exists := c.Get(OpsUpstreamModelKey)
|
||||
require.True(t, exists)
|
||||
require.Equal(t, "gpt-5.4-openai-compact", opsModel)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_APIKeyCompactSanitizesStatelessReplayAfterStoreWasDropped(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
body := []byte(`{"model":"gpt-5.5","parallel_tool_calls":true,"input":[` +
|
||||
`{"type":"reasoning","id":"rs_server_only","summary":[]},` +
|
||||
`{"type":"item_reference","id":"rs_server_only"},` +
|
||||
`{"type":"message","role":"user","content":"compact this"}` +
|
||||
`]}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"id":"resp_compact","status":"completed","output":[{"type":"compaction","encrypted_content":"cipher"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
||||
)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
httpUpstream: upstream,
|
||||
cfg: &config.Config{Security: config.SecurityConfig{
|
||||
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
|
||||
}},
|
||||
}
|
||||
account := &Account{
|
||||
ID: 7, Name: "azure-openai", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "test-key"}, Status: StatusActive, Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists())
|
||||
require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int())
|
||||
require.Equal(t, "message", gjson.GetBytes(upstream.lastBody, "input.0.type").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_NormalizesCompactionTriggerAfterHistoryCleanup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
body := []byte(`{"model":"gpt-5.4","stream":false,"input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_trigger","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 4, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Status: StatusActive, Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
items := gjson.GetBytes(upstream.lastBody, "input").Array()
|
||||
require.Len(t, items, 4)
|
||||
require.Equal(t, "function_call", items[0].Get("type").String())
|
||||
require.Equal(t, "function_call_output", items[1].Get("type").String())
|
||||
require.Equal(t, "message", items[2].Get("type").String())
|
||||
require.Equal(t, "compaction_trigger", items[3].Get("type").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_NonCompactRequestIgnoresCompactOnlyModelMapping(t *testing.T) {
|
||||
@@ -132,4 +208,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactOnlyModelMappingOverridesU
|
||||
require.Equal(t, "gpt-5.4-openai-compact", result.UpstreamModel)
|
||||
require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.Equal(t, "gpt-5.4", gjson.GetBytes(rec.Body.Bytes(), "model").String())
|
||||
opsModel, exists := c.Get(OpsUpstreamModelKey)
|
||||
require.True(t, exists)
|
||||
require.Equal(t, "gpt-5.4-openai-compact", opsModel)
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ func TestHandlePassthroughSSEToJSON_CompactRawOutputItemDoneRepairsEmptyTerminal
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}
|
||||
|
||||
result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, "gpt-5.5", "")
|
||||
result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, nil, "gpt-5.5", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
@@ -512,7 +512,7 @@ func TestHandleNonStreamingResponsePassthrough_CompactClientStreamBridgesToSSE(t
|
||||
}`)),
|
||||
}
|
||||
|
||||
result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, "gpt-5.5", "")
|
||||
result, err := svc.handleNonStreamingResponsePassthrough(context.Background(), resp, c, nil, "gpt-5.5", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
@@ -14,16 +16,50 @@ import (
|
||||
// repository 层 gatewayCache 附带实现(类型断言探测接入,不改 GatewayCache
|
||||
// 共享接口);测试 stub 不实现时屏蔽能力自动降级关闭。
|
||||
type CyberSessionBlockStore interface {
|
||||
SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error
|
||||
IsCyberSessionBlocked(ctx context.Context, key string) (bool, error)
|
||||
SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error
|
||||
IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error)
|
||||
FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error)
|
||||
}
|
||||
|
||||
// CyberSessionBlockKey 派生会话屏蔽 key:仅用显式会话标识(header
|
||||
// session_id/conversation_id 或 body prompt_cache_key),混入 apiKeyID 隔离后
|
||||
// sha256。无显式标识返回空串——调用方必须放行(粒度决策:不退化到
|
||||
// user/apikey/内容派生)。
|
||||
func CyberSessionBlockKey(apiKeyID int64, c *gin.Context, body []byte) string {
|
||||
raw := explicitOpenAISessionID(c, body)
|
||||
// CyberSessionExplicitBlockKey returns an inexpensive exact key when the
|
||||
// client supplies a stable session signal.
|
||||
func CyberSessionExplicitBlockKey(apiKeyID int64, c *gin.Context, body []byte) string {
|
||||
return hashCyberSessionBlockKey(apiKeyID, explicitOpenAISessionID(c, body))
|
||||
}
|
||||
|
||||
// CyberSessionTranscriptBlockKeys returns the exact full-request key followed
|
||||
// by an optional rewrite-tolerant context key. The latter is emitted only after
|
||||
// model-generated history has been observed.
|
||||
func CyberSessionTranscriptBlockKeys(apiKeyID int64, body []byte) []string {
|
||||
derived := deriveOpenAICyberTranscriptBlockKeys(apiKeyID, body)
|
||||
if len(derived.lookupKeys) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := []string{derived.lookupKeys[len(derived.lookupKeys)-1]}
|
||||
if derived.preLatestUserKey != "" && derived.preLatestUserKey != keys[0] {
|
||||
keys = append(keys, derived.preLatestUserKey)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func CyberSessionTranscriptLookupKeys(apiKeyID int64, body []byte) []string {
|
||||
return deriveOpenAICyberTranscriptBlockKeys(apiKeyID, body).lookupKeys
|
||||
}
|
||||
|
||||
// CyberSessionScopeKey is a coarse, non-blocking fingerprint used only to
|
||||
// avoid transcript parsing and MGET for sources that never produced a hit.
|
||||
func CyberSessionScopeKey(apiKeyID int64, clientIP, userAgent string) string {
|
||||
if apiKeyID <= 0 {
|
||||
return ""
|
||||
}
|
||||
raw := "cyber-scope:v1|api_key=" + strconv.FormatInt(apiKeyID, 10) +
|
||||
"|ip=" + strings.TrimSpace(clientIP) +
|
||||
"|ua=" + NormalizeSessionUserAgent(userAgent)
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func hashCyberSessionBlockKey(apiKeyID int64, raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -59,8 +95,8 @@ func (s *OpenAIGatewayService) CyberSessionBlockRuntime(ctx context.Context) (bo
|
||||
|
||||
// MarkCyberSessionBlocked 把会话写入屏蔽表(写入点:cyber 命中后)。
|
||||
// 开关关闭、key 为空或存储不可用时静默跳过。
|
||||
func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, key string) {
|
||||
if key == "" {
|
||||
func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string) {
|
||||
if s == nil || len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
enabled, ttl := s.CyberSessionBlockRuntime(ctx)
|
||||
@@ -71,29 +107,49 @@ func (s *OpenAIGatewayService) MarkCyberSessionBlocked(ctx context.Context, key
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
if err := store.SetCyberSessionBlocked(ctx, key, ttl); err != nil {
|
||||
if err := store.SetCyberSessionBlocked(ctx, scopeKey, keys, ttl); err != nil {
|
||||
logger.LegacyPrintf("service.openai_gateway", "cyber session block write failed: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// IsCyberSessionBlocked 查询会话是否被屏蔽(拦截点)。开关关闭、key 为空、
|
||||
// 存储不可用或查询出错时返回 false(fail-open:屏蔽是增强防护,不阻断主链路)。
|
||||
func (s *OpenAIGatewayService) IsCyberSessionBlocked(ctx context.Context, key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
// FindCyberSessionBlockedForRequest applies explicit-first lookup followed by
|
||||
// scope-gated transcript matching. All failures remain fail-open.
|
||||
func (s *OpenAIGatewayService) FindCyberSessionBlockedForRequest(ctx context.Context, apiKeyID int64, c *gin.Context, body []byte, clientIP, userAgent string) string {
|
||||
enabled, _ := s.CyberSessionBlockRuntime(ctx)
|
||||
if !enabled {
|
||||
return false
|
||||
return ""
|
||||
}
|
||||
store := s.cyberSessionBlockStore()
|
||||
if store == nil {
|
||||
return false
|
||||
return ""
|
||||
}
|
||||
blocked, err := store.IsCyberSessionBlocked(ctx, key)
|
||||
if explicitKey := CyberSessionExplicitBlockKey(apiKeyID, c, body); explicitKey != "" {
|
||||
key, err := store.FindCyberSessionBlocked(ctx, []string{explicitKey})
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.openai_gateway", "cyber explicit session read failed: err=%v", err)
|
||||
return ""
|
||||
}
|
||||
if key != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
scopeKey := CyberSessionScopeKey(apiKeyID, clientIP, userAgent)
|
||||
active, err := store.IsCyberSessionScopeActive(ctx, scopeKey)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.openai_gateway", "cyber session block read failed: err=%v", err)
|
||||
return false
|
||||
logger.LegacyPrintf("service.openai_gateway", "cyber session scope read failed: err=%v", err)
|
||||
return ""
|
||||
}
|
||||
return blocked
|
||||
if !active {
|
||||
return ""
|
||||
}
|
||||
keys := CyberSessionTranscriptLookupKeys(apiKeyID, body)
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
key, err := store.FindCyberSessionBlocked(ctx, keys)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.openai_gateway", "cyber session block batch read failed: err=%v", err)
|
||||
return ""
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -23,57 +23,94 @@ func newCyberBlockTestCtx(headers map[string]string, body string) (*gin.Context,
|
||||
return c, []byte(body)
|
||||
}
|
||||
|
||||
// TestCyberSessionBlockKey verifies F5a key derivation: explicit session signals
|
||||
// only (header session_id/conversation_id or body prompt_cache_key), apiKey
|
||||
// isolated, and EMPTY when no explicit signal (no content-derived fallback —
|
||||
// "不退化" decision).
|
||||
func TestCyberSessionBlockKey(t *testing.T) {
|
||||
func TestCyberSessionExplicitBlockKey(t *testing.T) {
|
||||
c1, b1 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
|
||||
k1 := CyberSessionBlockKey(101, c1, b1)
|
||||
k1 := CyberSessionExplicitBlockKey(101, c1, b1)
|
||||
require.NotEmpty(t, k1)
|
||||
|
||||
// Same session, different apiKey → different key (isolation).
|
||||
c2, b2 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
|
||||
require.NotEqual(t, k1, CyberSessionBlockKey(202, c2, b2))
|
||||
require.NotEqual(t, k1, CyberSessionExplicitBlockKey(202, c2, b2))
|
||||
|
||||
// Same session + same apiKey → stable key.
|
||||
c3, b3 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
|
||||
require.Equal(t, k1, CyberSessionBlockKey(101, c3, b3))
|
||||
require.Equal(t, k1, CyberSessionExplicitBlockKey(101, c3, b3))
|
||||
|
||||
// prompt_cache_key in body counts as explicit.
|
||||
c4, b4 := newCyberBlockTestCtx(nil, `{"prompt_cache_key":"pck-1"}`)
|
||||
require.NotEmpty(t, CyberSessionBlockKey(101, c4, b4))
|
||||
require.NotEmpty(t, CyberSessionExplicitBlockKey(101, c4, b4))
|
||||
|
||||
// No explicit signal → empty key → caller must skip blocking entirely.
|
||||
c5, b5 := newCyberBlockTestCtx(nil, `{"input":"hello world"}`)
|
||||
require.Empty(t, CyberSessionBlockKey(101, c5, b5))
|
||||
require.Empty(t, CyberSessionExplicitBlockKey(101, c5, b5))
|
||||
|
||||
// conversation_id header counts as explicit; key is stable and non-empty.
|
||||
c6, b6 := newCyberBlockTestCtx(map[string]string{"conversation_id": "conv-xyz"}, `{}`)
|
||||
k6 := CyberSessionBlockKey(101, c6, b6)
|
||||
k6 := CyberSessionExplicitBlockKey(101, c6, b6)
|
||||
require.NotEmpty(t, k6)
|
||||
c6b, b6b := newCyberBlockTestCtx(map[string]string{"conversation_id": "conv-xyz"}, `{}`)
|
||||
require.Equal(t, k6, CyberSessionBlockKey(101, c6b, b6b), "conversation_id key must be stable")
|
||||
require.Equal(t, k6, CyberSessionExplicitBlockKey(101, c6b, b6b), "conversation_id key must be stable")
|
||||
}
|
||||
|
||||
func TestCyberTranscriptBlockKeysRequireModelGeneratedHistory(t *testing.T) {
|
||||
first := []byte(`{"instructions":"shared","input":[{"role":"user","content":"fixed environment"},{"role":"user","content":"question one"}]}`)
|
||||
second := []byte(`{"instructions":"shared","input":[{"role":"user","content":"fixed environment"},{"role":"user","content":"question two"}]}`)
|
||||
firstKeys := CyberSessionTranscriptBlockKeys(77, first)
|
||||
secondKeys := CyberSessionTranscriptBlockKeys(77, second)
|
||||
require.Len(t, firstKeys, 1)
|
||||
require.Len(t, secondKeys, 1)
|
||||
require.NotEqual(t, firstKeys[0], secondKeys[0])
|
||||
|
||||
hit := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
|
||||
continuation := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"different trigger"},{"role":"assistant","content":"blocked"},{"role":"user","content":"continue"}]}`)
|
||||
hitKeys := CyberSessionTranscriptBlockKeys(77, hit)
|
||||
require.Len(t, hitKeys, 2)
|
||||
require.Contains(t, CyberSessionTranscriptLookupKeys(77, continuation), hitKeys[1])
|
||||
}
|
||||
|
||||
func TestCyberTranscriptBlockKeysWebSocketResponseCreate(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","response":{"prompt_cache_key":"ws-session","input":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}}`)
|
||||
c, _ := newCyberBlockTestCtx(nil, string(body))
|
||||
require.NotEmpty(t, CyberSessionExplicitBlockKey(88, c, body))
|
||||
require.Len(t, CyberSessionTranscriptBlockKeys(88, body), 2)
|
||||
}
|
||||
|
||||
// --- fakes ---
|
||||
|
||||
type fakeCyberBlockStore struct {
|
||||
blocked map[string]bool
|
||||
scopes map[string]bool
|
||||
}
|
||||
|
||||
var _ CyberSessionBlockStore = (*fakeCyberBlockStore)(nil)
|
||||
|
||||
func (f *fakeCyberBlockStore) SetCyberSessionBlocked(_ context.Context, key string, _ time.Duration) error {
|
||||
func (f *fakeCyberBlockStore) SetCyberSessionBlocked(_ context.Context, scopeKey string, keys []string, _ time.Duration) error {
|
||||
if f.blocked == nil {
|
||||
f.blocked = map[string]bool{}
|
||||
}
|
||||
f.blocked[key] = true
|
||||
for _, key := range keys {
|
||||
f.blocked[key] = true
|
||||
}
|
||||
if scopeKey != "" {
|
||||
if f.scopes == nil {
|
||||
f.scopes = map[string]bool{}
|
||||
}
|
||||
f.scopes[scopeKey] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCyberBlockStore) IsCyberSessionBlocked(_ context.Context, key string) (bool, error) {
|
||||
return f.blocked[key], nil
|
||||
func (f *fakeCyberBlockStore) IsCyberSessionScopeActive(_ context.Context, scopeKey string) (bool, error) {
|
||||
return f.scopes[scopeKey], nil
|
||||
}
|
||||
|
||||
func (f *fakeCyberBlockStore) FindCyberSessionBlocked(_ context.Context, keys []string) (string, error) {
|
||||
for _, key := range keys {
|
||||
if f.blocked[key] {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// fakeSettingRepo is a minimal SettingRepository stub for unit tests.
|
||||
@@ -155,25 +192,27 @@ func (c *comboCacheAndStore) GetReasoningContent(_ context.Context, _ string) (s
|
||||
return "", ErrReasoningContentNotFound
|
||||
}
|
||||
|
||||
func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error {
|
||||
return c.store.SetCyberSessionBlocked(ctx, key, ttl)
|
||||
func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error {
|
||||
return c.store.SetCyberSessionBlocked(ctx, scopeKey, keys, ttl)
|
||||
}
|
||||
func (c *comboCacheAndStore) IsCyberSessionBlocked(ctx context.Context, key string) (bool, error) {
|
||||
return c.store.IsCyberSessionBlocked(ctx, key)
|
||||
func (c *comboCacheAndStore) IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) {
|
||||
return c.store.IsCyberSessionScopeActive(ctx, scopeKey)
|
||||
}
|
||||
func (c *comboCacheAndStore) FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) {
|
||||
return c.store.FindCyberSessionBlocked(ctx, keys)
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
// TestIsCyberSessionBlocked_EmptyKeyAndNilService covers the fail-open paths:
|
||||
// empty key, nil service, store missing → always false / no panic.
|
||||
func TestIsCyberSessionBlocked_EmptyKeyAndNilService(t *testing.T) {
|
||||
func TestFindCyberSessionBlocked_EmptyAndNilService(t *testing.T) {
|
||||
var nilSvc *OpenAIGatewayService
|
||||
require.False(t, nilSvc.IsCyberSessionBlocked(context.Background(), "k"))
|
||||
require.NotPanics(t, func() { nilSvc.MarkCyberSessionBlocked(context.Background(), "k") })
|
||||
require.Empty(t, nilSvc.FindCyberSessionBlockedForRequest(context.Background(), 1, nil, nil, "", ""))
|
||||
require.NotPanics(t, func() { nilSvc.MarkCyberSessionBlocked(context.Background(), "", []string{"k"}) })
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
require.False(t, svc.IsCyberSessionBlocked(context.Background(), ""))
|
||||
require.False(t, svc.IsCyberSessionBlocked(context.Background(), "k"), "no store + no settings → fail-open false")
|
||||
require.Empty(t, svc.FindCyberSessionBlockedForRequest(context.Background(), 1, nil, nil, "", ""))
|
||||
}
|
||||
|
||||
// TestCyberSessionBlock_RoundTrip exercises the type-assertion success path:
|
||||
@@ -200,15 +239,42 @@ func TestCyberSessionBlock_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const testKey = "deadbeef1234"
|
||||
|
||||
// Before marking: not blocked.
|
||||
require.False(t, svc.IsCyberSessionBlocked(ctx, testKey))
|
||||
c, body := newCyberBlockTestCtx(map[string]string{"session_id": "sess-roundtrip"}, `{}`)
|
||||
explicitKey := CyberSessionExplicitBlockKey(1, c, body)
|
||||
require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, 1, c, body, "203.0.113.1", "client/1.0"))
|
||||
|
||||
// Mark as blocked.
|
||||
svc.MarkCyberSessionBlocked(ctx, testKey)
|
||||
svc.MarkCyberSessionBlocked(ctx, "", []string{explicitKey, testKey})
|
||||
|
||||
// After marking: blocked.
|
||||
require.True(t, svc.IsCyberSessionBlocked(ctx, testKey))
|
||||
require.Equal(t, explicitKey, svc.FindCyberSessionBlockedForRequest(ctx, 1, c, body, "203.0.113.1", "client/1.0"))
|
||||
}
|
||||
|
||||
// Different key: still not blocked.
|
||||
require.False(t, svc.IsCyberSessionBlocked(ctx, "other-key"))
|
||||
func TestFindCyberSessionBlockedForRequestUsesScopeForTranscript(t *testing.T) {
|
||||
settingSvc := &SettingService{settingRepo: &fakeSettingRepo{vals: map[string]string{
|
||||
SettingKeyCyberSessionBlockEnabled: "true",
|
||||
SettingKeyCyberSessionBlockTTLSeconds: "60",
|
||||
}}}
|
||||
combo := &comboCacheAndStore{}
|
||||
svc := &OpenAIGatewayService{cache: combo, settingService: settingSvc}
|
||||
ctx := context.Background()
|
||||
|
||||
hitBody := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
|
||||
nextBody := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"different trigger"},{"role":"assistant","content":"blocked"},{"role":"user","content":"continue"}]}`)
|
||||
nextCtx, _ := newCyberBlockTestCtx(nil, string(nextBody))
|
||||
const clientIP = "203.0.113.20"
|
||||
const userAgent = "Codex CLI 1.2.3"
|
||||
blockKey := CyberSessionTranscriptBlockKeys(9, hitBody)[1]
|
||||
|
||||
// Without an active source scope, transcript candidates are never blocks.
|
||||
require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, 9, nextCtx, nextBody, clientIP, userAgent))
|
||||
scopeKey := CyberSessionScopeKey(9, clientIP, userAgent)
|
||||
svc.MarkCyberSessionBlocked(ctx, scopeKey, []string{blockKey})
|
||||
require.Equal(t, blockKey, svc.FindCyberSessionBlockedForRequest(ctx, 9, nextCtx, nextBody, clientIP, "Codex CLI 1.2.4"))
|
||||
}
|
||||
|
||||
func TestCyberSessionScopeKeyNormalizesUserAgentVersion(t *testing.T) {
|
||||
base := CyberSessionScopeKey(7, "203.0.113.10", "Codex CLI 1.2.3")
|
||||
require.NotEmpty(t, base)
|
||||
require.Equal(t, base, CyberSessionScopeKey(7, "203.0.113.10", "Codex CLI 1.2.4"))
|
||||
require.NotEqual(t, base, CyberSessionScopeKey(8, "203.0.113.10", "Codex CLI 1.2.3"))
|
||||
require.NotEqual(t, base, CyberSessionScopeKey(7, "203.0.113.11", "Codex CLI 1.2.3"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type openAICyberTranscriptBlockKeys struct {
|
||||
lookupKeys []string
|
||||
preLatestUserKey string
|
||||
}
|
||||
|
||||
// deriveOpenAICyberTranscriptBlockKeys returns cumulative semantic-history
|
||||
// hashes plus the context key immediately before the latest user turn. The
|
||||
// context key requires model-generated history so shared first-turn templates
|
||||
// cannot block unrelated conversations.
|
||||
func deriveOpenAICyberTranscriptBlockKeys(apiKeyID int64, body []byte) openAICyberTranscriptBlockKeys {
|
||||
if len(body) == 0 {
|
||||
return openAICyberTranscriptBlockKeys{}
|
||||
}
|
||||
root := openAIRequestPayloadView(body)
|
||||
if !root.Exists() || !root.IsObject() {
|
||||
return openAICyberTranscriptBlockKeys{}
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
_, _ = h.Write([]byte("cyber-transcript:v3|api_key="))
|
||||
_, _ = h.Write([]byte(strconv.FormatInt(apiKeyID, 10)))
|
||||
// Model and tool definitions are request configuration rather than history.
|
||||
// Root instructions remain model-visible context and participate in identity.
|
||||
for _, field := range []string{"instructions"} {
|
||||
v := root.Get(field)
|
||||
if !v.Exists() || (v.Type == gjson.String && strings.TrimSpace(v.String()) == "") {
|
||||
continue
|
||||
}
|
||||
canonical := v.Raw
|
||||
if v.Type == gjson.String {
|
||||
canonical = v.String()
|
||||
} else {
|
||||
canonical = normalizeCompatSeedJSON(json.RawMessage(v.Raw))
|
||||
}
|
||||
_, _ = h.Write([]byte("|"))
|
||||
_, _ = h.Write([]byte(field))
|
||||
_, _ = h.Write([]byte("="))
|
||||
_, _ = h.Write([]byte(canonical))
|
||||
}
|
||||
|
||||
appendSequence := func(sequence gjson.Result) openAICyberTranscriptBlockKeys {
|
||||
if !sequence.Exists() || !sequence.IsArray() {
|
||||
return openAICyberTranscriptBlockKeys{}
|
||||
}
|
||||
result := openAICyberTranscriptBlockKeys{
|
||||
lookupKeys: make([]string, 0, int(sequence.Get("#").Int())),
|
||||
}
|
||||
// This is an entropy heuristic, not provenance proof: authenticated
|
||||
// server-side history would be required to distinguish fixed few-shot
|
||||
// assistant items perfectly.
|
||||
hasModelGeneratedItem := false
|
||||
sequence.ForEach(func(_, item gjson.Result) bool {
|
||||
canonical := item.Raw
|
||||
if item.Type == gjson.String {
|
||||
encoded, _ := json.Marshal(item.String())
|
||||
canonical = string(encoded)
|
||||
} else if item.Type == gjson.JSON {
|
||||
canonical = normalizeCompatSeedJSON(json.RawMessage(item.Raw))
|
||||
}
|
||||
if strings.TrimSpace(canonical) == "" {
|
||||
return true
|
||||
}
|
||||
if openAICyberTranscriptItemStartsUserTurn(item) && hasModelGeneratedItem && len(result.lookupKeys) > 0 {
|
||||
result.preLatestUserKey = result.lookupKeys[len(result.lookupKeys)-1]
|
||||
}
|
||||
_, _ = h.Write([]byte("|item="))
|
||||
_, _ = h.Write([]byte(canonical))
|
||||
result.lookupKeys = append(result.lookupKeys, hex.EncodeToString(h.Sum(nil)))
|
||||
if openAICyberTranscriptItemIsModelGenerated(item) {
|
||||
hasModelGeneratedItem = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
if messages := root.Get("messages"); messages.Exists() {
|
||||
return appendSequence(messages)
|
||||
}
|
||||
input := root.Get("input")
|
||||
if input.IsArray() {
|
||||
return appendSequence(input)
|
||||
}
|
||||
if input.Type == gjson.String && strings.TrimSpace(input.String()) != "" {
|
||||
encoded, _ := json.Marshal(input.String())
|
||||
_, _ = h.Write([]byte("|item="))
|
||||
_, _ = h.Write(encoded)
|
||||
return openAICyberTranscriptBlockKeys{lookupKeys: []string{hex.EncodeToString(h.Sum(nil))}}
|
||||
}
|
||||
return openAICyberTranscriptBlockKeys{}
|
||||
}
|
||||
|
||||
func openAICyberTranscriptItemStartsUserTurn(item gjson.Result) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Get("role").String()), "user") {
|
||||
content := item.Get("content")
|
||||
if content.IsArray() {
|
||||
hasUserContent := false
|
||||
content.ForEach(func(_, block gjson.Result) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(block.Get("type").String())) {
|
||||
case "tool_result", "function_call_output", "custom_tool_call_output", "computer_call_output":
|
||||
default:
|
||||
hasUserContent = true
|
||||
}
|
||||
return !hasUserContent
|
||||
})
|
||||
return hasUserContent
|
||||
}
|
||||
return content.Exists()
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(item.Get("type").String()), "input_text")
|
||||
}
|
||||
|
||||
func openAICyberTranscriptItemIsModelGenerated(item gjson.Result) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(item.Get("role").String())) {
|
||||
case "assistant", "model":
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Get("type").String())) {
|
||||
case "output_text", "function_call", "tool_call", "custom_tool_call", "computer_call":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
|
||||
|
||||
billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
|
||||
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
upstreamBody := body
|
||||
if upstreamModel != originalModel {
|
||||
upstreamBody = ReplaceModelInBody(body, upstreamModel)
|
||||
@@ -135,11 +136,14 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel)
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
|
||||
if account.IsOpenAIOAuth() && resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMsg, shouldDisable, retryableOnSameAccount)
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError(upstreamMsg, respBody) {
|
||||
return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMsg, retryableOnSameAccount)
|
||||
}
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
|
||||
}
|
||||
writeOpenAIEmbeddingsUpstreamResponse(c, resp, respBody, s.responseHeaderFilter)
|
||||
return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode)
|
||||
|
||||
@@ -104,3 +104,85 @@ func TestForwardEmbeddings_APIKeyPassthroughRecordsUsageAndBatchInput(t *testing
|
||||
require.Equal(t, "float", gjson.GetBytes(upstream.lastBody, "encoding_format").String())
|
||||
require.Equal(t, int64(256), gjson.GetBytes(upstream.lastBody, "dimensions").Int())
|
||||
}
|
||||
|
||||
func TestForwardEmbeddings_AccessStateUsesTypedFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
reqBody := []byte(`{"model":"text-embedding-3-small","input":"hello"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/embeddings", bytes.NewReader(reqBody))
|
||||
|
||||
upstreamBody := []byte(`{"error":{"code":"deactivated_workspace","message":"Workspace is deactivated"}}`)
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"req_embeddings_access_state"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader(upstreamBody)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 43,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardEmbeddings(context.Background(), c, account, reqBody, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
|
||||
require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage)
|
||||
require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
|
||||
require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
|
||||
require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
|
||||
require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Equal(t, "req_embeddings_access_state", failoverErr.ResponseHeaders.Get("x-request-id"))
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
func TestForwardEmbeddings_NonAccessFailoverKeepsLegacyShape(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
reqBody := []byte(`{"model":"text-embedding-3-small","input":"hello"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/embeddings", bytes.NewReader(reqBody))
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 44,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardEmbeddings(context.Background(), c, account, reqBody, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
|
||||
require.Empty(t, failoverErr.Stage)
|
||||
require.Empty(t, failoverErr.Scope)
|
||||
require.Empty(t, failoverErr.Reason)
|
||||
require.Zero(t, failoverErr.ClientStatusCode)
|
||||
require.Empty(t, failoverErr.ClientMessage)
|
||||
require.Nil(t, failoverErr.ResponseHeaders)
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
@@ -127,7 +127,8 @@ func TestOpenAIGatewayService_HandleFailoverSideEffects_DoesNotRereadResponseBod
|
||||
svc.handleFailoverSideEffects(context.Background(), resp, account, []byte(`{"error":{"type":"rate_limit_error","message":"rate limited"}}`))
|
||||
})
|
||||
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.True(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false))
|
||||
}
|
||||
|
||||
func TestGetOpenAIRequestBodyMap_IgnoresLegacyContextCache(t *testing.T) {
|
||||
|
||||
@@ -348,6 +348,23 @@ func TestPolicyEnforcingFrameConn_FollowupFrameWithoutModelUsesCapturedModel(t *
|
||||
require.Equal(t, "response.create", gjson.GetBytes(payload, "type").String())
|
||||
}
|
||||
|
||||
func TestOpenAIWSPassthroughPolicyModelDoesNotApplyAccountMapping(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"public-model": "private-model"},
|
||||
},
|
||||
Extra: map[string]any{"openai_passthrough": true},
|
||||
}
|
||||
|
||||
responseCreate := []byte(`{"type":"response.create","model":"public-model"}`)
|
||||
require.Equal(t, "public-model", openAIWSPassthroughPolicyModelForFrame(account, responseCreate))
|
||||
|
||||
sessionUpdate := []byte(`{"type":"session.update","session":{"model":"public-model"}}`)
|
||||
require.Equal(t, "public-model", openAIWSPassthroughPolicyModelFromSessionFrame(account, sessionUpdate))
|
||||
}
|
||||
|
||||
// TestPolicyEnforcingFrameConn_WithoutCapturedFallbackPolicyMisses pins the
|
||||
// inverse: when the wrapper has NO capturedSessionModel fallback (model is
|
||||
// empty per-frame and no fallback is wired up), the policy fails to match
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes
|
||||
{"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},
|
||||
{"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}},
|
||||
{"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"},
|
||||
{"type":"web_search_call","id":"item_unconstrained"}
|
||||
{"type":"web_search_call","id":"item_wrong_web"}
|
||||
]
|
||||
}`)
|
||||
|
||||
@@ -68,29 +68,31 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes
|
||||
require.Equal(t, "tsc_valid", gjson.GetBytes(forwarded, "input.7.id").String())
|
||||
require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.8.id").String())
|
||||
require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.8.call_id").String())
|
||||
require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.9.id").String())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.9.id").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_SanitizesNativeToolItemIDs(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := newOpenAIImageGenerationControlTestService(upstream)
|
||||
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
|
||||
account := newOpenAIImageGenerationControlTestAccount()
|
||||
account.Type = AccountTypeOAuth
|
||||
account.Credentials = map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
}
|
||||
account.Extra = map[string]any{"openai_passthrough": true}
|
||||
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
|
||||
t.Run(accountType, func(t *testing.T) {
|
||||
upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := newOpenAIImageGenerationControlTestService(upstream)
|
||||
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
|
||||
account := newOpenAIImageGenerationControlTestAccount()
|
||||
account.Type = accountType
|
||||
account.Credentials = map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
}
|
||||
account.Extra = map[string]any{"openai_passthrough": true}
|
||||
|
||||
body := []byte(`{
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-sol",
|
||||
"stream":true,
|
||||
"instructions":"test",
|
||||
@@ -102,14 +104,58 @@ func TestOpenAIGatewayService_OAuthPassthrough_SanitizesNativeToolItemIDs(t *tes
|
||||
]
|
||||
}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
|
||||
require.Equal(t, "ctc_valid", gjson.GetBytes(upstream.lastBody, "input.1.id").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input.2.id").Exists())
|
||||
require.Equal(t, "tsc_valid", gjson.GetBytes(upstream.lastBody, "input.3.id").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SetupTokenLegacy_SanitizesAndTransforms(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := newOpenAIImageGenerationControlTestService(upstream)
|
||||
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
|
||||
account := newOpenAIImageGenerationControlTestAccount()
|
||||
account.Type = AccountTypeSetupToken
|
||||
account.Credentials = map[string]any{
|
||||
"access_token": "setup-token",
|
||||
"chatgpt_account_id": "chatgpt-account",
|
||||
}
|
||||
account.Extra = map[string]any{"openai_passthrough": false}
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-sol",
|
||||
"stream":true,
|
||||
"store":true,
|
||||
"reasoning":{"mode":"pro"},
|
||||
"instructions":"test",
|
||||
"input":[
|
||||
{"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
|
||||
{"type":"function_call_output","call_id":"call_orphan","output":"orphan"}
|
||||
]
|
||||
}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
|
||||
require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool())
|
||||
require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning.mode").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
|
||||
require.Equal(t, "ctc_valid", gjson.GetBytes(upstream.lastBody, "input.1.id").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input.2.id").Exists())
|
||||
require.Equal(t, "tsc_valid", gjson.GetBytes(upstream.lastBody, "input.3.id").String())
|
||||
require.Len(t, gjson.GetBytes(upstream.lastBody, "input").Array(), 1)
|
||||
}
|
||||
|
||||
// TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs
|
||||
@@ -164,7 +210,7 @@ func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) {
|
||||
}{
|
||||
{"reasoning item_* id", "reasoning", "item_bad_reasoning", true},
|
||||
{"reasoning rs id", "reasoning", "rs_abc123", false},
|
||||
{"reasoning empty id", "reasoning", "", false},
|
||||
{"reasoning empty id", "reasoning", "", true},
|
||||
{"message msg id", "message", "msg_abc", false},
|
||||
{"message item id", "message", "item_x", true},
|
||||
{"function_call fc id", "function_call", "fc_abc", false},
|
||||
@@ -174,7 +220,10 @@ func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) {
|
||||
{"custom tool fc id", "custom_tool_call", "fc_abc", true},
|
||||
{"tool search tsc id", "tool_search_call", "tsc_abc", false},
|
||||
{"tool search fc id", "tool_search_call", "fc_abc", true},
|
||||
{"unconstrained type", "web_search_call", "ws_001", false},
|
||||
{"web search ws id", "web_search_call", "ws_001", false},
|
||||
{"web search item id", "web_search_call", "item_001", true},
|
||||
{"custom output fc id", "custom_tool_call_output", "fc_001", false},
|
||||
{"custom output ctco id", "custom_tool_call_output", "ctco_001", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -208,3 +257,37 @@ func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing
|
||||
require.Less(t, largeAllocated, smallAllocated*30,
|
||||
"10x more input items must not cause quadratic whole-body allocation growth")
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyClosesInvalidIDReferences(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","input":[
|
||||
{"type":"custom_tool_call","id":"ctc_call","call_id":"call_custom","name":"apply_patch","input":"patch"},
|
||||
{"type":"custom_tool_call_output","id":"ctco_bad","call_id":"call_custom","output":"done"},
|
||||
{"type":"item_reference","id":"ctco_bad"},
|
||||
{"type":"future_item","id":"item_future","payload":"keep"}
|
||||
]}`)
|
||||
|
||||
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
|
||||
t.Run(accountType, func(t *testing.T) {
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Len(t, gjson.GetBytes(normalized, "input").Array(), 3)
|
||||
require.Equal(t, "ctc_call", gjson.GetBytes(normalized, "input.0.id").String())
|
||||
require.Equal(t, "call_custom", gjson.GetBytes(normalized, "input.1.call_id").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "input.1.id").Exists())
|
||||
require.Equal(t, "item_future", gjson.GetBytes(normalized, "input.2.id").String())
|
||||
|
||||
second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changedAgain)
|
||||
require.JSONEq(t, string(normalized), string(second))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,11 +125,13 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError(
|
||||
if account.Platform != PlatformGrok && !tempUnscheduled {
|
||||
shouldDisable = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel)
|
||||
}
|
||||
return newOpenAIUpstreamFailoverError(
|
||||
return s.newOpenAIAccountFailoverError(
|
||||
account,
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
respBody,
|
||||
upstreamMsg,
|
||||
shouldDisable,
|
||||
!shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
|
||||
promptCacheKey = strings.TrimSpace(promptCacheKey)
|
||||
compatPromptCacheInjected := false
|
||||
if promptCacheKey == "" && account.Type == AccountTypeOAuth && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
|
||||
if promptCacheKey == "" && account.UsesOpenAICodexProtocol() && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
|
||||
promptCacheKey = deriveCompatPromptCacheKey(&chatReq, upstreamModel)
|
||||
compatPromptCacheInjected = promptCacheKey != ""
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
}
|
||||
logger.L().Debug("openai chat_completions: model mapping applied", logFields...)
|
||||
|
||||
if account.Type == AccountTypeOAuth {
|
||||
if account.UsesOpenAICodexProtocol() {
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(responsesBody, &reqBody); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal for codex transform: %w", err)
|
||||
@@ -234,6 +234,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
return nil, fmt.Errorf("remarshal after codex transform: %w", err)
|
||||
}
|
||||
}
|
||||
// Codex transforms may normalize the model after the initial mapping pass;
|
||||
// record the final slug immediately before policy/auth/upstream dispatch.
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
if trimmedKey := strings.TrimSpace(promptCacheKey); trimmedKey != "" {
|
||||
@@ -481,6 +484,9 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
|
||||
writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", message)
|
||||
return nil, fmt.Errorf("upstream response failed: %s", message)
|
||||
}
|
||||
if strings.TrimSpace(finalResponse.Status) == "completed" {
|
||||
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, "response.completed", false)
|
||||
}
|
||||
|
||||
if requiresBillableGrokChatUsage(account, billingModel, upstreamModel, finalResponse.Model) && !hasBillableGrokChatUsage(usage) {
|
||||
upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id"))
|
||||
@@ -596,6 +602,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
|
||||
refusalDetector := newOpenAIChatSilentRefusalDetector(requestBodyLen)
|
||||
var streamFailoverErr *UpstreamFailoverError
|
||||
var streamNonFailoverErr error
|
||||
terminalEventType := ""
|
||||
// Grok chat bridge reuses Responses SSE; count native search tools for surcharge.
|
||||
searchCount := 0
|
||||
streamSearchSeen := make(map[string]struct{})
|
||||
@@ -661,9 +668,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
|
||||
}
|
||||
observer.ObserveOpenAI([]byte(payload), event.Type)
|
||||
refusalDetector.ObservePayload([]byte(payload))
|
||||
s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage)
|
||||
|
||||
isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type)
|
||||
if isTerminalEvent {
|
||||
terminalEventType = strings.TrimSpace(event.Type)
|
||||
if event.Usage != nil {
|
||||
usage = copyOpenAIUsageFromResponsesUsage(event.Usage)
|
||||
}
|
||||
@@ -671,7 +680,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
|
||||
usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(event.Type) == "response.failed" {
|
||||
if strings.TrimSpace(event.Type) == "response.failed" || strings.TrimSpace(event.Type) == "error" {
|
||||
payloadBytes := []byte(payload)
|
||||
message := extractOpenAISSEErrorMessage(payloadBytes)
|
||||
if hit, code, msg := detectOpenAICyberPolicy(payloadBytes); hit {
|
||||
@@ -705,7 +714,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
|
||||
}
|
||||
return true
|
||||
}
|
||||
if openAIStreamFailedEventShouldFailover(payloadBytes, message) {
|
||||
shouldFailover := openAIStreamFailedEventShouldFailover(payloadBytes, message)
|
||||
if strings.TrimSpace(event.Type) == "error" {
|
||||
shouldFailover = openAIStreamErrorEventShouldFailover(payloadBytes, message)
|
||||
}
|
||||
if !clientOutputStarted && shouldFailover {
|
||||
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header)
|
||||
return true
|
||||
}
|
||||
@@ -874,6 +887,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
|
||||
if !clientDisconnected {
|
||||
c.Writer.Flush()
|
||||
}
|
||||
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, terminalEventType, clientDisconnected)
|
||||
return resultWithUsage(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
|
||||
// 2. Resolve model mapping (same as ForwardAsChatCompletions)
|
||||
billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
|
||||
upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
grokCacheIdentity := ""
|
||||
if account.Platform == PlatformGrok {
|
||||
// Resolve before image bridging or other body rewrites so the fallback is
|
||||
|
||||
@@ -489,6 +489,46 @@ func TestForwardAsChatCompletions_StreamContextWindowResponseFailedReturnsErrorW
|
||||
require.NotContains(t, rec.Body.String(), "[DONE]")
|
||||
}
|
||||
|
||||
func TestForwardAsChatCompletions_StreamBareErrorAfterOutputDoesNotFailOver(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"stream":true}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstreamBody := strings.Join([]string{
|
||||
`data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"partial"}`,
|
||||
"",
|
||||
`event: error`,
|
||||
`data: {"type":"error","error":{"type":"server_error","code":"server_error","message":"temporary upstream failure"}}`,
|
||||
"",
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.False(t, errors.As(err, &failoverErr))
|
||||
require.Contains(t, rec.Body.String(), "partial")
|
||||
require.Contains(t, rec.Body.String(), "temporary upstream failure")
|
||||
require.NotContains(t, rec.Body.String(), "[DONE]")
|
||||
}
|
||||
|
||||
func TestForwardAsChatCompletions_StreamCyberPolicyNoFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -49,24 +49,33 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if normalized {
|
||||
body = normalizedBody
|
||||
}
|
||||
legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngress(body)
|
||||
if legacyIngressErr != nil {
|
||||
return nil, legacyIngressErr
|
||||
}
|
||||
if legacyIngressChanged {
|
||||
body = legacyIngressBody
|
||||
}
|
||||
// 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正
|
||||
// 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502,
|
||||
// 同一份坏定义在账号池里反复重放。
|
||||
sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
if toolSchemaErr != nil {
|
||||
return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr)
|
||||
if shouldSanitizeOpenAIResponsesToolSchemas(account.Platform) {
|
||||
sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
if toolSchemaErr != nil {
|
||||
return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr)
|
||||
}
|
||||
if toolSchemaSanitized {
|
||||
body = sanitizedToolBody
|
||||
}
|
||||
patternSanitizedBody, patternSanitized, patternErr := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
if patternErr != nil {
|
||||
return nil, fmt.Errorf("sanitize OpenAI Responses tool schema patterns: %w", patternErr)
|
||||
}
|
||||
if patternSanitized {
|
||||
body = patternSanitizedBody
|
||||
}
|
||||
}
|
||||
if toolSchemaSanitized {
|
||||
body = sanitizedToolBody
|
||||
}
|
||||
patternSanitizedBody, patternSanitized, patternErr := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
if patternErr != nil {
|
||||
return nil, fmt.Errorf("sanitize OpenAI Responses tool schema patterns: %w", patternErr)
|
||||
}
|
||||
if patternSanitized {
|
||||
body = patternSanitizedBody
|
||||
}
|
||||
if account.IsOpenAI() && account.IsOAuth() {
|
||||
if account.IsOpenAIOAuthLike() {
|
||||
reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body)
|
||||
if reasoningErr != nil {
|
||||
return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr)
|
||||
@@ -75,7 +84,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
body = reasoningBody
|
||||
}
|
||||
}
|
||||
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
|
||||
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
|
||||
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
|
||||
if liteErr != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "")
|
||||
@@ -132,11 +141,28 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if account.IsAnthropicProtocol() {
|
||||
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
|
||||
}
|
||||
if account.IsOpenAIApiKey() {
|
||||
if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body); normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
} else if changed {
|
||||
body = normalized
|
||||
originalBody = normalized
|
||||
}
|
||||
if normalized, changed, normalizeErr := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, isOpenAIResponsesCompactPath(c)); normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
} else if changed {
|
||||
body = normalized
|
||||
originalBody = normalized
|
||||
}
|
||||
requestView = newOpenAIRequestView(body)
|
||||
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
||||
originalModel = reqModel
|
||||
}
|
||||
|
||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
|
||||
}
|
||||
if account.Platform == PlatformOpenAI && (account.Type == AccountTypeAPIKey || account.Type == AccountTypeOAuth) {
|
||||
if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
|
||||
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
if sanitizeErr != nil {
|
||||
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
|
||||
@@ -307,35 +333,26 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
markPatchSet("instructions", defaultCodexSynthInstructions(reqModel))
|
||||
}
|
||||
|
||||
billingModel := account.GetMappedModel(reqModel)
|
||||
if billingModel != reqModel {
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI)
|
||||
reqModel = billingModel
|
||||
markPatchSet("model", billingModel)
|
||||
}
|
||||
upstreamModel := billingModel
|
||||
isCompactRequest := compactPath
|
||||
compactMapped := false
|
||||
requestedModel := reqModel
|
||||
billingModel, upstreamModel := resolveOpenAIForwardMappedModels(account, requestedModel, isCompactRequest)
|
||||
if isCompactRequest {
|
||||
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
|
||||
if compactMappedModel != "" && compactMappedModel != billingModel {
|
||||
compactMapped = true
|
||||
upstreamModel = compactMappedModel
|
||||
reqModel = compactMappedModel
|
||||
markPatchSet("model", compactMappedModel)
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI)
|
||||
if compactModel := s.resolveOpenAICompactFallbackModel(account, requestedModel); compactModel != "" {
|
||||
upstreamModel = compactModel
|
||||
}
|
||||
}
|
||||
if !compactMapped {
|
||||
modelForNormalize := reqModel
|
||||
if modelForNormalize == "" {
|
||||
modelForNormalize = requestView.Model
|
||||
}
|
||||
upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize)
|
||||
if upstreamModel != "" && upstreamModel != modelForNormalize {
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI)
|
||||
reqModel = upstreamModel
|
||||
markPatchSet("model", upstreamModel)
|
||||
if billingModel != requestedModel {
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, billingModel, account.Name, isCodexCLI)
|
||||
}
|
||||
reqModel = billingModel
|
||||
if upstreamModel != requestedModel {
|
||||
markPatchSet("model", upstreamModel)
|
||||
}
|
||||
if upstreamModel != billingModel {
|
||||
if isCompactRequest {
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, upstreamModel, account.Name, isCodexCLI)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", billingModel, upstreamModel, account.Name, account.Type, isCodexCLI)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" {
|
||||
@@ -430,7 +447,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
if account.Type == AccountTypeOAuth {
|
||||
if account.UsesOpenAICodexProtocol() {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
@@ -532,7 +549,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
}
|
||||
if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && gjson.GetBytes(body, "previous_response_id").Exists() {
|
||||
if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 &&
|
||||
!account.IsOpenAIApiKey() && gjson.GetBytes(body, "previous_response_id").Exists() {
|
||||
markPatchDelete("previous_response_id")
|
||||
}
|
||||
if openAIRequestBodyMayContainEmptyBase64InputImage(body) {
|
||||
@@ -571,7 +589,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
if account.Type == AccountTypeOAuth {
|
||||
if account.UsesOpenAICodexProtocol() {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
@@ -616,6 +634,15 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
requestView = newOpenAIRequestView(body)
|
||||
}
|
||||
}
|
||||
// Run after orphan-output filtering and all request-map rebuilds so a
|
||||
// compaction trigger cannot remain ahead of surviving history items.
|
||||
if normalizedBody, changed, normalizeErr := NormalizeCompactionTriggerInputOrder(body); normalizeErr != nil {
|
||||
return nil, fmt.Errorf("normalize compaction trigger order: %w", normalizeErr)
|
||||
} else if changed {
|
||||
body = normalizedBody
|
||||
requestView = newOpenAIRequestView(body)
|
||||
reqBody = nil
|
||||
}
|
||||
imageBillingModel := ""
|
||||
imageSizeTier := ""
|
||||
imageInputSize := ""
|
||||
@@ -636,12 +663,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
imageSizeTier = imageCfg.SizeTier
|
||||
imageInputSize = imageCfg.InputSize
|
||||
}
|
||||
|
||||
// Get access token
|
||||
token, _, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
|
||||
// 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。
|
||||
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
|
||||
@@ -879,8 +906,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
|
||||
httpInvalidEncryptedContentRetryTried := false
|
||||
compactModelFallbackRetried := false
|
||||
agentTaskRecoveryTried := false
|
||||
rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body)
|
||||
rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body)
|
||||
for {
|
||||
// Build upstream request
|
||||
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
||||
@@ -982,6 +1010,23 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request after %s (account: %s)", reason, account.Name)
|
||||
continue
|
||||
}
|
||||
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
|
||||
c, account, requestedModel, body, resp.StatusCode, upstreamMsg, respBody, compactModelFallbackRetried,
|
||||
); retry {
|
||||
fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
||||
body = retryBody
|
||||
requestView = newOpenAIRequestView(body)
|
||||
reqBody = nil
|
||||
upstreamModel = fallbackModel
|
||||
compactModelFallbackRetried = true
|
||||
SetOpsUpstreamModel(c, fallbackModel)
|
||||
logger.LegacyPrintf(
|
||||
"service.openai_gateway",
|
||||
"[OpenAI] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
|
||||
account.Name, fromModel, fallbackModel, upstreamCode,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
@@ -1003,15 +1048,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
})
|
||||
|
||||
shouldDisable := s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
||||
return nil, newOpenAIUpstreamFailoverError(
|
||||
return nil, s.newOpenAIAccountFailoverError(
|
||||
account,
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
respBody,
|
||||
upstreamMsg,
|
||||
shouldDisable,
|
||||
!shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
|
||||
)
|
||||
}
|
||||
return s.handleErrorResponse(ctx, resp, c, account, body, billingModel)
|
||||
return s.handleErrorResponse(ctx, resp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -1029,6 +1076,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if reqStream {
|
||||
streamResult, err := s.handleStreamingResponseWithReasoning(ctx, resp, c, account, startTime, originalModel, upstreamModel, reasoningEffortValue)
|
||||
if err != nil {
|
||||
if signal, ok := asOpenAICompactFallbackSignal(err); ok {
|
||||
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
|
||||
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
|
||||
); retry {
|
||||
body = retryBody
|
||||
requestView = newOpenAIRequestView(body)
|
||||
upstreamModel = fallbackModel
|
||||
compactModelFallbackRetried = true
|
||||
SetOpsUpstreamModel(c, fallbackModel)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
usage = streamResult.usage
|
||||
@@ -1040,6 +1099,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
} else {
|
||||
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
|
||||
if err != nil {
|
||||
if signal, ok := asOpenAICompactFallbackSignal(err); ok {
|
||||
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
|
||||
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
|
||||
); retry {
|
||||
body = retryBody
|
||||
requestView = newOpenAIRequestView(body)
|
||||
upstreamModel = fallbackModel
|
||||
compactModelFallbackRetried = true
|
||||
SetOpsUpstreamModel(c, fallbackModel)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
usage = nonStreamResult.usage
|
||||
@@ -1052,7 +1123,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
|
||||
// Extract and save Codex usage snapshot from response headers (for OAuth accounts).
|
||||
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
|
||||
if account.Type == AccountTypeOAuth && !account.IsShadow() {
|
||||
if account.UsesOpenAICodexProtocol() && !account.IsShadow() {
|
||||
if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
|
||||
s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
|
||||
}
|
||||
@@ -1108,6 +1179,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
case AccountTypeOAuth:
|
||||
// OAuth accounts use ChatGPT internal API
|
||||
targetURL = chatgptCodexURL
|
||||
case AccountTypeSetupToken:
|
||||
if account.IsOpenAIOAuthLike() {
|
||||
targetURL = chatgptCodexURL
|
||||
} else {
|
||||
targetURL = openaiPlatformAPIURL
|
||||
}
|
||||
case AccountTypeAPIKey:
|
||||
// API Key accounts use Platform API or custom base URL
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
@@ -1148,7 +1225,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
}
|
||||
|
||||
// Set headers specific to OAuth accounts (ChatGPT internal API)
|
||||
if account.Type == AccountTypeOAuth {
|
||||
if account.UsesOpenAICodexProtocol() {
|
||||
// Required: set Host for ChatGPT API (must use req.Host, not Header.Set)
|
||||
req.Host = "chatgpt.com"
|
||||
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
|
||||
@@ -1168,7 +1245,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
// 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
|
||||
// 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。
|
||||
s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
|
||||
if account.Type == AccountTypeOAuth {
|
||||
if account.UsesOpenAICodexProtocol() {
|
||||
compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body)
|
||||
// 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
|
||||
clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
|
||||
@@ -1222,7 +1299,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
|
||||
// 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
|
||||
// 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
|
||||
if account.Type == AccountTypeOAuth {
|
||||
if account.UsesOpenAICodexProtocol() {
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
|
||||
}
|
||||
|
||||
|
||||
@@ -116,9 +116,9 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
// xAI can reject encrypted reasoning copied from a response produced under
|
||||
// another account or cache identity. Retry once with the same routing and
|
||||
// credential after removing only the rejected encrypted reasoning payload.
|
||||
// xAI can reject encrypted reasoning or a compaction blob copied from a
|
||||
// different decoder/cache context. Retry once on the same account after
|
||||
// preserving visible summaries and removing only opaque replay state.
|
||||
if attempt > 0 || resp.StatusCode != http.StatusBadRequest {
|
||||
break
|
||||
}
|
||||
@@ -126,14 +126,22 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if !isGrokInvalidEncryptedContentResponse(resp.StatusCode, respBody) {
|
||||
invalidEncryptedContent := isGrokInvalidEncryptedContentResponse(resp.StatusCode, respBody)
|
||||
if !invalidEncryptedContent && !isGrokCompactionReplayDecodeError(resp.StatusCode, respBody) {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
break
|
||||
}
|
||||
|
||||
retryBody, changed, trimErr := trimGrokInvalidEncryptedContentRetryBody(patchedBody)
|
||||
var retryBody []byte
|
||||
var changed bool
|
||||
var trimErr error
|
||||
if invalidEncryptedContent {
|
||||
retryBody, changed, trimErr = trimGrokInvalidEncryptedContentRetryBody(patchedBody)
|
||||
} else {
|
||||
retryBody, changed, trimErr = sanitizeGrokCompactionReplayBody(patchedBody)
|
||||
}
|
||||
if trimErr != nil {
|
||||
return nil, fmt.Errorf("prepare Grok invalid encrypted_content retry: %w", trimErr)
|
||||
return nil, fmt.Errorf("prepare Grok replay decode retry: %w", trimErr)
|
||||
}
|
||||
if !changed {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
@@ -141,7 +149,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
}
|
||||
|
||||
patchedBody = retryBody
|
||||
slog.Info("grok_invalid_encrypted_content_retry", "account_id", account.ID, "cache_identity_present", cacheIdentity != "")
|
||||
slog.Info("grok_replay_decode_retry", "account_id", account.ID, "cache_identity_present", cacheIdentity != "")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -262,22 +270,9 @@ func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool {
|
||||
// {"code":"invalid-argument","error":"Could not decrypt the provided encrypted_content."}
|
||||
// {"error":{"message":"Could not decrypt the provided encrypted_content."}}
|
||||
code := strings.TrimSpace(gjson.GetBytes(body, "code").String())
|
||||
message := ""
|
||||
errNode := gjson.GetBytes(body, "error")
|
||||
switch {
|
||||
case errNode.Type == gjson.String:
|
||||
message = errNode.String()
|
||||
case errNode.IsObject():
|
||||
message = firstNonEmpty(errNode.Get("message").String(), errNode.Get("error").String())
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(errNode.Get("code").String())
|
||||
}
|
||||
default:
|
||||
message = gjson.GetBytes(body, "message").String()
|
||||
}
|
||||
normalizedMessage := strings.ToLower(strings.TrimSpace(message))
|
||||
if normalizedMessage == "" {
|
||||
return false
|
||||
if code == "" && errNode.IsObject() {
|
||||
code = strings.TrimSpace(errNode.Get("code").String())
|
||||
}
|
||||
|
||||
if strings.EqualFold(code, "invalid_encrypted_content") {
|
||||
@@ -287,13 +282,102 @@ func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool {
|
||||
if !strings.EqualFold(code, "invalid-argument") && code != "" {
|
||||
return false
|
||||
}
|
||||
// Nested OpenAI-style envelopes may omit top-level code; require decrypt text.
|
||||
if code == "" && !strings.Contains(normalizedMessage, "decrypt") {
|
||||
for _, candidate := range grokStructuredErrorMessageCandidates(body) {
|
||||
normalizedMessage := strings.ToLower(candidate)
|
||||
// Nested OpenAI-style envelopes may omit top-level code; require decrypt text.
|
||||
if code == "" && !strings.Contains(normalizedMessage, "decrypt") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalizedMessage, "encrypted_content") &&
|
||||
(strings.Contains(normalizedMessage, "decrypt") || strings.Contains(normalizedMessage, "unmodified")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGrokCompactionReplayDecodeError(statusCode int, body []byte) bool {
|
||||
if statusCode != http.StatusBadRequest || len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(normalizedMessage, "encrypted_content") &&
|
||||
(strings.Contains(normalizedMessage, "decrypt") ||
|
||||
strings.Contains(normalizedMessage, "unmodified"))
|
||||
for _, candidate := range grokStructuredErrorMessageCandidates(body) {
|
||||
message := strings.ToLower(candidate)
|
||||
decodeSignal := strings.Contains(message, "decode") ||
|
||||
strings.Contains(message, "deserialize") ||
|
||||
strings.Contains(message, "decoder")
|
||||
replaySignal := strings.Contains(message, "compaction") ||
|
||||
strings.Contains(message, "summary") ||
|
||||
strings.Contains(message, "encrypted_content") ||
|
||||
strings.Contains(message, "response history")
|
||||
if decodeSignal && replaySignal {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeGrokCompactionReplayBody(body []byte) ([]byte, bool, error) {
|
||||
converted, err := convertOpenAICompactInputsForGrok(body)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("convert Grok compaction replay: %w", err)
|
||||
}
|
||||
var requestBody map[string]any
|
||||
decoder := json.NewDecoder(bytes.NewReader(converted))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&requestBody); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
changed := !bytes.Equal(converted, body)
|
||||
if trimOpenAIEncryptedReasoningItems(requestBody) {
|
||||
changed = true
|
||||
}
|
||||
if dropEmptyGrokReplayReasoning(requestBody) {
|
||||
changed = true
|
||||
}
|
||||
if previousID, _ := requestBody["previous_response_id"].(string); strings.TrimSpace(previousID) != "" && !HasFunctionCallOutput(requestBody) {
|
||||
delete(requestBody, "previous_response_id")
|
||||
if _, exists := requestBody["store"]; !exists {
|
||||
requestBody["store"] = false
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body, false, nil
|
||||
}
|
||||
retryBody, err := marshalOpenAIUpstreamJSON(requestBody)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return retryBody, true, nil
|
||||
}
|
||||
|
||||
func dropEmptyGrokReplayReasoning(requestBody map[string]any) bool {
|
||||
items, ok := requestBody["input"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
filtered := items[:0]
|
||||
changed := false
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok || strings.TrimSpace(grokStringValue(item["type"])) != "reasoning" {
|
||||
filtered = append(filtered, rawItem)
|
||||
continue
|
||||
}
|
||||
summary, _ := item["summary"].([]any)
|
||||
content, hasContent := item["content"]
|
||||
_, hasEncrypted := item["encrypted_content"]
|
||||
if hasEncrypted || len(summary) > 0 || (hasContent && content != nil) {
|
||||
filtered = append(filtered, rawItem)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
requestBody["input"] = filtered
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// requestHasGrokEncryptedReasoning reports whether the outbound Responses body
|
||||
@@ -338,7 +422,7 @@ func stripAnthropicThinkingSignatures(body []byte) ([]byte, bool) {
|
||||
return body, false
|
||||
}
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(body, &req); err != nil {
|
||||
return body, false
|
||||
}
|
||||
messages, ok := req["messages"].([]any)
|
||||
@@ -372,7 +456,7 @@ func stripAnthropicThinkingSignatures(body []byte) ([]byte, bool) {
|
||||
if !changed {
|
||||
return body, false
|
||||
}
|
||||
out, err := json.Marshal(req)
|
||||
out, err := marshalOpenAIUpstreamJSON(req)
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
@@ -495,6 +579,10 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = sanitizeGrokResponsesModelInput(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = stripRedundantGrokViewImageTool(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -666,13 +754,13 @@ func sanitizeGrokResponsesUnsupportedFields(body []byte) ([]byte, error) {
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deleteJSONFields(payload, grokResponsesUnsupportedRecursiveFields) {
|
||||
return body, nil
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
return marshalOpenAIUpstreamJSON(payload)
|
||||
}
|
||||
|
||||
func deleteJSONFields(value any, fields map[string]struct{}) bool {
|
||||
@@ -893,13 +981,10 @@ var grokResponsesSupportedToolTypes = map[string]struct{}{
|
||||
func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.Exists() {
|
||||
if gjson.GetBytes(body, "tool_choice").Exists() {
|
||||
return sjson.DeleteBytes(body, "tool_choice")
|
||||
}
|
||||
return body, nil
|
||||
return deleteGrokOrphanToolControls(body)
|
||||
}
|
||||
if !tools.IsArray() {
|
||||
return body, nil
|
||||
return deleteGrokOrphanToolControls(body)
|
||||
}
|
||||
|
||||
rawTools := tools.Array()
|
||||
@@ -911,11 +996,11 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
|
||||
raw := json.RawMessage(tool.Raw)
|
||||
if toolType == "function" && (!tool.Get("parameters").Exists() || tool.Get("parameters").Type == gjson.Null) {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(raw, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
encoded, err := json.Marshal(payload)
|
||||
encoded, err := marshalOpenAIUpstreamJSON(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -942,6 +1027,9 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(filteredTools) == 0 {
|
||||
return deleteGrokOrphanToolControls(body)
|
||||
}
|
||||
|
||||
toolChoice := gjson.GetBytes(body, "tool_choice")
|
||||
if !toolChoice.Exists() {
|
||||
@@ -956,6 +1044,20 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func deleteGrokOrphanToolControls(body []byte) ([]byte, error) {
|
||||
var err error
|
||||
for _, field := range []string{"tool_choice", "parallel_tool_calls"} {
|
||||
if !gjson.GetBytes(body, field).Exists() {
|
||||
continue
|
||||
}
|
||||
body, err = sjson.DeleteBytes(body, field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func shouldDropGrokToolChoice(toolChoice gjson.Result, tools []json.RawMessage) bool {
|
||||
if len(tools) == 0 {
|
||||
return true
|
||||
@@ -1014,7 +1116,7 @@ func (s *OpenAIGatewayService) bridgeGrokComposerImageInputs(
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(body, &reqBody); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
|
||||
return body, OpenAIUsage{}, false, fmt.Errorf("parse grok composer image bridge request: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// sanitizeGrokResponsesModelInput converts replay items from OpenAI and Chat
|
||||
// shapes into the subset accepted by xAI's ModelInput decoder. Call IDs are
|
||||
// assigned in a separate pass so an output can safely appear before its call.
|
||||
func sanitizeGrokResponsesModelInput(body []byte) ([]byte, error) {
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.Exists() || input.Type == gjson.String {
|
||||
return body, nil
|
||||
}
|
||||
if input.IsObject() {
|
||||
var err error
|
||||
body, err = sjson.SetRawBytes(body, "input", []byte("["+input.Raw+"]"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wrap Grok Responses model input: %w", err)
|
||||
}
|
||||
input = gjson.GetBytes(body, "input")
|
||||
}
|
||||
if !input.IsArray() {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var items []any
|
||||
decoder := json.NewDecoder(bytes.NewReader([]byte(input.Raw)))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&items); err != nil {
|
||||
return nil, fmt.Errorf("parse Grok Responses model input: %w", err)
|
||||
}
|
||||
callIDs, outputIDs := pairGrokReplayCallIDs(items)
|
||||
filtered := make([]any, 0, len(items))
|
||||
for index, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
if text, ok := rawItem.(string); ok && strings.TrimSpace(text) != "" {
|
||||
filtered = append(filtered, map[string]any{"type": "message", "role": "user", "content": text})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
|
||||
role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"])))
|
||||
if role == "tool" || role == "function" || isGrokReplayOutputType(itemType) {
|
||||
output := firstNonNilGrokJSONValue(item["output"], item["content"], item["results"])
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": outputIDs[index],
|
||||
"output": grokModelInputString(output, "(empty)"),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
switch itemType {
|
||||
case "text", "input_text", "output_text":
|
||||
text := strings.TrimSpace(grokStringValue(item["text"]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
item = map[string]any{"type": "message", "role": role, "content": text}
|
||||
itemType = "message"
|
||||
case "custom_tool_call", "tool_search_call":
|
||||
originalType := itemType
|
||||
item["type"] = "function_call"
|
||||
itemType = "function_call"
|
||||
if strings.TrimSpace(grokStringValue(item["name"])) == "" {
|
||||
if originalType == "tool_search_call" {
|
||||
item["name"] = "tool_search"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if itemType == "" && role != "" {
|
||||
itemType = "message"
|
||||
item["type"] = itemType
|
||||
}
|
||||
if itemType == "message" {
|
||||
if role == "" {
|
||||
role = "user"
|
||||
item["role"] = role
|
||||
}
|
||||
content, keep := sanitizeGrokMessageContent(item["content"])
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
item["content"] = content
|
||||
if role == "assistant" && !grokIsCompleteOutputMessage(item) {
|
||||
if text, ok := collapseGrokAssistantOutputText(content); ok {
|
||||
item["content"] = text
|
||||
}
|
||||
delete(item, "id")
|
||||
delete(item, "status")
|
||||
}
|
||||
} else if itemType == "reasoning" {
|
||||
delete(item, "status")
|
||||
if content, exists := item["content"]; exists && content == nil {
|
||||
delete(item, "content")
|
||||
}
|
||||
} else if itemType == "function_call" {
|
||||
name := strings.TrimSpace(grokStringValue(item["name"]))
|
||||
arguments := item["arguments"]
|
||||
if function, ok := item["function"].(map[string]any); ok {
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(grokStringValue(function["name"]))
|
||||
}
|
||||
if arguments == nil {
|
||||
arguments = function["arguments"]
|
||||
}
|
||||
}
|
||||
if arguments == nil {
|
||||
arguments = firstNonNilGrokJSONValue(item["input"], item["query"])
|
||||
if _, custom := item["input"]; custom {
|
||||
arguments = map[string]any{"input": arguments}
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown_tool"
|
||||
}
|
||||
item["call_id"] = callIDs[index]
|
||||
item["name"] = name
|
||||
item["arguments"] = grokModelInputString(arguments, "{}")
|
||||
for _, field := range []string{"id", "status", "tool_call_id", "function", "input", "query", "execution"} {
|
||||
delete(item, field)
|
||||
}
|
||||
}
|
||||
if shouldStripOpenAIResponsesNonPairCallID(itemType) {
|
||||
delete(item, "call_id")
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize Grok Responses model input: %w", err)
|
||||
}
|
||||
updated, err := sjson.SetRawBytes(body, "input", encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set Grok Responses model input: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func pairGrokReplayCallIDs(items []any) (map[int]string, map[int]string) {
|
||||
callIDs := make(map[int]string)
|
||||
outputIDs := make(map[int]string)
|
||||
aliases := make(map[string]string)
|
||||
conflictingAliases := make(map[string]struct{})
|
||||
pendingCalls := make([]string, 0)
|
||||
nextID := 0
|
||||
synthetic := func() string {
|
||||
nextID++
|
||||
return fmt.Sprintf("grok_replay_call_%d", nextID)
|
||||
}
|
||||
registerAlias := func(alias, canonical string) {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" {
|
||||
return
|
||||
}
|
||||
if _, conflict := conflictingAliases[alias]; conflict {
|
||||
return
|
||||
}
|
||||
if existing, exists := aliases[alias]; exists && existing != canonical {
|
||||
delete(aliases, alias)
|
||||
conflictingAliases[alias] = struct{}{}
|
||||
return
|
||||
}
|
||||
aliases[alias] = canonical
|
||||
}
|
||||
|
||||
for index, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
|
||||
if !isGrokReplayCallType(itemType) {
|
||||
continue
|
||||
}
|
||||
callID := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"])
|
||||
if callID == "" {
|
||||
callID = synthetic()
|
||||
}
|
||||
callIDs[index] = callID
|
||||
pendingCalls = append(pendingCalls, callID)
|
||||
registerAlias(callID, callID)
|
||||
registerAlias(grokStringValue(item["call_id"]), callID)
|
||||
registerAlias(grokStringValue(item["tool_call_id"]), callID)
|
||||
registerAlias(grokStringValue(item["id"]), callID)
|
||||
}
|
||||
|
||||
consumedCalls := make(map[string]struct{})
|
||||
consumeNextCall := func() string {
|
||||
for _, callID := range pendingCalls {
|
||||
if _, consumed := consumedCalls[callID]; consumed {
|
||||
continue
|
||||
}
|
||||
consumedCalls[callID] = struct{}{}
|
||||
return callID
|
||||
}
|
||||
return synthetic()
|
||||
}
|
||||
for index, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
|
||||
role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"])))
|
||||
if role != "tool" && role != "function" && !isGrokReplayOutputType(itemType) {
|
||||
continue
|
||||
}
|
||||
alias := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"], item["id"])
|
||||
_, conflict := conflictingAliases[alias]
|
||||
if canonical := aliases[alias]; canonical != "" && !conflict {
|
||||
outputIDs[index] = canonical
|
||||
consumedCalls[canonical] = struct{}{}
|
||||
} else if firstNonEmptyGrokString(item["call_id"], item["tool_call_id"]) != "" {
|
||||
// Explicit call identifiers remain authoritative even when an invalid
|
||||
// transcript reuses the same identifier for more than one call.
|
||||
outputIDs[index] = alias
|
||||
} else if conflict {
|
||||
// An item ID shared by multiple calls cannot safely identify either
|
||||
// one. Keep it out of the call_id namespace and leave the output orphaned.
|
||||
outputIDs[index] = synthetic()
|
||||
} else {
|
||||
outputIDs[index] = consumeNextCall()
|
||||
}
|
||||
}
|
||||
return callIDs, outputIDs
|
||||
}
|
||||
|
||||
func isGrokReplayCallType(itemType string) bool {
|
||||
switch itemType {
|
||||
case "function_call", "custom_tool_call", "tool_search_call":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isGrokReplayOutputType(itemType string) bool {
|
||||
switch itemType {
|
||||
case "function_call_output", "custom_tool_call_output", "tool_search_output", "tool_search_call_output":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyGrokString(values ...any) string {
|
||||
for _, value := range values {
|
||||
if text := strings.TrimSpace(grokStringValue(value)); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonNilGrokJSONValue(values ...any) any {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeGrokMessageContent(content any) (any, bool) {
|
||||
switch value := content.(type) {
|
||||
case nil:
|
||||
return nil, false
|
||||
case string:
|
||||
return value, strings.TrimSpace(value) != ""
|
||||
case []any:
|
||||
filtered := make([]any, 0, len(value))
|
||||
for _, rawPart := range value {
|
||||
part, ok := rawPart.(map[string]any)
|
||||
if !ok {
|
||||
if text, isText := rawPart.(string); !isText || strings.TrimSpace(text) != "" {
|
||||
filtered = append(filtered, rawPart)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) {
|
||||
case "text", "input_text", "output_text":
|
||||
if strings.TrimSpace(grokStringValue(part["text"])) == "" {
|
||||
continue
|
||||
}
|
||||
case "image_url", "input_image":
|
||||
if !grokContentPartHasImageURL(part) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
return filtered, len(filtered) > 0
|
||||
default:
|
||||
return content, true
|
||||
}
|
||||
}
|
||||
|
||||
func grokContentPartHasImageURL(part map[string]any) bool {
|
||||
for _, field := range []string{"file_id", "file_data"} {
|
||||
if strings.TrimSpace(grokStringValue(part[field])) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
raw := firstNonNilGrokJSONValue(part["image_url"], part["file_url"])
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
value = strings.TrimSpace(value)
|
||||
return value != "" && !isEmptyBase64DataURI(value)
|
||||
case map[string]any:
|
||||
url := strings.TrimSpace(grokStringValue(value["url"]))
|
||||
return url != "" && !isEmptyBase64DataURI(url)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func grokStringValue(value any) string {
|
||||
text, _ := value.(string)
|
||||
return text
|
||||
}
|
||||
|
||||
func grokModelInputString(value any, fallback string) string {
|
||||
if text, ok := value.(string); ok {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return fallback
|
||||
}
|
||||
return text
|
||||
}
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil || len(encoded) == 0 || string(encoded) == "null" {
|
||||
return fallback
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func grokIsCompleteOutputMessage(item map[string]any) bool {
|
||||
return strings.TrimSpace(grokStringValue(item["id"])) != "" &&
|
||||
strings.TrimSpace(grokStringValue(item["status"])) != ""
|
||||
}
|
||||
|
||||
func collapseGrokAssistantOutputText(content any) (string, bool) {
|
||||
parts, ok := content.([]any)
|
||||
if !ok || len(parts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, rawPart := range parts {
|
||||
part, ok := rawPart.(map[string]any)
|
||||
if !ok || strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) != "output_text" {
|
||||
return "", false
|
||||
}
|
||||
value, ok := part["text"].(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
text.WriteString(value)
|
||||
}
|
||||
if strings.TrimSpace(text.String()) == "" {
|
||||
return "", false
|
||||
}
|
||||
return text.String(), true
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSanitizeGrokResponsesModelInputNormalizesReplayHistory(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"input":[
|
||||
{"role":"system","content":"rules"},
|
||||
{"type":"message","role":"assistant","id":"msg_partial","content":[{"type":"output_text","text":"done"}]},
|
||||
{"type":"reasoning","id":"rs_1","status":"completed","content":null,"summary":[{"type":"summary_text","text":"keep"}]},
|
||||
{"type":"message","role":"assistant","id":"msg_complete","status":"completed","content":[{"type":"output_text","text":"kept"}]}
|
||||
]}`)
|
||||
|
||||
patched, err := sanitizeGrokResponsesModelInput(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String())
|
||||
require.Equal(t, "done", gjson.GetBytes(patched, "input.1.content").String())
|
||||
require.False(t, gjson.GetBytes(patched, "input.1.id").Exists())
|
||||
require.False(t, gjson.GetBytes(patched, "input.2.status").Exists())
|
||||
require.False(t, gjson.GetBytes(patched, "input.2.content").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(patched, "input.2.summary.0.text").String())
|
||||
require.Equal(t, "msg_complete", gjson.GetBytes(patched, "input.3.id").String())
|
||||
require.Equal(t, "completed", gjson.GetBytes(patched, "input.3.status").String())
|
||||
}
|
||||
|
||||
func TestSanitizeGrokResponsesModelInputStripsOnlyNonPairCallIDs(t *testing.T) {
|
||||
body := []byte(`{"input":[
|
||||
{"type":"message","role":"user","call_id":"remove_message","content":"continue"},
|
||||
{"type":"reasoning","call_id":"remove_reasoning","summary":[]},
|
||||
{"type":"image_generation_call","call_id":"remove_image","id":"ig_1","status":"completed"},
|
||||
{"type":"function_call","call_id":"keep_function","name":"lookup","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"keep_function","output":"ok"}
|
||||
]}`)
|
||||
|
||||
patched, err := sanitizeGrokResponsesModelInput(body)
|
||||
require.NoError(t, err)
|
||||
for i := 0; i < 3; i++ {
|
||||
require.False(t, gjson.GetBytes(patched, "input."+strconv.Itoa(i)+".call_id").Exists())
|
||||
}
|
||||
require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.3.call_id").String())
|
||||
require.Equal(t, "keep_function", gjson.GetBytes(patched, "input.4.call_id").String())
|
||||
}
|
||||
|
||||
func TestSanitizeGrokResponsesModelInputPairsCallsInTwoPasses(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"input":[
|
||||
{"type":"function_call_output","output":{"before":true,"large_id":9007199254740993}},
|
||||
{"type":"function_call","function":{"name":"lookup","arguments":{"query":"x","large_id":9007199254740993}}},
|
||||
{"type":"custom_tool_call","id":"custom_1","name":"apply_patch","input":"patch"},
|
||||
{"type":"custom_tool_call_output","tool_call_id":"custom_1","output":["ok"]},
|
||||
{"type":"tool_search_call","id":"search_1","arguments":{"query":"repo"}},
|
||||
{"type":"tool_search_output","call_id":"search_1","results":{"groups":["repo"]}},
|
||||
{"role":"tool","tool_call_id":"orphan","content":{"value":1}}
|
||||
]}`)
|
||||
|
||||
patched, err := sanitizeGrokResponsesModelInput(body)
|
||||
require.NoError(t, err)
|
||||
generated := gjson.GetBytes(patched, "input.1.call_id").String()
|
||||
require.NotEmpty(t, generated)
|
||||
require.Equal(t, generated, gjson.GetBytes(patched, "input.0.call_id").String())
|
||||
require.JSONEq(t, `{"before":true,"large_id":9007199254740993}`, gjson.GetBytes(patched, "input.0.output").String())
|
||||
require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.0.output").String(), "large_id").Raw)
|
||||
require.Equal(t, "lookup", gjson.GetBytes(patched, "input.1.name").String())
|
||||
require.JSONEq(t, `{"query":"x","large_id":9007199254740993}`, gjson.GetBytes(patched, "input.1.arguments").String())
|
||||
require.Equal(t, "9007199254740993", gjson.Get(gjson.GetBytes(patched, "input.1.arguments").String(), "large_id").Raw)
|
||||
customCallID := gjson.GetBytes(patched, "input.2.call_id").String()
|
||||
require.NotEmpty(t, customCallID)
|
||||
require.NotEqual(t, "custom_1", customCallID)
|
||||
require.JSONEq(t, `{"input":"patch"}`, gjson.GetBytes(patched, "input.2.arguments").String())
|
||||
require.Equal(t, customCallID, gjson.GetBytes(patched, "input.3.call_id").String())
|
||||
require.JSONEq(t, `["ok"]`, gjson.GetBytes(patched, "input.3.output").String())
|
||||
require.Equal(t, "tool_search", gjson.GetBytes(patched, "input.4.name").String())
|
||||
searchCallID := gjson.GetBytes(patched, "input.4.call_id").String()
|
||||
require.NotEmpty(t, searchCallID)
|
||||
require.NotEqual(t, "search_1", searchCallID)
|
||||
require.Equal(t, searchCallID, gjson.GetBytes(patched, "input.5.call_id").String())
|
||||
require.JSONEq(t, `{"groups":["repo"]}`, gjson.GetBytes(patched, "input.5.output").String())
|
||||
require.Equal(t, "function_call_output", gjson.GetBytes(patched, "input.6.type").String())
|
||||
require.Equal(t, "orphan", gjson.GetBytes(patched, "input.6.call_id").String())
|
||||
}
|
||||
|
||||
func TestSanitizeGrokResponsesModelInputMapsAllItemAliasesAndRejectsConflicts(t *testing.T) {
|
||||
body := []byte(`{"input":[
|
||||
{"type":"function_call","id":"fc_alias","call_id":"call_canonical","name":"one","arguments":"{}"},
|
||||
{"type":"function_call_output","id":"fc_alias","output":"ok"},
|
||||
{"type":"function_call","id":"duplicate_item","name":"two","arguments":"{}"},
|
||||
{"type":"function_call","id":"duplicate_item","name":"three","arguments":"{}"},
|
||||
{"type":"function_call_output","id":"duplicate_item","output":"ambiguous"}
|
||||
]}`)
|
||||
|
||||
patched, err := sanitizeGrokResponsesModelInput(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.0.call_id").String())
|
||||
require.Equal(t, "call_canonical", gjson.GetBytes(patched, "input.1.call_id").String())
|
||||
require.False(t, gjson.GetBytes(patched, "input.0.id").Exists())
|
||||
secondCallID := gjson.GetBytes(patched, "input.2.call_id").String()
|
||||
thirdCallID := gjson.GetBytes(patched, "input.3.call_id").String()
|
||||
ambiguousOutputID := gjson.GetBytes(patched, "input.4.call_id").String()
|
||||
require.NotEmpty(t, secondCallID)
|
||||
require.NotEmpty(t, thirdCallID)
|
||||
require.NotEmpty(t, ambiguousOutputID)
|
||||
require.NotEqual(t, "duplicate_item", secondCallID)
|
||||
require.NotEqual(t, secondCallID, thirdCallID)
|
||||
require.NotEqual(t, secondCallID, ambiguousOutputID)
|
||||
require.NotEqual(t, thirdCallID, ambiguousOutputID)
|
||||
}
|
||||
|
||||
func TestPatchGrokResponsesBodyCombinesAdditionalToolsAndDropsOrphanControls(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup"}]},{"role":"user","content":"hi"}],
|
||||
"tools":[{"type":"function","name":"lookup"}],
|
||||
"tool_choice":"auto","parallel_tool_calls":true
|
||||
}`)
|
||||
patched, err := patchGrokResponsesBody(body, "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), gjson.GetBytes(patched, "tools.#").Int())
|
||||
require.Equal(t, "lookup", gjson.GetBytes(patched, "tools.0.name").String())
|
||||
require.Equal(t, "message", gjson.GetBytes(patched, "input.0.type").String())
|
||||
|
||||
withoutTools, err := patchGrokResponsesBody([]byte(`{"input":"hi","tool_choice":"auto","parallel_tool_calls":true}`), "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(withoutTools, "tool_choice").Exists())
|
||||
require.False(t, gjson.GetBytes(withoutTools, "parallel_tool_calls").Exists())
|
||||
|
||||
for _, malformedTools := range []string{"null", `{}`, `"invalid"`} {
|
||||
malformedBody := []byte(`{"input":"hi","tools":` + malformedTools + `,"tool_choice":"auto","parallel_tool_calls":true}`)
|
||||
patched, err := patchGrokResponsesBody(malformedBody, "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists(), string(patched))
|
||||
require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists(), string(patched))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeGrokCompactionReplayBodyPreservesVisibleSummary(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"previous_response_id":"resp_stale",
|
||||
"input":[{"type":"compaction","encrypted_content":"opaque","summary":[{"type":"summary_text","text":"visible history"}]},{"type":"message","role":"user","content":"continue"}]
|
||||
}`)
|
||||
patched, changed, err := sanitizeGrokCompactionReplayBody(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(patched, "previous_response_id").Exists())
|
||||
require.False(t, gjson.GetBytes(patched, `input.#(type=="reasoning")`).Exists())
|
||||
require.Contains(t, gjson.GetBytes(patched, "input.0.content.0.text").String(), "visible history")
|
||||
require.Equal(t, "continue", gjson.GetBytes(patched, "input.1.content").String())
|
||||
require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"message":"failed to deserialize compaction summary history"}}`)))
|
||||
require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"detail":"could not decode compaction blob"}`)))
|
||||
}
|
||||
|
||||
func TestGrokStructuredErrorCandidatesDoNotShadowTopLevelMessages(t *testing.T) {
|
||||
shadowedInvalidEncrypted := []byte(`{"code":"invalid-argument","error":{"type":"invalid_request_error"},"message":"Could not decrypt encrypted_content because it was modified"}`)
|
||||
require.True(t, isGrokInvalidEncryptedContentResponse(http.StatusBadRequest, shadowedInvalidEncrypted))
|
||||
require.True(t, isGrokCompactionReplayDecodeError(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not decode compaction history"}`)))
|
||||
}
|
||||
|
||||
func TestGrokDecoderCompatibility422FailsOverWithoutCooldown(t *testing.T) {
|
||||
body := []byte(`{"detail":"data did not match any variant of untagged enum ModelInput at input[3]"}`)
|
||||
require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, body))
|
||||
require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"message":"could not decode ModelInput at input.3"}`)))
|
||||
require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"type":"invalid_request_error"},"message":"could not deserialize ModelInput at input[3]"}`)))
|
||||
require.True(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"Failed to deserialize the JSON body into the target type: messages[1]: data did not match any variant of untagged enum Content at line 1 column 6577"}`)))
|
||||
require.True(t, (&OpenAIGatewayService{}).shouldFailoverGrokUpstreamError(http.StatusUnprocessableEntity, body))
|
||||
decision := classifyGrokUpstreamFailure(http.StatusUnprocessableEntity, body, "grok-4.5")
|
||||
require.False(t, decision.ShouldCooldown)
|
||||
require.Equal(t, GrokFailureNone, decision.Class)
|
||||
|
||||
require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":{"message":"invalid user parameter"}}`)))
|
||||
require.False(t, isGrokDecoderCompatibilityError(http.StatusUnprocessableEntity, []byte(`{"error":"messages[1].content is required"}`)))
|
||||
require.False(t, isGrokDecoderCompatibilityError(http.StatusBadRequest, body))
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func TestPatchGrokResponsesBodyNormalizesReasoningEffortAliases(t *testing.T) {
|
||||
|
||||
func TestPatchGrokResponsesBodyAddsDefaultFunctionParameters(t *testing.T) {
|
||||
patched, err := patchGrokResponsesBody(
|
||||
[]byte(`{"input":"hi","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"wait","parameters":null}]}`),
|
||||
[]byte(`{"input":"hi","tools":[{"type":"function","name":"lookup","large_id":9007199254740993},{"type":"function","name":"wait","parameters":null}]}`),
|
||||
"grok-4.5",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -289,6 +289,7 @@ func TestPatchGrokResponsesBodyAddsDefaultFunctionParameters(t *testing.T) {
|
||||
require.Equal(t, "object", tool.Get("parameters.type").String(), string(patched))
|
||||
require.True(t, tool.Get("parameters.properties").IsObject(), string(patched))
|
||||
}
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "tools.0.large_id").Raw)
|
||||
}
|
||||
|
||||
func TestNormalizeGrokChatReasoningEffort(t *testing.T) {
|
||||
@@ -316,7 +317,7 @@ func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) {
|
||||
"tools": [
|
||||
{"type": "function", "name": "kept_fn", "external_web_access": true, "parameters": {"type": "object", "properties": {"q": {"type": "string", "external_web_access": true}}}}
|
||||
],
|
||||
"metadata": {"external_web_access": false}
|
||||
"metadata": {"external_web_access": false, "large_id": 9007199254740993}
|
||||
}`)
|
||||
|
||||
patched, err := patchGrokResponsesBody(body, "grok-4.3")
|
||||
@@ -324,6 +325,17 @@ func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) {
|
||||
require.True(t, json.Valid(patched))
|
||||
require.False(t, strings.Contains(string(patched), "external_web_access"))
|
||||
require.Equal(t, "kept_fn", gjson.GetBytes(patched, "tools.0.name").String())
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "metadata.large_id").Raw)
|
||||
}
|
||||
|
||||
func TestStripAnthropicThinkingSignaturesPreservesLargeIntegers(t *testing.T) {
|
||||
body := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"work","signature":"opaque"}]}],"metadata":{"large_id":9007199254740993}}`)
|
||||
|
||||
patched, changed := stripAnthropicThinkingSignatures(body)
|
||||
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(patched, "messages.0.content.0.signature").Exists())
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(patched, "metadata.large_id").Raw)
|
||||
}
|
||||
|
||||
func TestPatchGrokResponsesBodyFlattensNamespaceTools(t *testing.T) {
|
||||
@@ -1921,6 +1933,7 @@ func TestForwardGrokResponsesRetriesInvalidEncryptedContentOnce(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{
|
||||
"model":"grok",
|
||||
"previous_response_id":"resp_valid_history",
|
||||
"input":[
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"keep this summary"}],"encrypted_content":"encrypted-reasoning"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}
|
||||
@@ -1973,8 +1986,10 @@ func TestForwardGrokResponsesRetriesInvalidEncryptedContentOnce(t *testing.T) {
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
|
||||
require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[0], "input.0.type").String())
|
||||
require.Equal(t, "resp_valid_history", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String())
|
||||
require.Equal(t, "encrypted-reasoning", gjson.GetBytes(upstream.bodies[0], "input.0.encrypted_content").String())
|
||||
require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[1], "input.0.type").String())
|
||||
require.Equal(t, "resp_valid_history", gjson.GetBytes(upstream.bodies[1], "previous_response_id").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.encrypted_content").Exists())
|
||||
require.Equal(t, "keep this summary", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.text").String())
|
||||
require.Equal(t, "message", gjson.GetBytes(upstream.bodies[1], "input.1.type").String())
|
||||
@@ -2548,7 +2563,7 @@ func TestForwardAsChatCompletionsForGrokComposerBridgesImageInput(t *testing.T)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"model":"grok-composer-2.5-fast","messages":[{"role":"system","content":"You are concise."},{"role":"user","content":[{"type":"text","text":"What is shown?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}],"stream":false}`)
|
||||
body := []byte(`{"model":"grok-composer-2.5-fast","messages":[{"role":"system","content":"You are concise."},{"role":"user","content":[{"type":"text","text":"What is shown?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]}],"metadata":{"large_id":9007199254740993},"stream":false}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("api_key", &APIKey{ID: 5501})
|
||||
@@ -2597,6 +2612,7 @@ func TestForwardAsChatCompletionsForGrokComposerBridgesImageInput(t *testing.T)
|
||||
require.NotEmpty(t, upstream.requests[1].Header.Get(grokConversationIDHeader))
|
||||
require.Equal(t, "grok-composer-2.5-fast", gjson.GetBytes(upstream.bodies[1], "model").String())
|
||||
require.False(t, strings.Contains(string(upstream.bodies[1]), "image_url"))
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(upstream.bodies[1], "metadata.large_id").Raw)
|
||||
require.Contains(t, gjson.GetBytes(upstream.bodies[1], "messages.1.content").String(), "Image 1 description")
|
||||
require.Contains(t, gjson.GetBytes(upstream.bodies[1], "messages.1.content").String(), "A small diagram with ABC letters.")
|
||||
require.Equal(t, 14, result.Usage.InputTokens)
|
||||
|
||||
@@ -113,10 +113,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
compatContinuationDisabled := compatContinuationEnabled &&
|
||||
s.isOpenAICompatSessionContinuationDisabled(ctx, c, account, promptCacheKey)
|
||||
compatTurnState := ""
|
||||
// OAuth/Plus relies on session_id + x-codex-turn-state; trimming to a
|
||||
// ChatGPT/Codex credentials rely on session_id + x-codex-turn-state; trimming to a
|
||||
// sliding 12-message window makes the cached prefix stall at system/tools.
|
||||
// Keep full replay there so upstream prompt caching can grow turn by turn.
|
||||
if compatReplayGuardEnabled && account.Type != AccountTypeOAuth && previousResponseID == "" && !compatContinuationDisabled {
|
||||
if compatReplayGuardEnabled && !account.UsesOpenAICodexProtocol() && previousResponseID == "" && !compatContinuationDisabled {
|
||||
compatReplayTrimmed = applyAnthropicCompatFullReplayGuard(&anthropicReq)
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
responsesReq.PreviousResponseID = previousResponseID
|
||||
trimAnthropicCompatResponsesInputToLatestTurn(responsesReq)
|
||||
}
|
||||
if compatReplayGuardEnabled && account.Type != AccountTypeOAuth {
|
||||
if compatReplayGuardEnabled && !account.UsesOpenAICodexProtocol() {
|
||||
appendOpenAICompatClaudeCodeTodoGuard(responsesReq)
|
||||
}
|
||||
|
||||
@@ -179,13 +179,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
logger.L().Debug("openai messages: model mapping applied", logFields...)
|
||||
|
||||
// 4. Marshal Responses request body, then apply OAuth codex transform
|
||||
// 4. Marshal Responses request body, then apply the ChatGPT/Codex transform.
|
||||
responsesBody, err := json.Marshal(responsesReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal responses request: %w", err)
|
||||
}
|
||||
|
||||
if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok {
|
||||
if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok {
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(responsesBody, &reqBody); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal for codex transform: %w", err)
|
||||
@@ -311,7 +311,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
|
||||
// 6. Build upstream request
|
||||
if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok {
|
||||
if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok {
|
||||
// Messages 兼容桥即使 body 未带 todo-guard/prompt_cache_key 标记(如映射到非
|
||||
// gpt-5/codex 模型),也必须让 buildUpstreamRequest 走 bridge 分支,以保留
|
||||
// 既有 body/session/conversation 行为。身份头在 post-build 阶段统一恢复。
|
||||
@@ -338,7 +338,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
upstreamReq.Header.Set("conversation_id", isolatedSessionID)
|
||||
}
|
||||
}
|
||||
if account.Type == AccountTypeOAuth && account.Platform != PlatformGrok {
|
||||
if account.UsesOpenAICodexProtocol() && account.Platform != PlatformGrok {
|
||||
// buildUpstreamRequest 保留 Messages bridge 的 body/session 兼容行为,并会先
|
||||
// 清除身份头。真正发送前恢复完整 Codex 身份,避免 ChatGPT Codex 上游因缺失
|
||||
// originator/OpenAI-Beta 返回 404(issue #3901)。
|
||||
@@ -350,7 +350,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
zap.Bool("compat_identity_restored", true),
|
||||
)
|
||||
}
|
||||
if account.Type == AccountTypeOAuth && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" {
|
||||
if account.UsesOpenAICodexProtocol() && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" {
|
||||
upstreamReq.Header.Del("conversation_id")
|
||||
}
|
||||
if compatTurnState != "" && upstreamReq.Header.Get("x-codex-turn-state") == "" {
|
||||
@@ -461,7 +461,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
s.updateGrokUsageFromResponse(withGrokTeamRateLimitModel(ctx, upstreamModel), account, resp.Header, resp.StatusCode)
|
||||
}
|
||||
|
||||
if account.Type == AccountTypeOAuth && promptCacheKey != "" {
|
||||
if account.UsesOpenAICodexProtocol() && promptCacheKey != "" {
|
||||
if turnState := strings.TrimSpace(resp.Header.Get("x-codex-turn-state")); turnState != "" {
|
||||
s.bindOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey, turnState)
|
||||
}
|
||||
@@ -614,6 +614,9 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
|
||||
writeAnthropicError(c, http.StatusBadGateway, "api_error", message)
|
||||
return nil, fmt.Errorf("upstream response failed: %s", message)
|
||||
}
|
||||
if strings.TrimSpace(finalResponse.Status) == "completed" {
|
||||
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, "response.completed", false)
|
||||
}
|
||||
|
||||
// When the terminal event has an empty output array, reconstruct from
|
||||
// accumulated delta events so the client receives the full content.
|
||||
@@ -652,7 +655,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
|
||||
|
||||
func isOpenAICompatResponsesTerminalEvent(eventType string) bool {
|
||||
switch strings.TrimSpace(eventType) {
|
||||
case "response.completed", "response.done", "response.incomplete", "response.failed":
|
||||
case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled", "error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -694,6 +697,28 @@ type openAICompatBufferedReadError struct {
|
||||
func (e *openAICompatBufferedReadError) Error() string { return e.cause.Error() }
|
||||
func (e *openAICompatBufferedReadError) Unwrap() error { return e.cause }
|
||||
|
||||
func openAICompatTerminalResponse(event *apicompat.ResponsesStreamEvent, payload []byte) *apicompat.ResponsesResponse {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
if event.Response != nil {
|
||||
return event.Response
|
||||
}
|
||||
switch strings.TrimSpace(event.Type) {
|
||||
case "response.failed", "error":
|
||||
message := extractOpenAISSEErrorMessage(payload)
|
||||
if message == "" {
|
||||
message = "Upstream response failed"
|
||||
}
|
||||
return &apicompat.ResponsesResponse{
|
||||
Status: "failed",
|
||||
Error: &apicompat.ResponsesError{Code: event.Code, Message: message},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
@@ -779,18 +804,19 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
|
||||
payload = string(restoreCodexToolNamesFromContext(c, []byte(payload)))
|
||||
var event apicompat.ResponsesStreamEvent
|
||||
if err := json.Unmarshal([]byte(payload), &event); err == nil {
|
||||
s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage)
|
||||
acc.ProcessEvent(&event)
|
||||
if isOpenAICompatResponsesTerminalEvent(event.Type) && event.Response != nil {
|
||||
if response := openAICompatTerminalResponse(&event, []byte(payload)); isOpenAICompatResponsesTerminalEvent(event.Type) && response != nil {
|
||||
if event.Usage != nil {
|
||||
usage = copyOpenAIUsageFromResponsesUsage(event.Usage)
|
||||
if event.Response.Usage == nil {
|
||||
event.Response.Usage = event.Usage
|
||||
if response.Usage == nil {
|
||||
response.Usage = event.Usage
|
||||
}
|
||||
}
|
||||
if event.Response.Usage != nil {
|
||||
usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
|
||||
if response.Usage != nil {
|
||||
usage = copyOpenAIUsageFromResponsesUsage(response.Usage)
|
||||
}
|
||||
return event.Response, usage, acc, nil
|
||||
return response, usage, acc, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,20 +851,21 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
|
||||
)
|
||||
continue
|
||||
}
|
||||
s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage)
|
||||
|
||||
acc.ProcessEvent(&event)
|
||||
|
||||
if isOpenAICompatResponsesTerminalEvent(event.Type) && event.Response != nil {
|
||||
if response := openAICompatTerminalResponse(&event, []byte(payload)); isOpenAICompatResponsesTerminalEvent(event.Type) && response != nil {
|
||||
if event.Usage != nil {
|
||||
usage = copyOpenAIUsageFromResponsesUsage(event.Usage)
|
||||
if event.Response.Usage == nil {
|
||||
event.Response.Usage = event.Usage
|
||||
if response.Usage == nil {
|
||||
response.Usage = event.Usage
|
||||
}
|
||||
}
|
||||
if event.Response.Usage != nil {
|
||||
usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
|
||||
if response.Usage != nil {
|
||||
usage = copyOpenAIUsageFromResponsesUsage(response.Usage)
|
||||
}
|
||||
return event.Response, usage, acc, nil
|
||||
return response, usage, acc, nil
|
||||
}
|
||||
|
||||
case <-timeoutCh:
|
||||
@@ -879,6 +906,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
|
||||
clientOutputStarted := false
|
||||
var streamFailoverErr error
|
||||
var streamNonFailoverErr error
|
||||
terminalEventType := ""
|
||||
searchCount := 0
|
||||
streamSearchSeen := make(map[string]struct{})
|
||||
countSearch := account != nil && account.IsGrok()
|
||||
@@ -946,11 +974,13 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
|
||||
return false
|
||||
}
|
||||
observer.ObserveOpenAI([]byte(payload), event.Type)
|
||||
s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage)
|
||||
|
||||
eventType := strings.TrimSpace(event.Type)
|
||||
isBareErrorEvent := eventType == "error"
|
||||
isTerminalEvent := isOpenAICompatResponsesTerminalEvent(eventType) || isBareErrorEvent
|
||||
if isTerminalEvent {
|
||||
terminalEventType = eventType
|
||||
if event.Response != nil {
|
||||
if id := strings.TrimSpace(event.Response.ID); id != "" {
|
||||
responseID = id
|
||||
@@ -992,7 +1022,11 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
|
||||
// Once Anthropic output has started, switching accounts would splice
|
||||
// two model streams together. Surface a proper Anthropic error event
|
||||
// instead of returning a failover error that the handler cannot retry.
|
||||
if !clientOutputStarted && openAIStreamFailedEventShouldFailover(payloadBytes, message) {
|
||||
shouldFailover := openAIStreamFailedEventShouldFailover(payloadBytes, message)
|
||||
if isBareErrorEvent {
|
||||
shouldFailover = openAIStreamErrorEventShouldFailover(payloadBytes, message)
|
||||
}
|
||||
if !clientOutputStarted && shouldFailover {
|
||||
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header)
|
||||
return true
|
||||
}
|
||||
@@ -1082,6 +1116,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, &usage, terminalEventType, clientDisconnected)
|
||||
return resultWithUsage(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,33 @@ func TestForwardAsAnthropic_StreamingBareErrorBeforeOutputFailsOver(t *testing.T
|
||||
require.Empty(t, rec.Body.String(), "failover path must not commit downstream output")
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_StreamingGenericBareErrorBeforeOutputIsNotHiddenByFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
|
||||
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")
|
||||
|
||||
ssePayload := "event: error\n" +
|
||||
`data: {"type":"error","error":{"type":"server_error","code":"upstream_error","message":"mixed tools failed"}}` + "\n\n" +
|
||||
"data: [DONE]\n\n"
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(ssePayload)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
|
||||
_, err := svc.ForwardAsAnthropic(context.Background(), c, rawChatCompletionsTestAccount(), body, "", "")
|
||||
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.False(t, errors.As(err, &failoverErr))
|
||||
require.Contains(t, rec.Body.String(), "mixed tools failed")
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_BufferedResponseFailed_Failover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -115,9 +115,10 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
reqStream bool,
|
||||
startTime time.Time,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
requestedModel := reqModel
|
||||
upstreamPassthroughModel := ""
|
||||
if isOpenAIResponsesCompactPath(c) {
|
||||
compactMappedModel := resolveOpenAICompactForwardModel(account, reqModel)
|
||||
compactMappedModel := s.resolveOpenAICompactFallbackModel(account, reqModel)
|
||||
if compactMappedModel != "" && compactMappedModel != reqModel {
|
||||
nextBody, setErr := sjson.SetBytes(body, "model", compactMappedModel)
|
||||
if setErr != nil {
|
||||
@@ -129,7 +130,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
}
|
||||
|
||||
if account != nil && account.Type == AccountTypeOAuth {
|
||||
if account != nil && account.UsesOpenAICodexProtocol() {
|
||||
if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" {
|
||||
rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field"
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
@@ -182,6 +183,25 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
stageCodexFingerprintIDs(c, fpIDs)
|
||||
}
|
||||
}
|
||||
if account != nil && account.IsOpenAI() {
|
||||
normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account)
|
||||
if normalizeErr != nil {
|
||||
return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr)
|
||||
}
|
||||
if normalized {
|
||||
body = normalizedBody
|
||||
}
|
||||
if account.IsOpenAIOAuthLike() {
|
||||
aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(body)
|
||||
if aliasErr != nil {
|
||||
return nil, aliasErr
|
||||
}
|
||||
mergeCodexToolNameReverse(c, reverse)
|
||||
if aliased {
|
||||
body = aliasedBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey &&
|
||||
!isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) {
|
||||
@@ -303,8 +323,15 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
|
||||
agentTaskRecoveryTried := false
|
||||
compactModelFallbackRetried := false
|
||||
rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, body)
|
||||
var resp *http.Response
|
||||
for {
|
||||
actualModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
||||
if actualModel == "" {
|
||||
actualModel = reqModel
|
||||
}
|
||||
SetOpsUpstreamModel(c, actualModel)
|
||||
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
||||
upstreamReq, buildErr := s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)
|
||||
releaseUpstreamCtx()
|
||||
@@ -329,6 +356,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
probeBody := s.readUpstreamErrorBody(resp)
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(probeBody))
|
||||
if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, probeBody); retryErr != nil {
|
||||
return nil, fmt.Errorf("normalize passthrough rejected Responses field retry body: %w", retryErr)
|
||||
} else if changed && rejectedFieldRetryState.Allow(retryBody) {
|
||||
body = retryBody
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying passthrough request after %s (account: %s)", reason, account.Name)
|
||||
continue
|
||||
}
|
||||
if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, probeBody) {
|
||||
agentTaskRecoveryTried = true
|
||||
expectedTaskID := account.GetCredential("task_id")
|
||||
@@ -337,6 +371,22 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
continue
|
||||
}
|
||||
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(probeBody)))
|
||||
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
|
||||
c, account, requestedModel, body, resp.StatusCode, upstreamMsg, probeBody, compactModelFallbackRetried,
|
||||
); retry {
|
||||
fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
||||
body = retryBody
|
||||
upstreamPassthroughModel = fallbackModel
|
||||
compactModelFallbackRetried = true
|
||||
SetOpsUpstreamModel(c, fallbackModel)
|
||||
logger.LegacyPrintf(
|
||||
"service.openai_gateway",
|
||||
"[OpenAI passthrough] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
|
||||
account.Name, fromModel, fallbackModel, extractUpstreamErrorCode(probeBody),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 透传模式默认保持原样代理;容量错误以及 API-key 上游的瞬时
|
||||
// 5xx 应先触发多账号 failover,且此时尚未写入下游响应。
|
||||
@@ -372,6 +422,17 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
if reqStream {
|
||||
result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel)
|
||||
if err != nil {
|
||||
if signal, ok := asOpenAICompactFallbackSignal(err); ok {
|
||||
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
|
||||
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
|
||||
); retry {
|
||||
body = retryBody
|
||||
upstreamPassthroughModel = fallbackModel
|
||||
compactModelFallbackRetried = true
|
||||
SetOpsUpstreamModel(c, fallbackModel)
|
||||
return s.forwardOpenAIPassthrough(ctx, c, account, body, canonicalImageIntentBody, requestedModel, true, reasoningEffort, reqStream, startTime)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
usage = result.usage
|
||||
@@ -380,8 +441,19 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
imageCount = result.imageCount
|
||||
imageOutputSizes = result.imageOutputSizes
|
||||
} else {
|
||||
result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel)
|
||||
result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, account, reqModel, upstreamPassthroughModel)
|
||||
if err != nil {
|
||||
if signal, ok := asOpenAICompactFallbackSignal(err); ok {
|
||||
if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
|
||||
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
|
||||
); retry {
|
||||
body = retryBody
|
||||
upstreamPassthroughModel = fallbackModel
|
||||
compactModelFallbackRetried = true
|
||||
SetOpsUpstreamModel(c, fallbackModel)
|
||||
return s.forwardOpenAIPassthrough(ctx, c, account, body, canonicalImageIntentBody, requestedModel, true, reasoningEffort, reqStream, startTime)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
usage = result.usage
|
||||
@@ -469,6 +541,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
switch account.Type {
|
||||
case AccountTypeOAuth:
|
||||
targetURL = chatgptCodexURL
|
||||
case AccountTypeSetupToken:
|
||||
if account.IsOpenAIOAuthLike() {
|
||||
targetURL = chatgptCodexURL
|
||||
}
|
||||
case AccountTypeAPIKey:
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
if baseURL != "" {
|
||||
@@ -523,7 +599,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
}
|
||||
|
||||
// OAuth 透传到 ChatGPT internal API 时补齐必要头。
|
||||
if account.Type == AccountTypeOAuth {
|
||||
if account.UsesOpenAICodexProtocol() {
|
||||
// Current Codex OAuth HTTP no longer negotiates the legacy Responses
|
||||
// experiment. Passthrough may receive it from an older client, so remove
|
||||
// only that token while preserving any independent beta negotiation.
|
||||
@@ -585,7 +661,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
applyStagedCodexFingerprintHeaders(c, account, req.Header)
|
||||
// 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份
|
||||
// (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。
|
||||
if account.Type == AccountTypeOAuth {
|
||||
if account.UsesOpenAICodexProtocol() {
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
|
||||
}
|
||||
|
||||
@@ -636,9 +712,15 @@ func stripOpenAILegacyResponsesBeta(headers http.Header) {
|
||||
}
|
||||
|
||||
func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool {
|
||||
if hit, _, _ := detectOpenAICyberPolicy(responseBody); hit {
|
||||
return false
|
||||
}
|
||||
if isOpenAIContextWindowError("", responseBody) {
|
||||
return false
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError("", responseBody) {
|
||||
return true
|
||||
}
|
||||
if isOpenAIRequestBodyTooLargeError(statusCode, "", responseBody) {
|
||||
return true
|
||||
}
|
||||
@@ -774,11 +856,13 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
|
||||
Detail: upstreamDetail,
|
||||
UpstreamResponseBody: upstreamDetail,
|
||||
})
|
||||
return newOpenAIUpstreamFailoverError(
|
||||
return s.newOpenAIAccountFailoverError(
|
||||
account,
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
body,
|
||||
upstreamMsg,
|
||||
shouldDisable,
|
||||
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
)
|
||||
}
|
||||
@@ -1197,6 +1281,12 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
|
||||
errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
|
||||
}
|
||||
combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message)))
|
||||
for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} {
|
||||
if status := int(gjson.GetBytes(payload, path).Int()); status == http.StatusUnauthorized ||
|
||||
status == http.StatusForbidden || status == http.StatusTooManyRequests || status == 529 {
|
||||
return status
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(combined, "rate_limit"):
|
||||
return http.StatusTooManyRequests
|
||||
@@ -1206,6 +1296,8 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
|
||||
return http.StatusUnauthorized
|
||||
case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"):
|
||||
return http.StatusForbidden
|
||||
case isOpenAIUpstreamAccessStateError(message, payload):
|
||||
return http.StatusForbidden
|
||||
case isOpenAIUpstreamCapacityShedEvent(payload):
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
@@ -1217,15 +1309,52 @@ func openAIStreamFailureStatus(payload []byte, message string) int {
|
||||
if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
// Keep the existing 502 failover behavior for other response.failed events.
|
||||
// Only rate limits need promotion because they participate in the account's
|
||||
// configurable 429 same-account retry policy.
|
||||
if openAIStreamFailedEventSemanticStatus(payload, message) == http.StatusTooManyRequests {
|
||||
return http.StatusTooManyRequests
|
||||
semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
|
||||
switch semanticStatus {
|
||||
case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests, 529:
|
||||
return semanticStatus
|
||||
case http.StatusServiceUnavailable:
|
||||
if isOpenAIUpstreamCapacityShedEvent(payload) {
|
||||
return semanticStatus
|
||||
}
|
||||
}
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
// openAIStreamCredentialAuthFailure distinguishes credential failures from
|
||||
// request/content permission denials carried inside an HTTP 200 stream. Do not
|
||||
// infer credential health from free-form 403 messages: providers also use
|
||||
// permission_error/forbidden/access denied for request-scoped policy failures.
|
||||
func openAIStreamCredentialAuthFailure(payload []byte) bool {
|
||||
if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
|
||||
return false
|
||||
}
|
||||
for _, path := range []string{"response.error.status_code", "error.status_code", "status_code"} {
|
||||
if int(gjson.GetBytes(payload, path).Int()) == http.StatusUnauthorized {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"response.error.type", "error.type", "type"} {
|
||||
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String()))
|
||||
if errType == "authentication_error" || errType == "authentication_failed" || errType == "unauthorized_error" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"response.error.code", "error.code", "code"} {
|
||||
switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, path).String())) {
|
||||
case "invalid_api_key", "api_key_disabled", "unauthorized", "authentication_error",
|
||||
"invalid_token", "access_token_invalid", "token_revoked", "token_invalidated",
|
||||
"invalid_credentials", "credential_invalid":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func openAIStream403AccountFailure(payload []byte, message string) bool {
|
||||
return isOpenAIUpstreamAccessStateError(message, payload) || openAIStreamCredentialAuthFailure(payload)
|
||||
}
|
||||
|
||||
func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string) []byte {
|
||||
if len(payload) == 0 || !gjson.ValidBytes(payload) {
|
||||
return payload
|
||||
@@ -1299,13 +1428,23 @@ func applyOpenAIStreamFailedErrorPassthroughRule(
|
||||
}
|
||||
|
||||
func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool {
|
||||
if hit, _, _ := detectOpenAICyberPolicy(payload); hit {
|
||||
return false
|
||||
}
|
||||
if isOpenAIContextWindowError(message, payload) {
|
||||
return false
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError(message, payload) {
|
||||
return true
|
||||
}
|
||||
semanticStatus := openAIStreamFailureStatus(payload, message)
|
||||
if semanticStatus == http.StatusForbidden {
|
||||
return openAIStream403AccountFailure(payload, message)
|
||||
}
|
||||
// A response.failed event is transported over HTTP 200. Prefer its semantic
|
||||
// rate-limit status over a generic/invalid_request error type so it can enter
|
||||
// the same 429 retry policy as a regular upstream HTTP response.
|
||||
if openAIStreamFailureStatus(payload, message) == http.StatusTooManyRequests {
|
||||
if semanticStatus == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) {
|
||||
@@ -1341,15 +1480,49 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool
|
||||
}
|
||||
|
||||
func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool {
|
||||
if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "error" {
|
||||
if hit, _, _ := detectOpenAICyberPolicy(payload); hit {
|
||||
return false
|
||||
}
|
||||
if isOpenAIContextWindowError(message, payload) {
|
||||
return false
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError(message, payload) {
|
||||
return true
|
||||
}
|
||||
switch openAIStreamFailedEventSemanticStatus(payload, message) {
|
||||
case http.StatusForbidden:
|
||||
return openAIStream403AccountFailure(payload, message)
|
||||
case http.StatusUnauthorized, http.StatusTooManyRequests, 529:
|
||||
return true
|
||||
}
|
||||
return isOpenAITransientProcessingError(http.StatusBadRequest, message, payload)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleOpenAIStreamTerminalAccountSideEffects(
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
payload []byte,
|
||||
message string,
|
||||
headers http.Header,
|
||||
) (int, bool) {
|
||||
statusCode := openAIStreamFailureStatus(payload, message)
|
||||
switch statusCode {
|
||||
case http.StatusForbidden:
|
||||
if !openAIStream403AccountFailure(payload, message) {
|
||||
return statusCode, false
|
||||
}
|
||||
fallthrough
|
||||
case http.StatusUnauthorized, http.StatusTooManyRequests, 529:
|
||||
var ctx context.Context = context.Background()
|
||||
if c != nil && c.Request != nil {
|
||||
ctx = c.Request.Context()
|
||||
}
|
||||
return statusCode, s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, headers, payload)
|
||||
default:
|
||||
return statusCode, false
|
||||
}
|
||||
}
|
||||
|
||||
func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
@@ -1425,14 +1598,13 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
|
||||
if message == "" {
|
||||
message = "OpenAI stream disconnected before completion"
|
||||
}
|
||||
statusCode := openAIStreamFailureStatus(payload, message)
|
||||
var headers http.Header
|
||||
if len(responseHeaders) > 0 && responseHeaders[0] != nil {
|
||||
headers = responseHeaders[0].Clone()
|
||||
}
|
||||
// 流内 failed 事件承载于 HTTP 200,响应头是正常配额快照而非限流信号,
|
||||
// 不写账号级限流/封禁状态;重试与切号由 failover 引擎按
|
||||
// StatusCode/RetryableOnSameAccount 决定。
|
||||
statusCode, shouldDisable := s.handleOpenAIStreamTerminalAccountSideEffects(c, account, payload, message, headers)
|
||||
// 流内 failed 事件承载于 HTTP 200;使用事件的语义状态更新账号健康,
|
||||
// 再由 failover 引擎按 StatusCode/RetryableOnSameAccount 决定恢复策略。
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message)
|
||||
errType := "upstream_error"
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
@@ -1444,13 +1616,15 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
return &UpstreamFailoverError{
|
||||
StatusCode: statusCode,
|
||||
ResponseBody: body,
|
||||
ResponseHeaders: headers,
|
||||
RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message),
|
||||
RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload),
|
||||
retryableOnSameAccount := openAIStreamFailedEventRetryableOnSameAccount(account, payload, message)
|
||||
failoverErr := s.newOpenAIAccountFailoverError(account, statusCode, headers, payload, message, shouldDisable, retryableOnSameAccount)
|
||||
if failoverErr.IsCredentialFailure() || failoverErr.RequestScopedTransient {
|
||||
return failoverErr
|
||||
}
|
||||
// Preserve the existing generic envelope for unclassified stream failures;
|
||||
// only typed access/capacity failures need the original payload downstream.
|
||||
failoverErr.ResponseBody = body
|
||||
return failoverErr
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
@@ -1491,15 +1665,25 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
sawDone := false
|
||||
sawTerminalEvent := false
|
||||
sawFailedEvent := false
|
||||
sawBareError := false
|
||||
sawResponseFailed := false
|
||||
terminalEventType := ""
|
||||
semanticOutputSeen := false
|
||||
capacityFailoverSuppressedLogged := false
|
||||
failedMessage := ""
|
||||
clientOutputStarted := false
|
||||
codexFailureTerminal := account != nil && account.Platform == PlatformOpenAI
|
||||
failureDelivered := false
|
||||
suppressCurrentEvent := false
|
||||
responseFailedPending := false
|
||||
var bareErrorPayload []byte
|
||||
bareErrorAccountSideEffectsPending := false
|
||||
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
|
||||
// pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。
|
||||
pendingLines := make([]string, 0, 8)
|
||||
// flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。
|
||||
flushPending := false
|
||||
pendingSSEEventType := ""
|
||||
flushPendingOutput := func() {
|
||||
if clientDisconnected || !flushPending {
|
||||
return
|
||||
@@ -1519,6 +1703,26 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
pendingLines = pendingLines[:0]
|
||||
return true
|
||||
}
|
||||
ensureResponseFailedTerminal := func() {
|
||||
if !sawBareError || sawResponseFailed || failureDelivered {
|
||||
return
|
||||
}
|
||||
if bareErrorAccountSideEffectsPending {
|
||||
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header)
|
||||
bareErrorAccountSideEffectsPending = false
|
||||
}
|
||||
if clientDisconnected || !writePendingLines() {
|
||||
return
|
||||
}
|
||||
if _, err := fmt.Fprint(w, buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
|
||||
clientDisconnected = true
|
||||
return
|
||||
}
|
||||
clientOutputStarted = true
|
||||
failureDelivered = true
|
||||
flushPending = true
|
||||
flushPendingOutput()
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
maxLineSize := defaultMaxLineSize
|
||||
@@ -1543,12 +1747,17 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
|
||||
for documentScanner.Scan() {
|
||||
line := documentScanner.Text()
|
||||
if eventType, ok := extractOpenAISSEEventLine(line); ok {
|
||||
pendingSSEEventType = eventType
|
||||
eventType = strings.TrimSpace(eventType)
|
||||
suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed"))
|
||||
}
|
||||
lineStartsClientOutput := false
|
||||
forceFlushFailedEvent := false
|
||||
if data, ok := extractOpenAISSEDataLine(line); ok {
|
||||
dataBytes := []byte(data)
|
||||
trimmedData := strings.TrimSpace(data)
|
||||
rawEventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
|
||||
rawEventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType)
|
||||
observer.ObserveOpenAI(dataBytes, rawEventType)
|
||||
if needModelReplace && strings.Contains(data, mappedModel) {
|
||||
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
|
||||
@@ -1572,14 +1781,17 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
if restoreErr != nil {
|
||||
return resultWithUsage(), fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
|
||||
}
|
||||
restoredData = restoreCodexToolNamesFromContext(c, restoredData)
|
||||
restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, rawEventType)
|
||||
if !bytes.Equal(restoredData, dataBytes) {
|
||||
dataBytes = restoredData
|
||||
trimmedData = strings.TrimSpace(string(restoredData))
|
||||
line = "data: " + string(restoredData)
|
||||
}
|
||||
}
|
||||
eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String())
|
||||
eventType := effectiveOpenAISSEEventType(dataBytes, rawEventType)
|
||||
if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" {
|
||||
suppressCurrentEvent = true
|
||||
}
|
||||
if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI &&
|
||||
(eventType == "error" || eventType == "response.failed") &&
|
||||
openAIStreamClientOutputStarted(c, clientOutputStarted) &&
|
||||
@@ -1587,32 +1799,26 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType)
|
||||
capacityFailoverSuppressedLogged = true
|
||||
}
|
||||
if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
||||
errorMessage := extractOpenAISSEErrorMessage(dataBytes)
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched {
|
||||
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, errorMessage)
|
||||
MarkResponseCommitted(c)
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
return resultWithUsage(), fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg)
|
||||
cyberHit := false
|
||||
if eventType == "response.failed" || eventType == "error" {
|
||||
if codexFailureTerminal && eventType == "error" {
|
||||
sawBareError = true
|
||||
bareErrorPayload = append(bareErrorPayload[:0], dataBytes...)
|
||||
suppressCurrentEvent = true
|
||||
} else if codexFailureTerminal && eventType == "response.failed" {
|
||||
sawResponseFailed = true
|
||||
}
|
||||
if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) {
|
||||
return resultWithUsage(),
|
||||
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, errorMessage, resp.Header)
|
||||
}
|
||||
}
|
||||
if eventType == "response.failed" {
|
||||
responseFailedPending = !codexFailureTerminal || eventType == "response.failed"
|
||||
failedMessage = extractOpenAISSEErrorMessage(dataBytes)
|
||||
if failedMessage == "" {
|
||||
failedMessage = "Upstream response failed"
|
||||
}
|
||||
// response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析
|
||||
// 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费
|
||||
// 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。
|
||||
s.parseSSEUsageBytes(dataBytes, usage)
|
||||
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
|
||||
if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit {
|
||||
cyberHit = true
|
||||
MarkOpsCyberPolicy(c, CyberPolicyMark{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
@@ -1622,34 +1828,64 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
UpstreamOutTok: usage.OutputTokens,
|
||||
})
|
||||
}
|
||||
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
|
||||
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
|
||||
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
|
||||
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
|
||||
MarkResponseCommitted(c)
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
|
||||
outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted)
|
||||
if !outputStarted && !cyberHit {
|
||||
if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil {
|
||||
return resultWithUsage(), compactErr
|
||||
}
|
||||
if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) {
|
||||
}
|
||||
if outputStarted && !cyberHit {
|
||||
if codexFailureTerminal && eventType == "error" {
|
||||
// Wait for the authoritative response.failed before mutating
|
||||
// account health; EOF synthesis applies the pending effect.
|
||||
bareErrorAccountSideEffectsPending = true
|
||||
} else {
|
||||
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header)
|
||||
bareErrorAccountSideEffectsPending = false
|
||||
}
|
||||
}
|
||||
if !outputStarted {
|
||||
shouldFailover := false
|
||||
if !cyberHit {
|
||||
if eventType == "error" {
|
||||
shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage)
|
||||
} else {
|
||||
shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage)
|
||||
}
|
||||
}
|
||||
if shouldFailover {
|
||||
return resultWithUsage(),
|
||||
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage, resp.Header)
|
||||
}
|
||||
if !cyberHit && !sawBareError {
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
|
||||
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
|
||||
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
|
||||
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
|
||||
MarkResponseCommitted(c)
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
forceFlushFailedEvent = true
|
||||
sawFailedEvent = true
|
||||
}
|
||||
if trimmedData == "[DONE]" {
|
||||
sawDone = true
|
||||
terminalEventType = "[DONE]"
|
||||
}
|
||||
if openAIStreamEventIsTerminal(trimmedData) {
|
||||
if openAIStreamEventIsTerminalWithType(trimmedData, eventType) {
|
||||
sawTerminalEvent = true
|
||||
if trimmedData != "[DONE]" {
|
||||
terminalEventType = eventType
|
||||
}
|
||||
}
|
||||
if responseID == "" {
|
||||
responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes)
|
||||
@@ -1681,10 +1917,18 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
s.parseSSEUsageBytes(dataBytes, usage)
|
||||
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
|
||||
}
|
||||
if line == "" {
|
||||
pendingSSEEventType = ""
|
||||
if suppressCurrentEvent {
|
||||
suppressCurrentEvent = false
|
||||
responseFailedPending = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !clientDisconnected {
|
||||
if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
|
||||
if !clientOutputStarted && !lineStartsClientOutput {
|
||||
pendingLines = append(pendingLines, line)
|
||||
continue
|
||||
@@ -1705,7 +1949,12 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
}
|
||||
}
|
||||
}
|
||||
if line == "" && responseFailedPending {
|
||||
responseFailedPending = false
|
||||
failureDelivered = true
|
||||
}
|
||||
}
|
||||
ensureResponseFailedTerminal()
|
||||
if err := documentScanner.Err(); err != nil {
|
||||
if (sawDone || sawTerminalEvent) && !sawFailedEvent {
|
||||
s.clearOpenAIProxyStreamDisconnect(account)
|
||||
@@ -1760,6 +2009,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
if (sawDone || sawTerminalEvent) && !sawFailedEvent {
|
||||
s.clearOpenAIProxyStreamDisconnect(account)
|
||||
}
|
||||
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected)
|
||||
|
||||
return resultWithUsage(), nil
|
||||
}
|
||||
@@ -1768,6 +2018,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
|
||||
ctx context.Context,
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
originalModel string,
|
||||
mappedModel string,
|
||||
) (*openaiNonStreamingResultPassthrough, error) {
|
||||
@@ -1790,7 +2041,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
|
||||
// stream=false was requested. Without this conversion the client would
|
||||
// receive raw SSE text or a terminal event with empty output.
|
||||
if isEventStreamResponse(resp.Header) {
|
||||
return s.handlePassthroughSSEToJSON(resp, c, body, originalModel, mappedModel)
|
||||
return s.handlePassthroughSSEToJSON(resp, c, account, body, originalModel, mappedModel)
|
||||
}
|
||||
|
||||
usage := &OpenAIUsage{}
|
||||
@@ -1805,6 +2056,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
|
||||
// 兜底:尝试从 SSE 文本中解析 usage
|
||||
usage = s.parseSSEUsageFromBody(string(body))
|
||||
}
|
||||
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false)
|
||||
|
||||
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
|
||||
@@ -1842,11 +2094,22 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
|
||||
// response for the passthrough path. It mirrors handleSSEToJSON while
|
||||
// preserving passthrough payloads, except compact-only model remapping may
|
||||
// rewrite model fields back to the original requested model.
|
||||
func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) {
|
||||
func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) {
|
||||
bodyText := string(body)
|
||||
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
|
||||
if terminalOK && (terminalType == "response.failed" || terminalType == "error") {
|
||||
msg := extractOpenAISSEErrorMessage(terminalPayload)
|
||||
if msg == "" {
|
||||
msg = "Upstream compact response failed"
|
||||
}
|
||||
if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil {
|
||||
return nil, compactErr
|
||||
}
|
||||
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
|
||||
}
|
||||
finalResponse, ok := extractCodexFinalResponse(bodyText)
|
||||
|
||||
usage := &OpenAIUsage{}
|
||||
usage := s.parseSSEUsageFromBody(bodyText)
|
||||
if ok {
|
||||
if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
|
||||
*usage = parsedUsage
|
||||
@@ -1874,15 +2137,6 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
|
||||
restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
|
||||
body = restoredBody
|
||||
} else {
|
||||
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
|
||||
if terminalOK && terminalType == "response.failed" {
|
||||
msg := extractOpenAISSEErrorMessage(terminalPayload)
|
||||
if msg == "" {
|
||||
msg = "Upstream compact response failed"
|
||||
}
|
||||
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
|
||||
}
|
||||
usage = s.parseSSEUsageFromBody(bodyText)
|
||||
if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
|
||||
bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
|
||||
}
|
||||
@@ -1890,6 +2144,7 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
|
||||
}
|
||||
|
||||
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false)
|
||||
|
||||
contentType := "application/json; charset=utf-8"
|
||||
if !ok {
|
||||
|
||||
@@ -191,6 +191,48 @@ func TestOpenAIStreamingPassthroughNonRetryableFailedBeforeOutputFlushesAtBounda
|
||||
require.Zero(t, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughBareErrorTerminatesBeforeDone(t *testing.T) {
|
||||
errorEvent := "event: error\n" +
|
||||
`data: {"type":"error","error":{"code":"content_policy","message":"request blocked by policy"},"usage":{"input_tokens":6,"output_tokens":0,"total_tokens":6}}` + "\n\n"
|
||||
upstream := errorEvent + "data: [DONE]\n\n"
|
||||
|
||||
result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
|
||||
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.False(t, errors.As(err, &failoverErr))
|
||||
require.NotNil(t, result)
|
||||
body := recorder.Body.String()
|
||||
require.NotContains(t, body, `"type":"error"`)
|
||||
require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
|
||||
require.Contains(t, body, `"status":"failed"`)
|
||||
require.NotContains(t, body, "[DONE]")
|
||||
require.Equal(t, []int{len(body)}, writer.flushBodyLengths)
|
||||
require.Equal(t, 6, result.usage.InputTokens)
|
||||
require.Zero(t, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughBareErrorDrainsAuthoritativeFailedUsage(t *testing.T) {
|
||||
errorEvent := "event: error\n" +
|
||||
`data: {"type":"error","error":{"code":"content_policy","message":"request blocked by policy"}}` + "\n\n"
|
||||
failedEvent := "event: response.failed\n" +
|
||||
`data: {"type":"response.failed","response":{"id":"resp_failed","usage":{"input_tokens":9,"output_tokens":2},"error":{"code":"content_policy","message":"request blocked by policy"}}}` + "\n\n"
|
||||
upstream := errorEvent + failedEvent + "data: [DONE]\n\n"
|
||||
|
||||
result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
body := recorder.Body.String()
|
||||
require.NotContains(t, body, `"type":"error"`)
|
||||
require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
|
||||
require.Contains(t, body, `"id":"resp_failed"`)
|
||||
require.NotContains(t, body, "[DONE]")
|
||||
require.Equal(t, []int{len(body)}, writer.flushBodyLengths)
|
||||
require.Equal(t, 9, result.usage.InputTokens)
|
||||
require.Equal(t, 2, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughFailedAfterOutputFlushesAtBoundaryAndKeepsUsage(t *testing.T) {
|
||||
firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n"
|
||||
failedEvent := "event: response.failed\n" +
|
||||
|
||||
@@ -213,9 +213,7 @@ func SanitizeOpenAICrossModeFailoverReasoning(body []byte) (sanitized []byte, ch
|
||||
return body, false, nil
|
||||
}
|
||||
var decoded map[string]any
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&decoded); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(body, &decoded); err != nil {
|
||||
return body, false, fmt.Errorf("decode cross-mode failover body: %w", err)
|
||||
}
|
||||
if !dropOpenAIEncryptedReasoningInputItems(decoded) {
|
||||
@@ -326,7 +324,6 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
|
||||
if len(body) == 0 {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
normalized := []byte(`{}`)
|
||||
// Keep the current Codex /compact schema while still dropping request-scoped
|
||||
// fields such as prompt_cache_key, store, and stream.
|
||||
@@ -351,6 +348,11 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
|
||||
}
|
||||
normalized = next
|
||||
}
|
||||
if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil {
|
||||
return body, false, err
|
||||
} else if removed {
|
||||
normalized = next
|
||||
}
|
||||
|
||||
if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) {
|
||||
return body, false, nil
|
||||
@@ -358,8 +360,91 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, error) {
|
||||
parallel := gjson.GetBytes(body, "parallel_tool_calls")
|
||||
if !parallel.Exists() {
|
||||
return body, false, nil
|
||||
}
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if tools.IsArray() && len(tools.Array()) > 0 {
|
||||
return body, false, nil
|
||||
}
|
||||
normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls")
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("normalize parallel_tool_calls without tools: %w", err)
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
|
||||
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
|
||||
return body, false, nil
|
||||
}
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.IsArray() {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
|
||||
return body, false, fmt.Errorf("normalize API-key store=false reasoning replay: %w", err)
|
||||
}
|
||||
items, ok := reqBody["input"].([]any)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
filtered := make([]any, 0, len(items))
|
||||
changed := false
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
filtered = append(filtered, rawItem)
|
||||
continue
|
||||
}
|
||||
typ := strings.TrimSpace(firstNonEmptyString(item["type"]))
|
||||
id := strings.TrimSpace(firstNonEmptyString(item["id"]))
|
||||
switch typ {
|
||||
case "reasoning":
|
||||
encryptedContent, hasEncryptedContent := item["encrypted_content"].(string)
|
||||
if !hasEncryptedContent || strings.TrimSpace(encryptedContent) == "" {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(id, "rs_") {
|
||||
delete(item, "id")
|
||||
changed = true
|
||||
}
|
||||
if summary, ok := item["summary"]; !ok || summary == nil {
|
||||
item["summary"] = []any{}
|
||||
changed = true
|
||||
}
|
||||
case "item_reference":
|
||||
if strings.HasPrefix(id, "rs_") {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if shouldStripOpenAIResponsesNonPairCallID(typ) {
|
||||
if _, hasCallID := item["call_id"]; hasCallID {
|
||||
delete(item, "call_id")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
if !changed {
|
||||
return body, false, nil
|
||||
}
|
||||
reqBody["input"] = filtered
|
||||
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("serialize API-key store=false reasoning replay: %w", err)
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeOpenAICodexCompactReasoningEffortForAccount(c *gin.Context, account *Account, body []byte) ([]byte, bool, error) {
|
||||
if account == nil || !account.IsOpenAIOAuth() || !isOpenAIResponsesCompactPath(c) {
|
||||
if account == nil || !account.IsOpenAIOAuthLike() || !isOpenAIResponsesCompactPath(c) {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
@@ -825,9 +910,7 @@ func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error)
|
||||
return body, false, nil
|
||||
}
|
||||
var reqBody map[string]any
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&reqBody); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
|
||||
return body, false, fmt.Errorf("normalize responses schema body: %w", err)
|
||||
}
|
||||
if !normalizeOpenAIResponseFormatSchemas(reqBody) {
|
||||
@@ -841,8 +924,32 @@ func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error)
|
||||
}
|
||||
|
||||
func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account) ([]byte, bool, error) {
|
||||
if account == nil || !account.IsOpenAI() {
|
||||
return body, false, nil
|
||||
}
|
||||
normalized := body
|
||||
changed := false
|
||||
if account.IsOpenAIOAuthLike() {
|
||||
var err error
|
||||
normalized, changed, err = normalizeOpenAIResponsesLegacyIngress(body)
|
||||
if err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
}
|
||||
if account.IsOpenAIApiKey() {
|
||||
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil {
|
||||
return body, false, err
|
||||
} else if normalizedParallel {
|
||||
normalized = next
|
||||
changed = true
|
||||
}
|
||||
if next, normalizedReasoning, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(normalized, false); err != nil {
|
||||
return body, false, err
|
||||
} else if normalizedReasoning {
|
||||
normalized = next
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if sanitized, idsChanged, err := sanitizeOpenAIResponsesInputItemIDs(normalized); err != nil {
|
||||
return body, false, fmt.Errorf("sanitize websocket Responses input item IDs: %w", err)
|
||||
} else if idsChanged {
|
||||
@@ -857,7 +964,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if account != nil && account.IsOpenAIOAuth() {
|
||||
if account != nil && account.IsOpenAIOAuthLike() {
|
||||
oauthBody, oauthChanged, err := normalizeOpenAIOAuthResponsesCompatibilityBody(normalized)
|
||||
if err != nil {
|
||||
return body, false, err
|
||||
@@ -876,6 +983,35 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
needsOrphanCleanup := account != nil && account.IsOpenAIOAuthLike() &&
|
||||
gjson.GetBytes(normalized, "input").IsArray()
|
||||
if needsOrphanCleanup || openAIResponsesInputMayNeedTruncation(normalized) {
|
||||
var reqBody map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(normalized, &reqBody); err != nil {
|
||||
return body, false, fmt.Errorf("normalize websocket Responses body: %w", err)
|
||||
}
|
||||
mapChanged := false
|
||||
if needsOrphanCleanup {
|
||||
if input, ok := reqBody["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs(
|
||||
reqBody,
|
||||
input,
|
||||
strings.TrimSpace(firstNonEmptyString(reqBody["previous_response_id"])) != "",
|
||||
) {
|
||||
mapChanged = true
|
||||
}
|
||||
}
|
||||
if truncateOpenAIResponsesInputText(reqBody) {
|
||||
mapChanged = true
|
||||
}
|
||||
if mapChanged {
|
||||
next, err := marshalOpenAIUpstreamJSON(reqBody)
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("serialize normalized websocket Responses body: %w", err)
|
||||
}
|
||||
normalized = next
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if schemaBody, schemaChanged, err := normalizeOpenAIResponseFormatSchemasBody(normalized); err != nil {
|
||||
return body, false, err
|
||||
} else if schemaChanged {
|
||||
@@ -896,16 +1032,26 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if toolBody, toolChanged, err := sanitizeOpenAIResponsesToolParameterTypes(normalized); err != nil {
|
||||
return body, false, fmt.Errorf("normalize websocket tool parameter types: %w", err)
|
||||
} else if toolChanged {
|
||||
normalized = toolBody
|
||||
changed = true
|
||||
if account != nil && shouldSanitizeOpenAIResponsesToolSchemas(account.Platform) {
|
||||
if toolBody, toolChanged, err := sanitizeOpenAIResponsesToolParameterTypes(normalized); err != nil {
|
||||
return body, false, fmt.Errorf("normalize websocket tool parameter types: %w", err)
|
||||
} else if toolChanged {
|
||||
normalized = toolBody
|
||||
changed = true
|
||||
}
|
||||
if patternBody, patternChanged, err := sanitizeOpenAIResponsesToolSchemaPatterns(normalized); err != nil {
|
||||
return body, false, fmt.Errorf("normalize websocket tool schema patterns: %w", err)
|
||||
} else if patternChanged {
|
||||
normalized = patternBody
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if patternBody, patternChanged, err := sanitizeOpenAIResponsesToolSchemaPatterns(normalized); err != nil {
|
||||
return body, false, fmt.Errorf("normalize websocket tool schema patterns: %w", err)
|
||||
} else if patternChanged {
|
||||
normalized = patternBody
|
||||
// Keep this last: earlier compatibility passes may filter or rebuild input.
|
||||
// Remote compaction v2 requires one trigger as the final input item.
|
||||
if triggerBody, triggerChanged, err := NormalizeCompactionTriggerInputOrder(normalized); err != nil {
|
||||
return body, false, fmt.Errorf("normalize websocket compaction trigger order: %w", err)
|
||||
} else if triggerChanged {
|
||||
normalized = triggerBody
|
||||
changed = true
|
||||
}
|
||||
return normalized, changed, nil
|
||||
@@ -1657,7 +1803,7 @@ func isEmptyBase64DataURI(raw string) bool {
|
||||
|
||||
func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) {
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(body, &reqBody); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
|
||||
return nil, fmt.Errorf("parse request: %w", err)
|
||||
}
|
||||
return reqBody, nil
|
||||
|
||||
@@ -199,3 +199,74 @@ func TestTrimOpenAIEncryptedReasoningItems_ContentNullDropsBareSkeleton(t *testi
|
||||
_, hasInput := reqBody["input"]
|
||||
assert.False(t, hasInput, "bare reasoning skeleton should be dropped, emptying input")
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplay(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","store":false,"input":[` +
|
||||
`{"type":"reasoning","id":"rs_encrypted","call_id":"remove","encrypted_content":"cipher","summary":null,"opaque":9007199254740993},` +
|
||||
`{"type":"reasoning","id":"rs_server_only","summary":[{"type":"summary_text","text":"drop"}]},` +
|
||||
`{"type":"item_reference","id":"rs_server_only"},` +
|
||||
`{"type":"item_reference","id":"msg_keep"},` +
|
||||
`{"type":"message","id":"msg_keep","role":"user","content":"continue"}` +
|
||||
`]}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, int64(3), gjson.GetBytes(normalized, "input.#").Int())
|
||||
require.Equal(t, "reasoning", gjson.GetBytes(normalized, "input.0.type").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "input.0.call_id").Exists())
|
||||
require.Equal(t, "cipher", gjson.GetBytes(normalized, "input.0.encrypted_content").String())
|
||||
require.True(t, gjson.GetBytes(normalized, "input.0.summary").IsArray())
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.0.opaque").Raw)
|
||||
require.Equal(t, "msg_keep", gjson.GetBytes(normalized, "input.1.id").String())
|
||||
require.Equal(t, "message", gjson.GetBytes(normalized, "input.2.type").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRequiresExplicitStoreFalse(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"input":[{"type":"reasoning","id":"rs_keep"}]}`,
|
||||
`{"store":true,"input":[{"type":"reasoning","id":"rs_keep"}]}`,
|
||||
} {
|
||||
normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay([]byte(body), false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, string(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayKnownCompactMode(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"reasoning","id":"rs_drop","summary":[]},{"type":"message","content":"continue"}]}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, true)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int())
|
||||
require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRejectsEmptyEncryptedContent(t *testing.T) {
|
||||
for _, encrypted := range []string{"null", `""`, `" "`, "123"} {
|
||||
body := []byte(`{"store":false,"input":[{"type":"reasoning","id":"rs_drop","encrypted_content":` + encrypted + `},{"type":"message","content":"continue"}]}`)
|
||||
normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int())
|
||||
require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
|
||||
withTools := []byte(`{"tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`)
|
||||
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(withTools), string(normalized))
|
||||
|
||||
withoutTools := []byte(`{"input":"hi","parallel_tool_calls":true}`)
|
||||
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -84,6 +87,101 @@ func TestForwardAsChatCompletions_ResponseFailed_PassthroughRule(t *testing.T) {
|
||||
require.Contains(t, errMsg, "context window")
|
||||
}
|
||||
|
||||
func TestResponsesStreamAccessStateFailoverPrecedesPassthroughRule(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stream := "event: response.failed\n" +
|
||||
`data: {"type":"response.failed","response":{"status":"failed","error":{"code":"account_disabled","message":"Your account is disabled"}}}` + "\n\n"
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error
|
||||
}{
|
||||
{
|
||||
name: "native",
|
||||
run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "passthrough",
|
||||
run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
|
||||
_, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
bindPassthroughRule(c, PlatformOpenAI, []string{"account is disabled"}, http.StatusTeapot)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(stream)),
|
||||
}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}}
|
||||
err := tt.run(svc, c, resp, &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth})
|
||||
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.True(t, failoverErr.IsCredentialFailure())
|
||||
require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
|
||||
require.False(t, c.Writer.Written(), "passthrough rule must not commit a response before account failover")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesStreamCyberPolicyPrecedesPassthroughRule(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stream := "event: error\n" +
|
||||
`data: {"type":"error","error":{"code":"cyber_policy","message":"blocked by cyber policy"}}` + "\n\n"
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error
|
||||
}{
|
||||
{
|
||||
name: "native",
|
||||
run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "passthrough",
|
||||
run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
|
||||
_, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5", "gpt-5")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
bindPassthroughRule(c, PlatformOpenAI, []string{"cyber policy"}, http.StatusTeapot)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(stream)),
|
||||
}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}}
|
||||
err := tt.run(svc, c, resp, &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth})
|
||||
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.False(t, errors.As(err, &failoverErr))
|
||||
require.NotNil(t, GetOpsCyberPolicy(c))
|
||||
require.NotEqual(t, http.StatusTeapot, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "cyber_policy")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_ResponseFailed_PassthroughRule(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -387,20 +387,41 @@ func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T)
|
||||
require.Contains(t, flushes[1], "response.failed")
|
||||
})
|
||||
|
||||
t.Run("retryable error event buffered until terminal", func(t *testing.T) {
|
||||
// 可重试类 error 帧不算客户端输出:保持在 attempt 缓冲中不单独 flush,
|
||||
// 为随后可能到达的 response.failed 保留 pre-output failover 能力,
|
||||
// 与终止帧一起出站。
|
||||
t.Run("bare error synthesizes failed before done", func(t *testing.T) {
|
||||
body := "data: {\"type\":\"error\",\"error\":{\"message\":\"failed\"}}\n\n" +
|
||||
"data: [DONE]\n\n"
|
||||
recorder := newOpenAIResponseFlushRecorder()
|
||||
|
||||
result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
gotBody, flushes := recorder.snapshot()
|
||||
require.Equal(t, body, gotBody)
|
||||
require.NotContains(t, gotBody, `"type":"error"`)
|
||||
require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
|
||||
require.Contains(t, gotBody, `"status":"failed"`)
|
||||
require.NotContains(t, gotBody, "[DONE]")
|
||||
require.Len(t, flushes, 1)
|
||||
})
|
||||
|
||||
t.Run("bare error forwards following failed and drains terminal usage", func(t *testing.T) {
|
||||
errorEvent := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n"
|
||||
body := errorEvent +
|
||||
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_failed\",\"usage\":{\"input_tokens\":9,\"output_tokens\":2},\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}}\n\n" +
|
||||
"data: [DONE]\n\n"
|
||||
recorder := newOpenAIResponseFlushRecorder()
|
||||
|
||||
result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 9, result.usage.InputTokens)
|
||||
require.Equal(t, 2, result.usage.OutputTokens)
|
||||
gotBody, flushes := recorder.snapshot()
|
||||
require.NotContains(t, gotBody, `"type":"error"`)
|
||||
require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
|
||||
require.Contains(t, gotBody, `"id":"resp_failed"`)
|
||||
require.NotContains(t, gotBody, "[DONE]")
|
||||
require.Len(t, flushes, 1)
|
||||
})
|
||||
|
||||
@@ -411,14 +432,56 @@ func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T)
|
||||
|
||||
result, err := runOpenAIResponseFlushTest(recorder, io.NopCloser(strings.NewReader(body)), config.GatewayConfig{})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
gotBody, flushes := recorder.snapshot()
|
||||
require.Equal(t, body, gotBody)
|
||||
require.Len(t, flushes, 2)
|
||||
require.NotContains(t, gotBody, `"type":"error"`)
|
||||
require.Equal(t, 1, strings.Count(gotBody, `"type":"response.failed"`))
|
||||
require.Len(t, flushes, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIResponseFlush_BareErrorTimeoutSynthesizesFailed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.GatewayConfig
|
||||
}{
|
||||
{
|
||||
name: "stream interval timeout",
|
||||
cfg: config.GatewayConfig{StreamDataIntervalTimeout: 1},
|
||||
},
|
||||
{
|
||||
name: "first output timeout",
|
||||
cfg: config.GatewayConfig{OpenAIFirstOutputTimeoutSeconds: 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reader, writer := io.Pipe()
|
||||
defer writer.Close()
|
||||
recorder := newOpenAIResponseFlushRecorder()
|
||||
resultCh, errCh := runOpenAIResponseFlushTestAsync(recorder, reader, tt.cfg)
|
||||
|
||||
_, writeErr := io.WriteString(writer, "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_request\",\"message\":\"bad request\"}}\n\n")
|
||||
require.NoError(t, writeErr)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "upstream response failed")
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for bare error terminal synthesis")
|
||||
}
|
||||
require.NotNil(t, <-resultCh)
|
||||
body, flushes := recorder.snapshot()
|
||||
require.NotContains(t, body, `"type":"error"`)
|
||||
require.Equal(t, 1, strings.Count(body, `"type":"response.failed"`))
|
||||
require.Len(t, flushes, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponseFlush_ReusedTypeKeepsSSEBytesAndTerminalSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -17,8 +17,10 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// openaiStreamingResult streaming response result
|
||||
@@ -241,12 +243,22 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage
|
||||
sawTerminalEvent := false
|
||||
sawFailedEvent := false
|
||||
sawBareError := false
|
||||
sawResponseFailed := false
|
||||
terminalEventType := ""
|
||||
responsesSemanticOutputSeen := false
|
||||
capacityFailoverSuppressedLogged := false
|
||||
failedMessage := ""
|
||||
clientOutputStarted := false
|
||||
codexFailureTerminal := account != nil && account.Platform == PlatformOpenAI
|
||||
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
|
||||
var streamEarlyErr error
|
||||
terminalFailurePending := false
|
||||
failureDelivered := false
|
||||
suppressCurrentEvent := false
|
||||
var bareErrorPayload []byte
|
||||
bareErrorAccountSideEffectsPending := false
|
||||
pendingSSEEventType := ""
|
||||
eventInProgress := false
|
||||
eventStartsClientOutput := false
|
||||
eventStartsVisibleOutput := false
|
||||
@@ -300,7 +312,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
eventShouldFlush = false
|
||||
}
|
||||
sendErrorEvent := func(reason string) {
|
||||
if errorEventSent || clientDisconnected {
|
||||
if errorEventSent || clientDisconnected || failureDelivered {
|
||||
return
|
||||
}
|
||||
errorEventSent = true
|
||||
@@ -356,6 +368,18 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
// EOF dispatches the final SSE event even without a trailing blank line.
|
||||
completeGuardedEvent(true)
|
||||
}
|
||||
if codexFailureTerminal && sawBareError && !sawResponseFailed && bareErrorAccountSideEffectsPending {
|
||||
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, bareErrorPayload, failedMessage, resp.Header)
|
||||
bareErrorAccountSideEffectsPending = false
|
||||
}
|
||||
if codexFailureTerminal && sawBareError && !sawResponseFailed && !clientDisconnected {
|
||||
applyAttemptResponseHeaders()
|
||||
if _, err := writePendingString(buildOpenAIResponseFailedSSE(responseID, originalModel, bareErrorPayload, failedMessage)); err != nil {
|
||||
handlePendingWriteError(err)
|
||||
} else {
|
||||
failureDelivered = true
|
||||
}
|
||||
}
|
||||
if sawTerminalEvent && !sawFailedEvent {
|
||||
s.clearOpenAIProxyStreamDisconnect(account)
|
||||
}
|
||||
@@ -379,6 +403,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
if sawFailedEvent {
|
||||
return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
|
||||
}
|
||||
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, terminalEventType, clientDisconnected)
|
||||
return resultWithUsage(), nil
|
||||
}
|
||||
handleScanErr := func(scanErr error) (*openaiStreamingResult, error, bool) {
|
||||
@@ -443,15 +468,26 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
if streamEarlyErr != nil {
|
||||
return
|
||||
}
|
||||
if eventType, ok := extractOpenAISSEEventLine(line); ok {
|
||||
pendingSSEEventType = eventType
|
||||
eventType = strings.TrimSpace(eventType)
|
||||
suppressCurrentEvent = codexFailureTerminal && (eventType == "error" || (sawBareError && !sawResponseFailed && eventType != "response.failed"))
|
||||
}
|
||||
// Extract data from SSE line (supports both "data: " and "data:" formats)
|
||||
if data, ok := extractOpenAISSEDataLine(line); ok {
|
||||
dataBytes := []byte(data)
|
||||
eventTypeRaw := gjson.GetBytes(dataBytes, "type").String()
|
||||
eventType := strings.TrimSpace(eventTypeRaw)
|
||||
observer.ObserveOpenAI(dataBytes, eventTypeRaw)
|
||||
eventType := effectiveOpenAISSEEventType(dataBytes, pendingSSEEventType)
|
||||
if codexFailureTerminal && sawBareError && !sawResponseFailed && eventType != "response.failed" {
|
||||
suppressCurrentEvent = true
|
||||
}
|
||||
observer.ObserveOpenAI(dataBytes, eventType)
|
||||
// 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。
|
||||
if openAIStreamEventIsTerminalWithType(data, eventTypeRaw) {
|
||||
if openAIStreamEventIsTerminalWithType(data, eventType) {
|
||||
sawTerminalEvent = true
|
||||
terminalEventType = eventType
|
||||
if strings.TrimSpace(data) == "[DONE]" {
|
||||
terminalEventType = "[DONE]"
|
||||
}
|
||||
}
|
||||
if responseID == "" {
|
||||
responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes)
|
||||
@@ -464,33 +500,25 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
logOpenAICapacityFailoverSuppressed(ctx, account, "native_sse", upstreamRequestID, eventType)
|
||||
capacityFailoverSuppressedLogged = true
|
||||
}
|
||||
if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
||||
errorMessage := extractOpenAISSEErrorMessage(dataBytes)
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched {
|
||||
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, errorMessage)
|
||||
MarkResponseCommitted(c)
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
streamEarlyErr = fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg)
|
||||
return
|
||||
cyberHit := false
|
||||
if eventType == "response.failed" || eventType == "error" {
|
||||
if codexFailureTerminal && eventType == "error" {
|
||||
sawBareError = true
|
||||
bareErrorPayload = append(bareErrorPayload[:0], dataBytes...)
|
||||
suppressCurrentEvent = true
|
||||
} else if codexFailureTerminal && eventType == "response.failed" {
|
||||
sawResponseFailed = true
|
||||
}
|
||||
if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) {
|
||||
streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, errorMessage, resp.Header)
|
||||
return
|
||||
}
|
||||
}
|
||||
if eventType == "response.failed" {
|
||||
failedMessage = extractOpenAISSEErrorMessage(dataBytes)
|
||||
if failedMessage == "" {
|
||||
failedMessage = "Upstream response failed"
|
||||
}
|
||||
// response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析
|
||||
// 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费
|
||||
// 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。
|
||||
s.parseSSEUsageBytes(dataBytes, usage)
|
||||
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
|
||||
if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit {
|
||||
cyberHit = true
|
||||
MarkOpsCyberPolicy(c, CyberPolicyMark{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
@@ -500,31 +528,60 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
UpstreamOutTok: usage.OutputTokens,
|
||||
})
|
||||
}
|
||||
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
|
||||
outputStarted := openAIStreamClientOutputStarted(c, clientOutputStarted)
|
||||
if !outputStarted && !cyberHit {
|
||||
if compactErr := newOpenAICompactFallbackSignal(c, dataBytes, failedMessage); compactErr != nil {
|
||||
sawFailedEvent = true
|
||||
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
|
||||
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
|
||||
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage)
|
||||
MarkResponseCommitted(c)
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
streamEarlyErr = fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
|
||||
streamEarlyErr = compactErr
|
||||
return
|
||||
}
|
||||
if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) {
|
||||
}
|
||||
if outputStarted && !cyberHit {
|
||||
if codexFailureTerminal && eventType == "error" {
|
||||
// OpenAI commonly follows a bare error with response.failed.
|
||||
// Defer account health updates so the pair is applied once.
|
||||
bareErrorAccountSideEffectsPending = true
|
||||
} else {
|
||||
s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header)
|
||||
bareErrorAccountSideEffectsPending = false
|
||||
}
|
||||
}
|
||||
if !outputStarted {
|
||||
shouldFailover := false
|
||||
if !cyberHit {
|
||||
if eventType == "error" {
|
||||
shouldFailover = openAIStreamErrorEventShouldFailover(dataBytes, failedMessage)
|
||||
} else {
|
||||
shouldFailover = openAIStreamFailedEventShouldFailover(dataBytes, failedMessage)
|
||||
}
|
||||
}
|
||||
if shouldFailover {
|
||||
sawFailedEvent = true
|
||||
streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage, resp.Header)
|
||||
return
|
||||
}
|
||||
if !cyberHit && !sawBareError {
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
|
||||
sawFailedEvent = true
|
||||
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
|
||||
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
|
||||
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage)
|
||||
MarkResponseCommitted(c)
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
streamEarlyErr = fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
forceFlushFailedEvent = true
|
||||
sawFailedEvent = true
|
||||
terminalFailurePending = !codexFailureTerminal || eventType == "response.failed"
|
||||
}
|
||||
if normalizedData, normalized := normalizeCompletedImageGenerationStatus(dataBytes); normalized {
|
||||
dataBytes = normalizedData
|
||||
@@ -539,7 +596,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
dataBytes = correctedData
|
||||
data = string(correctedData)
|
||||
line = "data: " + data
|
||||
eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
|
||||
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
|
||||
}
|
||||
if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok {
|
||||
streamImageOutputs = append(streamImageOutputs, imageOutput)
|
||||
@@ -554,7 +611,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
dataBytes = normalizedData
|
||||
data = string(normalizedData)
|
||||
line = "data: " + data
|
||||
eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
|
||||
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
|
||||
}
|
||||
restoredData, restoreErr := restoreGrokResponsesClientToolPayload(c, dataBytes)
|
||||
if restoreErr != nil {
|
||||
@@ -566,12 +623,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
streamEarlyErr = fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
|
||||
return
|
||||
}
|
||||
restoredData = restoreCodexToolNamesFromContext(c, restoredData)
|
||||
restoredData = restoreCodexToolNamesFromSSEContext(c, restoredData, eventType)
|
||||
if !bytes.Equal(restoredData, dataBytes) {
|
||||
dataBytes = restoredData
|
||||
data = string(restoredData)
|
||||
line = "data: " + data
|
||||
eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
|
||||
eventType = effectiveOpenAISSEEventType(dataBytes, eventType)
|
||||
}
|
||||
if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(
|
||||
dataBytes,
|
||||
@@ -613,7 +670,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
}
|
||||
|
||||
// 写入客户端(客户端断开后继续 drain 上游)
|
||||
if !clientDisconnected {
|
||||
if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
|
||||
shouldFlush := queueDrained && (clientOutputStarted || startsClientOutput)
|
||||
if firstTokenMs == nil && startsVisibleOutput {
|
||||
// 保证首个 token 事件尽快出站,避免影响 TTFT。
|
||||
@@ -635,12 +692,30 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
firstTokenMs = &ms
|
||||
stopFirstOutputTimer()
|
||||
}
|
||||
s.parseSSEUsageBytes(dataBytes, usage)
|
||||
s.parseSSEUsageBytesWithType(dataBytes, eventType, usage)
|
||||
return
|
||||
}
|
||||
|
||||
// A blank line dispatches a guarded event from the attempt-local stage.
|
||||
if stageFirstOutput && line == "" {
|
||||
pendingSSEEventType = ""
|
||||
if suppressCurrentEvent {
|
||||
suppressCurrentEvent = false
|
||||
terminalFailurePending = false
|
||||
eventInProgress = false
|
||||
eventStartsClientOutput = false
|
||||
eventStartsVisibleOutput = false
|
||||
eventShouldFlush = false
|
||||
return
|
||||
}
|
||||
if failureDelivered {
|
||||
terminalFailurePending = false
|
||||
eventInProgress = false
|
||||
eventStartsClientOutput = false
|
||||
eventStartsVisibleOutput = false
|
||||
eventShouldFlush = false
|
||||
return
|
||||
}
|
||||
if !clientDisconnected {
|
||||
if _, err := writePendingString("\n"); err != nil {
|
||||
handlePendingWriteError(err)
|
||||
@@ -649,16 +724,31 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
if streamEarlyErr == nil {
|
||||
completeGuardedEvent(queueDrained)
|
||||
}
|
||||
if terminalFailurePending && streamEarlyErr == nil {
|
||||
terminalFailurePending = false
|
||||
failureDelivered = true
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non-guarded streams retain upstream's event-boundary flushing: a keepalive
|
||||
// or queue-drain flush must never split an open SSE event.
|
||||
shouldFlush := false
|
||||
if line == "" {
|
||||
pendingSSEEventType = ""
|
||||
if suppressCurrentEvent {
|
||||
suppressCurrentEvent = false
|
||||
terminalFailurePending = false
|
||||
eventInProgress = false
|
||||
eventShouldFlush = false
|
||||
return
|
||||
}
|
||||
shouldFlush = eventShouldFlush || (queueDrained && clientOutputStarted)
|
||||
eventShouldFlush = false
|
||||
if failureDelivered {
|
||||
terminalFailurePending = false
|
||||
}
|
||||
}
|
||||
if !clientDisconnected {
|
||||
if !clientDisconnected && !failureDelivered && !suppressCurrentEvent {
|
||||
if _, err := writePendingString(line); err != nil {
|
||||
handlePendingWriteError(err)
|
||||
} else if _, err := writePendingString("\n"); err != nil {
|
||||
@@ -675,6 +765,10 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
}
|
||||
}
|
||||
}
|
||||
if line == "" && terminalFailurePending && streamEarlyErr == nil {
|
||||
terminalFailurePending = false
|
||||
failureDelivered = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,6 +861,13 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
}
|
||||
|
||||
case <-intervalCh:
|
||||
if codexFailureTerminal && sawBareError && !sawResponseFailed {
|
||||
_ = resp.Body.Close()
|
||||
return finalizeStream()
|
||||
}
|
||||
if failureDelivered {
|
||||
return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
|
||||
}
|
||||
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
|
||||
if time.Since(lastRead) < streamInterval {
|
||||
continue
|
||||
@@ -797,6 +898,10 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
stopFirstOutputTimer()
|
||||
continue
|
||||
}
|
||||
if codexFailureTerminal && sawBareError && !sawResponseFailed {
|
||||
_ = resp.Body.Close()
|
||||
return finalizeStream()
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
for ev := range events {
|
||||
markEventProcessed(ev)
|
||||
@@ -807,7 +912,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
)
|
||||
|
||||
case <-keepaliveCh:
|
||||
if clientDisconnected {
|
||||
if clientDisconnected || failureDelivered {
|
||||
continue
|
||||
}
|
||||
if eventInProgress {
|
||||
@@ -922,7 +1027,7 @@ func openAICompatPayloadWithEventType(payload, eventType string) string {
|
||||
if eventType == "" || strings.TrimSpace(payload) == "" || strings.TrimSpace(payload) == "[DONE]" {
|
||||
return payload
|
||||
}
|
||||
if gjson.Get(payload, "type").Exists() {
|
||||
if strings.TrimSpace(gjson.Get(payload, "type").String()) != "" {
|
||||
return payload
|
||||
}
|
||||
patched, err := sjson.Set(payload, "type", eventType)
|
||||
@@ -932,6 +1037,13 @@ func openAICompatPayloadWithEventType(payload, eventType string) string {
|
||||
return patched
|
||||
}
|
||||
|
||||
func effectiveOpenAISSEEventType(payload []byte, eventType string) string {
|
||||
if payloadType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()); payloadType != "" {
|
||||
return payloadType
|
||||
}
|
||||
return strings.TrimSpace(eventType)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string {
|
||||
data, ok := extractOpenAISSEDataLine(line)
|
||||
if !ok {
|
||||
@@ -1061,6 +1173,10 @@ func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) {
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsage) {
|
||||
s.parseSSEUsageBytesWithType(data, "", usage)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) parseSSEUsageBytesWithType(data []byte, eventType string, usage *OpenAIUsage) {
|
||||
if usage == nil || len(data) == 0 || bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) {
|
||||
return
|
||||
}
|
||||
@@ -1068,7 +1184,10 @@ func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsag
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if openAIStreamEventTypeIsTerminal(strings.TrimSpace(gjson.GetBytes(data, "type").String())) {
|
||||
if openAIStreamEventTypeIsTerminal(effectiveOpenAISSEEventType(data, eventType)) {
|
||||
if !openAIUsageHasTokens(&parsedUsage) && openAIUsageHasTokens(usage) {
|
||||
return
|
||||
}
|
||||
*usage = parsedUsage
|
||||
return
|
||||
}
|
||||
@@ -1076,7 +1195,9 @@ func (s *OpenAIGatewayService) parseSSEUsageBytes(data []byte, usage *OpenAIUsag
|
||||
}
|
||||
|
||||
// Compatible Responses upstreams may report usage before the terminal event.
|
||||
// Retain those non-zero fields as a fallback; terminal usage remains authoritative.
|
||||
// Retain those non-zero fields as a fallback. A terminal usage with any billed
|
||||
// tokens is authoritative as a whole; an all-zero terminal does not erase a
|
||||
// non-zero progressive observation from the same turn.
|
||||
func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) {
|
||||
if dst == nil {
|
||||
return
|
||||
@@ -1101,6 +1222,73 @@ func mergeOpenAIUsageNonZero(dst *OpenAIUsage, src OpenAIUsage) {
|
||||
}
|
||||
}
|
||||
|
||||
func openAIUsageHasTokens(usage *OpenAIUsage) bool {
|
||||
return usage != nil && (usage.InputTokens > 0 || usage.ImageInputTokens > 0 ||
|
||||
usage.OutputTokens > 0 || usage.CacheCreationInputTokens > 0 ||
|
||||
usage.CacheReadInputTokens > 0 || usage.ImageOutputTokens > 0)
|
||||
}
|
||||
|
||||
const openAIMissingUsageLogInterval = time.Minute
|
||||
|
||||
type openAIMissingUsageLogSampler struct {
|
||||
total atomic.Uint64
|
||||
suppressed atomic.Uint64
|
||||
lastLog atomic.Int64
|
||||
}
|
||||
|
||||
var openAIMissingUsageSampler openAIMissingUsageLogSampler
|
||||
|
||||
func (s *openAIMissingUsageLogSampler) sample(now time.Time) (logNow bool, total uint64, suppressed uint64) {
|
||||
total = s.total.Add(1)
|
||||
nowNanos := now.UnixNano()
|
||||
for {
|
||||
last := s.lastLog.Load()
|
||||
if last != 0 && nowNanos-last < int64(openAIMissingUsageLogInterval) {
|
||||
s.suppressed.Add(1)
|
||||
return false, total, 0
|
||||
}
|
||||
if s.lastLog.CompareAndSwap(last, nowNanos) {
|
||||
return true, total, s.suppressed.Swap(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logOpenAISuccessMissingUsage(ctx context.Context, c *gin.Context, account *Account, resp *http.Response, usage *OpenAIUsage, terminalEvent string, clientDisconnected bool) {
|
||||
if resp == nil || resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || openAIUsageHasTokens(usage) {
|
||||
return
|
||||
}
|
||||
terminalEvent = strings.TrimSpace(terminalEvent)
|
||||
if terminalEvent != "response.completed" && terminalEvent != "response.done" && terminalEvent != "[DONE]" && terminalEvent != "json" {
|
||||
return
|
||||
}
|
||||
logNow, total, suppressed := openAIMissingUsageSampler.sample(time.Now())
|
||||
if !logNow {
|
||||
return
|
||||
}
|
||||
accountID := int64(0)
|
||||
accountType := ""
|
||||
if account != nil {
|
||||
accountID = account.ID
|
||||
accountType = string(account.Type)
|
||||
}
|
||||
inboundEndpoint := ""
|
||||
if c != nil && c.Request != nil && c.Request.URL != nil {
|
||||
inboundEndpoint = c.Request.URL.Path
|
||||
}
|
||||
logger.FromContext(ctx).With(
|
||||
zap.String("component", "service.openai_gateway"),
|
||||
zap.Int64("account_id", accountID),
|
||||
zap.String("account_type", accountType),
|
||||
zap.String("inbound_endpoint", inboundEndpoint),
|
||||
zap.String("upstream_request_id", strings.TrimSpace(resp.Header.Get("x-request-id"))),
|
||||
zap.Int("upstream_status_code", resp.StatusCode),
|
||||
zap.String("terminal_event", terminalEvent),
|
||||
zap.Bool("client_disconnected", clientDisconnected),
|
||||
zap.Uint64("missing_usage_total", total),
|
||||
zap.Uint64("suppressed_since_last", suppressed),
|
||||
).Warn("openai_usage.success_missing_usage")
|
||||
}
|
||||
|
||||
func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) {
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return OpenAIUsage{}, false
|
||||
@@ -1320,6 +1508,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
|
||||
return nil, fmt.Errorf("parse response: invalid json response")
|
||||
}
|
||||
usage := &usageValue
|
||||
logOpenAISuccessMissingUsage(ctx, c, account, resp, usage, "json", false)
|
||||
|
||||
// Replace model in response if needed
|
||||
if originalModel != mappedModel {
|
||||
@@ -1383,9 +1572,20 @@ func bodyHasSSEFraming(body []byte) bool {
|
||||
|
||||
func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
|
||||
bodyText := string(body)
|
||||
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
|
||||
if terminalOK && (terminalType == "response.failed" || terminalType == "error") {
|
||||
msg := extractOpenAISSEErrorMessage(terminalPayload)
|
||||
if msg == "" {
|
||||
msg = "Upstream compact response failed"
|
||||
}
|
||||
if compactErr := newOpenAICompactFallbackSignal(c, terminalPayload, msg); compactErr != nil {
|
||||
return nil, compactErr
|
||||
}
|
||||
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
|
||||
}
|
||||
finalResponse, ok := extractCodexFinalResponse(bodyText)
|
||||
|
||||
usage := &OpenAIUsage{}
|
||||
usage := s.parseSSEUsageFromBody(bodyText)
|
||||
if ok {
|
||||
if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
|
||||
*usage = parsedUsage
|
||||
@@ -1418,15 +1618,6 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
|
||||
restoredBody = restoreCodexToolNamesFromContext(c, restoredBody)
|
||||
body = restoredBody
|
||||
} else {
|
||||
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
|
||||
if terminalOK && terminalType == "response.failed" {
|
||||
msg := extractOpenAISSEErrorMessage(terminalPayload)
|
||||
if msg == "" {
|
||||
msg = "Upstream compact response failed"
|
||||
}
|
||||
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
|
||||
}
|
||||
usage = s.parseSSEUsageFromBody(bodyText)
|
||||
if originalModel != mappedModel {
|
||||
bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
|
||||
}
|
||||
@@ -1434,6 +1625,7 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
|
||||
}
|
||||
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
logOpenAISuccessMissingUsage(c.Request.Context(), c, account, resp, usage, terminalType, false)
|
||||
s.relayOpenAICodexTurnState(c, account, resp.Header)
|
||||
|
||||
contentType := "application/json; charset=utf-8"
|
||||
@@ -1460,13 +1652,12 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
|
||||
func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) {
|
||||
var terminalType string
|
||||
var terminalPayload []byte
|
||||
forEachOpenAISSEDataPayload(body, func(data []byte) {
|
||||
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
|
||||
if terminalPayload != nil {
|
||||
return
|
||||
}
|
||||
eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String())
|
||||
switch eventType {
|
||||
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
||||
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error":
|
||||
terminalType = eventType
|
||||
terminalPayload = append([]byte(nil), data...)
|
||||
}
|
||||
@@ -1489,6 +1680,54 @@ func extractOpenAISSEErrorMessage(payload []byte) string {
|
||||
return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload)))
|
||||
}
|
||||
|
||||
func buildOpenAIResponseFailedSSE(responseID, model string, source []byte, fallbackMessage string) string {
|
||||
responseID = strings.TrimSpace(responseID)
|
||||
if responseID == "" {
|
||||
responseID = "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
errorType := strings.TrimSpace(gjson.GetBytes(source, "error.type").String())
|
||||
if errorType == "" {
|
||||
errorType = strings.TrimSpace(gjson.GetBytes(source, "response.error.type").String())
|
||||
}
|
||||
code := strings.TrimSpace(gjson.GetBytes(source, "error.code").String())
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(gjson.GetBytes(source, "response.error.code").String())
|
||||
}
|
||||
if code == "" {
|
||||
code = "upstream_error"
|
||||
}
|
||||
message := extractOpenAISSEErrorMessage(source)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(fallbackMessage)
|
||||
}
|
||||
if message == "" {
|
||||
message = "Upstream response failed"
|
||||
}
|
||||
errorBody := gin.H{"code": code, "message": message}
|
||||
if errorType != "" {
|
||||
errorBody["type"] = errorType
|
||||
}
|
||||
response := gin.H{
|
||||
"id": responseID,
|
||||
"object": "response",
|
||||
"status": "failed",
|
||||
"output": []any{},
|
||||
"error": errorBody,
|
||||
}
|
||||
if model = strings.TrimSpace(model); model != "" {
|
||||
response["model"] = model
|
||||
}
|
||||
payload, err := marshalOpenAIUpstreamJSON(gin.H{
|
||||
"type": "response.failed",
|
||||
"response": response,
|
||||
})
|
||||
if err != nil {
|
||||
// All values above are JSON primitives, so this is only a defensive fallback.
|
||||
payload = []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
|
||||
}
|
||||
return "event: response.failed\ndata: " + string(payload) + "\n\n"
|
||||
}
|
||||
|
||||
func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string, clientOutputStarted bool) ([]byte, bool) {
|
||||
eventType = strings.TrimSpace(eventType)
|
||||
isFailedEvent := eventType == "response.failed"
|
||||
@@ -1577,14 +1816,13 @@ func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.R
|
||||
|
||||
func extractCodexFinalResponse(body string) ([]byte, bool) {
|
||||
var finalResponse []byte
|
||||
forEachOpenAISSEDataPayload(body, func(data []byte) {
|
||||
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
|
||||
if finalResponse != nil {
|
||||
return
|
||||
}
|
||||
if normalized, changed := normalizeCompletedImageGenerationStatus(data); changed {
|
||||
data = normalized
|
||||
}
|
||||
eventType := gjson.GetBytes(data, "type").String()
|
||||
if eventType == "response.done" || eventType == "response.completed" {
|
||||
if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" {
|
||||
finalResponse = []byte(response.Raw)
|
||||
@@ -1717,11 +1955,12 @@ func collectRawResponsesOutputItemsFromSSE(bodyText string) ([]byte, bool) {
|
||||
}
|
||||
items = append(items, json.RawMessage(item.Raw))
|
||||
}
|
||||
forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
|
||||
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
|
||||
data = []byte(openAICompatPayloadWithEventType(string(data), eventType))
|
||||
if normalized, changed := normalizeCompletedImageGenerationStatus(data); changed {
|
||||
data = normalized
|
||||
}
|
||||
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.done" {
|
||||
if eventType != "response.output_item.done" {
|
||||
return
|
||||
}
|
||||
appendItem(gjson.GetBytes(data, "item"))
|
||||
@@ -1730,8 +1969,8 @@ func collectRawResponsesOutputItemsFromSSE(bodyText string) ([]byte, bool) {
|
||||
// compaction 只在 added 中"的混合形态;done 已含 compaction 时跳过,
|
||||
// 避免同一 item 在无 id 可去重时被收集两份(Codex 要求恰好一个)。
|
||||
if !hasCompactionItem {
|
||||
forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
|
||||
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.added" {
|
||||
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
|
||||
if eventType != "response.output_item.added" {
|
||||
return
|
||||
}
|
||||
item := gjson.GetBytes(data, "item")
|
||||
@@ -1806,11 +2045,11 @@ func responsesOutputHasCompactionItem(response []byte) bool {
|
||||
func findRawCompactionItemFromSSE(bodyText string) (json.RawMessage, bool) {
|
||||
var found json.RawMessage
|
||||
pick := func(eventType string) {
|
||||
forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
|
||||
forEachOpenAISSEFrame(bodyText, func(effectiveType string, data []byte) {
|
||||
if found != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != eventType {
|
||||
if effectiveType != eventType {
|
||||
return
|
||||
}
|
||||
item := gjson.GetBytes(data, "item")
|
||||
@@ -1842,11 +2081,11 @@ func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) {
|
||||
acc := apicompat.NewBufferedResponseAccumulator()
|
||||
imageOutputs := make([]json.RawMessage, 0, 1)
|
||||
seenImages := make(map[string]struct{})
|
||||
forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
|
||||
forEachOpenAISSEFrame(bodyText, func(eventType string, data []byte) {
|
||||
data = []byte(openAICompatPayloadWithEventType(string(data), eventType))
|
||||
if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok {
|
||||
imageOutputs = append(imageOutputs, imageOutput)
|
||||
}
|
||||
eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String())
|
||||
if responsesStreamEventMayContributeToOutput(eventType) {
|
||||
var event apicompat.ResponsesStreamEvent
|
||||
if err := json.Unmarshal(data, &event); err == nil {
|
||||
@@ -1909,8 +2148,8 @@ func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct
|
||||
|
||||
func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage {
|
||||
usage := &OpenAIUsage{}
|
||||
forEachOpenAISSEDataPayload(body, func(data []byte) {
|
||||
s.parseSSEUsageBytes(data, usage)
|
||||
forEachOpenAISSEFrame(body, func(eventType string, data []byte) {
|
||||
s.parseSSEUsageBytesWithType(data, eventType, usage)
|
||||
})
|
||||
return usage
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestOpenAIStreamEventIsTerminalWithTypeMatchesExistingSemantics(t *testing.
|
||||
{name: "invalid JSON", data: `{"type":`, want: false},
|
||||
{name: "terminal with trailing garbage", data: `{"type":"response.completed"} trailing`, want: true},
|
||||
{name: "nonterminal with trailing garbage", data: `{"type":"response.output_text.delta"} trailing`, want: false},
|
||||
{name: "type whitespace remains nonterminal", data: `{"type":" response.completed "}`, want: false},
|
||||
{name: "type whitespace is normalized", data: `{"type":" response.completed "}`, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -98,6 +98,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
|
||||
zap.String("upstream_model", upstreamModel),
|
||||
zap.Bool("stream", clientStream),
|
||||
)
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
|
||||
// Build and send upstream request via the shared CC pipeline
|
||||
apiKey, targetURL, err := s.resolveCCFallbackTarget(account)
|
||||
|
||||
@@ -77,6 +77,26 @@ func TestAdaptOpenAIResponsesClientToolsRejectsTrailingData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesFunctionUpstreamsLowerToolSearchDiscoveryOutput(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"tool_search"}],"input":[{"type":"tool_search_output","call_id":"search_1","tools":[{"type":"namespace","name":"github"}],"status":"completed","execution":"client"}]}`)
|
||||
adapters := map[string]func([]byte) ([]byte, apicompat.ResponsesClientToolMapping, error){
|
||||
"OpenAI API-key": adaptOpenAIResponsesClientTools,
|
||||
"Grok": adaptGrokResponsesClientTools,
|
||||
}
|
||||
for name, adapt := range adapters {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
adapted, mapping, err := adapt(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, mapping.ToolSearch)
|
||||
require.Equal(t, "function_call_output", gjson.GetBytes(adapted, "input.0.type").String())
|
||||
require.JSONEq(t, `[{"name":"github","type":"namespace"}]`, gjson.GetBytes(adapted, "input.0.output").String())
|
||||
require.False(t, gjson.GetBytes(adapted, "input.0.tools").Exists())
|
||||
require.False(t, gjson.GetBytes(adapted, "input.0.status").Exists())
|
||||
require.False(t, gjson.GetBytes(adapted, "input.0.execution").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearOpenAIResponsesClientToolMappingRemovesStaleContextState(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
@@ -66,11 +66,25 @@ func explicitOpenAISessionID(c *gin.Context, body []byte) string {
|
||||
|
||||
sessionID := explicitOpenAIHeaderSessionID(c)
|
||||
if sessionID == "" && len(body) > 0 {
|
||||
sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
|
||||
sessionID = strings.TrimSpace(openAIRequestPayloadView(body).Get("prompt_cache_key").String())
|
||||
}
|
||||
return sessionID
|
||||
}
|
||||
|
||||
// openAIRequestPayloadView unwraps Responses WebSocket event envelopes while
|
||||
// leaving ordinary HTTP objects untouched even when they contain a response
|
||||
// field for another purpose.
|
||||
func openAIRequestPayloadView(body []byte) gjson.Result {
|
||||
root := parseRawJSONView(body)
|
||||
eventType := strings.ToLower(strings.TrimSpace(root.Get("type").String()))
|
||||
if strings.HasPrefix(eventType, "response.") {
|
||||
if response := root.Get("response"); response.Exists() && response.IsObject() {
|
||||
return response
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// explicitOpenAIRequestSessionID extends the common OpenAI session signals
|
||||
// with Grok's native conversation header only for requests authenticated to a
|
||||
// Grok group. This keeps an unrelated x-grok-conv-id header from changing
|
||||
@@ -91,7 +105,7 @@ func explicitOpenAIRequestSessionID(c *gin.Context, body []byte) string {
|
||||
sessionID = strings.TrimSpace(c.GetHeader(grokConversationIDHeader))
|
||||
}
|
||||
if sessionID == "" && len(body) > 0 {
|
||||
sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
|
||||
sessionID = strings.TrimSpace(openAIRequestPayloadView(body).Get("prompt_cache_key").String())
|
||||
}
|
||||
if sessionID == "" && isGrokRequestContext(c) && len(body) > 0 {
|
||||
sessionID = grokPreviousResponseSessionSeed(body)
|
||||
@@ -719,6 +733,17 @@ func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedMode
|
||||
return upstreamModel
|
||||
}
|
||||
|
||||
// Compact mappings are keyed by the client-visible model. Prefer an exact
|
||||
// compact rule before ordinary account mapping; otherwise a normal alias can
|
||||
// hide the compact-specific rule and make scheduling disagree with Forward.
|
||||
if requireCompact && account != nil {
|
||||
if compactModel, matched := account.ResolveCompactMappedModel(strings.TrimSpace(requestedModel)); matched {
|
||||
if compactModel = strings.TrimSpace(compactModel); compactModel != "" {
|
||||
return compactModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upstreamModel := resolveOpenAIForwardModel(account, requestedModel, "")
|
||||
if upstreamModel == "" {
|
||||
return ""
|
||||
@@ -732,6 +757,39 @@ func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedMode
|
||||
return normalizeOpenAIModelForUpstream(account, upstreamModel)
|
||||
}
|
||||
|
||||
// ResolveOpenAIAccountUpstreamModelForRequest exposes the scheduler's exact
|
||||
// account mapping chain to handler-side outcome reporting.
|
||||
func ResolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedModel string, requireCompact bool) string {
|
||||
return resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)
|
||||
}
|
||||
|
||||
// resolveOpenAIForwardMappedModels is the shared account mapping chain for
|
||||
// Forward callers. billingModel retains the ordinary mapping used for usage
|
||||
// accounting, while upstreamModel is the model the scheduler has admitted.
|
||||
func resolveOpenAIForwardMappedModels(account *Account, requestedModel string, requireCompact bool) (billingModel, upstreamModel string) {
|
||||
requestedModel = strings.TrimSpace(requestedModel)
|
||||
if account != nil && account.IsOpenAIPassthroughEnabled() {
|
||||
billingModel = requestedModel
|
||||
} else if account != nil {
|
||||
billingModel = strings.TrimSpace(account.GetMappedModel(requestedModel))
|
||||
}
|
||||
if billingModel == "" {
|
||||
billingModel = requestedModel
|
||||
}
|
||||
upstreamModel = resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)
|
||||
if strings.TrimSpace(upstreamModel) == "" {
|
||||
upstreamModel = billingModel
|
||||
}
|
||||
return billingModel, upstreamModel
|
||||
}
|
||||
|
||||
func resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel string) string {
|
||||
if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" {
|
||||
return upstreamModel
|
||||
}
|
||||
return strings.TrimSpace(billingModel)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, error) {
|
||||
platform = NormalizeOpenAICompatiblePlatform(platform)
|
||||
if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
|
||||
|
||||
@@ -444,6 +444,7 @@ type OpenAIGatewayService struct {
|
||||
openaiWSStateStore OpenAIWSStateStore
|
||||
openaiScheduler OpenAIAccountScheduler
|
||||
openaiWSPassthroughDialer openAIWSClientDialer
|
||||
openaiWSSessionPreemptions openAIWSSessionPreemptRegistry
|
||||
openaiAccountStats *openAIAccountRuntimeStats
|
||||
openaiModelTransient *openAIAccountModelTransientState
|
||||
openaiProxyStreamCircuit *openAIProxyStreamCircuit
|
||||
@@ -454,6 +455,7 @@ type OpenAIGatewayService struct {
|
||||
openaiAccountRuntimeBlockLocks sync.Map // key: int64(accountID), value: *sync.Mutex
|
||||
openaiAccountRuntimeBlockGeneration sync.Map // key: int64(accountID), value: uint64
|
||||
openaiAccountRuntimeBlockSequence atomic.Uint64
|
||||
openaiOAuth429RetryStartedAt sync.Map // key: int64(accountID), value: time.Time
|
||||
grokCredentialMutationLocks sync.Map // key: int64(accountID), value: *sync.Mutex
|
||||
openaiOAuth429WindowStartUnixNano atomic.Int64
|
||||
openaiOAuth429WindowCount atomic.Int64
|
||||
@@ -1198,6 +1200,17 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco
|
||||
return "", "", errors.New("access_token not found in credentials")
|
||||
}
|
||||
return accessToken, "oauth", nil
|
||||
case AccountTypeSetupToken:
|
||||
if !account.IsOpenAIOAuthLike() {
|
||||
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
|
||||
}
|
||||
// OpenAI setup tokens are inference-only bearer credentials. They use the
|
||||
// Codex OAuth forwarding protocol but have no refresh-token lifecycle.
|
||||
accessToken := account.GetOpenAIAccessToken()
|
||||
if accessToken == "" {
|
||||
return "", "", errors.New("access_token not found in credentials")
|
||||
}
|
||||
return accessToken, "oauth", nil
|
||||
case AccountTypeAPIKey:
|
||||
if account.Platform == PlatformGrok {
|
||||
apiKey := strings.TrimSpace(account.GetCredential("api_key"))
|
||||
|
||||
@@ -719,7 +719,7 @@ func TestOpenAIGatewayService_Forward_CodexBridgeInjectionSetsImageBilling(t *te
|
||||
require.Equal(t, "gpt-image-2", result.BillingModel)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_HTTPDeletesPreviousResponseIDWhenPresent(t *testing.T) {
|
||||
func TestOpenAIGatewayService_Forward_HTTPPreservesPreviousResponseIDForAPIKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
@@ -756,7 +756,7 @@ func TestOpenAIGatewayService_Forward_HTTPDeletesPreviousResponseIDWhenPresent(t
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1768,7 +1768,7 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
|
||||
require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
|
||||
require.Contains(t, string(failoverErr.ResponseBody), "Please retry later")
|
||||
// 容量降载是请求级信号:非池模式账号也要先在同账号重试,且不得据此临时封禁账号。
|
||||
// 否则单个被降载的请求会把整池账号逐个消耗掉,而降载因素在每个账号上都相同。
|
||||
@@ -1840,8 +1840,8 @@ func TestOpenAIStreamingResponseFailedBeforeOutputRateLimitUsesPoolRetryPolicy(t
|
||||
require.Equal(t, http.StatusTooManyRequests, opsEvents[len(opsEvents)-1].UpstreamStatusCode)
|
||||
}
|
||||
|
||||
// 流内 rate limit 只产生 failover 错误,不写账号级限流/封禁状态:
|
||||
// HTTP 200 流的 x-codex-* 头是正常配额快照,不能按 429 头驱动账号冷却。
|
||||
// 流内 rate limit 进入 OAuth 同账号重试窗口,但不立即写账号级限流/封禁状态:
|
||||
// HTTP 200 流的 x-codex-* 头不能让窗口内的账号提前失去调度资格。
|
||||
func TestOpenAIStreamingResponseFailedRateLimitDoesNotBlockAccountScheduling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
@@ -1885,7 +1885,8 @@ func TestOpenAIStreamingResponseFailedRateLimitDoesNotBlockAccountScheduling(t *
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.False(t, failoverErr.SameAccountRetryDeadline.IsZero())
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
}
|
||||
|
||||
@@ -2957,6 +2958,26 @@ func TestNormalizeOpenAICompactRequestBodyPreservesCurrentCodexPayloadFields(t *
|
||||
require.False(t, gjson.GetBytes(normalized, "prompt_cache_key").Exists())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAICompactRequestBodyDropsParallelToolCallsWithoutUsableTools(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"model":"gpt-5.5","input":[],"parallel_tool_calls":true}`,
|
||||
`{"model":"gpt-5.5","input":[],"tools":[],"parallel_tool_calls":false}`,
|
||||
`{"model":"gpt-5.5","input":[],"tools":null,"parallel_tool_calls":true}`,
|
||||
`{"model":"gpt-5.5","input":[],"tools":{},"parallel_tool_calls":true}`,
|
||||
} {
|
||||
normalized, changed, err := normalizeOpenAICompactRequestBody([]byte(body))
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists(), string(normalized))
|
||||
}
|
||||
|
||||
withTools := []byte(`{"model":"gpt-5.5","tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`)
|
||||
normalized, _, err := normalizeOpenAICompactRequestBody(withTools)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Bool())
|
||||
}
|
||||
|
||||
func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesCompactPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -251,9 +251,18 @@ func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
|
||||
// cyber_policy is request-scoped even when an intermediary wraps the
|
||||
// provider response in a retryable 5xx status. Never punish or rotate the
|
||||
// selected credential for it.
|
||||
if hit, _, _ := detectOpenAICyberPolicy(upstreamBody); hit {
|
||||
return false
|
||||
}
|
||||
if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
|
||||
return false
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError(upstreamMsg, upstreamBody) {
|
||||
return true
|
||||
}
|
||||
if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) {
|
||||
return true
|
||||
}
|
||||
@@ -297,15 +306,139 @@ func newOpenAIUpstreamFailoverError(
|
||||
failoverErr.ClientStatusCode = http.StatusRequestEntityTooLarge
|
||||
failoverErr.ClientMessage = OpenAIRequestBodyTooLargeClientMessage
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError(upstreamMsg, responseBody) {
|
||||
failoverErr.RetryableOnSameAccount = false
|
||||
failoverErr.RequestScopedTransient = false
|
||||
failoverErr.Stage = GatewayFailureStageAccountAuth
|
||||
failoverErr.Scope = GatewayFailureScopeAccount
|
||||
failoverErr.Reason = OpenAIUpstreamAccessStateReason
|
||||
failoverErr.NextAccountAction = NextAccountRetry
|
||||
failoverErr.ClientStatusCode = http.StatusBadGateway
|
||||
failoverErr.ClientMessage = openAIUpstreamAccessUnavailableClientMessage
|
||||
} else if requestScopedCapacity {
|
||||
// Preserve the provider's actionable overload message after gateway
|
||||
// retries are exhausted, but expose it as a retryable server_error.
|
||||
failoverErr.ClientStatusCode = http.StatusServiceUnavailable
|
||||
failoverErr.ClientMessage = openAICapacityShedClientMessage(upstreamMsg, responseBody)
|
||||
}
|
||||
return failoverErr
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) newOpenAIAccountFailoverError(
|
||||
account *Account,
|
||||
statusCode int,
|
||||
responseHeaders http.Header,
|
||||
responseBody []byte,
|
||||
upstreamMsg string,
|
||||
shouldDisable bool,
|
||||
retryableOnSameAccount bool,
|
||||
) *UpstreamFailoverError {
|
||||
oauth429Retry := s.shouldRetryOpenAIOAuth429OnSameAccount(account, statusCode, shouldDisable)
|
||||
failoverErr := newOpenAIUpstreamFailoverError(
|
||||
statusCode,
|
||||
responseHeaders,
|
||||
responseBody,
|
||||
upstreamMsg,
|
||||
retryableOnSameAccount || oauth429Retry,
|
||||
)
|
||||
if oauth429Retry {
|
||||
failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account)
|
||||
failoverErr.SameAccountRetryDelay = openAIOAuth429SameAccountRetryDelay(responseHeaders, failoverErr.SameAccountRetryDeadline)
|
||||
}
|
||||
return failoverErr
|
||||
}
|
||||
|
||||
const (
|
||||
openAIUpstreamAccessUnavailableClientMessage = "Upstream access is temporarily unavailable, please retry later"
|
||||
// OpenAIUpstreamAccessStateReason marks a provider credential whose
|
||||
// account, workspace, or organization is unavailable.
|
||||
OpenAIUpstreamAccessStateReason = GatewayFailureReason("openai_upstream_access_state")
|
||||
// OpenAIHTTPContinuationUnsupportedReason identifies accounts that cannot
|
||||
// preserve an official Responses HTTP continuation without dropping state.
|
||||
OpenAIHTTPContinuationUnsupportedReason = GatewayFailureReason("openai_http_continuation_unsupported")
|
||||
)
|
||||
|
||||
// isOpenAIUpstreamAccessStateError recognizes provider-side credential state
|
||||
// failures from explicit structured fields. Valid JSON is never scanned as a
|
||||
// blob because it may contain echoed user input with the same words.
|
||||
func isOpenAIUpstreamAccessStateError(upstreamMsg string, body []byte) bool {
|
||||
matchCode := func(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "deactivated_workspace" {
|
||||
return true
|
||||
}
|
||||
for _, subject := range []string{"workspace", "account", "organization", "org"} {
|
||||
for _, state := range []string{"deactivated", "disabled", "suspended"} {
|
||||
if value == subject+"_"+state || value == state+"_"+subject {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
matchMessage := func(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
for _, subject := range []string{"workspace", "account", "organization", "org"} {
|
||||
for _, state := range []string{"deactivated", "disabled", "suspended"} {
|
||||
if strings.Contains(value, subject+" is "+state) ||
|
||||
strings.Contains(value, subject+" has been "+state) ||
|
||||
strings.Contains(value, subject+" "+state) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if matchMessage(upstreamMsg) {
|
||||
return true
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
if !gjson.ValidBytes(body) {
|
||||
return matchMessage(string(body)) || matchCode(string(body))
|
||||
}
|
||||
for _, path := range []string{"error.code", "response.error.code", "detail.code", "code"} {
|
||||
if matchCode(gjson.GetBytes(body, path).String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"error.message", "response.error.message", "detail.message", "detail", "message"} {
|
||||
if matchMessage(gjson.GetBytes(body, path).String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func openAICapacityShedClientMessage(upstreamMsg string, body []byte) string {
|
||||
for _, candidate := range []string{
|
||||
upstreamMsg,
|
||||
gjson.GetBytes(body, "error.message").String(),
|
||||
gjson.GetBytes(body, "response.error.message").String(),
|
||||
gjson.GetBytes(body, "message").String(),
|
||||
} {
|
||||
candidate = sanitizeUpstreamErrorMessage(strings.TrimSpace(candidate))
|
||||
if candidate != "" && isOpenAICapacityShedMessage(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return "Upstream service is temporarily overloaded, please retry later"
|
||||
}
|
||||
|
||||
// IsOpenAIRequestBodyTooLarge reports whether another account may accept the
|
||||
// same request even though the selected account rejected its serialized size.
|
||||
func (e *UpstreamFailoverError) IsOpenAIRequestBodyTooLarge() bool {
|
||||
return e != nil && e.Reason == openAIRequestBodyTooLargeReason
|
||||
}
|
||||
|
||||
// IsOpenAICapacityShed reports whether typed client fields were derived from a
|
||||
// recognized provider overload rather than supplied by an unrelated failure.
|
||||
func (e *UpstreamFailoverError) IsOpenAICapacityShed() bool {
|
||||
return e != nil && e.RequestScopedTransient && isOpenAIRequestScopedCapacityShed("", e.ResponseBody)
|
||||
}
|
||||
|
||||
func marshalOpenAIUpstreamJSON(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
|
||||
@@ -562,7 +562,7 @@ func (s *OpenAIGatewayService) ForwardImages(
|
||||
switch account.Type {
|
||||
case AccountTypeAPIKey:
|
||||
return s.forwardOpenAIImagesAPIKey(ctx, c, account, body, parsed, channelMappedModel)
|
||||
case AccountTypeOAuth:
|
||||
case AccountTypeOAuth, AccountTypeSetupToken:
|
||||
return s.forwardOpenAIImagesOAuth(ctx, c, account, parsed, channelMappedModel)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
|
||||
@@ -589,6 +589,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
||||
if err := validateOpenAIImagesModel(upstreamModel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
logger.LegacyPrintf(
|
||||
"service.openai_gateway",
|
||||
"[OpenAI] Images request routing request_model=%s upstream_model=%s endpoint=%s account_type=%s",
|
||||
@@ -658,11 +659,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
|
||||
Message: upstreamMsg,
|
||||
})
|
||||
shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, upstreamModel)
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
|
||||
if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMsg, shouldDisable, retryableOnSameAccount)
|
||||
}
|
||||
if isOpenAIUpstreamAccessStateError(upstreamMsg, respBody) {
|
||||
return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMsg, retryableOnSameAccount)
|
||||
}
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
|
||||
}
|
||||
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, upstreamModel)
|
||||
}
|
||||
|
||||
@@ -902,8 +902,18 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
|
||||
modelForCooldown = strings.TrimSpace(requestedModel[0])
|
||||
}
|
||||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, modelForCooldown)
|
||||
failoverErr := s.newOpenAIAccountFailoverError(
|
||||
account,
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
body,
|
||||
upstreamMsg,
|
||||
shouldDisable,
|
||||
false,
|
||||
)
|
||||
shouldFailover := shouldDisable || (account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests && failoverErr.RetryableOnSameAccount)
|
||||
kind := "http_error"
|
||||
if shouldDisable {
|
||||
if shouldFailover {
|
||||
kind = "failover"
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@@ -916,12 +926,8 @@ func (s *OpenAIGatewayService) handleOpenAIImagesErrorResponse(
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
if shouldDisable {
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: body,
|
||||
RetryableOnSameAccount: false,
|
||||
}
|
||||
if shouldFailover {
|
||||
return nil, failoverErr
|
||||
}
|
||||
|
||||
// Surface the real upstream error to the client.
|
||||
@@ -1760,11 +1766,15 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
Message: upstreamMsg,
|
||||
})
|
||||
shouldDisable := s.handleFailoverSideEffects(upstreamCtx, resp, account, respBody, requestModel)
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
return nil, s.newOpenAIAccountFailoverError(
|
||||
account,
|
||||
resp.StatusCode,
|
||||
resp.Header,
|
||||
respBody,
|
||||
upstreamMsg,
|
||||
shouldDisable,
|
||||
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
)
|
||||
}
|
||||
return s.handleOpenAIImagesErrorResponse(upstreamCtx, resp, c, account, requestModel)
|
||||
}
|
||||
@@ -1879,8 +1889,15 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
|
||||
}
|
||||
responseBody := []byte(fmt.Sprintf(`{"error":{"type":"upstream_error","code":%q,"message":%q}}`, code, message))
|
||||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, statusCode, headers, responseBody, requestedModel)
|
||||
return &UpstreamFailoverError{StatusCode: statusCode, ResponseBody: responseBody, ResponseHeaders: headers,
|
||||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode)}
|
||||
return s.newOpenAIAccountFailoverError(
|
||||
account,
|
||||
statusCode,
|
||||
headers,
|
||||
responseBody,
|
||||
message,
|
||||
shouldDisable,
|
||||
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode),
|
||||
)
|
||||
}
|
||||
var upstreamErr *OpenAIImagesUpstreamError
|
||||
if !errors.As(err, &upstreamErr) {
|
||||
@@ -1921,10 +1938,13 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
|
||||
|
||||
responseBody := openAIImagesUpstreamErrorResponseBody(upstreamErr)
|
||||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.StatusCode, headers, responseBody, requestedModel)
|
||||
return &UpstreamFailoverError{
|
||||
StatusCode: upstreamErr.StatusCode,
|
||||
ResponseBody: responseBody,
|
||||
ResponseHeaders: headers,
|
||||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(upstreamErr.StatusCode),
|
||||
}
|
||||
return s.newOpenAIAccountFailoverError(
|
||||
account,
|
||||
upstreamErr.StatusCode,
|
||||
headers,
|
||||
responseBody,
|
||||
upstreamErr.clientMessage(),
|
||||
shouldDisable,
|
||||
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(upstreamErr.StatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -471,6 +472,17 @@ func TestAccountSupportsOpenAIImageCapability_OAuthSupportsNative(t *testing.T)
|
||||
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative))
|
||||
}
|
||||
|
||||
func TestAccountSupportsOpenAIImageCapability_SetupTokenSupportsNative(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeSetupToken,
|
||||
}
|
||||
|
||||
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityBasic))
|
||||
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative))
|
||||
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||
}
|
||||
|
||||
func TestAccountSupportsOpenAIImageCapability_EmptyRequirementDoesNotRejectGrok(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
@@ -1039,6 +1051,34 @@ func TestOpenAIGatewayServiceForwardImages_OAuthNonStreamServerErrorReturnsFailo
|
||||
require.Equal(t, http.StatusBadGateway, events[0].UpstreamStatusCode)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_OAuth429CarriesSameAccountRetryWindow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
svc := &OpenAIGatewayService{httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{"Retry-After": []string{"1"}, "X-Request-Id": []string{"req_img_oauth_429"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"rate limited"}}`)),
|
||||
}}}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
account := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "token-123"}}
|
||||
startedAt := time.Now()
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Equal(t, time.Second, failoverErr.SameAccountRetryDelay)
|
||||
require.WithinDuration(t, startedAt.Add(openAIOAuth429RetryWindow), failoverErr.SameAccountRetryDeadline, time.Second)
|
||||
}
|
||||
|
||||
func TestOpenAIImagesOAuthBodyReadTransportErrorFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1291,6 +1331,52 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyGenerationUsesConfiguredV1BaseU
|
||||
require.Equal(t, "aGVsbG8=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_APIKeyAccessStateUsesTypedFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstreamBody := []byte(`{"error":{"code":"organization_suspended","message":"Organization has been suspended"}}`)
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"req_images_access_state"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader(upstreamBody)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
account := &Account{
|
||||
ID: 51,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
|
||||
require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage)
|
||||
require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope)
|
||||
require.Equal(t, OpenAIUpstreamAccessStateReason, failoverErr.Reason)
|
||||
require.Equal(t, NextAccountRetry, failoverErr.NextAccountAction)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.ClientStatusCode)
|
||||
require.Equal(t, openAIUpstreamAccessUnavailableClientMessage, failoverErr.ClientMessage)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Equal(t, "req_images_access_state", failoverErr.ResponseHeaders.Get("x-request-id"))
|
||||
require.False(t, c.Writer.Written())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_APIKeyStreamJSONResponseBillsImage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
func decodeOpenAIJSONUseNumber(data []byte, dst any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("multiple JSON values are not allowed")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -222,6 +222,126 @@ func TestResolveOpenAICompactForwardModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIForwardMappedModels_CompactMappingPrecedence(t *testing.T) {
|
||||
conflictingMappings := map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.4"},
|
||||
"compact_model_mapping": map[string]any{"gpt-5.5": "gpt-5.5-openai-compact"},
|
||||
}
|
||||
mappedOnlyCompact := map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.4"},
|
||||
"compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.4-openai-compact"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
requireCompact bool
|
||||
wantBilling string
|
||||
wantUpstream string
|
||||
}{
|
||||
{
|
||||
name: "compact uses client-visible model before ordinary mapping",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Credentials: conflictingMappings},
|
||||
requireCompact: true,
|
||||
wantBilling: "gpt-5.4",
|
||||
wantUpstream: "gpt-5.5-openai-compact",
|
||||
},
|
||||
{
|
||||
name: "non-compact uses ordinary mapping",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Credentials: conflictingMappings},
|
||||
wantBilling: "gpt-5.4",
|
||||
wantUpstream: "gpt-5.4",
|
||||
},
|
||||
{
|
||||
name: "compact falls back to ordinary mapped model",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Credentials: mappedOnlyCompact},
|
||||
requireCompact: true,
|
||||
wantBilling: "gpt-5.4",
|
||||
wantUpstream: "gpt-5.4-openai-compact",
|
||||
},
|
||||
{
|
||||
name: "passthrough ignores ordinary mapping",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Credentials: conflictingMappings, Extra: map[string]any{"openai_passthrough": true}},
|
||||
requireCompact: true,
|
||||
wantBilling: "gpt-5.5",
|
||||
wantUpstream: "gpt-5.5-openai-compact",
|
||||
},
|
||||
{
|
||||
name: "raw chat fallback never applies compact mapping",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: conflictingMappings, Extra: map[string]any{"openai_responses_supported": false}},
|
||||
requireCompact: true,
|
||||
wantBilling: "gpt-5.4",
|
||||
wantUpstream: "gpt-5.4",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
billing, upstream := resolveOpenAIForwardMappedModels(tt.account, "gpt-5.5", tt.requireCompact)
|
||||
if billing != tt.wantBilling {
|
||||
t.Fatalf("billing model = %q, want %q", billing, tt.wantBilling)
|
||||
}
|
||||
if upstream != tt.wantUpstream {
|
||||
t.Fatalf("upstream model = %q, want %q", upstream, tt.wantUpstream)
|
||||
}
|
||||
if scheduler := resolveOpenAIAccountUpstreamModelForRequest(tt.account, "gpt-5.5", tt.requireCompact); scheduler != upstream {
|
||||
t.Fatalf("scheduler model %q disagrees with Forward model %q", scheduler, upstream)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalOpenAIAccountSchedulingModelMatchesForwardSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "OpenAI OAuth applies Codex alias normalization",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
|
||||
model: "gpt-5.6",
|
||||
want: "gpt-5.6-sol",
|
||||
},
|
||||
{
|
||||
name: "OpenAI passthrough ignores ordinary account mapping",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"public": "private"}},
|
||||
Extra: map[string]any{"openai_passthrough": true}},
|
||||
model: "public",
|
||||
want: "public",
|
||||
},
|
||||
{
|
||||
name: "Grok OAuth does not inherit OpenAI Codex aliases",
|
||||
account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth},
|
||||
model: "gpt-5.6",
|
||||
want: "gpt-5.6",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := canonicalOpenAIAccountSchedulingModel(tt.account, tt.model); got != tt.want {
|
||||
t.Fatalf("canonical scheduling model = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIErrorSchedulingModelPrefersActualUpstreamModel(t *testing.T) {
|
||||
if got := resolveOpenAIErrorSchedulingModel("gpt-5.4", "gpt-5.5-openai-compact"); got != "gpt-5.5-openai-compact" {
|
||||
t.Fatalf("error scheduling model = %q, want compact upstream model", got)
|
||||
}
|
||||
if got := resolveOpenAIErrorSchedulingModel("gpt-5.4", ""); got != "gpt-5.4" {
|
||||
t.Fatalf("empty upstream fallback = %q, want billing model", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexModel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
|
||||
|
||||
@@ -666,7 +666,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t *
|
||||
setOpenAIResponsesNamespaceNames(c, names)
|
||||
|
||||
result, err := (&OpenAIGatewayService{cfg: &config.Config{}}).handleNonStreamingResponsePassthrough(
|
||||
context.Background(), resp, c, "gpt-5.5", "",
|
||||
context.Background(), resp, c, &Account{ID: 91}, "gpt-5.5", "",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
@@ -95,6 +95,28 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemI
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_APIKeyStoreFalseReplay(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","store":false,"parallel_tool_calls":true,"input":[` +
|
||||
`{"type":"reasoning","id":"rs_drop","summary":[]},` +
|
||||
`{"type":"reasoning","id":"rs_keep","call_id":"remove","encrypted_content":"cipher"},` +
|
||||
`{"type":"message","content":"continue"}` +
|
||||
`]}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
|
||||
require.Equal(t, int64(2), gjson.GetBytes(normalized, "input.#").Int())
|
||||
require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "input.0.call_id").Exists())
|
||||
require.True(t, gjson.GetBytes(normalized, "input.0.summary").IsArray())
|
||||
require.Equal(t, "message", gjson.GetBytes(normalized, "input.1.type").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEffectiveOpenAISSEEventTypePrefersPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, "response.failed", effectiveOpenAISSEEventType([]byte(`{"type":"response.failed"}`), "error"))
|
||||
require.Equal(t, "error", effectiveOpenAISSEEventType([]byte(`{"error":{"message":"failed"}}`), " error "))
|
||||
require.JSONEq(t, `{"type":"error","error":{"message":"failed"}}`, openAICompatPayloadWithEventType(`{"type":"","error":{"message":"failed"}}`, "error"))
|
||||
}
|
||||
|
||||
func TestExtractOpenAISSETerminalEventUsesEventField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := "event: error\n" +
|
||||
"data: {\"error\":{\"message\":\"provider failed\"}}\n\n" +
|
||||
"data: [DONE]\n\n"
|
||||
eventType, payload, ok := extractOpenAISSETerminalEvent(body)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "error", eventType)
|
||||
require.Equal(t, "provider failed", extractOpenAISSEErrorMessage(payload))
|
||||
}
|
||||
|
||||
func TestParseSSEUsageEffectiveTerminalRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
usage := &OpenAIUsage{}
|
||||
svc.parseSSEUsageBytesWithType([]byte(`{"usage":{"input_tokens":17,"output_tokens":5,"input_tokens_details":{"cached_tokens":3}}}`), "response.in_progress", usage)
|
||||
svc.parseSSEUsageBytesWithType([]byte(`{"response":{"id":"resp_1"}}`), "response.completed", usage)
|
||||
require.Equal(t, OpenAIUsage{InputTokens: 17, OutputTokens: 5, CacheReadInputTokens: 3}, *usage)
|
||||
|
||||
svc.parseSSEUsageBytesWithType([]byte(`{"response":{"usage":{"input_tokens":0,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`), "response.completed", usage)
|
||||
require.Equal(t, OpenAIUsage{InputTokens: 17, OutputTokens: 5, CacheReadInputTokens: 3}, *usage)
|
||||
|
||||
svc.parseSSEUsageBytesWithType([]byte(`{"response":{"usage":{"input_tokens":2,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`), "response.completed", usage)
|
||||
require.Equal(t, OpenAIUsage{InputTokens: 2}, *usage)
|
||||
}
|
||||
|
||||
func TestOpenAICompatTerminalResponseSynthesizesBareError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{"type":"error","code":"upstream_error","error":{"message":"provider failed"}}`)
|
||||
event := &apicompat.ResponsesStreamEvent{Type: "error", Code: "upstream_error"}
|
||||
response := openAICompatTerminalResponse(event, payload)
|
||||
require.NotNil(t, response)
|
||||
require.Equal(t, "failed", response.Status)
|
||||
require.Equal(t, "upstream_error", response.Error.Code)
|
||||
require.Equal(t, "provider failed", response.Error.Message)
|
||||
}
|
||||
|
||||
func TestForEachOpenAISSEFrameDataTypeOverridesEventField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var types []string
|
||||
forEachOpenAISSEFrame(strings.Join([]string{
|
||||
"event: response.in_progress",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1"}}`,
|
||||
"",
|
||||
}, "\n"), func(eventType string, _ []byte) {
|
||||
types = append(types, eventType)
|
||||
})
|
||||
require.Equal(t, []string{"response.completed"}, types)
|
||||
}
|
||||
|
||||
func TestSampleOpenAIMissingUsageLogRateLimitsAndCounts(t *testing.T) {
|
||||
var sampler openAIMissingUsageLogSampler
|
||||
now := time.Unix(100, 0)
|
||||
logNow, total, suppressed := sampler.sample(now)
|
||||
require.True(t, logNow)
|
||||
require.Equal(t, uint64(1), total)
|
||||
require.Zero(t, suppressed)
|
||||
|
||||
logNow, total, _ = sampler.sample(now.Add(time.Second))
|
||||
require.False(t, logNow)
|
||||
require.Equal(t, uint64(2), total)
|
||||
|
||||
logNow, total, suppressed = sampler.sample(now.Add(openAIMissingUsageLogInterval))
|
||||
require.True(t, logNow)
|
||||
require.Equal(t, uint64(3), total)
|
||||
require.Equal(t, uint64(1), suppressed)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
)
|
||||
|
||||
// normalizeOpenAIResponsesLegacyIngress accepts the Chat Completions-shaped
|
||||
// payloads that a few Responses clients still emit. Native Responses input is
|
||||
// always authoritative because this path has no separate full-replay attempt.
|
||||
func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) {
|
||||
if len(body) == 0 {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
var request map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(body, &request); err != nil {
|
||||
return body, false, fmt.Errorf("normalize legacy Responses ingress: %w", err)
|
||||
}
|
||||
|
||||
changed := false
|
||||
messagesValue, hasMessages := request["messages"]
|
||||
if hasMessages {
|
||||
messages, messagesAreArray := messagesValue.([]any)
|
||||
if messagesAreArray && len(messages) > 0 {
|
||||
legacy, err := convertLegacyResponsesMessages(body)
|
||||
if err != nil {
|
||||
return body, false, err
|
||||
}
|
||||
|
||||
input, hasInput := request["input"]
|
||||
hasNativeInput := hasInput && input != nil
|
||||
if !hasNativeInput {
|
||||
request["input"] = legacy.input
|
||||
applyLegacyResponsesTopLevelFields(request, legacy)
|
||||
delete(request, "previous_response_id")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// messages is not a Responses field. When native input is present but the
|
||||
// two histories are ambiguous, retain input and drop only the legacy alias.
|
||||
delete(request, "messages")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if prompt, hasPrompt := request["prompt"]; hasPrompt {
|
||||
if input, hasInput := request["input"]; (!hasInput || input == nil) && prompt != nil {
|
||||
request["input"] = prompt
|
||||
}
|
||||
delete(request, "prompt")
|
||||
changed = true
|
||||
}
|
||||
if _, hasCommands := request["commands"]; hasCommands {
|
||||
delete(request, "commands")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return body, false, nil
|
||||
}
|
||||
normalized, err := marshalOpenAIUpstreamJSON(request)
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("serialize legacy Responses ingress: %w", err)
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
type convertedLegacyResponsesMessages struct {
|
||||
input any
|
||||
instructions string
|
||||
maxOutputTokens *int
|
||||
temperature *float64
|
||||
topP *float64
|
||||
tools any
|
||||
reasoning any
|
||||
toolChoice any
|
||||
serviceTier string
|
||||
hasChatTools bool
|
||||
hasChatToolChoice bool
|
||||
hasReasoningEffort bool
|
||||
}
|
||||
|
||||
func convertLegacyResponsesMessages(body []byte) (convertedLegacyResponsesMessages, error) {
|
||||
var converted convertedLegacyResponsesMessages
|
||||
var chatRequest apicompat.ChatCompletionsRequest
|
||||
if err := json.Unmarshal(body, &chatRequest); err != nil {
|
||||
return converted, fmt.Errorf("normalize legacy Responses messages: %w", err)
|
||||
}
|
||||
responsesRequest, err := apicompat.ChatCompletionsToResponses(&chatRequest)
|
||||
if err != nil {
|
||||
return converted, fmt.Errorf("normalize legacy Responses messages: %w", err)
|
||||
}
|
||||
if len(responsesRequest.Input) == 0 {
|
||||
return converted, fmt.Errorf("normalize legacy Responses messages: empty input")
|
||||
}
|
||||
if err := json.Unmarshal(responsesRequest.Input, &converted.input); err != nil {
|
||||
return converted, fmt.Errorf("decode normalized legacy Responses input: %w", err)
|
||||
}
|
||||
|
||||
converted.instructions = strings.TrimSpace(responsesRequest.Instructions)
|
||||
converted.maxOutputTokens = responsesRequest.MaxOutputTokens
|
||||
converted.temperature = responsesRequest.Temperature
|
||||
converted.topP = responsesRequest.TopP
|
||||
converted.serviceTier = strings.TrimSpace(responsesRequest.ServiceTier)
|
||||
converted.hasReasoningEffort = strings.TrimSpace(chatRequest.ReasoningEffort) != ""
|
||||
if responsesRequest.Reasoning != nil {
|
||||
converted.reasoning = responsesRequest.Reasoning
|
||||
}
|
||||
|
||||
var rawRequest map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(body, &rawRequest); err != nil {
|
||||
return converted, fmt.Errorf("inspect legacy Responses fields: %w", err)
|
||||
}
|
||||
converted.hasChatTools = hasChatStyleTools(rawRequest) || rawRequest["functions"] != nil
|
||||
if converted.hasChatTools && len(responsesRequest.Tools) > 0 {
|
||||
converted.tools = responsesRequest.Tools
|
||||
}
|
||||
converted.hasChatToolChoice = hasChatStyleToolChoice(rawRequest) || rawRequest["function_call"] != nil
|
||||
if converted.hasChatToolChoice && len(responsesRequest.ToolChoice) > 0 {
|
||||
choice, err := normalizeLegacyResponsesToolChoice(responsesRequest.ToolChoice)
|
||||
if err != nil {
|
||||
return converted, err
|
||||
}
|
||||
converted.toolChoice = choice
|
||||
}
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
func hasChatStyleTools(request map[string]any) bool {
|
||||
tools, ok := request["tools"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, rawTool := range tools {
|
||||
tool, _ := rawTool.(map[string]any)
|
||||
if _, ok := tool["function"].(map[string]any); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasChatStyleToolChoice(request map[string]any) bool {
|
||||
choice, _ := request["tool_choice"].(map[string]any)
|
||||
_, ok := choice["function"].(map[string]any)
|
||||
return ok
|
||||
}
|
||||
|
||||
func normalizeLegacyResponsesToolChoice(raw json.RawMessage) (any, error) {
|
||||
var choice any
|
||||
if err := json.Unmarshal(raw, &choice); err != nil {
|
||||
return nil, fmt.Errorf("normalize legacy Responses tool_choice: %w", err)
|
||||
}
|
||||
object, ok := choice.(map[string]any)
|
||||
if !ok || strings.TrimSpace(firstNonEmptyString(object["type"])) != "function" {
|
||||
return choice, nil
|
||||
}
|
||||
function, ok := object["function"].(map[string]any)
|
||||
if !ok {
|
||||
return choice, nil
|
||||
}
|
||||
name := strings.TrimSpace(firstNonEmptyString(function["name"]))
|
||||
if name == "" {
|
||||
return choice, nil
|
||||
}
|
||||
return map[string]any{"type": "function", "name": name}, nil
|
||||
}
|
||||
|
||||
func applyLegacyResponsesTopLevelFields(request map[string]any, legacy convertedLegacyResponsesMessages) {
|
||||
if legacy.instructions != "" && strings.TrimSpace(firstNonEmptyString(request["instructions"])) == "" {
|
||||
request["instructions"] = legacy.instructions
|
||||
}
|
||||
if legacy.maxOutputTokens != nil {
|
||||
request["max_output_tokens"] = *legacy.maxOutputTokens
|
||||
}
|
||||
if legacy.temperature != nil {
|
||||
request["temperature"] = *legacy.temperature
|
||||
}
|
||||
if legacy.topP != nil {
|
||||
request["top_p"] = *legacy.topP
|
||||
}
|
||||
if legacy.hasChatTools && legacy.tools != nil {
|
||||
request["tools"] = legacy.tools
|
||||
}
|
||||
if legacy.hasReasoningEffort && legacy.reasoning != nil {
|
||||
request["reasoning"] = legacy.reasoning
|
||||
}
|
||||
if legacy.hasChatToolChoice && legacy.toolChoice != nil {
|
||||
request["tool_choice"] = legacy.toolChoice
|
||||
}
|
||||
if legacy.serviceTier != "" {
|
||||
request["service_tier"] = legacy.serviceTier
|
||||
}
|
||||
for _, key := range []string{"max_tokens", "max_completion_tokens", "reasoning_effort", "functions", "function_call"} {
|
||||
delete(request, key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestNormalizeOpenAIResponsesLegacyIngressMessagesOnly(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"system","content":"repo policy"},{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"hello\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}],"previous_response_id":"resp_stale"}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "messages").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists())
|
||||
require.Equal(t, "system", gjson.GetBytes(normalized, "input.0.role").String())
|
||||
require.Equal(t, "repo policy", gjson.GetBytes(normalized, "input.0.content").String())
|
||||
require.Equal(t, "function_call", gjson.GetBytes(normalized, "input.1.type").String())
|
||||
require.Equal(t, "call_1", gjson.GetBytes(normalized, "input.1.call_id").String())
|
||||
require.Equal(t, "function_call_output", gjson.GetBytes(normalized, "input.2.type").String())
|
||||
require.Equal(t, "call_1", gjson.GetBytes(normalized, "input.2.call_id").String())
|
||||
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "stream").Type)
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLegacyIngressConvertsChatTopLevelFields(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-4.1","messages":[{"role":"user","content":"weather?"}],"tools":[{"type":"function","function":{"name":"lookup","description":"Lookup weather","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"lookup"}},"max_tokens":64,"reasoning_effort":"high","service_tier":"flex","temperature":0.2,"top_p":0.7}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "user", gjson.GetBytes(normalized, "input.0.role").String())
|
||||
require.Equal(t, "function", gjson.GetBytes(normalized, "tools.0.type").String())
|
||||
require.Equal(t, "lookup", gjson.GetBytes(normalized, "tools.0.name").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "tools.0.function").Exists())
|
||||
require.Equal(t, "function", gjson.GetBytes(normalized, "tool_choice.type").String())
|
||||
require.Equal(t, "lookup", gjson.GetBytes(normalized, "tool_choice.name").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "tool_choice.function").Exists())
|
||||
require.Equal(t, int64(128), gjson.GetBytes(normalized, "max_output_tokens").Int())
|
||||
require.Equal(t, "high", gjson.GetBytes(normalized, "reasoning.effort").String())
|
||||
require.Equal(t, "auto", gjson.GetBytes(normalized, "reasoning.summary").String())
|
||||
require.Equal(t, "flex", gjson.GetBytes(normalized, "service_tier").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "max_tokens").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "reasoning_effort").Exists())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLegacyIngressNativeInputRemainsAuthoritative(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.4","input":[{"id":"msg_native","type":"message","role":"user","content":[{"type":"input_text","text":"keep me"}]}],"messages":[{"role":"user","content":"do not merge"}],"tools":[{"type":"function","function":{"name":"native_shape"}}],"tool_choice":{"type":"function","function":{"name":"native_shape"}},"previous_response_id":"resp_keep"}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "messages").Exists())
|
||||
require.Equal(t, "msg_native", gjson.GetBytes(normalized, "input.0.id").String())
|
||||
require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int())
|
||||
require.Equal(t, "native_shape", gjson.GetBytes(normalized, "tools.0.function.name").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "tools.0.name").Exists())
|
||||
require.Equal(t, "native_shape", gjson.GetBytes(normalized, "tool_choice.function.name").String())
|
||||
require.Equal(t, "resp_keep", gjson.GetBytes(normalized, "previous_response_id").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLegacyIngressKeepsPromptAliasAndDropsCommands(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-4.1","prompt":"hello","commands":[{"name":"legacy"}]}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesLegacyIngress(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "hello", gjson.GetBytes(normalized, "input").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "prompt").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "commands").Exists())
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -62,91 +61,16 @@ func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []an
|
||||
return true
|
||||
}
|
||||
|
||||
func truncateOpenAIResponsesInputText(reqBody map[string]any) bool {
|
||||
input, ok := reqBody["input"].([]any)
|
||||
if !ok || len(input) == 0 {
|
||||
return false
|
||||
}
|
||||
modified := false
|
||||
for _, rawItem := range input {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
|
||||
if isCodexToolCallOutputItemType(itemType) {
|
||||
if output, ok := item["output"].(string); ok {
|
||||
if truncated, changed := truncateOpenAIResponsesInputString(output); changed {
|
||||
item["output"] = truncated
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if truncateOpenAIResponsesMessageText(item) {
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
return modified
|
||||
}
|
||||
|
||||
func openAIResponsesInputMayNeedTruncation(body []byte) bool {
|
||||
if len(body) <= openAIResponsesInputTextMaxChars {
|
||||
return false
|
||||
}
|
||||
if bytes.Contains(body, []byte(`"text"`)) && bytes.Contains(body, []byte(`"content"`)) {
|
||||
return true
|
||||
}
|
||||
for _, itemType := range []string{
|
||||
"function_call_output",
|
||||
"tool_search_output",
|
||||
"custom_tool_call_output",
|
||||
"mcp_tool_call_output",
|
||||
} {
|
||||
if bytes.Contains(body, []byte(itemType)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
func truncateOpenAIResponsesInputText(_ map[string]any) bool {
|
||||
// Do not silently rewrite client or tool output. If an upstream enforces a
|
||||
// text limit, forwarding the original value preserves its explicit error for
|
||||
// the client and the normal Ops error pipeline. This compatibility shim is
|
||||
// retained until the two callers can remove the old mutation hook together.
|
||||
return false
|
||||
}
|
||||
|
||||
func truncateOpenAIResponsesMessageText(item map[string]any) bool {
|
||||
itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
|
||||
role := strings.TrimSpace(firstNonEmptyString(item["role"]))
|
||||
if itemType != "message" && role == "" {
|
||||
return false
|
||||
}
|
||||
parts, ok := item["content"].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
modified := false
|
||||
for _, rawPart := range parts {
|
||||
part, ok := rawPart.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text, ok := part["text"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if truncated, changed := truncateOpenAIResponsesInputString(text); changed {
|
||||
part["text"] = truncated
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
return modified
|
||||
}
|
||||
|
||||
func truncateOpenAIResponsesInputString(value string) (string, bool) {
|
||||
if len(value) <= openAIResponsesInputTextMaxChars {
|
||||
return value, false
|
||||
}
|
||||
chars := 0
|
||||
for index := range value {
|
||||
if chars == openAIResponsesInputTextMaxChars {
|
||||
return value[:index], true
|
||||
}
|
||||
chars++
|
||||
}
|
||||
return value, false
|
||||
func openAIResponsesInputMayNeedTruncation(_ []byte) bool {
|
||||
// See truncateOpenAIResponsesInputText. Returning false also avoids decoding
|
||||
// very large bodies solely for a mutation that must not happen.
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -71,9 +70,11 @@ func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestTruncateOpenAIResponsesInputText(t *testing.T) {
|
||||
oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars+1)
|
||||
func TestOpenAIResponsesInputTextIsNeverSilentlyTruncated(t *testing.T) {
|
||||
atLimit := strings.Repeat("z", openAIResponsesInputTextMaxChars)
|
||||
oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars) + "中"
|
||||
input := []any{
|
||||
map[string]any{"type": "function_call_output", "call_id": "limit", "output": atLimit},
|
||||
map[string]any{"type": "function_call_output", "call_id": "a", "output": oversized},
|
||||
map[string]any{"type": "tool_search_output", "call_id": "b", "output": oversized},
|
||||
map[string]any{"type": "custom_tool_call_output", "call_id": "c", "output": oversized},
|
||||
@@ -88,35 +89,25 @@ func TestTruncateOpenAIResponsesInputText(t *testing.T) {
|
||||
}
|
||||
reqBody := map[string]any{"input": input}
|
||||
|
||||
require.True(t, truncateOpenAIResponsesInputText(reqBody))
|
||||
for _, rawItem := range input[:4] {
|
||||
require.False(t, truncateOpenAIResponsesInputText(reqBody))
|
||||
require.Equal(t, atLimit, input[0].(map[string]any)["output"])
|
||||
for _, rawItem := range input[1:5] {
|
||||
item := rawItem.(map[string]any)
|
||||
require.Len(t, item["output"].(string), openAIResponsesInputTextMaxChars)
|
||||
require.Equal(t, oversized, item["output"])
|
||||
}
|
||||
content := input[4].(map[string]any)["content"].([]any)
|
||||
content := input[5].(map[string]any)["content"].([]any)
|
||||
require.Equal(t, "short", content[0].(map[string]any)["text"])
|
||||
require.Len(t, content[1].(map[string]any)["text"].(string), openAIResponsesInputTextMaxChars)
|
||||
require.Equal(t, oversized, content[1].(map[string]any)["text"])
|
||||
}
|
||||
|
||||
func TestTruncateOpenAIResponsesInputStringPreservesUTF8Boundary(t *testing.T) {
|
||||
value := strings.Repeat("a", openAIResponsesInputTextMaxChars-1) + "中中"
|
||||
|
||||
got, changed := truncateOpenAIResponsesInputString(value)
|
||||
|
||||
require.True(t, changed)
|
||||
require.True(t, utf8.ValidString(got))
|
||||
require.Equal(t, openAIResponsesInputTextMaxChars, utf8.RuneCountInString(got))
|
||||
require.Equal(t, "中", got[len(got)-len("中"):])
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesInputMayNeedTruncation(t *testing.T) {
|
||||
func TestOpenAIResponsesInputNeverRequestsPreemptiveTruncation(t *testing.T) {
|
||||
short := []byte(`{"input":[{"type":"function_call_output","output":"ok"}]}`)
|
||||
largeUnrelated := []byte(`{"input":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}`)
|
||||
largeOutput := []byte(`{"input":[{"type":"function_call_output","output":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}]}`)
|
||||
|
||||
require.False(t, openAIResponsesInputMayNeedTruncation(short))
|
||||
require.False(t, openAIResponsesInputMayNeedTruncation(largeUnrelated))
|
||||
require.True(t, openAIResponsesInputMayNeedTruncation(largeOutput))
|
||||
require.False(t, openAIResponsesInputMayNeedTruncation(largeOutput))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthDropsOrphanAfterDroppingPreviousResponse(t *testing.T) {
|
||||
@@ -139,8 +130,9 @@ func TestOpenAIGatewayService_OAuthDropsOrphanAfterDroppingPreviousResponse(t *t
|
||||
require.Empty(t, gjson.GetBytes(upstream.bodies[0], "input").Array())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_TruncatesOversizedToolOutputBeforeForward(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"` + strings.Repeat("x", openAIResponsesInputTextMaxChars+1) + `"}]}`)
|
||||
func TestOpenAIGatewayService_PreservesOversizedToolOutputForUpstream(t *testing.T) {
|
||||
oversized := strings.Repeat("x", openAIResponsesInputTextMaxChars) + "中"
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"` + oversized + `"}]}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
|
||||
}}
|
||||
@@ -155,5 +147,5 @@ func TestOpenAIGatewayService_TruncatesOversizedToolOutputBeforeForward(t *testi
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
require.Equal(t, openAIResponsesInputTextMaxChars, len(gjson.GetBytes(upstream.bodies[0], "input.1.output").String()))
|
||||
require.Equal(t, oversized, gjson.GetBytes(upstream.bodies[0], "input.1.output").String())
|
||||
}
|
||||
|
||||
@@ -14,10 +14,16 @@ func openAIResponsesInputItemIDPrefix(itemType string) (string, bool) {
|
||||
return "msg", true
|
||||
case "reasoning":
|
||||
return "rs", true
|
||||
case "web_search_call":
|
||||
return "ws", true
|
||||
case "custom_tool_call":
|
||||
return openAIResponsesToolCallIDPrefix(itemType), true
|
||||
case "tool_search_call":
|
||||
return openAIResponsesToolCallIDPrefix(itemType), true
|
||||
case "custom_tool_call_output":
|
||||
// Although custom calls use ctc IDs, OpenAI validates replayed custom
|
||||
// call output item IDs against the generic fc namespace.
|
||||
return "fc", true
|
||||
default:
|
||||
if isCodexToolCallInputType(itemType) {
|
||||
return openAIResponsesToolCallIDPrefix(itemType), true
|
||||
@@ -40,14 +46,20 @@ func openAIResponsesToolCallIDPrefix(itemType string) string {
|
||||
// Invalid replayed IDs are removed rather than rewritten because a fabricated
|
||||
// ID may point at a different upstream object.
|
||||
func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
prefix, constrained := openAIResponsesInputItemIDPrefix(itemType)
|
||||
if !constrained {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(id, prefix)
|
||||
return id == "" || !strings.HasPrefix(id, prefix)
|
||||
}
|
||||
|
||||
func shouldStripOpenAIResponsesNonPairCallID(itemType string) bool {
|
||||
switch strings.TrimSpace(itemType) {
|
||||
case "message", "reasoning", "image_generation_call":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) {
|
||||
@@ -56,40 +68,119 @@ func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
items := make([][]byte, 0)
|
||||
changed := false
|
||||
var sanitizeErr error
|
||||
index := 0
|
||||
type inputItem struct {
|
||||
body []byte
|
||||
itemType string
|
||||
id string
|
||||
callID string
|
||||
stripID bool
|
||||
stripCallID bool
|
||||
drop bool
|
||||
isObject bool
|
||||
}
|
||||
|
||||
items := make([]inputItem, 0)
|
||||
strippedIDs := make(map[string]struct{})
|
||||
validCallIDs := make(map[string]struct{})
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
currentIndex := index
|
||||
index++
|
||||
itemBody := []byte(item.Raw)
|
||||
parsed := inputItem{body: []byte(item.Raw), isObject: item.IsObject()}
|
||||
if item.IsObject() {
|
||||
itemType := item.Get("type")
|
||||
id := item.Get("id")
|
||||
if itemType.Type == gjson.String && id.Type == gjson.String &&
|
||||
shouldStripOpenAIResponsesInputItemID(itemType.String(), id.String()) {
|
||||
itemBody, sanitizeErr = sjson.DeleteBytes(itemBody, "id")
|
||||
if sanitizeErr != nil {
|
||||
sanitizeErr = fmt.Errorf("delete input.%d.id: %w", currentIndex, sanitizeErr)
|
||||
return false
|
||||
parsed.itemType = strings.TrimSpace(itemType.String())
|
||||
parsed.callID = strings.TrimSpace(item.Get("call_id").String())
|
||||
parsed.stripCallID = item.Get("call_id").Exists() && shouldStripOpenAIResponsesNonPairCallID(parsed.itemType)
|
||||
if id.Type == gjson.String {
|
||||
parsed.id = id.String()
|
||||
parsed.stripID = shouldStripOpenAIResponsesInputItemID(parsed.itemType, parsed.id)
|
||||
if parsed.stripID && parsed.id != "" {
|
||||
strippedIDs[parsed.id] = struct{}{}
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if isCodexToolCallContextItemType(parsed.itemType) && parsed.callID != "" {
|
||||
validCallIDs[parsed.callID] = struct{}{}
|
||||
}
|
||||
}
|
||||
items = append(items, itemBody)
|
||||
items = append(items, parsed)
|
||||
return true
|
||||
})
|
||||
if sanitizeErr != nil {
|
||||
return nil, false, sanitizeErr
|
||||
hasSanitization := false
|
||||
for _, item := range items {
|
||||
if item.stripID || item.stripCallID {
|
||||
hasSanitization = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
if !hasSanitization {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
// First decide which outputs become dangling because their call_id points at
|
||||
// an item ID that is being removed and no call item owns that call_id. This
|
||||
// must happen before computing retained IDs: a dropped output cannot keep an
|
||||
// item_reference alive merely because it used to have the same id.
|
||||
for index := range items {
|
||||
item := &items[index]
|
||||
if !item.isObject || !isCodexToolCallOutputItemType(item.itemType) {
|
||||
continue
|
||||
}
|
||||
if _, pointsAtStrippedID := strippedIDs[item.callID]; !pointsAtStrippedID {
|
||||
continue
|
||||
}
|
||||
if _, hasMatchingCallID := validCallIDs[item.callID]; !hasMatchingCallID {
|
||||
item.drop = true
|
||||
}
|
||||
}
|
||||
|
||||
removedItemIDs := make(map[string]struct{}, len(strippedIDs))
|
||||
retainedItemIDs := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item.id == "" || item.itemType == "item_reference" {
|
||||
continue
|
||||
}
|
||||
if item.stripID || item.drop {
|
||||
removedItemIDs[item.id] = struct{}{}
|
||||
continue
|
||||
}
|
||||
retainedItemIDs[item.id] = struct{}{}
|
||||
}
|
||||
for id := range retainedItemIDs {
|
||||
delete(removedItemIDs, id)
|
||||
}
|
||||
|
||||
rebuiltItems := make([][]byte, 0, len(items))
|
||||
for index, item := range items {
|
||||
if item.isObject {
|
||||
if item.itemType == "item_reference" {
|
||||
if _, dangling := removedItemIDs[item.id]; dangling {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if item.drop {
|
||||
continue
|
||||
}
|
||||
}
|
||||
itemBody := item.body
|
||||
if item.stripID {
|
||||
var err error
|
||||
itemBody, err = sjson.DeleteBytes(itemBody, "id")
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("delete input.%d.id: %w", index, err)
|
||||
}
|
||||
}
|
||||
if item.stripCallID {
|
||||
var err error
|
||||
itemBody, err = sjson.DeleteBytes(itemBody, "call_id")
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("delete input.%d.call_id: %w", index, err)
|
||||
}
|
||||
}
|
||||
rebuiltItems = append(rebuiltItems, itemBody)
|
||||
}
|
||||
|
||||
rebuiltInput := make([]byte, 0, len(input.Raw))
|
||||
rebuiltInput = append(rebuiltInput, '[')
|
||||
for i, item := range items {
|
||||
for i, item := range rebuiltItems {
|
||||
if i > 0 {
|
||||
rebuiltInput = append(rebuiltInput, ',')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestOpenAIResponsesInputItemIDPrefixUsesObservedOutputContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
itemType string
|
||||
id string
|
||||
strip bool
|
||||
}{
|
||||
{itemType: "message", id: "msg_123", strip: false},
|
||||
{itemType: "message", id: "item_123", strip: true},
|
||||
{itemType: "reasoning", id: "rs_123", strip: false},
|
||||
{itemType: "reasoning", id: "item_123", strip: true},
|
||||
{itemType: "function_call", id: "fc_123", strip: false},
|
||||
{itemType: "function_call", id: "call_123", strip: true},
|
||||
{itemType: "tool_call", id: "fc_123", strip: false},
|
||||
{itemType: "local_shell_call", id: "fc_123", strip: false},
|
||||
{itemType: "mcp_tool_call", id: "fc_123", strip: false},
|
||||
{itemType: "custom_tool_call", id: "ctc_123", strip: false},
|
||||
{itemType: "custom_tool_call", id: "fc_123", strip: true},
|
||||
{itemType: "tool_search_call", id: "tsc_123", strip: false},
|
||||
{itemType: "tool_search_call", id: "fc_123", strip: true},
|
||||
{itemType: "web_search_call", id: "ws_123", strip: false},
|
||||
{itemType: "web_search_call", id: "item_123", strip: true},
|
||||
{itemType: "custom_tool_call_output", id: "fc_123", strip: false},
|
||||
{itemType: "custom_tool_call_output", id: "ctco_123", strip: true},
|
||||
// Do not impose an inferred contract on output types for which there is
|
||||
// no observed upstream prefix rejection.
|
||||
{itemType: "function_call_output", id: "fco_123", strip: false},
|
||||
{itemType: "tool_search_output", id: "tso_123", strip: false},
|
||||
{itemType: "mcp_tool_call_output", id: "mcpo_123", strip: false},
|
||||
{itemType: "future_item", id: "item_123", strip: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.itemType+"/"+tt.id, func(t *testing.T) {
|
||||
require.Equal(t, tt.strip, shouldStripOpenAIResponsesInputItemID(tt.itemType, tt.id))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesInputItemIDsKeepsReferenceGraphConsistent(t *testing.T) {
|
||||
body := []byte(`{"input":[
|
||||
{"type":"function_call","id":"item_bad_call","call_id":"call_valid","name":"lookup","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_valid","output":"preserve paired output"},
|
||||
{"type":"function_call_output","call_id":"item_bad_call","output":"drop dangling output"},
|
||||
{"type":"item_reference","id":"item_bad_call"},
|
||||
{"type":"item_reference","id":"remote_valid"},
|
||||
{"type":"custom_tool_call","id":"ctc_valid","call_id":"ctco_bad_output","name":"apply_patch","input":"patch"},
|
||||
{"type":"custom_tool_call_output","id":"ctco_bad_output","call_id":"ctco_bad_output","output":"preserve by call_id"}
|
||||
]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
items := gjson.GetBytes(sanitized, "input").Array()
|
||||
require.Len(t, items, 5)
|
||||
require.False(t, items[0].Get("id").Exists())
|
||||
require.Equal(t, "call_valid", items[0].Get("call_id").String())
|
||||
require.Equal(t, "preserve paired output", items[1].Get("output").String())
|
||||
require.Equal(t, "remote_valid", items[2].Get("id").String())
|
||||
require.Equal(t, "ctc_valid", items[3].Get("id").String())
|
||||
require.False(t, items[4].Get("id").Exists())
|
||||
require.Equal(t, "ctco_bad_output", items[4].Get("call_id").String())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesInputItemIDsLeavesUnrelatedReferencesUntouched(t *testing.T) {
|
||||
body := []byte(`{"previous_response_id":"resp_1","input":[{"type":"item_reference","id":"remote_item"}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, sanitized)
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesInputItemIDsPreservesReferenceToDuplicateRetainedID(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"function_call","id":"ctc_shared","call_id":"call_1"},{"type":"custom_tool_call","id":"ctc_shared","call_id":"call_2"},{"type":"item_reference","id":"ctc_shared"}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists())
|
||||
require.Equal(t, "ctc_shared", gjson.GetBytes(sanitized, "input.1.id").String())
|
||||
require.Equal(t, "ctc_shared", gjson.GetBytes(sanitized, "input.2.id").String())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesInputItemIDsClosesReferencesAfterDroppingOutput(t *testing.T) {
|
||||
body := []byte(`{"input":[
|
||||
{"type":"function_call","id":"item_shared","call_id":"call_real"},
|
||||
{"type":"function_call_output","id":"item_shared","call_id":"item_shared","output":"dangling"},
|
||||
{"type":"item_reference","id":"item_shared"},
|
||||
{"type":"function_call_output","id":"kept_output","call_id":"call_real","output":"kept"},
|
||||
{"type":"item_reference","id":"kept_output"}
|
||||
]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Len(t, gjson.GetBytes(sanitized, "input").Array(), 3)
|
||||
require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists())
|
||||
require.Equal(t, "kept_output", gjson.GetBytes(sanitized, "input.1.id").String())
|
||||
require.Equal(t, "kept_output", gjson.GetBytes(sanitized, "input.2.id").String())
|
||||
|
||||
second, changedAgain, err := sanitizeOpenAIResponsesInputItemIDs(sanitized)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, sanitized, second)
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesInputItemIDsStripsEmptyKnownIDsOnly(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"message","id":"","content":"hello"},{"type":"future_item","id":""}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(sanitized, "input.0.id").Exists())
|
||||
require.True(t, gjson.GetBytes(sanitized, "input.1.id").Exists())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesInputItemIDsStripsOnlyNonPairCallIDs(t *testing.T) {
|
||||
body := []byte(`{"input":[
|
||||
{"type":"message","call_id":"remove_message","content":"hi"},
|
||||
{"type":"reasoning","call_id":"remove_reasoning","id":"rs_keep","encrypted_content":"cipher","summary":[]},
|
||||
{"type":"image_generation_call","call_id":"remove_image","id":"ig_keep","status":"completed"},
|
||||
{"type":"function_call","call_id":"keep_function","name":"lookup","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"keep_function","output":"ok"},
|
||||
{"type":"custom_tool_call","call_id":"keep_custom","name":"patch","input":"x"},
|
||||
{"type":"custom_tool_call_output","call_id":"keep_custom","output":"ok"},
|
||||
{"type":"tool_search_call","call_id":"keep_search","arguments":"{}"},
|
||||
{"type":"tool_search_output","call_id":"keep_search","output":"ok"},
|
||||
{"type":"local_shell_call","call_id":"keep_shell","name":"shell","arguments":"{}"}
|
||||
]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
for i := 0; i < 3; i++ {
|
||||
require.False(t, gjson.GetBytes(sanitized, "input."+strconv.Itoa(i)+".call_id").Exists())
|
||||
}
|
||||
for i := 3; i < 10; i++ {
|
||||
require.True(t, gjson.GetBytes(sanitized, "input."+strconv.Itoa(i)+".call_id").Exists())
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func shouldFlattenOpenAIResponsesNamespaces(
|
||||
passthroughEnabled bool,
|
||||
compactPath bool,
|
||||
) bool {
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
if account == nil || !account.IsOpenAIOAuthLike() {
|
||||
return false
|
||||
}
|
||||
if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() {
|
||||
@@ -58,7 +58,7 @@ func shouldFlattenOpenAIResponsesNamespaces(
|
||||
// namespaces for OpenAI OAuth and API Key HTTP forwarding. Native WSv2 keeps
|
||||
// namespaces because that protocol supports them and does not restore payloads.
|
||||
func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
|
||||
if account == nil || (!account.IsOpenAIOAuth() && !account.IsOpenAIApiKey()) {
|
||||
if account == nil || (!account.IsOpenAIOAuthLike() && !account.IsOpenAIApiKey()) {
|
||||
return false
|
||||
}
|
||||
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
|
||||
@@ -87,7 +87,7 @@ func shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
passthroughEnabled bool,
|
||||
compactPath bool,
|
||||
) bool {
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
if account == nil || !account.IsOpenAIOAuthLike() {
|
||||
return false
|
||||
}
|
||||
if compactPath {
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
|
||||
{name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
|
||||
// API Key 出口是标准 Responses API,不认识该字段。
|
||||
{name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "setup_token_strips", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "setup_token_keeps", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -125,7 +125,7 @@ func TestShouldStripOpenAIResponsesInputNamespaces(t *testing.T) {
|
||||
{name: "apikey_wsv2", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
{name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
|
||||
{name: "apikey_wsv2_passthrough", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
|
||||
{name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
@@ -19,18 +21,54 @@ var (
|
||||
openAIResponsesRejectedStatusParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.status$`)
|
||||
openAIResponsesRejectedContentParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.content$`)
|
||||
openAIResponsesRejectedCacheParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.prompt_cache_breakpoint$`)
|
||||
openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`)
|
||||
openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|truncation|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`)
|
||||
openAIResponsesInvalidTypeMessageParamPattern = regexp.MustCompile(`(?i)invalid[ _-]+type\s+for\s+["']?(input\[\d+\]\.content)(?:["']|\b)[^\n]*\b(?:got|received)\s+null\b`)
|
||||
openAIResponsesMaxZeroContentMessagePattern = regexp.MustCompile(`(?i)invalid\s+["']?(input\[\d+\]\.content)["']?\s*:\s*array too long\.[^\n]*maximum length 0\b`)
|
||||
openAIResponsesCacheModelRejectionPattern = regexp.MustCompile(`(?i)["']?(prompt_cache_breakpoint|input\[\d+\]\.prompt_cache_breakpoint)["']?\s+is\s+not\s+supported\s+on\s+this\s+model\b`)
|
||||
openAIResponsesToolParametersParamPattern = regexp.MustCompile(`(?i)^(?:tools|input)\[\d+\](?:\.tools\[\d+\])*(?:\.function)?\.parameters$`)
|
||||
openAIResponsesMissingSchemaTypePattern = regexp.MustCompile(`(?i)\bgot\s+["']?type\s*:\s*["']?none["']?`)
|
||||
)
|
||||
|
||||
type openAIResponsesRejectedFieldRetryState struct {
|
||||
attempts int
|
||||
mu sync.Mutex
|
||||
budget *openAIResponsesRejectedFieldRetryBudget
|
||||
seenBodyHashes map[[sha256.Size]byte]struct{}
|
||||
}
|
||||
|
||||
type openAIResponsesRejectedFieldRetryBudget struct {
|
||||
mu sync.Mutex
|
||||
attempts int
|
||||
}
|
||||
|
||||
const openAIResponsesRejectedFieldRetryBudgetContextKey = "openai_responses_rejected_field_retry_budget"
|
||||
|
||||
// openAIResponsesRejectedFieldRetryStateForRequest returns a fresh loop guard
|
||||
// for one account attempt backed by the inbound request's shared retry budget.
|
||||
// A later account may apply the same compatibility transform, while all account
|
||||
// attempts together remain bounded.
|
||||
func openAIResponsesRejectedFieldRetryStateForRequest(c *gin.Context, initialBody []byte) *openAIResponsesRejectedFieldRetryState {
|
||||
var budget *openAIResponsesRejectedFieldRetryBudget
|
||||
if c != nil {
|
||||
if existing, ok := c.Get(openAIResponsesRejectedFieldRetryBudgetContextKey); ok {
|
||||
budget, _ = existing.(*openAIResponsesRejectedFieldRetryBudget)
|
||||
}
|
||||
}
|
||||
if budget == nil {
|
||||
budget = &openAIResponsesRejectedFieldRetryBudget{}
|
||||
if c != nil {
|
||||
c.Set(openAIResponsesRejectedFieldRetryBudgetContextKey, budget)
|
||||
}
|
||||
}
|
||||
return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, budget)
|
||||
}
|
||||
|
||||
func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIResponsesRejectedFieldRetryState {
|
||||
return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, &openAIResponsesRejectedFieldRetryBudget{})
|
||||
}
|
||||
|
||||
func newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody []byte, budget *openAIResponsesRejectedFieldRetryBudget) *openAIResponsesRejectedFieldRetryState {
|
||||
state := &openAIResponsesRejectedFieldRetryState{
|
||||
budget: budget,
|
||||
seenBodyHashes: make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1),
|
||||
}
|
||||
state.remember(initialBody)
|
||||
@@ -38,15 +76,22 @@ func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIRespon
|
||||
}
|
||||
|
||||
func (s *openAIResponsesRejectedFieldRetryState) Allow(nextBody []byte) bool {
|
||||
if s == nil || len(nextBody) == 0 || s.attempts >= maxOpenAIResponsesRejectedFieldRetries {
|
||||
if s == nil || s.budget == nil || len(nextBody) == 0 {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
bodyHash := sha256.Sum256(nextBody)
|
||||
if _, seen := s.seenBodyHashes[bodyHash]; seen {
|
||||
return false
|
||||
}
|
||||
s.budget.mu.Lock()
|
||||
defer s.budget.mu.Unlock()
|
||||
if s.budget.attempts >= maxOpenAIResponsesRejectedFieldRetries {
|
||||
return false
|
||||
}
|
||||
s.seenBodyHashes[bodyHash] = struct{}{}
|
||||
s.attempts++
|
||||
s.budget.attempts++
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -54,6 +99,12 @@ func (s *openAIResponsesRejectedFieldRetryState) remember(body []byte) {
|
||||
if s == nil || len(body) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rememberLocked(body)
|
||||
}
|
||||
|
||||
func (s *openAIResponsesRejectedFieldRetryState) rememberLocked(body []byte) {
|
||||
if s.seenBodyHashes == nil {
|
||||
s.seenBodyHashes = make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1)
|
||||
}
|
||||
@@ -68,6 +119,17 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
|
||||
code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody)))
|
||||
message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
|
||||
param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String()))
|
||||
if code == "invalid_function_parameters" &&
|
||||
openAIResponsesToolParametersParamPattern.MatchString(param) &&
|
||||
openAIResponsesMissingSchemaTypePattern.MatchString(message) {
|
||||
retryBody, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("repair rejected tool parameter root type: %w", err)
|
||||
}
|
||||
if changed {
|
||||
return retryBody, "tool parameter root type rejection", true, nil
|
||||
}
|
||||
}
|
||||
cacheMessageParam := openAIResponsesCacheModelRejectionParamFromMessage(message)
|
||||
cacheParam := param
|
||||
if cacheParam == "" {
|
||||
@@ -88,8 +150,12 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
|
||||
}
|
||||
}
|
||||
if isExplicitOpenAIResponsesFieldRejection(code, message) {
|
||||
messageParam := openAIResponsesRejectedParamFromMessage(message)
|
||||
if param != "" && messageParam != "" && param != messageParam {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
if param == "" {
|
||||
param = openAIResponsesRejectedParamFromMessage(message)
|
||||
param = messageParam
|
||||
}
|
||||
if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok {
|
||||
return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index)
|
||||
@@ -104,6 +170,13 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
|
||||
}
|
||||
return retryBody, "max_output_tokens parameter rejection", true, nil
|
||||
}
|
||||
if param == "truncation" && gjson.GetBytes(body, "truncation").Exists() {
|
||||
retryBody, err := sjson.DeleteBytes(body, "truncation")
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("delete rejected truncation: %w", err)
|
||||
}
|
||||
return retryBody, "truncation parameter rejection", true, nil
|
||||
}
|
||||
}
|
||||
|
||||
messageContentParam := openAIResponsesInvalidTypeParamFromMessage(message)
|
||||
@@ -115,6 +188,11 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon
|
||||
contentParam == messageContentParam && isExplicitOpenAIResponsesNullContentRejection(code, message) {
|
||||
return normalizeOpenAIResponsesRejectedNullContentAtIndex(body, index)
|
||||
}
|
||||
maxZeroContentParam := openAIResponsesMaxZeroContentParamFromMessage(message)
|
||||
if index, ok := openAIResponsesRejectedContentIndex(param); ok &&
|
||||
param == maxZeroContentParam && code == "array_above_max_length" {
|
||||
return removeOpenAIResponsesRejectedReasoningContentAtIndex(body, index)
|
||||
}
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
||||
@@ -135,6 +213,14 @@ func openAIResponsesRejectedParamFromMessage(message string) string {
|
||||
return strings.ToLower(strings.TrimSpace(match[1]))
|
||||
}
|
||||
|
||||
func openAIResponsesMaxZeroContentParamFromMessage(message string) string {
|
||||
match := openAIResponsesMaxZeroContentMessagePattern.FindStringSubmatch(strings.TrimSpace(message))
|
||||
if len(match) != 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(match[1]))
|
||||
}
|
||||
|
||||
func openAIResponsesInvalidTypeParamFromMessage(message string) string {
|
||||
match := openAIResponsesInvalidTypeMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message))
|
||||
if len(match) != 2 {
|
||||
@@ -246,6 +332,20 @@ func normalizeOpenAIResponsesRejectedNullContentAtIndex(body []byte, index int)
|
||||
}
|
||||
}
|
||||
|
||||
func removeOpenAIResponsesRejectedReasoningContentAtIndex(body []byte, index int) ([]byte, string, bool, error) {
|
||||
itemPath := fmt.Sprintf("input.%d", index)
|
||||
item := gjson.GetBytes(body, itemPath)
|
||||
content := item.Get("content")
|
||||
if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != "reasoning" || !content.IsArray() || len(content.Array()) == 0 {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
retryBody, err := sjson.DeleteBytes(body, itemPath+".content")
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("delete rejected reasoning content at input[%d]: %w", index, err)
|
||||
}
|
||||
return retryBody, "indexed reasoning content maximum-length rejection", true, nil
|
||||
}
|
||||
|
||||
func removeOpenAIResponsesRejectedNamespaceAtIndex(body []byte, index int) ([]byte, string, bool, error) {
|
||||
itemPath := fmt.Sprintf("input.%d", index)
|
||||
itemType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, itemPath+".type").String()))
|
||||
|
||||
@@ -30,6 +30,33 @@ func TestOpenAIResponsesRejectedFieldRetryStateRejectsDuplicateBodyAndCap(t *tes
|
||||
require.False(t, state.Allow([]byte(`{"model":"gpt-5.5","variant":"overflow"}`)))
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesRejectedFieldRetryStateForRequestAllowsSameTransformAcrossAccounts(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
initialBody := []byte(`{"model":"gpt-5.5","truncation":"auto"}`)
|
||||
retryBody := []byte(`{"model":"gpt-5.5"}`)
|
||||
|
||||
accountA := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody)
|
||||
require.True(t, accountA.Allow(retryBody))
|
||||
require.False(t, accountA.Allow(retryBody), "one account must not repeat the same transform")
|
||||
|
||||
accountB := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody)
|
||||
require.NotSame(t, accountA, accountB)
|
||||
require.Same(t, accountA.budget, accountB.budget)
|
||||
require.True(t, accountB.Allow(retryBody), "a failover account must be allowed to apply the same transform")
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesRejectedFieldRetryStateForRequestSharesBoundedBudgetAcrossAccounts(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ {
|
||||
state := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(fmt.Sprintf(`{"account":%d}`, attempt)))
|
||||
require.True(t, state.Allow([]byte(`{"same":"retry"}`)))
|
||||
}
|
||||
overflow := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(`{"account":"overflow"}`))
|
||||
require.False(t, overflow.Allow([]byte(`{"new":"retry"}`)))
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -56,6 +83,11 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t
|
||||
body: []byte(`{"max_output_tokens":4096,"input":[{"type":"message","content":{"max_output_tokens":"keep"}}]}`),
|
||||
responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.max_output_tokens","param":"input[0].content.max_output_tokens"}}`),
|
||||
},
|
||||
{
|
||||
name: "structured target conflicts with message target",
|
||||
body: []byte(`{"max_output_tokens":4096,"truncation":"auto"}`),
|
||||
responseBody: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: truncation.","param":"max_output_tokens"}}`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -68,6 +100,33 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRepairsAutomationMissingRootType(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"object","properties":{}}]}}]}`)
|
||||
responseBody := []byte(`{"error":{"code":"invalid_function_parameters","message":"Invalid schema for function 'automation_update': got 'type: \"None\"'.","param":"tools[0].parameters"}}`)
|
||||
|
||||
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "tool parameter root type rejection", reason)
|
||||
require.Equal(t, "object", gjson.GetBytes(retryBody, "tools.0.parameters.type").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotGuessAutomationRootType(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"}]}}]}`)
|
||||
tests := []string{
|
||||
`{"error":{"code":"invalid_function_parameters","message":"got type: \"None\"","param":"metadata.parameters"}}`,
|
||||
`{"error":{"code":"invalid_request_error","message":"got type: \"None\"","param":"tools[0].parameters"}}`,
|
||||
`{"error":{"code":"invalid_function_parameters","message":"expected an object","param":"tools[0].parameters"}}`,
|
||||
}
|
||||
for _, response := range tests {
|
||||
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, []byte(response))
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Nil(t, retryBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyFindsNamespacePathInMessage(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`)
|
||||
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0] was accepted; Unknown parameter: 'input[1].namespace'."}}`)
|
||||
@@ -192,6 +251,86 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyNormalizesExactNullConten
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactReasoningContentAboveMaximumZero(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"reasoning","content":[{"type":"reasoning_text","text":"remove"}],"summary":[]},{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`)
|
||||
responseBody := []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content","type":"invalid_request_error"}}`)
|
||||
|
||||
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "indexed reasoning content maximum-length rejection", reason)
|
||||
require.False(t, gjson.GetBytes(retryBody, "input.0.content").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.1.content.0.text").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeReasoningMaximumZeroMutations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
responseBody []byte
|
||||
}{
|
||||
{
|
||||
name: "message content is not reasoning",
|
||||
body: []byte(`{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`),
|
||||
responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
|
||||
},
|
||||
{
|
||||
name: "structured param and message disagree",
|
||||
body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]},{"type":"reasoning","content":[{"text":"keep too"}]}]}`),
|
||||
responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[1].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
|
||||
},
|
||||
{
|
||||
name: "different error code",
|
||||
body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]}]}`),
|
||||
responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Nil(t, retryBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExplicitlyRejectedTopLevelTruncation(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","truncation":"auto","input":"keep"}`)
|
||||
responses := [][]byte{
|
||||
[]byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`),
|
||||
[]byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: truncation."}}`),
|
||||
}
|
||||
for _, responseBody := range responses {
|
||||
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "truncation parameter rejection", reason)
|
||||
require.False(t, gjson.GetBytes(retryBody, "truncation").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_APIKeyRetriesExplicitlyRejectedTopLevelTruncation(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"truncation":"auto","input":"keep"}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`),
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
|
||||
}}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Equal(t, "auto", gjson.GetBytes(upstream.bodies[0], "truncation").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "truncation").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeIndexedMutations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -3,95 +3,67 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// Keep this aligned with the gateway's default maximum request-body size. A
|
||||
// caller with a larger custom limit must not make this compatibility pass an
|
||||
// unbounded parser or patch accumulator.
|
||||
openAIResponsesToolSchemaMaxBodySize = 256 << 20
|
||||
openAIResponsesToolSchemaMaxDepth = 128
|
||||
openAIResponsesToolSchemaMaxEdits = 1 << 20
|
||||
openAIResponsesObjectUnionMaxSize = 1 << 20
|
||||
openAIResponsesObjectUnionMaxDepth = 32
|
||||
|
||||
openAIResponsesToolSchemaFallbackType = `"object"`
|
||||
)
|
||||
|
||||
var errOpenAIResponsesToolSchemaLimit = errors.New("OpenAI Responses tool schema safety limit exceeded")
|
||||
|
||||
// shouldSanitizeOpenAIResponsesToolSchemas centralizes the platform boundary
|
||||
// for callers. These rewrites describe OpenAI Responses constraints, not the
|
||||
// behavior of every provider routed through the generic OpenAI gateway.
|
||||
func shouldSanitizeOpenAIResponsesToolSchemas(platform string) bool {
|
||||
return platform == PlatformOpenAI
|
||||
}
|
||||
|
||||
// sanitizeOpenAIResponsesToolSchemaPatterns removes only schema constraints
|
||||
// containing regex lookaround, which OpenAI rejects. It deliberately does not
|
||||
// descend into instance-valued keywords such as default, examples, const, or
|
||||
// enum.
|
||||
func sanitizeOpenAIResponsesToolSchemaPatterns(body []byte) ([]byte, bool, error) {
|
||||
if len(body) == 0 || !bytes.Contains(body, []byte(`"pattern"`)) {
|
||||
if len(body) == 0 || !openAIResponsesBodyMayContainRegexLookaround(body) {
|
||||
return body, false, nil
|
||||
}
|
||||
var root any
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&root); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
changed := false
|
||||
var visitSchema func(any)
|
||||
visitSchema = func(value any) {
|
||||
node, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pattern, ok := node["pattern"].(string); ok && hasRegexLookaround(pattern) {
|
||||
delete(node, "pattern")
|
||||
changed = true
|
||||
}
|
||||
for _, key := range []string{
|
||||
"additionalProperties", "additionalItems", "contains", "not", "if", "then", "else",
|
||||
"propertyNames", "unevaluatedProperties", "unevaluatedItems",
|
||||
} {
|
||||
visitSchema(node[key])
|
||||
}
|
||||
if items, ok := node["items"].(map[string]any); ok {
|
||||
visitSchema(items)
|
||||
} else if items, ok := node["items"].([]any); ok {
|
||||
for _, child := range items {
|
||||
visitSchema(child)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
|
||||
children, _ := node[key].([]any)
|
||||
for _, child := range children {
|
||||
visitSchema(child)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"} {
|
||||
children, _ := node[key].(map[string]any)
|
||||
for _, child := range children {
|
||||
visitSchema(child)
|
||||
}
|
||||
}
|
||||
if dependencies, ok := node["dependencies"].(map[string]any); ok {
|
||||
for _, child := range dependencies {
|
||||
visitSchema(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
var visitTools func(any)
|
||||
visitTools = func(value any) {
|
||||
switch node := value.(type) {
|
||||
case map[string]any:
|
||||
if parameters, ok := node["parameters"]; ok {
|
||||
visitSchema(parameters)
|
||||
}
|
||||
if function, ok := node["function"]; ok {
|
||||
visitTools(function)
|
||||
}
|
||||
if tools, ok := node["tools"]; ok {
|
||||
visitTools(tools)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range node {
|
||||
visitTools(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
if document, ok := root.(map[string]any); ok {
|
||||
visitTools(document["tools"])
|
||||
visitTools(document["input"])
|
||||
}
|
||||
if !changed {
|
||||
return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{removeLookaroundPatterns: true})
|
||||
}
|
||||
|
||||
// sanitizeOpenAIResponsesToolParameterTypes replaces an explicitly null type
|
||||
// on a tool's parameter root with object. It also repairs the known Codex
|
||||
// missing root type when it is unambiguously implied by an object-only
|
||||
// oneOf/anyOf. Other missing types and nested property schemas are left
|
||||
// unchanged.
|
||||
func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) {
|
||||
if len(body) == 0 {
|
||||
return body, false, nil
|
||||
}
|
||||
sanitized, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return sanitized, true, nil
|
||||
return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{
|
||||
replaceNullParameterTypes: true,
|
||||
injectObjectUnionRootObjectType: true,
|
||||
})
|
||||
}
|
||||
|
||||
func openAIResponsesBodyMayContainRegexLookaround(body []byte) bool {
|
||||
// An opening parenthesis may be literal or encoded as JSON's canonical
|
||||
// Unicode escape. Other lookaround characters may themselves be escaped, so
|
||||
// the full decoded pattern is checked only after scoped parsing.
|
||||
return bytes.Contains(body, []byte("(")) || bytes.Contains(body, []byte(`\u0028`))
|
||||
}
|
||||
|
||||
func hasRegexLookaround(pattern string) bool {
|
||||
@@ -99,121 +71,599 @@ func hasRegexLookaround(pattern string) bool {
|
||||
strings.Contains(pattern, "(?<=") || strings.Contains(pattern, "(?<!")
|
||||
}
|
||||
|
||||
type openAIResponsesToolSchemaOptions struct {
|
||||
removeLookaroundPatterns bool
|
||||
replaceNullParameterTypes bool
|
||||
injectObjectUnionRootObjectType bool
|
||||
}
|
||||
|
||||
type openAIResponsesToolSchemaContext uint8
|
||||
|
||||
const (
|
||||
// 工具定义在多轮历史里最多再嵌套一层 tools,留出余量后截断,避免畸形请求体
|
||||
// 造成无界递归。
|
||||
openAIResponsesToolSchemaMaxDepth = 4
|
||||
// JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法,
|
||||
// 补成 object 与 upstream 对该工具的实际期望一致。
|
||||
openAIResponsesToolSchemaFallbackType = `"object"`
|
||||
// 显式 null 在 JSON 里只有这一种字面量形态。
|
||||
openAIResponsesToolSchemaNullLiteral = "null"
|
||||
openAIResponsesToolSchemaSkip openAIResponsesToolSchemaContext = iota
|
||||
openAIResponsesToolSchemaDocument
|
||||
openAIResponsesToolSchemaTools
|
||||
openAIResponsesToolSchemaTool
|
||||
openAIResponsesToolSchemaInput
|
||||
openAIResponsesToolSchemaInputItem
|
||||
openAIResponsesToolSchemaToolCarrier
|
||||
openAIResponsesToolSchemaTypeProbe
|
||||
openAIResponsesToolSchemaFunction
|
||||
openAIResponsesToolSchema
|
||||
openAIResponsesToolSchemaArray
|
||||
openAIResponsesToolSchemaMap
|
||||
openAIResponsesToolSchemaOrArray
|
||||
)
|
||||
|
||||
// openAIResponsesToolSchemaNullType 记录一处待修正的 null,用原始 body 上的
|
||||
// 绝对字节偏移表示,便于最后一次性拼接。
|
||||
type openAIResponsesToolSchemaNullType struct {
|
||||
offset int
|
||||
length int
|
||||
type openAIResponsesToolSchemaEdit struct {
|
||||
start int
|
||||
end int
|
||||
replacement string
|
||||
}
|
||||
|
||||
// sanitizeOpenAIResponsesToolParameterTypes 修正请求体中显式为 null 的
|
||||
// tools[].parameters.type。
|
||||
//
|
||||
// Codex Desktop 内置的 automation_update 工具会带 parameters.type = null,
|
||||
// OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的
|
||||
// 502 upstream_error;该工具定义又会沉进多轮历史,导致之后每一轮继续失败并在
|
||||
// 账号池里反复重放同一份坏 Schema。
|
||||
//
|
||||
// 只修正显式 null:缺失 type 的 Schema 本身合法(等价于不约束),补写会收窄
|
||||
// 客户端语义,因此保持原样。
|
||||
//
|
||||
// 实现上先收集全部命中的绝对偏移,再一次性拼出新 body:逐个 sjson.SetBytes 每次
|
||||
// 都会重扫并全量拷贝整个文档,命中 N 处就是 N 次全量拷贝,而 /v1/responses 的
|
||||
// body 上限是 gateway.max_body_size(默认 256MB),构造请求能塞进百万级命中。
|
||||
func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) {
|
||||
if len(body) == 0 {
|
||||
type openAIResponsesToolSchemaParser struct {
|
||||
body []byte
|
||||
pos int
|
||||
options openAIResponsesToolSchemaOptions
|
||||
edits []openAIResponsesToolSchemaEdit
|
||||
probeType string
|
||||
}
|
||||
|
||||
func sanitizeOpenAIResponsesToolSchemas(
|
||||
body []byte, options openAIResponsesToolSchemaOptions,
|
||||
) ([]byte, bool, error) {
|
||||
if len(body) > openAIResponsesToolSchemaMaxBodySize {
|
||||
return body, false, nil
|
||||
}
|
||||
if !utf8.Valid(body) {
|
||||
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid UTF-8")
|
||||
}
|
||||
p := openAIResponsesToolSchemaParser{body: body, options: options}
|
||||
if err := p.parseValue(openAIResponsesToolSchemaDocument, false, 0); err != nil {
|
||||
if errors.Is(err, errOpenAIResponsesToolSchemaLimit) {
|
||||
return body, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
p.skipWhitespace()
|
||||
if p.pos != len(body) {
|
||||
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: trailing data at byte %d", p.pos)
|
||||
}
|
||||
if !json.Valid(body) {
|
||||
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid JSON")
|
||||
}
|
||||
if len(p.edits) == 0 {
|
||||
return body, false, nil
|
||||
}
|
||||
return applyOpenAIResponsesToolSchemaEdits(body, p.edits)
|
||||
}
|
||||
|
||||
hits := make([]openAIResponsesToolSchemaNullType, 0, 2)
|
||||
collectOpenAIResponsesToolSchemaNullTypes(body, gjson.GetBytes(body, "tools"), 0, &hits)
|
||||
if input := gjson.GetBytes(body, "input"); input.IsArray() {
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.IsObject() {
|
||||
collectOpenAIResponsesToolSchemaNullTypes(body, item.Get("tools"), 0, &hits)
|
||||
func (p *openAIResponsesToolSchemaParser) parseValue(
|
||||
context openAIResponsesToolSchemaContext, schemaRoot bool, depth int,
|
||||
) error {
|
||||
if depth > openAIResponsesToolSchemaMaxDepth {
|
||||
return errOpenAIResponsesToolSchemaLimit
|
||||
}
|
||||
p.skipWhitespace()
|
||||
if p.pos >= len(p.body) {
|
||||
return p.syntaxError("expected value")
|
||||
}
|
||||
if p.body[p.pos] == '{' {
|
||||
switch context {
|
||||
case openAIResponsesToolSchemaInput:
|
||||
context = openAIResponsesToolSchemaInputItem
|
||||
case openAIResponsesToolSchemaOrArray, openAIResponsesToolSchemaArray:
|
||||
context = openAIResponsesToolSchema
|
||||
}
|
||||
}
|
||||
if context == openAIResponsesToolSchemaInputItem && p.body[p.pos] == '{' {
|
||||
probe := openAIResponsesToolSchemaParser{body: p.body, pos: p.pos}
|
||||
if err := probe.parseValue(openAIResponsesToolSchemaTypeProbe, false, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
switch probe.probeType {
|
||||
case "function", "custom", "tool_search", "namespace":
|
||||
context = openAIResponsesToolSchemaTool
|
||||
case "additional_tools":
|
||||
context = openAIResponsesToolSchemaToolCarrier
|
||||
default:
|
||||
context = openAIResponsesToolSchemaSkip
|
||||
}
|
||||
}
|
||||
switch p.body[p.pos] {
|
||||
case '{':
|
||||
return p.parseObject(context, schemaRoot, depth+1)
|
||||
case '[':
|
||||
return p.parseArray(context, depth+1)
|
||||
case '"':
|
||||
_, _, err := p.parseString()
|
||||
return err
|
||||
case 't':
|
||||
return p.parseKeyword("true")
|
||||
case 'f':
|
||||
return p.parseKeyword("false")
|
||||
case 'n':
|
||||
return p.parseKeyword("null")
|
||||
default:
|
||||
return p.parseNumber()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) parseObject(
|
||||
context openAIResponsesToolSchemaContext, schemaRoot bool, depth int,
|
||||
) error {
|
||||
p.pos++
|
||||
p.skipWhitespace()
|
||||
if p.consume('}') {
|
||||
return nil
|
||||
}
|
||||
previousComma := -1
|
||||
deleteRunStart := -1
|
||||
deleteRunPreviousComma := -1
|
||||
rootTypeCount := 0
|
||||
nullRootTypeStart := -1
|
||||
nullRootTypeEnd := -1
|
||||
parameterCount := 0
|
||||
parameterValueStart := -1
|
||||
parameterValueEnd := -1
|
||||
for {
|
||||
p.skipWhitespace()
|
||||
memberStart := p.pos
|
||||
keyStart, keyEnd, err := p.parseString()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := p.body[keyStart:keyEnd]
|
||||
p.skipWhitespace()
|
||||
if !p.consume(':') {
|
||||
return p.syntaxError("expected colon")
|
||||
}
|
||||
p.skipWhitespace()
|
||||
valueStart := p.pos
|
||||
|
||||
childContext, childSchemaRoot := openAIResponsesToolSchemaChildContext(context, key)
|
||||
if err := p.parseValue(childContext, childSchemaRoot, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
valueEnd := p.pos
|
||||
p.skipWhitespace()
|
||||
|
||||
deleteMember := false
|
||||
if context == openAIResponsesToolSchemaTypeProbe && openAIResponsesJSONStringEquals(key, "type") {
|
||||
if value, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok {
|
||||
p.probeType = strings.TrimSpace(value)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return body, false, nil
|
||||
}
|
||||
}
|
||||
if (context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) &&
|
||||
openAIResponsesJSONStringEquals(key, "parameters") {
|
||||
parameterCount++
|
||||
parameterValueStart = valueStart
|
||||
parameterValueEnd = valueEnd
|
||||
}
|
||||
if context == openAIResponsesToolSchema && openAIResponsesJSONStringEquals(key, "pattern") && p.options.removeLookaroundPatterns {
|
||||
if pattern, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok && hasRegexLookaround(pattern) {
|
||||
deleteMember = true
|
||||
}
|
||||
}
|
||||
if context == openAIResponsesToolSchema && schemaRoot && openAIResponsesJSONStringEquals(key, "type") {
|
||||
// Duplicate JSON keys have parser-dependent effective values. Repair a
|
||||
// root type only when it is unique rather than choosing a winner and
|
||||
// potentially changing the client's effective schema.
|
||||
rootTypeCount++
|
||||
if bytes.Equal(p.body[valueStart:valueEnd], []byte("null")) {
|
||||
nullRootTypeStart = valueStart
|
||||
nullRootTypeEnd = valueEnd
|
||||
}
|
||||
}
|
||||
|
||||
// tools 与 input 在 body 里的先后顺序由客户端决定,收集顺序不保证单调。
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].offset < hits[j].offset })
|
||||
|
||||
sanitized := make([]byte, 0, len(body)+len(hits)*len(openAIResponsesToolSchemaFallbackType))
|
||||
cursor := 0
|
||||
for _, hit := range hits {
|
||||
// 收集阶段已逐个校验过区间,这里再挡一次重叠,保证拼接严格单调向前。
|
||||
if hit.offset < cursor {
|
||||
if p.pos >= len(p.body) {
|
||||
return p.syntaxError("unterminated object")
|
||||
}
|
||||
if deleteMember && deleteRunStart < 0 {
|
||||
deleteRunStart = memberStart
|
||||
deleteRunPreviousComma = previousComma
|
||||
}
|
||||
if !deleteMember && deleteRunStart >= 0 {
|
||||
// Remove through the delimiter after the deleted run, but retain the
|
||||
// whitespace that originally preceded the next member.
|
||||
if err := p.addEdit(deleteRunStart, previousComma+1, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
deleteRunStart = -1
|
||||
deleteRunPreviousComma = -1
|
||||
}
|
||||
if p.body[p.pos] == ',' {
|
||||
comma := p.pos
|
||||
p.pos++
|
||||
previousComma = comma
|
||||
continue
|
||||
}
|
||||
sanitized = append(sanitized, body[cursor:hit.offset]...)
|
||||
sanitized = append(sanitized, openAIResponsesToolSchemaFallbackType...)
|
||||
cursor = hit.offset + hit.length
|
||||
if p.body[p.pos] != '}' {
|
||||
return p.syntaxError("expected comma or closing brace")
|
||||
}
|
||||
if deleteRunStart >= 0 {
|
||||
start := deleteRunStart
|
||||
if deleteRunPreviousComma >= 0 {
|
||||
start = deleteRunPreviousComma
|
||||
}
|
||||
if err := p.addEdit(start, p.pos, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.options.replaceNullParameterTypes && rootTypeCount == 1 && nullRootTypeStart >= 0 {
|
||||
if err := p.addEdit(nullRootTypeStart, nullRootTypeEnd, openAIResponsesToolSchemaFallbackType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.options.injectObjectUnionRootObjectType &&
|
||||
(context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) &&
|
||||
parameterCount == 1 && parameterValueStart >= 0 {
|
||||
if edit, ok := openAIMissingRootObjectUnionTypeEdit(
|
||||
p.body[parameterValueStart:parameterValueEnd], parameterValueStart,
|
||||
); ok {
|
||||
if err := p.addEdit(edit.start, edit.end, edit.replacement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
p.pos++
|
||||
return nil
|
||||
}
|
||||
sanitized = append(sanitized, body[cursor:]...)
|
||||
return sanitized, true, nil
|
||||
}
|
||||
|
||||
// collectOpenAIResponsesToolSchemaNullTypes 收集一个 tools 数组里所有需要修正的
|
||||
// parameters.type 位置。不按 tool type 过滤:null 的 schema type 在 function、
|
||||
// custom 以及任何 hosted 工具上都同样非法。
|
||||
func collectOpenAIResponsesToolSchemaNullTypes(
|
||||
body []byte, tools gjson.Result, depth int, hits *[]openAIResponsesToolSchemaNullType,
|
||||
) {
|
||||
if depth > openAIResponsesToolSchemaMaxDepth || !tools.IsArray() {
|
||||
return
|
||||
func openAIMissingRootObjectUnionTypeEdit(raw []byte, absoluteStart int) (openAIResponsesToolSchemaEdit, bool) {
|
||||
if len(raw) > openAIResponsesObjectUnionMaxSize {
|
||||
return openAIResponsesToolSchemaEdit{}, false
|
||||
}
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
if !tool.IsObject() {
|
||||
if !bytes.Contains(raw, []byte(`"oneOf"`)) && !bytes.Contains(raw, []byte(`"anyOf"`)) &&
|
||||
!bytes.Contains(raw, []byte{'\\'}) {
|
||||
return openAIResponsesToolSchemaEdit{}, false
|
||||
}
|
||||
var schema map[string]json.RawMessage
|
||||
if len(raw) < 2 || raw[0] != '{' || json.Unmarshal(raw, &schema) != nil {
|
||||
return openAIResponsesToolSchemaEdit{}, false
|
||||
}
|
||||
if _, hasType := schema["type"]; hasType || !openAIResponsesSchemaHasObjectOnlyUnion(schema, 0) {
|
||||
return openAIResponsesToolSchemaEdit{}, false
|
||||
}
|
||||
return openAIResponsesToolSchemaEdit{
|
||||
start: absoluteStart + 1,
|
||||
end: absoluteStart + 1,
|
||||
replacement: `"type":"object",`,
|
||||
}, true
|
||||
}
|
||||
|
||||
func openAIResponsesSchemaHasObjectOnlyUnion(schema map[string]json.RawMessage, depth int) bool {
|
||||
if depth > openAIResponsesObjectUnionMaxDepth {
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
for _, keyword := range []string{"oneOf", "anyOf"} {
|
||||
raw, ok := schema[keyword]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
var branches []json.RawMessage
|
||||
if json.Unmarshal(raw, &branches) != nil || len(branches) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, branch := range branches {
|
||||
if !openAIResponsesSchemaIsObjectOnly(branch, depth+1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func openAIResponsesSchemaIsObjectOnly(raw json.RawMessage, depth int) bool {
|
||||
if depth > openAIResponsesObjectUnionMaxDepth {
|
||||
return false
|
||||
}
|
||||
var schema map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &schema) != nil {
|
||||
return false
|
||||
}
|
||||
if rawType, ok := schema["type"]; ok {
|
||||
var schemaType string
|
||||
return json.Unmarshal(rawType, &schemaType) == nil && schemaType == "object"
|
||||
}
|
||||
return openAIResponsesSchemaHasObjectOnlyUnion(schema, depth+1)
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) parseArray(
|
||||
context openAIResponsesToolSchemaContext, depth int,
|
||||
) error {
|
||||
p.pos++
|
||||
p.skipWhitespace()
|
||||
if p.consume(']') {
|
||||
return nil
|
||||
}
|
||||
childContext := openAIResponsesToolSchemaSkip
|
||||
switch context {
|
||||
case openAIResponsesToolSchemaTools:
|
||||
childContext = openAIResponsesToolSchemaTool
|
||||
case openAIResponsesToolSchemaInput:
|
||||
childContext = openAIResponsesToolSchemaInputItem
|
||||
case openAIResponsesToolSchemaArray, openAIResponsesToolSchemaOrArray:
|
||||
childContext = openAIResponsesToolSchema
|
||||
}
|
||||
for {
|
||||
if err := p.parseValue(childContext, false, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
p.skipWhitespace()
|
||||
if p.consume(',') {
|
||||
continue
|
||||
}
|
||||
if !p.consume(']') {
|
||||
return p.syntaxError("expected comma or closing bracket")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func openAIResponsesToolSchemaChildContext(
|
||||
context openAIResponsesToolSchemaContext, key []byte,
|
||||
) (openAIResponsesToolSchemaContext, bool) {
|
||||
switch context {
|
||||
case openAIResponsesToolSchemaDocument:
|
||||
switch {
|
||||
case openAIResponsesJSONStringEquals(key, "tools"):
|
||||
return openAIResponsesToolSchemaTools, false
|
||||
case openAIResponsesJSONStringEquals(key, "input"):
|
||||
return openAIResponsesToolSchemaInput, false
|
||||
}
|
||||
case openAIResponsesToolSchemaTool:
|
||||
switch {
|
||||
case openAIResponsesJSONStringEquals(key, "parameters"):
|
||||
return openAIResponsesToolSchema, true
|
||||
case openAIResponsesJSONStringEquals(key, "function"):
|
||||
return openAIResponsesToolSchemaFunction, false
|
||||
case openAIResponsesJSONStringEquals(key, "tools"):
|
||||
return openAIResponsesToolSchemaTools, false
|
||||
}
|
||||
case openAIResponsesToolSchemaToolCarrier:
|
||||
if openAIResponsesJSONStringEquals(key, "tools") {
|
||||
return openAIResponsesToolSchemaTools, false
|
||||
}
|
||||
case openAIResponsesToolSchemaFunction:
|
||||
switch {
|
||||
case openAIResponsesJSONStringEquals(key, "parameters"):
|
||||
return openAIResponsesToolSchema, true
|
||||
case openAIResponsesJSONStringEquals(key, "tools"):
|
||||
return openAIResponsesToolSchemaTools, false
|
||||
}
|
||||
case openAIResponsesToolSchema:
|
||||
switch {
|
||||
case openAIResponsesJSONStringMatchesAny(key,
|
||||
"additionalProperties", "additionalItems", "contains", "not", "if", "then", "else",
|
||||
"propertyNames", "unevaluatedProperties", "unevaluatedItems"):
|
||||
return openAIResponsesToolSchema, false
|
||||
case openAIResponsesJSONStringEquals(key, "items"):
|
||||
return openAIResponsesToolSchemaOrArray, false
|
||||
case openAIResponsesJSONStringMatchesAny(key, "anyOf", "oneOf", "allOf", "prefixItems"):
|
||||
return openAIResponsesToolSchemaArray, false
|
||||
case openAIResponsesJSONStringMatchesAny(key,
|
||||
"properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"):
|
||||
return openAIResponsesToolSchemaMap, false
|
||||
}
|
||||
case openAIResponsesToolSchemaMap:
|
||||
return openAIResponsesToolSchema, false
|
||||
}
|
||||
return openAIResponsesToolSchemaSkip, false
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) parseString() (int, int, error) {
|
||||
if p.pos >= len(p.body) || p.body[p.pos] != '"' {
|
||||
return 0, 0, p.syntaxError("expected string")
|
||||
}
|
||||
start := p.pos
|
||||
p.pos++
|
||||
for p.pos < len(p.body) {
|
||||
switch p.body[p.pos] {
|
||||
case '"':
|
||||
p.pos++
|
||||
return start, p.pos, nil
|
||||
case '\\':
|
||||
p.pos++
|
||||
if p.pos >= len(p.body) {
|
||||
return 0, 0, p.syntaxError("unterminated string escape")
|
||||
}
|
||||
switch p.body[p.pos] {
|
||||
case 'u':
|
||||
if p.pos+4 >= len(p.body) {
|
||||
return 0, 0, p.syntaxError("short unicode escape")
|
||||
}
|
||||
for _, digit := range p.body[p.pos+1 : p.pos+5] {
|
||||
if !isOpenAIResponsesJSONHexDigit(digit) {
|
||||
return 0, 0, p.syntaxError("invalid unicode escape")
|
||||
}
|
||||
}
|
||||
p.pos += 5
|
||||
continue
|
||||
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
|
||||
p.pos++
|
||||
default:
|
||||
return 0, 0, p.syntaxError("invalid string escape")
|
||||
}
|
||||
default:
|
||||
if p.body[p.pos] < 0x20 {
|
||||
return 0, 0, p.syntaxError("control character in string")
|
||||
}
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
return 0, 0, p.syntaxError("unterminated string")
|
||||
}
|
||||
|
||||
func isOpenAIResponsesJSONHexDigit(value byte) bool {
|
||||
return value >= '0' && value <= '9' || value >= 'a' && value <= 'f' || value >= 'A' && value <= 'F'
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) parseKeyword(keyword string) error {
|
||||
if len(p.body)-p.pos < len(keyword) || string(p.body[p.pos:p.pos+len(keyword)]) != keyword {
|
||||
return p.syntaxError("invalid literal")
|
||||
}
|
||||
p.pos += len(keyword)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) parseNumber() error {
|
||||
start := p.pos
|
||||
if p.consume('-') && p.pos >= len(p.body) {
|
||||
return p.syntaxError("invalid number")
|
||||
}
|
||||
if p.consume('0') {
|
||||
if p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
|
||||
return p.syntaxError("invalid leading zero")
|
||||
}
|
||||
} else {
|
||||
if p.pos >= len(p.body) || p.body[p.pos] < '1' || p.body[p.pos] > '9' {
|
||||
return p.syntaxError("invalid number")
|
||||
}
|
||||
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
if p.consume('.') {
|
||||
fractionStart := p.pos
|
||||
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
|
||||
p.pos++
|
||||
}
|
||||
if p.pos == fractionStart {
|
||||
return p.syntaxError("invalid fraction")
|
||||
}
|
||||
}
|
||||
if p.pos < len(p.body) && (p.body[p.pos] == 'e' || p.body[p.pos] == 'E') {
|
||||
p.pos++
|
||||
if p.pos < len(p.body) && (p.body[p.pos] == '+' || p.body[p.pos] == '-') {
|
||||
p.pos++
|
||||
}
|
||||
exponentStart := p.pos
|
||||
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
|
||||
p.pos++
|
||||
}
|
||||
if p.pos == exponentStart {
|
||||
return p.syntaxError("invalid exponent")
|
||||
}
|
||||
}
|
||||
if p.pos == start {
|
||||
return p.syntaxError("invalid value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) addEdit(start, end int, replacement string) error {
|
||||
if start < 0 || end < start || end > len(p.body) {
|
||||
return p.syntaxError("invalid patch span")
|
||||
}
|
||||
if len(p.edits) >= openAIResponsesToolSchemaMaxEdits {
|
||||
return errOpenAIResponsesToolSchemaLimit
|
||||
}
|
||||
p.edits = append(p.edits, openAIResponsesToolSchemaEdit{start: start, end: end, replacement: replacement})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) skipWhitespace() {
|
||||
for p.pos < len(p.body) {
|
||||
switch p.body[p.pos] {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
p.pos++
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) consume(want byte) bool {
|
||||
if p.pos < len(p.body) && p.body[p.pos] == want {
|
||||
p.pos++
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *openAIResponsesToolSchemaParser) syntaxError(message string) error {
|
||||
return fmt.Errorf("sanitize OpenAI Responses tool schemas: %s at byte %d", message, p.pos)
|
||||
}
|
||||
|
||||
func decodeOpenAIResponsesJSONString(raw []byte) (string, error) {
|
||||
decoded, err := strconv.Unquote(string(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func openAIResponsesJSONStringEquals(raw []byte, want string) bool {
|
||||
if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
|
||||
return false
|
||||
}
|
||||
if !bytes.Contains(raw, []byte{'\\'}) {
|
||||
return bytes.Equal(raw[1:len(raw)-1], []byte(want))
|
||||
}
|
||||
decoded, err := decodeOpenAIResponsesJSONString(raw)
|
||||
return err == nil && decoded == want
|
||||
}
|
||||
|
||||
func openAIResponsesJSONStringMatchesAny(raw []byte, candidates ...string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if openAIResponsesJSONStringEquals(raw, candidate) {
|
||||
return true
|
||||
}
|
||||
// Responses 形态用顶层 parameters,ChatCompletions 形态用 function.parameters,
|
||||
// 两种都可能出现在 Responses 请求里(见 normalizeCodexTools)。
|
||||
for _, suffix := range []string{"parameters", "function.parameters"} {
|
||||
params := tool.Get(suffix)
|
||||
if !params.IsObject() {
|
||||
continue
|
||||
}
|
||||
// gjson 用 Type==Null 同时表示「显式 null」和「路径不存在」,靠 Raw
|
||||
// 区分:不存在时 Raw 为空串。
|
||||
if typ := params.Get("type"); typ.Type == gjson.Null && typ.Raw == openAIResponsesToolSchemaNullLiteral {
|
||||
appendOpenAIResponsesToolSchemaNullType(body, typ, hits)
|
||||
}
|
||||
}
|
||||
// 历史输入里的工具定义会再嵌套一层 tools(upstream 报错路径形如
|
||||
// input[234].tools[0].tools[3].parameters)。
|
||||
collectOpenAIResponsesToolSchemaNullTypes(body, tool.Get("tools"), depth+1, hits)
|
||||
return true
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// appendOpenAIResponsesToolSchemaNullType 先校验 gjson 给出的偏移确实指向原始
|
||||
// body 上那段 null,再记录。gjson 对嵌套取值同样返回相对原始文档的绝对偏移,但
|
||||
// Index 为 0 表示未知;偏移不可用时跳过该处而不是猜位置——少修一个工具只是维持
|
||||
// 现状,拼错位置会损坏整个请求体。
|
||||
func appendOpenAIResponsesToolSchemaNullType(
|
||||
body []byte, typ gjson.Result, hits *[]openAIResponsesToolSchemaNullType,
|
||||
) {
|
||||
end := typ.Index + len(typ.Raw)
|
||||
if typ.Index <= 0 || end > len(body) {
|
||||
return
|
||||
func decodeOpenAIResponsesJSONStringValue(raw []byte) (string, bool) {
|
||||
if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
|
||||
return "", false
|
||||
}
|
||||
if !bytes.Equal(body[typ.Index:end], []byte(typ.Raw)) {
|
||||
return
|
||||
decoded, err := decodeOpenAIResponsesJSONString(raw)
|
||||
return decoded, err == nil
|
||||
}
|
||||
|
||||
func applyOpenAIResponsesToolSchemaEdits(
|
||||
body []byte, edits []openAIResponsesToolSchemaEdit,
|
||||
) ([]byte, bool, error) {
|
||||
sort.Slice(edits, func(i, j int) bool {
|
||||
if edits[i].start != edits[j].start {
|
||||
return edits[i].start < edits[j].start
|
||||
}
|
||||
return edits[i].end < edits[j].end
|
||||
})
|
||||
|
||||
merged := edits[:0]
|
||||
for _, edit := range edits {
|
||||
if len(merged) == 0 || edit.start >= merged[len(merged)-1].end {
|
||||
merged = append(merged, edit)
|
||||
continue
|
||||
}
|
||||
previous := &merged[len(merged)-1]
|
||||
if previous.replacement != "" || edit.replacement != "" {
|
||||
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: overlapping patches")
|
||||
}
|
||||
if edit.end > previous.end {
|
||||
previous.end = edit.end
|
||||
}
|
||||
}
|
||||
*hits = append(*hits, openAIResponsesToolSchemaNullType{offset: typ.Index, length: len(typ.Raw)})
|
||||
|
||||
delta := 0
|
||||
for _, edit := range merged {
|
||||
delta += len(edit.replacement) - (edit.end - edit.start)
|
||||
}
|
||||
sanitized := make([]byte, 0, len(body)+delta)
|
||||
cursor := 0
|
||||
for _, edit := range merged {
|
||||
sanitized = append(sanitized, body[cursor:edit.start]...)
|
||||
sanitized = append(sanitized, edit.replacement...)
|
||||
cursor = edit.end
|
||||
}
|
||||
sanitized = append(sanitized, body[cursor:]...)
|
||||
if !json.Valid(sanitized) {
|
||||
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: patch produced invalid JSON")
|
||||
}
|
||||
return sanitized, true, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -62,6 +63,59 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_MissingTypeNotInvented(t *tes
|
||||
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.type").Exists())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_ObjectOnlyRootUnion(t *testing.T) {
|
||||
body := []byte(`{"tools":[
|
||||
{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object","properties":{"id":{"type":"string"}}},{"anyOf":[{"type":"object"},{"type":"object","properties":{}}]}]}},
|
||||
{"type":"function","name":"codex_app__automation_update","parameters":{"anyOf":[{"type":"object"}]}},
|
||||
{"type":"function","name":"unrelated_tool","parameters":{"oneOf":[{"type":"object"}]}}
|
||||
]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
for i := 0; i < 3; i++ {
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools."+strconv.Itoa(i)+".parameters.type").String())
|
||||
}
|
||||
require.Equal(t, "string", gjson.GetBytes(sanitized, "tools.0.parameters.oneOf.0.properties.id.type").String())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_EscapedUnionKeyword(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"function","name":"other","parameters":{"one\u004ff":[{"type":"object"}]}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_ObjectUnionMissingTypeSafetyBoundary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "mixed union", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"string"}]}}]}`},
|
||||
{name: "unproven branch", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"properties":{}}]}}]}`},
|
||||
{name: "empty union", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[]}}]}`},
|
||||
{name: "existing null handled by existing repair", body: `{"tools":[{"type":"function","name":"automation_update","parameters":{"type":null,"oneOf":[{"type":"object"}]}}]}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes([]byte(tt.body))
|
||||
require.NoError(t, err)
|
||||
if tt.name == "existing null handled by existing repair" {
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.parameters.type").String())
|
||||
return
|
||||
}
|
||||
require.False(t, changed)
|
||||
require.Equal(t, tt.body, string(sanitized))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 多轮历史:工具定义沉进 input 后,upstream 报错路径形如
|
||||
// input[N].tools[i].tools[j].parameters,两层都要修。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing.T) {
|
||||
@@ -71,6 +125,7 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"role": "developer",
|
||||
"parameters": {"type": null},
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
@@ -92,6 +147,7 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.1.parameters.type").String())
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.1.parameters.type").String())
|
||||
require.Equal(t, "null", gjson.GetBytes(sanitized, "input.1.parameters.type").Raw)
|
||||
// 原本合法的兄弟条目保持不变。
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.0.parameters.type").String())
|
||||
require.Equal(t, "hi", gjson.GetBytes(sanitized, "input.0.content").String())
|
||||
@@ -110,6 +166,86 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_ChatCompletionsShape(t *testi
|
||||
require.Equal(t, "legacy", gjson.GetBytes(sanitized, "tools.0.function.name").String())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_InputItemShapes(t *testing.T) {
|
||||
body := []byte(`{"input":[{"parameters":{"type":null},"name":"direct","type":"function"},{"function":{"name":"wrapped","parameters":{"type":null}},"type":"function"}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.0.parameters.type").String())
|
||||
require.Equal(t, "object", gjson.GetBytes(sanitized, "input.1.function.parameters.type").String())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotTreatMessagesAsTools(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"message","role":"user","parameters":{"type":null},"function":{"parameters":{"type":null}},"metadata":{"parameters":{"type":null}}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(sanitized))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_PreservesUntouchedBytes(t *testing.T) {
|
||||
body := []byte("{\n \"z\":1e+999, \"integer\":900719925474099312345, \"escaped\":\"\\u003chtml\\u003e\", \"tools\":[{\"parameters\":{\"properties\":{}, \"type\" : null},\"name\":\"x\"}], \"a\":-0.00\n}")
|
||||
want := []byte("{\n \"z\":1e+999, \"integer\":900719925474099312345, \"escaped\":\"\\u003chtml\\u003e\", \"tools\":[{\"parameters\":{\"properties\":{}, \"type\" : \"object\"},\"name\":\"x\"}], \"a\":-0.00\n}")
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, string(want), string(sanitized))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_EscapedKey(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"function","parameters":{"ty\u0070e":null}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, `{"tools":[{"type":"function","parameters":{"ty\u0070e":"object"}}]}`, string(sanitized))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_DuplicateTypeIsAmbiguousNoOp(t *testing.T) {
|
||||
for _, body := range [][]byte{
|
||||
[]byte(`{"tools":[{"type":"function","parameters":{"type":null,"type":"string"}}]}`),
|
||||
[]byte(`{"tools":[{"type":"function","parameters":{"type":"string","ty\u0070e":null}}]}`),
|
||||
[]byte(`{"tools":[{"type":"function","parameters":{"type":null,"type":null}}]}`),
|
||||
} {
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(sanitized))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemas_InvalidAndTrailingJSON(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body []byte
|
||||
call func([]byte) ([]byte, bool, error)
|
||||
}{
|
||||
{"null_type_trailing", []byte(`{"tools":[{"parameters":{"type":null}}]} trailing`), sanitizeOpenAIResponsesToolParameterTypes},
|
||||
{"pattern_trailing", []byte(`{"tools":[{"parameters":{"pattern":"(?=x)"}}]} trailing`), sanitizeOpenAIResponsesToolSchemaPatterns},
|
||||
{"invalid_utf8", append([]byte(`{"tools":[{"parameters":{"pattern":"(?=x)"}}],"bad":"`), 0xff, '"', '}'), sanitizeOpenAIResponsesToolSchemaPatterns},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, changed, err := tc.call(tc.body)
|
||||
require.Error(t, err)
|
||||
require.False(t, changed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSanitizeOpenAIResponsesToolSchemas_PlatformBoundary(t *testing.T) {
|
||||
require.True(t, shouldSanitizeOpenAIResponsesToolSchemas(PlatformOpenAI))
|
||||
for _, platform := range []string{PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformComposite, ""} {
|
||||
require.False(t, shouldSanitizeOpenAIResponsesToolSchemas(platform), platform)
|
||||
}
|
||||
}
|
||||
|
||||
// 索引映射:只有坏条目被改,前后兄弟条目按原下标保持不变。
|
||||
func TestSanitizeOpenAIResponsesToolParameterTypes_OnlyOffendingIndexRewritten(t *testing.T) {
|
||||
body := []byte(`{"tools":[
|
||||
@@ -212,6 +348,143 @@ func TestSanitizeOpenAIResponsesToolSchemaPatterns_RemovesLookaroundOnly(t *test
|
||||
require.Equal(t, "^[a-z]+$", gjson.GetBytes(sanitized, "tools.0.parameters.properties.id.pattern").String())
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemaPatterns_PreservesRawDocumentRepresentation(t *testing.T) {
|
||||
body := []byte("{\n \"number\":1e+999,\n \"tools\":[{\"parameters\":{\"pattern\":\"(?=drop)\", \"description\":\"\\u003ctag\\u003e\", \"type\":\"string\"}}],\n \"tail\":-0.00\n}")
|
||||
want := []byte("{\n \"number\":1e+999,\n \"tools\":[{\"parameters\":{ \"description\":\"\\u003ctag\\u003e\", \"type\":\"string\"}}],\n \"tail\":-0.00\n}")
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, string(want), string(sanitized))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemaPatterns_AllMemberPositions(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"parameters":{"properties":{"first":{"pattern":"(?=a)","type":"string"},"middle":{"title":"x","pattern":"(?!b)","type":"string"},"last":{"type":"string","pattern":"(?<=c)"},"only":{"pattern":"(?<!d)"}}}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
for _, name := range []string{"first", "middle", "last", "only"} {
|
||||
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties."+name+".pattern").Exists(), name)
|
||||
require.True(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties."+name).IsObject(), name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemaPatterns_TraversesConservativeSchemaKeywords(t *testing.T) {
|
||||
body := []byte(`{"input":[{"type":"function","name":"direct","parameters":{"type":"object","properties":{"p":{"anyOf":[{"pattern":"(?=any)"}],"items":{"pattern":"(?!item)"}}},"$defs":{"d":{"pattern":"(?<=def)"}},"additionalProperties":{"pattern":"(?<!additional)"}}},{"type":"function","function":{"name":"wrapped","parameters":{"oneOf":[{"pattern":"(?=wrapped)"}]}}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
for _, path := range []string{
|
||||
"input.0.parameters.properties.p.anyOf.0.pattern",
|
||||
"input.0.parameters.properties.p.items.pattern",
|
||||
"input.0.parameters.$defs.d.pattern",
|
||||
"input.0.parameters.additionalProperties.pattern",
|
||||
"input.1.function.parameters.oneOf.0.pattern",
|
||||
} {
|
||||
require.False(t, gjson.GetBytes(sanitized, path).Exists(), path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTraverseMessageMetadata(t *testing.T) {
|
||||
body := []byte(`{"input":[{"parameters":{"pattern":"(?=missing-type)"}},{"type":"message","parameters":{"pattern":"(?=parameters)"},"function":{"parameters":{"pattern":"(?=function)"}},"metadata":{"tools":[{"type":"function","parameters":{"pattern":"(?=metadata)"}}]}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(sanitized))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DecodesEscapedLookaround(t *testing.T) {
|
||||
for _, pattern := range []string{`\u0028?\u003dfoo`, `(\u003f\u003dfoo)`} {
|
||||
body := []byte(`{"tools":[{"parameters":{"type":"string","pattern":"` + pattern + `"}}]}`)
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.pattern").Exists())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemaPatterns_EscapedKey(t *testing.T) {
|
||||
body := []byte(`{"to\u006fls":[{"par\u0061meters":{"type":"string","pat\u0074ern":"(?=drop)"}}]}`)
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, `{"to\u006fls":[{"par\u0061meters":{"type":"string"}}]}`, string(sanitized))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DuplicateKeysStayValid(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
`{"tools":[{"parameters":{"pattern":"(?=drop)","pattern":"^[a-z]+$","type":"string"}}]}`,
|
||||
`{"tools":[{"parameters":{"pattern":"^[a-z]+$","type":"string"}}]}`,
|
||||
},
|
||||
{
|
||||
`{"tools":[{"parameters":{"type":"string","pattern":"(?=first)","pattern":"(?!second)"}}]}`,
|
||||
`{"tools":[{"parameters":{"type":"string"}}]}`,
|
||||
},
|
||||
} {
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns([]byte(tc.body))
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.True(t, json.Valid(sanitized))
|
||||
require.Equal(t, tc.want, string(sanitized))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"type":"function","parameters":{"type":null,"pattern":"(?=drop)"}}]}`)
|
||||
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
|
||||
t.Run(accountType, func(t *testing.T) {
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "tools.0.parameters.pattern").Exists())
|
||||
})
|
||||
}
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(normalized))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemas_DepthLimitReturnsOriginal(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"parameters":`)
|
||||
for i := 0; i < openAIResponsesToolSchemaMaxDepth+2; i++ {
|
||||
body = append(body, []byte(`{"allOf":[`)...)
|
||||
}
|
||||
body = append(body, []byte(`{"pattern":"(?=too-deep)"}`)...)
|
||||
for i := 0; i < openAIResponsesToolSchemaMaxDepth+2; i++ {
|
||||
body = append(body, []byte(`]}`)...)
|
||||
}
|
||||
body = append(body, []byte(`}]}`)...)
|
||||
require.True(t, json.Valid(body))
|
||||
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(sanitized))
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAIResponsesToolSchemaPatterns_DoesNotTouchUserInputPattern(t *testing.T) {
|
||||
body := []byte(`{"input":{"pattern":"(?=keep)"},"metadata":{"pattern":"(?!keep)"}}`)
|
||||
sanitized, changed, err := sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
@@ -279,3 +552,42 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_RewriteCountIndependentOfHits
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkSanitizeOpenAIResponsesToolSchemaPatterns_ByteSpanPatch(b *testing.B) {
|
||||
var tools []any
|
||||
for i := 0; i < 2000; i++ {
|
||||
tools = append(tools, map[string]any{
|
||||
"type": "function",
|
||||
"name": "search",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"query": map[string]any{"type": "string", "pattern": "(?=foo).*"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"model": "gpt-5.6", "tools": tools})
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(body)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = sanitizeOpenAIResponsesToolSchemaPatterns(body)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSanitizeOpenAIResponsesToolParameterTypes_ByteSpanPatch(b *testing.B) {
|
||||
body := make([]byte, 0, 256*2000)
|
||||
body = append(body, []byte(`{"tools":[`)...)
|
||||
for i := 0; i < 2000; i++ {
|
||||
if i > 0 {
|
||||
body = append(body, ',')
|
||||
}
|
||||
body = append(body, []byte(`{"type":"function","name":"automation_update","parameters":{"properties":{},"type":null}}`)...)
|
||||
}
|
||||
body = append(body, []byte(`]}`)...)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(body)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = sanitizeOpenAIResponsesToolParameterTypes(body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func setOpenAICodexRoutingHint(headers http.Header, account *Account, model stri
|
||||
// through a caller- or account-override-supplied hint. http.Header.Del only
|
||||
// removes the canonical map key; inbound maps can contain raw lowercase keys.
|
||||
deleteOpenAIHeaderEqualFold(headers, openAICodexRoutingHintHeader)
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
if account == nil || !account.IsOpenAIOAuthLike() {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"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"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestIsOpenAIOAuthLike(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
want bool
|
||||
codex bool
|
||||
}{
|
||||
{name: "openai_oauth", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, want: true, codex: true},
|
||||
{name: "openai_setup_token", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}, want: true, codex: true},
|
||||
{name: "openai_api_key", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, want: false, codex: false},
|
||||
{name: "anthropic_setup_token", account: &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}, want: false, codex: false},
|
||||
{name: "grok_setup_token", account: &Account{Platform: PlatformGrok, Type: AccountTypeSetupToken}, want: false, codex: false},
|
||||
{name: "nil", account: nil, want: false, codex: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, tt.account.IsOpenAIOAuthLike())
|
||||
require.Equal(t, tt.codex, tt.account.UsesOpenAICodexProtocol())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceGetAccessTokenSetupToken(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{openAITokenProvider: &OpenAITokenProvider{}}
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeSetupToken,
|
||||
Credentials: map[string]any{"access_token": "setup-token-value"},
|
||||
}
|
||||
|
||||
token, tokenType, err := svc.GetAccessToken(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "setup-token-value", token)
|
||||
require.Equal(t, "oauth", tokenType)
|
||||
|
||||
delete(account.Credentials, "access_token")
|
||||
_, _, err = svc.GetAccessToken(context.Background(), account)
|
||||
require.EqualError(t, err, "access_token not found in credentials")
|
||||
|
||||
for _, platform := range []string{PlatformAnthropic, PlatformGrok} {
|
||||
foreign := &Account{
|
||||
Platform: platform,
|
||||
Type: AccountTypeSetupToken,
|
||||
Credentials: map[string]any{"access_token": "foreign-token"},
|
||||
}
|
||||
_, _, err = svc.GetAccessToken(context.Background(), foreign)
|
||||
require.EqualError(t, err, "unsupported account type: setup-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAISetupTokenImagesUsesOAuthResponsesPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 73,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeSetupToken,
|
||||
Credentials: map[string]any{"access_token": "setup-token"},
|
||||
}
|
||||
parsed := &OpenAIImagesRequest{
|
||||
Endpoint: openAIImagesGenerationsEndpoint,
|
||||
Model: "gpt-image-2",
|
||||
Prompt: "draw a square",
|
||||
N: 1,
|
||||
ResponseFormat: "b64_json",
|
||||
}
|
||||
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, nil, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.False(t, failoverErr.SameAccountRetryDeadline.IsZero())
|
||||
require.Contains(t, upstream.lastReq.URL.String(), "/backend-api/codex/responses")
|
||||
}
|
||||
|
||||
func TestOpenAISetupTokenWSCompatibility(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("session_id", "session-one")
|
||||
c.Set("api_key", &APIKey{ID: 17})
|
||||
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeSetupToken,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "setup-token-value",
|
||||
"chatgpt_account_id": "chatgpt-setup",
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}}
|
||||
|
||||
wsURL, err := svc.buildOpenAIResponsesWSURL(account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "wss://chatgpt.com/backend-api/codex/responses", wsURL)
|
||||
foreignURL, err := svc.buildOpenAIResponsesWSURL(&Account{Platform: PlatformGrok, Type: AccountTypeSetupToken})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "wss://api.openai.com/v1/responses", foreignURL)
|
||||
|
||||
headers, session, err := svc.buildOpenAIWSHeaders(
|
||||
context.Background(), c, account, "setup-token-value",
|
||||
OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2},
|
||||
true, "", "", "", "gpt-5.1-codex", "",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bearer setup-token-value", headers.Get("authorization"))
|
||||
require.Equal(t, "chatgpt-setup", headers.Get("chatgpt-account-id"))
|
||||
require.NotEmpty(t, headers.Get("originator"))
|
||||
require.Equal(t, "session-one", session.SessionID)
|
||||
require.NotEqual(t, session.SessionID, headers.Get("session_id"))
|
||||
|
||||
payload := svc.buildOpenAIWSCreatePayload(map[string]any{"store": true}, account)
|
||||
require.Equal(t, false, payload["store"])
|
||||
}
|
||||
|
||||
func TestOpenAISetupTokenChatCompletionsUsesCodexTransform(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"setup instructions"},{"role":"user","content":"hello"}],"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop after request capture"}}`)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := openAISetupTokenCompatAccount(71)
|
||||
|
||||
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
|
||||
require.Equal(t, "Bearer setup-token-value", upstream.lastReq.Header.Get("Authorization"))
|
||||
require.Equal(t, "chatgpt-setup", upstream.lastReq.Header.Get("chatgpt-account-id"))
|
||||
require.NotEmpty(t, upstream.lastReq.Header.Get("originator"))
|
||||
require.Equal(t, "setup instructions", gjson.GetBytes(upstream.lastBody, "instructions").String())
|
||||
require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int())
|
||||
require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
|
||||
require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
|
||||
}
|
||||
|
||||
func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
firstResp := openAICompatSSECompletedResponse("resp_setup_first", "gpt-5.4")
|
||||
firstResp.Header.Set("x-codex-turn-state", "turn_state_setup")
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
firstResp,
|
||||
openAICompatSSECompletedResponse("resp_setup_second", "gpt-5.4"),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
account := openAISetupTokenCompatAccount(72)
|
||||
|
||||
messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3)
|
||||
for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
|
||||
messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
|
||||
}
|
||||
firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
|
||||
firstRec := httptest.NewRecorder()
|
||||
firstCtx, _ := gin.CreateTestContext(firstRec)
|
||||
firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
|
||||
firstCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.4")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, firstResult)
|
||||
require.True(t, isOpenAICompatMessagesBridgeContext(firstCtx))
|
||||
require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+4), gjson.GetBytes(upstream.bodies[0], "input.#").Int())
|
||||
require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[0], "input.0.role").String())
|
||||
require.Contains(t, gjson.GetBytes(upstream.bodies[0], "input.0.content.0.text").String(), openAICompatClaudeCodeTodoGuardMarker)
|
||||
require.Equal(t, "message-00", gjson.GetBytes(upstream.bodies[0], "input.1.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
|
||||
require.Equal(t, chatgptCodexURL, upstream.requests[0].URL.String())
|
||||
require.Equal(t, "Bearer setup-token-value", upstream.requests[0].Header.Get("Authorization"))
|
||||
require.Equal(t, "chatgpt-setup", upstream.requests[0].Header.Get("chatgpt-account-id"))
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex-tui")
|
||||
require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
|
||||
|
||||
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"next"}],"stream":false}`)
|
||||
secondRec := httptest.NewRecorder()
|
||||
secondCtx, _ := gin.CreateTestContext(secondRec)
|
||||
secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
|
||||
secondCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.4")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, secondResult)
|
||||
require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx))
|
||||
require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state"))
|
||||
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
|
||||
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex-tui")
|
||||
}
|
||||
|
||||
func openAISetupTokenCompatAccount(id int64) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "openai-setup-token",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeSetupToken,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "setup-token-value",
|
||||
"chatgpt_account_id": "chatgpt-setup",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,37 @@ func forEachOpenAISSEDataPayload(body string, fn func([]byte)) {
|
||||
acc.Flush(fn)
|
||||
}
|
||||
|
||||
func forEachOpenAISSEFrame(body string, fn func(string, []byte)) {
|
||||
if fn == nil || strings.TrimSpace(body) == "" {
|
||||
return
|
||||
}
|
||||
var parser openAICompatSSEFrameParser
|
||||
emit := func(frame openAICompatSSEFrame, ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
emitData := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "[DONE]" {
|
||||
return
|
||||
}
|
||||
data := []byte(value)
|
||||
fn(effectiveOpenAISSEEventType(data, frame.EventType), data)
|
||||
}
|
||||
if gjson.Valid(frame.Data) {
|
||||
emitData(frame.Data)
|
||||
return
|
||||
}
|
||||
for _, value := range strings.Split(frame.Data, "\n") {
|
||||
emitData(value)
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
emit(parser.AddLine(strings.TrimRight(line, "\r")))
|
||||
}
|
||||
emit(parser.Finish())
|
||||
}
|
||||
|
||||
func emitOpenAISSEDataPayloads(lines []string, fn func([]byte)) {
|
||||
if fn == nil || len(lines) == 0 {
|
||||
return
|
||||
|
||||
@@ -183,6 +183,13 @@ func TestHandleErrorResponse_NonDeterministicStatusesKeepGeneric502(t *testing.T
|
||||
newOpenAIUpstreamErrorResponse(tc.statusCode, tc.body),
|
||||
c, newOpenAIUpstreamErrorTestAccount(), nil,
|
||||
)
|
||||
if tc.name == "forbidden" {
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
|
||||
require.False(t, c.Writer.Written())
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tc.wantStatus, rec.Code)
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Excluded(t *test
|
||||
require.Nil(t, selection)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_ForceHTTPIgnored(t *testing.T) {
|
||||
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_APIKeyForceHTTPHit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(23)
|
||||
account := Account{
|
||||
@@ -239,7 +239,44 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_ForceHTTPIgnored
|
||||
|
||||
selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_force_http", "gpt-5.1", nil, false)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, selection, "force_http 场景应忽略 previous_response_id 粘连")
|
||||
require.NotNil(t, selection, "API-key HTTP continuation must retain the key/project that created the response")
|
||||
require.NotNil(t, selection.Account)
|
||||
require.Equal(t, account.ID, selection.Account.ID)
|
||||
if selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_OAuthForceHTTPIgnored(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(23)
|
||||
account := Account{
|
||||
ID: 12,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Extra: map[string]any{
|
||||
"openai_ws_force_http": true,
|
||||
"responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
cache := &stubGatewayCache{}
|
||||
store := NewOpenAIWSStateStore(cache)
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||
cache: cache,
|
||||
cfg: newOpenAIWSV2TestConfig(),
|
||||
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||
openaiWSStateStore: store,
|
||||
}
|
||||
|
||||
require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_oauth_force_http", account.ID, time.Hour))
|
||||
|
||||
selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_oauth_force_http", "gpt-5.1", nil, false)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, selection, "OAuth HTTP fallback cannot preserve WSv2 continuation state")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_BusyKeepsSticky(t *testing.T) {
|
||||
|
||||
@@ -37,6 +37,18 @@ func TestParseOpenAIWSResponseUsageFromCompletedEvent(t *testing.T) {
|
||||
require.Equal(t, 19, usage.InputTokens)
|
||||
require.Equal(t, 5, usage.OutputTokens)
|
||||
require.Equal(t, 4, usage.CacheReadInputTokens)
|
||||
|
||||
parseOpenAIWSResponseUsageFromCompletedEvent(
|
||||
[]byte(`{"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`),
|
||||
usage,
|
||||
)
|
||||
require.Equal(t, OpenAIUsage{InputTokens: 19, OutputTokens: 5, CacheReadInputTokens: 4}, *usage)
|
||||
|
||||
parseOpenAIWSResponseUsageFromCompletedEvent(
|
||||
[]byte(`{"type":"response.failed","response":{"usage":{"input_tokens":3,"output_tokens":0,"input_tokens_details":{"cached_tokens":0}}}}`),
|
||||
usage,
|
||||
)
|
||||
require.Equal(t, OpenAIUsage{InputTokens: 3}, *usage)
|
||||
}
|
||||
|
||||
func TestOpenAIWSEventShouldParseUsageTerminalEvents(t *testing.T) {
|
||||
@@ -54,7 +66,11 @@ func TestOpenAIWSEventShouldParseUsageTerminalEvents(t *testing.T) {
|
||||
require.True(t, openAIWSEventShouldParseUsage(" "+eventType+" "), eventType)
|
||||
}
|
||||
require.False(t, openAIWSEventShouldParseUsage("response.output_text.delta"))
|
||||
require.True(t, openAIWSEventShouldParseUsage("response.output_text.done"))
|
||||
require.False(t, openAIWSEventShouldParseUsage(""))
|
||||
require.False(t, openAIWSMessageShouldParseUsage("response.in_progress", []byte(`{"type":"response.in_progress"}`)))
|
||||
require.True(t, openAIWSMessageShouldParseUsage("response.in_progress", []byte(`{"type":"response.in_progress","usage":{}}`)))
|
||||
require.False(t, openAIWSMessageShouldParseUsage("response.output_text.delta", []byte(`{"type":"response.output_text.delta","usage":{}}`)))
|
||||
}
|
||||
|
||||
func TestOpenAIWSErrorEventHelpers_ConsistentWithWrapper(t *testing.T) {
|
||||
|
||||
@@ -18,6 +18,28 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
type openAIWSRejectedFieldRetryError struct {
|
||||
body []byte
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e *openAIWSRejectedFieldRetryError) Error() string {
|
||||
if e == nil || strings.TrimSpace(e.reason) == "" {
|
||||
return "retry websocket turn after rejected field normalization"
|
||||
}
|
||||
return "retry websocket turn after rejected field normalization: " + e.reason
|
||||
}
|
||||
|
||||
func openAIWSRejectedFieldRetryHTTPStatus(message []byte) int {
|
||||
for _, value := range gjson.GetManyBytes(message, "status", "status_code", "error.status", "error.status_code") {
|
||||
status := int(value.Int())
|
||||
if status >= 100 && status <= 599 {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return openAIWSErrorHTTPStatus(message)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIWSIngressInterTurnIdleTimeout() time.Duration {
|
||||
if s == nil || s.cfg == nil || s.cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds <= 0 {
|
||||
return 0
|
||||
@@ -48,7 +70,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
token string,
|
||||
firstClientMessage []byte,
|
||||
hooks *OpenAIWSIngressHooks,
|
||||
) error {
|
||||
) (returnErr error) {
|
||||
if s == nil {
|
||||
return errors.New("service is nil")
|
||||
}
|
||||
@@ -61,6 +83,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
if account == nil {
|
||||
return errors.New("account is nil")
|
||||
}
|
||||
// A handler may reuse the same gin context across account failover attempts.
|
||||
// Never let an OAuth attempt's response aliases leak into the next account.
|
||||
setCodexToolNameReverse(c, nil)
|
||||
if err := validateOpenAIWSBearerToken(account, token); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -74,6 +99,37 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
}
|
||||
|
||||
// Only persistent inbound WebSocket sessions participate in preemption.
|
||||
// HTTP ingress that opportunistically uses an upstream WS is handled by
|
||||
// forwardOpenAIWSV2 and deliberately never reaches this registration.
|
||||
preemptSessionHash := ""
|
||||
preemptGroupID := getOpenAIGroupIDFromContext(c)
|
||||
if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth {
|
||||
preemptSessionHash = s.GenerateSessionHash(c, firstClientMessage)
|
||||
}
|
||||
if preemptCtx, cleanupPreempt, armed, preemptedPrevious := s.beginOpenAIWSSessionPreemptContext(
|
||||
ctx,
|
||||
account,
|
||||
preemptGroupID,
|
||||
getAPIKeyIDFromContext(c),
|
||||
preemptSessionHash,
|
||||
false,
|
||||
); armed {
|
||||
ctx = preemptCtx
|
||||
defer cleanupPreempt()
|
||||
defer func() {
|
||||
if isOpenAIWSSessionPreempted(ctx) {
|
||||
returnErr = errOpenAIWSSessionPreempted
|
||||
}
|
||||
}()
|
||||
if preemptedPrevious {
|
||||
if stateStore := s.getOpenAIWSStateStore(); stateStore != nil {
|
||||
stateStore.DeleteSessionTurnState(preemptGroupID, preemptSessionHash)
|
||||
stateStore.DeleteSessionConn(preemptGroupID, preemptSessionHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
|
||||
forceHTTPBridge := account.Platform == PlatformGrok
|
||||
modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled
|
||||
@@ -225,12 +281,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
} else if compatibilityChanged {
|
||||
normalized = compatibilityBody
|
||||
}
|
||||
if account.IsOpenAIOAuth() && !forceHTTPBridge {
|
||||
if account.IsOpenAIOAuthLike() {
|
||||
aliasedBody, reverse, aliased, aliasErr := aliasOpenAIOAuthReservedToolNamesBody(normalized)
|
||||
if aliasErr != nil {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, aliasErr.Error(), aliasErr)
|
||||
}
|
||||
setCodexToolNameReverse(c, reverse)
|
||||
updateCodexToolNameReverseForWSFrame(c, normalized, reverse)
|
||||
if aliased {
|
||||
normalized = aliasedBody
|
||||
}
|
||||
@@ -270,7 +326,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
normalized = next
|
||||
}
|
||||
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(normalized) {
|
||||
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) {
|
||||
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized)
|
||||
if liteErr != nil {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(
|
||||
@@ -294,7 +350,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
|
||||
if codexBridgeEnabled {
|
||||
payloadMap := make(map[string]any)
|
||||
if err := json.Unmarshal(normalized, &payloadMap); err != nil {
|
||||
if err := decodeOpenAIJSONUseNumber(normalized, &payloadMap); err != nil {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", err)
|
||||
}
|
||||
bridgeModified := false
|
||||
@@ -339,6 +395,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
normalized = next
|
||||
}
|
||||
SetOpsUpstreamModel(c, upstreamModel)
|
||||
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
|
||||
if stripped, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(normalized); stripErr != nil {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr)
|
||||
@@ -594,6 +651,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
turn,
|
||||
writeClientMessage,
|
||||
)
|
||||
if bridgeErr != nil && isOpenAIWSSessionPreempted(ctx) {
|
||||
return errOpenAIWSSessionPreempted
|
||||
}
|
||||
if hooks != nil && hooks.AfterTurn != nil {
|
||||
hooks.AfterTurn(turn, result, bridgeErr)
|
||||
}
|
||||
@@ -647,6 +707,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
nextClientMessage, readErr := readClientMessage()
|
||||
if readErr != nil {
|
||||
if isOpenAIWSSessionPreempted(ctx) {
|
||||
return errOpenAIWSSessionPreempted
|
||||
}
|
||||
if isOpenAIWSClientDisconnectError(readErr) {
|
||||
closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr)
|
||||
logOpenAIWSModeInfo(
|
||||
@@ -774,6 +837,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
return acquireTurnLease(turn, preferred, forcePreferredConn)
|
||||
}
|
||||
if acquireErr != nil {
|
||||
if isOpenAIWSSessionPreempted(ctx) {
|
||||
return nil, errOpenAIWSSessionPreempted
|
||||
}
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, ingressSessionOriginalModel)
|
||||
s.handleOpenAIWSDialTransientFailure(ctx, account, canonicalModel, acquireErr)
|
||||
dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(acquireErr)
|
||||
@@ -800,10 +866,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
var dialErr *openAIWSDialError
|
||||
if errors.As(acquireErr, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests {
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(acquireErr.Error()))
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
ResponseHeaders: cloneHeader(dialErr.ResponseHeaders),
|
||||
}
|
||||
return nil, s.newOpenAIWSRateLimitFailoverError(account, dialErr.ResponseHeaders, nil, acquireErr.Error())
|
||||
}
|
||||
if errors.Is(acquireErr, errOpenAIWSPreferredConnUnavailable) {
|
||||
return nil, NewOpenAIWSClientCloseError(
|
||||
@@ -847,6 +910,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
var rejectedFieldRetryState *openAIResponsesRejectedFieldRetryState
|
||||
sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) {
|
||||
responseModelObserver := &upstreamResponseModelObserver{}
|
||||
if lease == nil {
|
||||
@@ -928,9 +992,32 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
lastEventType = eventType
|
||||
}
|
||||
if eventType == "error" {
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel)
|
||||
s.handleOpenAIWSErrorEventTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage)
|
||||
s.handleOpenAIWSErrorEventTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), upstreamMessage)
|
||||
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage)
|
||||
statusCode := openAIWSRejectedFieldRetryHTTPStatus(upstreamMessage)
|
||||
if !wroteDownstream && statusCode == http.StatusBadRequest && rejectedFieldRetryState != nil {
|
||||
retryBody, retryReason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(
|
||||
statusCode,
|
||||
payload,
|
||||
upstreamMessage,
|
||||
)
|
||||
if retryErr != nil {
|
||||
return nil, fmt.Errorf("normalize websocket rejected field retry: %w", retryErr)
|
||||
}
|
||||
if changed && rejectedFieldRetryState.Allow(retryBody) {
|
||||
logOpenAIWSModeInfo(
|
||||
"ingress_ws_rejected_field_retry account_id=%d turn=%d conn_id=%s reason=%s",
|
||||
account.ID,
|
||||
turn,
|
||||
truncateOpenAIWSLogValue(lease.ConnID(), openAIWSIDValueMaxLen),
|
||||
truncateOpenAIWSLogValue(retryReason, openAIWSLogValueMaxLen),
|
||||
)
|
||||
return nil, &openAIWSRejectedFieldRetryError{
|
||||
body: append([]byte(nil), retryBody...),
|
||||
reason: retryReason,
|
||||
}
|
||||
}
|
||||
}
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
@@ -991,11 +1078,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
if !wroteDownstream && isOpenAIWSRateLimitError(errCodeRaw, errTypeRaw, errMsgRaw) {
|
||||
lease.MarkBroken()
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
ResponseBody: append([]byte(nil), upstreamMessage...),
|
||||
ResponseHeaders: cloneHeader(lease.HandshakeHeaders()),
|
||||
}
|
||||
return nil, s.newOpenAIWSRateLimitFailoverError(account, lease.HandshakeHeaders(), upstreamMessage, errMsgRaw)
|
||||
}
|
||||
}
|
||||
isTokenEvent := isOpenAIWSTokenEvent(eventType)
|
||||
@@ -1010,7 +1093,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
ms := int(time.Since(turnStart).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
if openAIWSEventShouldParseUsage(eventType) {
|
||||
if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) {
|
||||
parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage)
|
||||
}
|
||||
imageCounter.AddSSEData(upstreamMessage)
|
||||
@@ -1027,7 +1110,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if !clientDisconnected {
|
||||
if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) {
|
||||
upstreamMessage = replaceOpenAIWSMessageModel(upstreamMessage, mappedModel, originalModel)
|
||||
@@ -1059,11 +1141,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
} else {
|
||||
wroteDownstream = true
|
||||
markOpenAIWSClientVisibleFailure(c, eventType, upstreamMessage)
|
||||
}
|
||||
}
|
||||
if isTerminalEvent {
|
||||
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel)
|
||||
terminalEvent := s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalModel, lease.HandshakeHeaders(), upstreamMessage)
|
||||
terminalEvent := s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), upstreamMessage)
|
||||
// 客户端已断连时,上游连接的 session 状态不可信,标记 broken 避免回池复用。
|
||||
if clientDisconnected {
|
||||
lease.MarkBroken()
|
||||
@@ -1185,6 +1267,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
defer releaseSessionLease()
|
||||
|
||||
turn := 1
|
||||
rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload)
|
||||
turnRetry := 0
|
||||
turnPrevRecoveryTried := false
|
||||
lastTurnFinishedAt := time.Time{}
|
||||
@@ -1618,6 +1701,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize)
|
||||
if relayErr != nil {
|
||||
lastTurnClean = false
|
||||
if isOpenAIWSSessionPreempted(ctx) {
|
||||
sessionLease.MarkBroken()
|
||||
return errOpenAIWSSessionPreempted
|
||||
}
|
||||
var rejectedFieldErr *openAIWSRejectedFieldRetryError
|
||||
if errors.As(relayErr, &rejectedFieldErr) && rejectedFieldErr != nil && len(rejectedFieldErr.body) > 0 {
|
||||
currentPayload = append([]byte(nil), rejectedFieldErr.body...)
|
||||
currentPayloadBytes = len(currentPayload)
|
||||
skipBeforeTurn = true
|
||||
continue
|
||||
}
|
||||
if recoverIngressPrevResponseNotFound(relayErr, turn, connID) {
|
||||
continue
|
||||
}
|
||||
@@ -1681,6 +1775,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
|
||||
nextClientMessage, readErr := readClientMessage()
|
||||
if readErr != nil {
|
||||
if isOpenAIWSSessionPreempted(ctx) {
|
||||
return errOpenAIWSSessionPreempted
|
||||
}
|
||||
if isOpenAIWSClientDisconnectError(readErr) {
|
||||
closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr)
|
||||
logOpenAIWSModeInfo(
|
||||
@@ -1763,6 +1860,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
currentImageSizeTier = nextPayload.imageSizeTier
|
||||
currentImageInputSize = nextPayload.imageInputSize
|
||||
currentPayloadBytes = nextPayload.payloadBytes
|
||||
rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload)
|
||||
storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account)
|
||||
if !storeDisabled {
|
||||
unpinSessionConn(sessionConnID)
|
||||
|
||||
@@ -732,7 +732,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
|
||||
}()
|
||||
|
||||
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat"}`))
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","sequence":900719925474099312345}`))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -803,6 +803,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
|
||||
require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String())
|
||||
require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation")
|
||||
require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists())
|
||||
require.Equal(t, "900719925474099312345", gjson.Get(nonLitePayload, "sequence").Raw)
|
||||
|
||||
litePayload := requestToJSONString(captureConn.writes[1])
|
||||
require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists())
|
||||
|
||||
@@ -161,12 +161,15 @@ func openAIWSEventMayContainToolCalls(eventType string) bool {
|
||||
}
|
||||
|
||||
func openAIWSEventShouldParseUsage(eventType string) bool {
|
||||
switch strings.TrimSpace(eventType) {
|
||||
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
||||
eventType = strings.TrimSpace(eventType)
|
||||
if eventType == "error" || openAIStreamEventTypeIsTerminal(eventType) {
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(eventType, "response.") && !strings.HasSuffix(eventType, ".delta")
|
||||
}
|
||||
|
||||
func openAIWSMessageShouldParseUsage(eventType string, message []byte) bool {
|
||||
return openAIWSEventShouldParseUsage(eventType) && bytes.Contains(message, []byte(`"usage"`))
|
||||
}
|
||||
|
||||
func parseOpenAIWSEventEnvelope(message []byte) (eventType string, responseID string, response gjson.Result) {
|
||||
@@ -193,11 +196,18 @@ func openAIWSMessageLikelyContainsToolCalls(message []byte) bool {
|
||||
}
|
||||
|
||||
func parseOpenAIWSResponseUsageFromCompletedEvent(message []byte, usage *OpenAIUsage) {
|
||||
if usage == nil || len(message) == 0 {
|
||||
if usage == nil || len(message) == 0 || !bytes.Contains(message, []byte(`"usage"`)) {
|
||||
return
|
||||
}
|
||||
if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(message); ok {
|
||||
*usage = parsedUsage
|
||||
if openAIStreamEventTypeIsTerminal(effectiveOpenAISSEEventType(message, "")) {
|
||||
if !openAIUsageHasTokens(&parsedUsage) && openAIUsageHasTokens(usage) {
|
||||
return
|
||||
}
|
||||
*usage = parsedUsage
|
||||
} else {
|
||||
mergeOpenAIUsageNonZero(usage, parsedUsage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user