mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #4311 from jianjianai/codex/perf-openai-forwarding-final
perf(openai): 优化 Responses 图片意图判定与透传流式刷新
This commit is contained in:
@@ -56,6 +56,14 @@ func openAIModelMappedBody(body []byte, mapped bool, mappedModel string, replace
|
||||
return replace(body, mappedModel)
|
||||
}
|
||||
|
||||
func seedOpenAIForwardImageIntentHint(c *gin.Context, channelMapped bool, imageIntent bool) {
|
||||
if channelMapped {
|
||||
// 渠道映射改变了规范请求,保持 unknown,由 Forward 按映射后的 model/body 初始化。
|
||||
return
|
||||
}
|
||||
service.SetOpenAIImageIntentHint(c, imageIntent)
|
||||
}
|
||||
|
||||
func newOpenAIModelMappedBodyCache(body []byte, replace openAIModelBodyReplaceFunc) func(bool, string) []byte {
|
||||
replacedBodies := make(map[string][]byte)
|
||||
return func(mapped bool, mappedModel string) []byte {
|
||||
@@ -282,6 +290,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
// 解析渠道级模型映射
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody)
|
||||
seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent)
|
||||
|
||||
// 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。
|
||||
if !h.validateFunctionCallOutputRequest(c, body, reqLog) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSeedOpenAIForwardImageIntentHint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
channelMapped bool
|
||||
imageIntent bool
|
||||
wantHint bool
|
||||
}{
|
||||
{name: "seed true", imageIntent: true, wantHint: true},
|
||||
{name: "seed false", imageIntent: false, wantHint: true},
|
||||
{name: "mapped body stays unknown", channelMapped: true, imageIntent: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &gin.Context{}
|
||||
service.SetOpenAIClientTransport(c, service.OpenAIClientTransportHTTP)
|
||||
|
||||
seedOpenAIForwardImageIntentHint(c, tt.channelMapped, tt.imageIntent)
|
||||
|
||||
var hintValues []bool
|
||||
for _, value := range c.Keys {
|
||||
if hint, ok := value.(bool); ok {
|
||||
hintValues = append(hintValues, hint)
|
||||
}
|
||||
}
|
||||
if !tt.wantHint {
|
||||
require.Empty(t, hintValues)
|
||||
return
|
||||
}
|
||||
require.Equal(t, []bool{tt.imageIntent}, hintValues)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -47,16 +47,36 @@ func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return false
|
||||
}
|
||||
if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); isOpenAIImageGenerationModel(model) {
|
||||
return true
|
||||
}
|
||||
if openAIJSONToolsContainImageGeneration(gjson.GetBytes(body, "tools")) {
|
||||
return true
|
||||
}
|
||||
if openAIJSONInputContainsImageGenTool(gjson.GetBytes(body, "input")) {
|
||||
return true
|
||||
}
|
||||
return openAIJSONToolChoiceSelectsImageGeneration(gjson.GetBytes(body, "tool_choice"))
|
||||
|
||||
var modelSeen, toolsSeen, inputSeen, toolChoiceSeen bool
|
||||
imageIntent := false
|
||||
parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool {
|
||||
// GetBytes returns the first duplicate key; retain that behavior while walking the root once.
|
||||
switch key.Str {
|
||||
case "model":
|
||||
if !modelSeen {
|
||||
modelSeen = true
|
||||
imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String()))
|
||||
}
|
||||
case "tools":
|
||||
if !toolsSeen {
|
||||
toolsSeen = true
|
||||
imageIntent = openAIJSONToolsContainImageGeneration(value)
|
||||
}
|
||||
case "input":
|
||||
if !inputSeen {
|
||||
inputSeen = true
|
||||
imageIntent = openAIJSONInputContainsImageGenTool(value)
|
||||
}
|
||||
case "tool_choice":
|
||||
if !toolChoiceSeen {
|
||||
toolChoiceSeen = true
|
||||
imageIntent = openAIJSONToolChoiceSelectsImageGeneration(value)
|
||||
}
|
||||
}
|
||||
return !imageIntent && (!modelSeen || !toolsSeen || !inputSeen || !toolChoiceSeen)
|
||||
})
|
||||
return imageIntent
|
||||
}
|
||||
|
||||
// IsImageGenerationIntentMap is the map-backed variant used after service-side request mutation.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var imageGenerationIntentBenchmarkResult bool
|
||||
|
||||
func BenchmarkIsImageGenerationIntent(b *testing.B) {
|
||||
largeInput := strings.Repeat("x", 1<<20)
|
||||
benchmarks := []struct {
|
||||
name string
|
||||
body []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "1MiBInputNoImage",
|
||||
body: []byte(`{"model":"gpt-5.5","tools":[],"input":"` + largeInput + `","tool_choice":"auto"}`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "1MiBInputLeadingImageTool",
|
||||
body: []byte(`{"model":"gpt-5.5","tools":[{"type":"image_generation"}],"input":"` + largeInput + `"}`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "1MiBInputTrailingToolChoice",
|
||||
body: []byte(`{"model":"gpt-5.5","tools":[],"input":"` + largeInput + `","tool_choice":{"type":"image_generation"}}`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid1MiBJSON",
|
||||
body: []byte(`{"model":"gpt-5.5","input":"` + largeInput),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DuplicateKeysFirstWins",
|
||||
body: []byte(`{"model":"gpt-5.5","model":"gpt-image-2","tools":[],"tools":[{"type":"image_generation"}],"input":"` + largeInput + `","tool_choice":"auto","tool_choice":{"type":"image_generation"}}`),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, benchmark := range benchmarks {
|
||||
b.Run(benchmark.name, func(b *testing.B) {
|
||||
if got := IsImageGenerationIntent("/v1/responses", "gpt-5.5", benchmark.body); got != benchmark.want {
|
||||
b.Fatalf("IsImageGenerationIntent() = %v, want %v", got, benchmark.want)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(benchmark.body)))
|
||||
b.ResetTimer()
|
||||
var result bool
|
||||
for i := 0; i < b.N; i++ {
|
||||
result = IsImageGenerationIntent("/v1/responses", "gpt-5.5", benchmark.body)
|
||||
}
|
||||
imageGenerationIntentBenchmarkResult = result
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -106,6 +107,77 @@ func TestIsImageGenerationIntent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImageGenerationIntentJSONSemantics(t *testing.T) {
|
||||
largeInput := strings.Repeat("x", 1<<20)
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
body []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "chat body image model",
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: []byte(`{"model":"gpt-image-2"}`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "large responses input with trailing namespace tool choice",
|
||||
endpoint: "/v1/responses",
|
||||
body: []byte(`{"model":"gpt-5.5","input":"` + largeInput + `","tool_choice":{"type":"namespace","name":"image_gen"}}`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid json with image tool",
|
||||
endpoint: "/v1/responses",
|
||||
body: []byte(`{"tools":[{"type":"image_generation"}]`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate model uses first value",
|
||||
endpoint: "/v1/responses",
|
||||
body: []byte(`{"model":"gpt-5.5","model":"gpt-image-2"}`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate null model still uses first value",
|
||||
endpoint: "/v1/responses",
|
||||
body: []byte(`{"model":null,"model":"gpt-image-2"}`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate tools uses first value",
|
||||
endpoint: "/v1/responses",
|
||||
body: []byte(`{"tools":[],"tools":[{"type":"image_generation"}]}`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate input uses first value",
|
||||
endpoint: "/v1/responses",
|
||||
body: []byte(`{"input":[],"input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"image_gen"}]}]}`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate tool choice uses first value",
|
||||
endpoint: "/v1/responses",
|
||||
body: []byte(`{"tool_choice":"required","tool_choice":{"type":"image_generation"}}`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "escaped top level key",
|
||||
endpoint: "/v1/responses",
|
||||
body: []byte(`{"tool_\u0063hoice":{"type":"image_generation"}}`),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, IsImageGenerationIntent(tt.endpoint, "gpt-5.5", tt.body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImageGenerationIntentMap_NamespaceImageGen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
// Forward forwards request to OpenAI API
|
||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
|
||||
canonicalImageIntentBody := body
|
||||
|
||||
restrictionResult := s.detectCodexClientRestriction(c, account, body)
|
||||
apiKeyID := getAPIKeyIDFromContext(c)
|
||||
@@ -107,6 +109,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2")
|
||||
}
|
||||
if passthroughEnabled {
|
||||
attemptImageIntentInvalidated := false
|
||||
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
|
||||
strippedBody, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(body)
|
||||
if stripErr != nil {
|
||||
@@ -115,6 +118,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if changed {
|
||||
body = strippedBody
|
||||
originalBody = strippedBody
|
||||
attemptImageIntentInvalidated = true
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy")
|
||||
}
|
||||
}
|
||||
@@ -123,7 +127,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, mappedModel)
|
||||
// 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。
|
||||
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel)
|
||||
return s.forwardOpenAIPassthrough(ctx, c, account, originalBody, reqModel, reasoningEffort, reqStream, startTime)
|
||||
return s.forwardOpenAIPassthrough(
|
||||
ctx,
|
||||
c,
|
||||
account,
|
||||
originalBody,
|
||||
canonicalImageIntentBody,
|
||||
reqModel,
|
||||
attemptImageIntentInvalidated,
|
||||
reasoningEffort,
|
||||
reqStream,
|
||||
startTime,
|
||||
)
|
||||
}
|
||||
|
||||
bodyModified := false
|
||||
@@ -188,6 +203,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip &&
|
||||
s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
|
||||
var imageIntent bool
|
||||
canonicalImageIntent := resolveOpenAIImageIntentHint(c, reqModel, canonicalImageIntentBody, IsImageGenerationIntent)
|
||||
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
@@ -199,7 +215,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded)
|
||||
} else {
|
||||
imageIntent = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body)
|
||||
imageIntent = canonicalImageIntent
|
||||
}
|
||||
if imageIntent && !imageGenerationAllowed {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package service
|
||||
|
||||
// 本文件由 openai_gateway_service.go 纯移动拆分而来:/v1/responses 直通
|
||||
// (passthrough)转发路径及其流式/非流式响应处理与错误处理。仅做代码搬迁,
|
||||
// 无任何行为变更。
|
||||
// 本文件承载 /v1/responses 透传转发及其流式、非流式响应与错误处理。
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -31,7 +29,9 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
body []byte,
|
||||
canonicalImageIntentBody []byte,
|
||||
reqModel string,
|
||||
attemptImageIntentInvalidated bool,
|
||||
reasoningEffort *string,
|
||||
reqStream bool,
|
||||
startTime time.Time,
|
||||
@@ -46,6 +46,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
body = nextBody
|
||||
upstreamPassthroughModel = compactMappedModel
|
||||
attemptImageIntentInvalidated = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +103,17 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
body = updatedBody
|
||||
|
||||
apiKey := getAPIKeyFromContext(c)
|
||||
if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) {
|
||||
// 同一 attempt 的最终 model/body 只判定一次,权限检查与后续图片状态设置共用该结果。
|
||||
imageIntent := resolveOpenAIPassthroughImageIntent(
|
||||
c,
|
||||
reqModel,
|
||||
canonicalImageIntentBody,
|
||||
policyModel,
|
||||
body,
|
||||
attemptImageIntentInvalidated,
|
||||
IsImageGenerationIntent,
|
||||
)
|
||||
if imageIntent && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": gin.H{
|
||||
@@ -115,7 +126,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
imageBillingModel := ""
|
||||
imageSizeTier := ""
|
||||
imageInputSize := ""
|
||||
if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) {
|
||||
if imageIntent {
|
||||
var imageCfgErr error
|
||||
imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, reqModel)
|
||||
if imageCfgErr != nil {
|
||||
@@ -950,7 +961,18 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
failedMessage := ""
|
||||
clientOutputStarted := false
|
||||
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
|
||||
// pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。
|
||||
pendingLines := make([]string, 0, 8)
|
||||
// flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。
|
||||
flushPending := false
|
||||
flushPendingOutput := func() {
|
||||
if clientDisconnected || !flushPending {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
flushPending = false
|
||||
}
|
||||
defer flushPendingOutput()
|
||||
writePendingLines := func() bool {
|
||||
for _, pending := range pendingLines {
|
||||
if _, err := fmt.Fprintln(w, pending); err != nil {
|
||||
@@ -1101,7 +1123,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
|
||||
} else {
|
||||
clientOutputStarted = true
|
||||
flusher.Flush()
|
||||
flushPending = true
|
||||
if line == "" {
|
||||
flushPendingOutput()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type passthroughFlushTestWriter struct {
|
||||
gin.ResponseWriter
|
||||
recorder *httptest.ResponseRecorder
|
||||
failAfterWrites int
|
||||
successfulWrites int
|
||||
failedWrites int
|
||||
flushBodyLengths []int
|
||||
}
|
||||
|
||||
func (w *passthroughFlushTestWriter) Write(data []byte) (int, error) {
|
||||
if w.failAfterWrites >= 0 && w.successfulWrites >= w.failAfterWrites {
|
||||
w.failedWrites++
|
||||
return 0, errors.New("client disconnected")
|
||||
}
|
||||
n, err := w.ResponseWriter.Write(data)
|
||||
if err == nil {
|
||||
w.successfulWrites++
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *passthroughFlushTestWriter) WriteString(data string) (int, error) {
|
||||
return w.Write([]byte(data))
|
||||
}
|
||||
|
||||
func (w *passthroughFlushTestWriter) Flush() {
|
||||
w.ResponseWriter.Flush()
|
||||
w.flushBodyLengths = append(w.flushBodyLengths, w.recorder.Body.Len())
|
||||
}
|
||||
|
||||
type passthroughFlushTestErrorBody struct {
|
||||
payload []byte
|
||||
err error
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (r *passthroughFlushTestErrorBody) Read(p []byte) (int, error) {
|
||||
if !r.sent {
|
||||
r.sent = true
|
||||
return copy(p, r.payload), nil
|
||||
}
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func (r *passthroughFlushTestErrorBody) Close() error { return nil }
|
||||
|
||||
func runPassthroughFlushTest(
|
||||
t *testing.T,
|
||||
body io.ReadCloser,
|
||||
failAfterWrites int,
|
||||
setups ...func(*gin.Context),
|
||||
) (*openaiStreamingResultPassthrough, *httptest.ResponseRecorder, *passthroughFlushTestWriter, error) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
writer := &passthroughFlushTestWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
recorder: recorder,
|
||||
failAfterWrites: failAfterWrites,
|
||||
}
|
||||
c.Writer = writer
|
||||
for _, setup := range setups {
|
||||
setup(c)
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
|
||||
}}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: body,
|
||||
}
|
||||
result, err := svc.handleStreamingResponsePassthrough(
|
||||
context.Background(),
|
||||
resp,
|
||||
c,
|
||||
&Account{ID: 1, Platform: PlatformOpenAI, Name: "flush-test"},
|
||||
time.Now(),
|
||||
"",
|
||||
"",
|
||||
)
|
||||
return result, recorder, writer, err
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughFlushesAtCompleteEventBoundaries(t *testing.T) {
|
||||
firstEvent := "event: response.output_text.delta\n" +
|
||||
"id: event-1\n" +
|
||||
`data: {"type":"response.output_text.delta","delta":"hello"}` + "\n\n"
|
||||
heartbeat := ": keepalive\n\n"
|
||||
terminalEvent := "event: response.completed\n" +
|
||||
`data: {"type":"response.completed","response":{"id":"resp_flush","usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}` + "\n\n"
|
||||
upstream := firstEvent + heartbeat + terminalEvent
|
||||
|
||||
result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, upstream, recorder.Body.String())
|
||||
require.Equal(t, []int{
|
||||
len(firstEvent),
|
||||
len(firstEvent) + len(heartbeat),
|
||||
len(upstream),
|
||||
}, writer.flushBodyLengths)
|
||||
require.Equal(t, 3, result.usage.InputTokens)
|
||||
require.Equal(t, 2, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughKeepsPreamblePendingUntilFirstOutputBoundary(t *testing.T) {
|
||||
preamble := "event: response.created\n" +
|
||||
`data: {"type":"response.created","response":{"id":"resp_pending"}}` + "\n\n" +
|
||||
": waiting\n\n"
|
||||
firstOutput := `data: {"type":"response.output_text.delta","delta":"ready"}` + "\n\n"
|
||||
terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_pending","usage":{"input_tokens":4,"output_tokens":1,"total_tokens":5}}}` + "\n\n"
|
||||
upstream := preamble + firstOutput + terminalEvent
|
||||
|
||||
_, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, upstream, recorder.Body.String())
|
||||
require.Equal(t, []int{
|
||||
len(preamble) + len(firstOutput),
|
||||
len(upstream),
|
||||
}, writer.flushBodyLengths)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughFlushesTerminalEventAtEOFWithoutBlankLine(t *testing.T) {
|
||||
upstream := "event: response.completed\n" +
|
||||
`data: {"type":"response.completed","response":{"id":"resp_eof","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`
|
||||
wantBody := upstream + "\n"
|
||||
|
||||
result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, wantBody, recorder.Body.String())
|
||||
require.Equal(t, []int{len(wantBody)}, writer.flushBodyLengths)
|
||||
require.Equal(t, 5, result.usage.InputTokens)
|
||||
require.Equal(t, 2, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughFailedBeforeOutputCanStillFailOverWithoutFlush(t *testing.T) {
|
||||
upstream := "event: response.created\n" +
|
||||
`data: {"type":"response.created","response":{"id":"resp_failover"}}` + "\n\n" +
|
||||
"event: response.failed\n" +
|
||||
`data: {"type":"response.failed","error":{"code":"server_error","message":"upstream processing failed"}}` + "\n\n"
|
||||
|
||||
_, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1)
|
||||
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Empty(t, recorder.Body.String())
|
||||
require.Empty(t, writer.flushBodyLengths)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughNonRetryableFailedBeforeOutputFlushesAtBoundary(t *testing.T) {
|
||||
upstream := "event: response.failed\n" +
|
||||
`data: {"type":"response.failed","error":{"code":"content_policy","message":"request blocked by policy"},"usage":{"input_tokens":6,"output_tokens":0,"total_tokens":6}}` + "\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)
|
||||
require.Equal(t, upstream, recorder.Body.String())
|
||||
require.Equal(t, []int{len(upstream)}, writer.flushBodyLengths)
|
||||
require.Equal(t, 6, result.usage.InputTokens)
|
||||
require.Zero(t, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughFailedAfterOutputFlushesAtBoundaryAndKeepsUsage(t *testing.T) {
|
||||
firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n"
|
||||
failedEvent := "event: response.failed\n" +
|
||||
`data: {"type":"response.failed","error":{"code":"server_error","message":"upstream processing failed"},"usage":{"input_tokens":7,"output_tokens":2,"total_tokens":9}}` + "\n\n"
|
||||
upstream := firstOutput + failedEvent
|
||||
|
||||
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)
|
||||
require.Equal(t, upstream, recorder.Body.String())
|
||||
require.Equal(t, []int{len(firstOutput), len(upstream)}, writer.flushBodyLengths)
|
||||
require.Equal(t, 7, result.usage.InputTokens)
|
||||
require.Equal(t, 2, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughClientDisconnectStillDrainsTerminalUsage(t *testing.T) {
|
||||
firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n"
|
||||
terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_drain","usage":{"input_tokens":11,"output_tokens":4,"total_tokens":15}}}` + "\n\n"
|
||||
|
||||
result, recorder, writer, err := runPassthroughFlushTest(
|
||||
t,
|
||||
io.NopCloser(strings.NewReader(firstOutput+terminalEvent)),
|
||||
2,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, firstOutput, recorder.Body.String())
|
||||
require.Equal(t, []int{len(firstOutput)}, writer.flushBodyLengths)
|
||||
require.Equal(t, 1, writer.failedWrites)
|
||||
require.Equal(t, 11, result.usage.InputTokens)
|
||||
require.Equal(t, 4, result.usage.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughScannerErrorFlushesWrittenResidual(t *testing.T) {
|
||||
upstream := []byte(`data: {"type":"response.output_text.delta","delta":"partial"}`)
|
||||
readErr := errors.New("upstream read failed")
|
||||
|
||||
_, recorder, writer, err := runPassthroughFlushTest(t, &passthroughFlushTestErrorBody{
|
||||
payload: upstream,
|
||||
err: readErr,
|
||||
}, -1)
|
||||
|
||||
require.ErrorIs(t, err, readErr)
|
||||
wantBody := string(upstream) + "\n"
|
||||
require.Equal(t, wantBody, recorder.Body.String())
|
||||
require.Equal(t, []int{len(wantBody)}, writer.flushBodyLengths)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughNamespaceRestoreErrorFlushesWrittenResidualOnce(t *testing.T) {
|
||||
writtenPrefix := `data: {"type":"response.output_text.delta","delta":"prefix"}` + "\n"
|
||||
overflowData := `data: {"type":"response.output_text.delta","delta":"not-written","overflow":1e1000}`
|
||||
|
||||
_, recorder, writer, err := runPassthroughFlushTest(
|
||||
t,
|
||||
io.NopCloser(strings.NewReader(writtenPrefix+overflowData)),
|
||||
-1,
|
||||
func(c *gin.Context) {
|
||||
setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{
|
||||
"collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
require.ErrorContains(t, err, "restore OpenAI passthrough namespace response")
|
||||
require.Equal(t, writtenPrefix, recorder.Body.String())
|
||||
require.Equal(t, []int{len(writtenPrefix)}, writer.flushBodyLengths)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughBlankWriteFailureDoesNotFlushAndStillDrainsUsage(t *testing.T) {
|
||||
writtenDataLine := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n"
|
||||
terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_blank_failure","usage":{"input_tokens":13,"output_tokens":5,"total_tokens":18}}}` + "\n\n"
|
||||
|
||||
result, recorder, writer, err := runPassthroughFlushTest(
|
||||
t,
|
||||
io.NopCloser(strings.NewReader(writtenDataLine+"\n"+terminalEvent)),
|
||||
1,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, writtenDataLine, recorder.Body.String())
|
||||
require.Empty(t, writer.flushBodyLengths)
|
||||
require.Equal(t, 1, writer.successfulWrites)
|
||||
require.Equal(t, 1, writer.failedWrites)
|
||||
require.Equal(t, 13, result.usage.InputTokens)
|
||||
require.Equal(t, 5, result.usage.OutputTokens)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
var passthroughImageIntentBenchmarkSink bool
|
||||
|
||||
func BenchmarkOpenAIPassthroughImageIntentReuse_LargeBody(b *testing.B) {
|
||||
body := buildLargeOpenAIResponsesImageToolBody(32 << 20)
|
||||
|
||||
b.Run("Once", func(b *testing.B) {
|
||||
b.SetBytes(int64(len(body)))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
passthroughImageIntentBenchmarkSink = IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Twice", func(b *testing.B) {
|
||||
b.SetBytes(int64(len(body)))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
permissionIntent := IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body)
|
||||
billingIntent := IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body)
|
||||
passthroughImageIntentBenchmarkSink = permissionIntent && billingIntent
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestOpenAIGatewayService_APIKeyPassthrough_ImageIntentPreservesGateAndBilling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"gpt-5.4","stream":false,"tools":[{"type":"image_generation","model":"gpt-image-2","size":"2048x1152"}],"input":"draw"}`)
|
||||
|
||||
t.Run("disabled group rejects before upstream", func(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{}
|
||||
svc := newOpenAIImageGenerationControlTestService(upstream)
|
||||
c, recorder := newOpenAIImageGenerationControlTestContext(false, "curl/8.0")
|
||||
account := newOpenAIImageGenerationControlTestAccount()
|
||||
account.Extra = map[string]any{"openai_passthrough": true}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
require.Equal(t, "permission_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String())
|
||||
require.Nil(t, upstream.lastReq)
|
||||
})
|
||||
|
||||
t.Run("allowed group keeps image billing", func(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-image","size":"2048x1152"}],"usage":{"input_tokens":1,"output_tokens":2}}`,
|
||||
)),
|
||||
}}
|
||||
svc := newOpenAIImageGenerationControlTestService(upstream)
|
||||
c, _ := newOpenAIImageGenerationControlTestContext(true, "curl/8.0")
|
||||
account := newOpenAIImageGenerationControlTestAccount()
|
||||
account.Extra = map[string]any{"openai_passthrough": true}
|
||||
|
||||
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, body, upstream.lastBody)
|
||||
require.Equal(t, 1, result.ImageCount)
|
||||
require.Equal(t, "gpt-image-2", result.BillingModel)
|
||||
require.Equal(t, "2K", result.ImageSize)
|
||||
require.Equal(t, "2048x1152", result.ImageInputSize)
|
||||
})
|
||||
}
|
||||
@@ -221,6 +221,24 @@ func TestOpenAIGatewayService_Forward_MappedImageModelUsesImageGate(t *testing.T
|
||||
require.Nil(t, result)
|
||||
require.Nil(t, upstream.lastReq)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
cached, known := getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.False(t, cached)
|
||||
|
||||
textAccount := *account
|
||||
textAccount.ID = 4
|
||||
textAccount.Credentials = map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://example.com",
|
||||
}
|
||||
result, err = svc.Forward(context.Background(), c, &textAccount, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
cached, known = getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.False(t, cached)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_TextResponsesSetsBillingModelToMappedModel(t *testing.T) {
|
||||
|
||||
@@ -242,6 +242,7 @@ func TestOpenAIGatewayServiceForward_AccountPolicyStripsImageNamespaceTools(t *t
|
||||
}
|
||||
svc := newOpenAIImageGenerationControlTestService(upstream)
|
||||
c, _ := newOpenAIImageGenerationControlTestContext(false, "codex_cli_rs/0.144.1")
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
|
||||
account := newOpenAIImageGenerationControlTestAccount()
|
||||
account.Extra = map[string]any{
|
||||
featureKeyCodexImageGenerationExplicitToolPolicy: codexImageGenerationExplicitToolPolicyStrip,
|
||||
@@ -274,6 +275,9 @@ func TestOpenAIGatewayServiceForward_AccountPolicyStripsImageNamespaceTools(t *t
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(name=="shell")`).Exists())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(name=="code_tools")`).Exists())
|
||||
require.Equal(t, "write code", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
|
||||
cached, known := getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.True(t, cached)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// 请求级 hint 仅限 HTTP:缺失表示 unknown,false/true 都表示已完成 canonical 判定。
|
||||
const openAIImageIntentHintContextKey = "openai_image_intent_hint"
|
||||
|
||||
type openAIImageIntentClassifier func(endpoint string, requestedModel string, body []byte) bool
|
||||
|
||||
// SetOpenAIImageIntentHint 只写入请求级 canonical 判定,不记录 attempt-local 结果。
|
||||
func SetOpenAIImageIntentHint(c *gin.Context, imageIntent bool) {
|
||||
if c == nil || GetOpenAIClientTransport(c) != OpenAIClientTransportHTTP {
|
||||
return
|
||||
}
|
||||
c.Set(openAIImageIntentHintContextKey, imageIntent)
|
||||
}
|
||||
|
||||
func getOpenAIImageIntentHint(c *gin.Context) (imageIntent bool, known bool) {
|
||||
if c == nil || GetOpenAIClientTransport(c) != OpenAIClientTransportHTTP {
|
||||
return false, false
|
||||
}
|
||||
value, ok := c.Get(openAIImageIntentHintContextKey)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
imageIntent, ok = value.(bool)
|
||||
return imageIntent, ok
|
||||
}
|
||||
|
||||
func resolveOpenAIImageIntentHint(
|
||||
c *gin.Context,
|
||||
requestedModel string,
|
||||
canonicalBody []byte,
|
||||
classify openAIImageIntentClassifier,
|
||||
) bool {
|
||||
if imageIntent, known := getOpenAIImageIntentHint(c); known {
|
||||
return imageIntent
|
||||
}
|
||||
imageIntent := classify(openAIResponsesEndpoint, requestedModel, canonicalBody)
|
||||
SetOpenAIImageIntentHint(c, imageIntent)
|
||||
return imageIntent
|
||||
}
|
||||
|
||||
func resolveOpenAIPassthroughImageIntent(
|
||||
c *gin.Context,
|
||||
canonicalRequestedModel string,
|
||||
canonicalBody []byte,
|
||||
attemptRequestedModel string,
|
||||
attemptBody []byte,
|
||||
attemptInvalidated bool,
|
||||
classify openAIImageIntentClassifier,
|
||||
) bool {
|
||||
imageIntent := resolveOpenAIImageIntentHint(c, canonicalRequestedModel, canonicalBody, classify)
|
||||
if attemptInvalidated {
|
||||
// strip/compact 改写只重算当前 attempt,不得把变换后的结果写回请求级 canonical hint。
|
||||
imageIntent = classify(openAIResponsesEndpoint, attemptRequestedModel, attemptBody)
|
||||
}
|
||||
return imageIntent
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func newOpenAIImageIntentHintTestContext(transport OpenAIClientTransport) *gin.Context {
|
||||
c := &gin.Context{}
|
||||
SetOpenAIClientTransport(c, transport)
|
||||
return c
|
||||
}
|
||||
|
||||
func countingOpenAIImageIntentClassifier(calls *atomic.Int64) openAIImageIntentClassifier {
|
||||
return func(endpoint string, requestedModel string, body []byte) bool {
|
||||
calls.Add(1)
|
||||
return IsImageGenerationIntent(endpoint, requestedModel, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIImageIntentHintCachesTrueAndFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
want bool
|
||||
}{
|
||||
{name: "true", body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`), want: true},
|
||||
{name: "false is known", body: []byte(`{"model":"gpt-5.4","input":"write code"}`), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
|
||||
require.Equal(t, tt.want, resolveOpenAIImageIntentHint(c, "gpt-5.4", tt.body, classify))
|
||||
require.Equal(t, tt.want, resolveOpenAIImageIntentHint(c, "gpt-5.4", tt.body, classify))
|
||||
require.Equal(t, int64(1), calls.Load())
|
||||
cached, known := getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.Equal(t, tt.want, cached)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIImageIntentHintUsesHandlerSeed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, seeded := range []bool{false, true} {
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
SetOpenAIImageIntentHint(c, seeded)
|
||||
var calls atomic.Int64
|
||||
|
||||
got := resolveOpenAIImageIntentHint(c, "gpt-5.4", []byte(`{"model":"gpt-5.4"}`), countingOpenAIImageIntentClassifier(&calls))
|
||||
|
||||
require.Equal(t, seeded, got)
|
||||
require.Zero(t, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIPassthroughImageIntentReusesCanonicalAcrossFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
body := []byte(`{"model":"gpt-5.4","input":"write code"}`)
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
|
||||
for range 3 {
|
||||
require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", body, "gpt-5.4", body, false, classify))
|
||||
}
|
||||
require.Equal(t, int64(1), calls.Load())
|
||||
}
|
||||
|
||||
func TestResolveOpenAIPassthroughImageIntentKeepsCompactMappingAttemptLocal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Run("text to image", func(t *testing.T) {
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
body := []byte(`{"model":"draw-alias","input":"draw"}`)
|
||||
compactBody := []byte(`{"model":"gpt-image-2","input":"draw"}`)
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
|
||||
require.True(t, resolveOpenAIPassthroughImageIntent(c, "draw-alias", body, "gpt-image-2", compactBody, true, classify))
|
||||
cached, known := getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.False(t, cached)
|
||||
|
||||
require.False(t, resolveOpenAIPassthroughImageIntent(c, "draw-alias", body, "draw-alias", body, false, classify))
|
||||
require.Equal(t, int64(2), calls.Load())
|
||||
cached, known = getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.False(t, cached)
|
||||
})
|
||||
|
||||
t.Run("image to text", func(t *testing.T) {
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
body := []byte(`{"model":"gpt-image-2","input":"draw"}`)
|
||||
compactBody := []byte(`{"model":"gpt-5.4","input":"draw"}`)
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
|
||||
require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", body, "gpt-5.4", compactBody, true, classify))
|
||||
cached, known := getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.True(t, cached)
|
||||
|
||||
require.True(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", body, "gpt-image-2", body, false, classify))
|
||||
require.Equal(t, int64(2), calls.Load())
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveOpenAIPassthroughImageIntentInvalidationDoesNotPolluteCanonical(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
canonicalBody := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`)
|
||||
strippedBody := []byte(`{"model":"gpt-5.4","tools":[]}`)
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
|
||||
require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", canonicalBody, "gpt-5.4", strippedBody, true, classify))
|
||||
require.Equal(t, int64(2), calls.Load(), "unknown canonical and invalidated attempt are classified independently")
|
||||
cached, known := getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.True(t, cached)
|
||||
|
||||
require.True(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", canonicalBody, "gpt-5.4", canonicalBody, false, classify))
|
||||
require.Equal(t, int64(2), calls.Load())
|
||||
}
|
||||
|
||||
func TestResolveOpenAIPassthroughImageIntentMappedBodyStartsUnknownThenSeedsCanonical(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
canonicalBody := []byte(`{"model":"gpt-image-2","input":"draw"}`)
|
||||
strippedAttemptBody := []byte(`{"model":"gpt-5.4","input":"draw"}`)
|
||||
_, known := getOpenAIImageIntentHint(c)
|
||||
require.False(t, known)
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
|
||||
require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", canonicalBody, "gpt-5.4", strippedAttemptBody, true, classify))
|
||||
require.Equal(t, int64(2), calls.Load())
|
||||
cached, known := getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.True(t, cached)
|
||||
}
|
||||
|
||||
func TestResolveOpenAIPassthroughImageIntentReusesAcrossInvariantMutations(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
canonicalBody []byte
|
||||
attemptBody []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "oauth sanitize fast policy and reasoning",
|
||||
canonicalBody: []byte(`{"model":"gpt-5.4","input":[{"type":"input_image","image_url":"data:image/png;base64,"}],"service_tier":"fast","reasoning":{"effort":"minimal"}}`),
|
||||
attemptBody: []byte(`{"model":"gpt-5.4","input":[],"service_tier":"priority","reasoning":{"effort":"none"},"store":false,"stream":true}`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "namespace flatten",
|
||||
canonicalBody: []byte(`{"model":"gpt-5.4","tools":[{"type":"namespace","name":"code_tools"}]}`),
|
||||
attemptBody: []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"code_tools.run"}]}`),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
|
||||
require.Equal(t, tt.want, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", tt.canonicalBody, "gpt-5.4", tt.attemptBody, false, classify))
|
||||
require.Equal(t, tt.want, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", tt.canonicalBody, "gpt-5.4", tt.attemptBody, false, classify))
|
||||
require.Equal(t, int64(1), calls.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServicePassthroughCompactImageIntentIsAttemptLocal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
canonicalModel string
|
||||
compactModel string
|
||||
wantRejected bool
|
||||
wantCanonical bool
|
||||
}{
|
||||
{
|
||||
name: "text to image rejects",
|
||||
canonicalModel: "gpt-5.4",
|
||||
compactModel: "gpt-image-2",
|
||||
wantRejected: true,
|
||||
},
|
||||
{
|
||||
name: "image to text reaches upstream",
|
||||
canonicalModel: "gpt-image-2",
|
||||
compactModel: "gpt-5.4",
|
||||
wantCanonical: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","model":"` + tt.compactModel + `","usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
}}
|
||||
svc := newOpenAIImageGenerationControlTestService(upstream)
|
||||
c, recorder := newOpenAIImageGenerationControlTestContext(false, "unit-test-agent/1.0")
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil)
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
|
||||
account := newOpenAIImageGenerationControlTestAccount()
|
||||
account.Extra = map[string]any{"openai_passthrough": true}
|
||||
account.Credentials = map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"compact_model_mapping": map[string]any{
|
||||
tt.canonicalModel: tt.compactModel,
|
||||
},
|
||||
}
|
||||
body := []byte(`{"model":"` + tt.canonicalModel + `","stream":false,"input":"draw"}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
cached, known := getOpenAIImageIntentHint(c)
|
||||
require.True(t, known)
|
||||
require.Equal(t, tt.wantCanonical, cached)
|
||||
if tt.wantRejected {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
require.Nil(t, upstream.lastReq)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, tt.compactModel, gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIImageIntentHintExcludesWebSocketAndUnknownTransport(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, transport := range []OpenAIClientTransport{OpenAIClientTransportWS, OpenAIClientTransportUnknown} {
|
||||
c := newOpenAIImageIntentHintTestContext(transport)
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
body := []byte(`{"model":"gpt-5.4","input":"write code"}`)
|
||||
|
||||
require.False(t, resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify))
|
||||
require.False(t, resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify))
|
||||
require.Equal(t, int64(2), calls.Load())
|
||||
_, known := getOpenAIImageIntentHint(c)
|
||||
require.False(t, known)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIImageIntentHintConcurrentRequestsAreIsolated(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const requests = 32
|
||||
var calls atomic.Int64
|
||||
classify := countingOpenAIImageIntentClassifier(&calls)
|
||||
var wg sync.WaitGroup
|
||||
results := make([][2]bool, requests)
|
||||
|
||||
for i := range requests {
|
||||
wg.Add(1)
|
||||
go func(index int, image bool) {
|
||||
defer wg.Done()
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
body := []byte(`{"model":"gpt-5.4","input":"write code"}`)
|
||||
if image {
|
||||
body = []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`)
|
||||
}
|
||||
results[index][0] = resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify)
|
||||
results[index][1] = resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify)
|
||||
}(i, i%2 == 0)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, result := range results {
|
||||
require.Equal(t, i%2 == 0, result[0])
|
||||
require.Equal(t, result[0], result[1])
|
||||
}
|
||||
require.Equal(t, int64(requests), calls.Load())
|
||||
}
|
||||
|
||||
var openAIImageIntentHintBenchmarkSink bool
|
||||
|
||||
func BenchmarkOpenAIPassthroughImageIntentHintLargeBody(b *testing.B) {
|
||||
body := []byte(`{"model":"gpt-5.4","input":"` + strings.Repeat("x", 4<<20) + `"}`)
|
||||
const attempts = 4
|
||||
|
||||
b.Run("scan_each_attempt", func(b *testing.B) {
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
b.ReportAllocs()
|
||||
calls := 0
|
||||
for range b.N {
|
||||
c.Set(openAIImageIntentHintContextKey, struct{}{})
|
||||
for range attempts {
|
||||
calls++
|
||||
openAIImageIntentHintBenchmarkSink = IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(calls)/float64(b.N), "classifier_calls/op")
|
||||
})
|
||||
|
||||
b.Run("request_scoped_hint", func(b *testing.B) {
|
||||
c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
|
||||
b.ReportAllocs()
|
||||
calls := 0
|
||||
classify := func(endpoint string, requestedModel string, candidate []byte) bool {
|
||||
calls++
|
||||
return IsImageGenerationIntent(endpoint, requestedModel, candidate)
|
||||
}
|
||||
for range b.N {
|
||||
c.Set(openAIImageIntentHintContextKey, struct{}{})
|
||||
for range attempts {
|
||||
openAIImageIntentHintBenchmarkSink = resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", body, "gpt-5.4", body, false, classify)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(calls)/float64(b.N), "classifier_calls/op")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user