Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/internal/handler/account_auto_config_outcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ func observeAutoConfigRequest(c *gin.Context, ops *service.OpsService, w *opsCap
}
}
for id := range failed {
if service.IsPrismBrowserAttempt(c, id) {
continue
}
ops.ObserveConcurrencyResult(service.AccountConcurrencyResult{AccountID: id, StartedAt: started})
}
id := c.GetInt64(opsAccountIDKey)
if id <= 0 || failed[id] {
if id <= 0 || failed[id] || service.IsPrismBrowserAttempt(c, id) {
return
}
if c.Request.Context().Err() != nil || c.Writer.Status() == 499 {
Expand Down
21 changes: 21 additions & 0 deletions backend/internal/handler/account_auto_config_outcome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,24 @@ func TestAutoConfigSplitSSEAndWriterReuse(t *testing.T) {
require.False(t, state.terminalSuccess)
state.mu.RUnlock()
}

func TestAutoConfigPrismDoesNotChangeNativeTier(t *testing.T) {
for _, status := range []int{200, 422, 429, 502} {
ops := service.NewOpsService(nil, nil, &config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil)
var got []service.AccountConcurrencyResult
ops.SetAutoConfigObserver(func(r service.AccountConcurrencyResult) { got = append(got, r) })
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1/responses", func(c *gin.Context) {
c.Set(opsAccountIDKey, int64(8))
service.MarkPrismBrowserAttempt(c, 7)
service.MarkPrismBrowserAttempt(c, 8)
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{AccountID: 7}, {AccountID: 9}})
c.JSON(status, gin.H{"fixture": true})
})
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
require.Len(t, got, 1)
require.Equal(t, int64(9), got[0].AccountID)
require.False(t, got[0].Success)
}
}
98 changes: 98 additions & 0 deletions backend/internal/handler/openai_prism_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package handler

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

func TestPrismErrorDoesNotAppendFallback(t *testing.T) {
for _, status := range []int{400, 401, 409, 422, 429, 502, 200} {
for _, mode := range []string{"json", "stream", "stream_started"} {
func() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = io.WriteString(w, `{"error":{"type":"unsupported_request","message":"fixture"}}`)
}))
defer server.Close()
cfg := &config.Config{}
cfg.Gateway.PrismBrowser = config.GatewayPrismBrowserConfig{Enabled: true, BaseURL: server.URL + "/v1", APIKey: "fixture"}
account := &service.Account{ID: 300, Status: service.StatusActive, Schedulable: true, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Credentials: map[string]any{"access_token": "fixture"}, Extra: map[string]any{"openai_prism_browser": true}}
gateway := service.NewOpenAIGatewayService(excelBPSErrorAccountRepo{account: account}, nil, nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
body, err := json.Marshal(map[string]any{"model": "gpt-6.1-sol", "input": "hi", "stream": mode != "json"})
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
if mode == "stream_started" {
c.Header("Content-Type", "text/event-stream")
_, err = c.Writer.WriteString(": keepalive\n\n")
require.NoError(t, err)
c.Writer.Flush()
}
before := c.Writer.Size()
_, err = gateway.Forward(context.Background(), c, account, body)
require.Error(t, err)
response := rec.Body.String()
if !openAIForwardErrorAlreadyCommunicated(c, before, err) {
require.False(t, (&OpenAIGatewayHandler{}).ensureForwardErrorResponse(c, false))
}
require.Equal(t, response, rec.Body.String())
if mode == "stream_started" {
require.Equal(t, 1, strings.Count(response, "event: response.failed"))
for _, line := range strings.Split(response, "\n") {
if strings.HasPrefix(line, "data: ") {
require.True(t, json.Valid([]byte(strings.TrimPrefix(line, "data: "))))
}
}
} else {
require.True(t, json.Valid(rec.Body.Bytes()), response)
require.NotContains(t, response, "event:")
}
require.True(t, service.IsPrismBrowserAttempt(c, 300))
}()
}
}
}

type prismScopeNativeUpstream struct {
service.HTTPUpstream
calls int
}

func (u *prismScopeNativeUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
u.calls++
return &http.Response{StatusCode: 400, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":{"message":"native fixture"}}`))}, nil
}

func TestPrismUnselectedModelsUseExistingNativeRoute(t *testing.T) {
for _, model := range []string{"gpt-4o-audio-preview", "gpt-5.6-sol"} {
t.Run(model, func(t *testing.T) {
upstream := &prismScopeNativeUpstream{}
cfg := &config.Config{}
// An unreachable adapter must not interfere with unselected models.
cfg.Gateway.PrismBrowser = config.GatewayPrismBrowserConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1", APIKey: "fixture"}
account := &service.Account{ID: 300, Status: service.StatusActive, Schedulable: true, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Credentials: map[string]any{"access_token": "fixture"}, Extra: map[string]any{"openai_prism_browser": true,
service.PrismBrowserModelsKey: []string{"gpt-6.1-sol"}, "openai_passthrough": true}}
gateway := service.NewOpenAIGatewayService(excelBPSErrorAccountRepo{account: account}, nil, nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, upstream, nil, nil, nil, nil, nil, nil, nil, nil)
body, err := json.Marshal(map[string]any{"model": model, "input": "hi"})
require.NoError(t, err)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
_, _ = gateway.Forward(context.Background(), c, account, body)
require.Equal(t, 1, upstream.calls)
require.False(t, service.IsPrismBrowserAttempt(c, account.ID))
})
}
}
58 changes: 58 additions & 0 deletions backend/internal/service/account_prism.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package service

import "strings"

const PrismBrowserModelsKey = "openai_prism_browser_models"

var prismBrowserModels = [...]string{"gpt-6.1-sol", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-luna"}

// PrismBrowserSupportedModels returns the adapter contract, not a statement of
// live account entitlement. Never treat an absent scope as all OpenAI models.
func PrismBrowserSupportedModels() []string {
return append([]string(nil), prismBrowserModels[:]...)
}

func isPrismBrowserModel(model string) bool {
for _, supported := range prismBrowserModels {
if model == supported {
return true
}
}
return false
}

// IsPrismBrowserEnabledForModel follows the same account-mapping order as BPS.
// Models outside this scope retain their ordinary native/BPS routing.
func (a *Account) IsPrismBrowserEnabledForModel(requestedModel string) bool {
if !accountHasPrismBrowser(a) {
return false
}
upstream := a.GetMappedModel(strings.TrimSpace(requestedModel))
return a.isPrismBrowserUpstreamModelEnabled(upstream)
}

func (a *Account) isPrismBrowserUpstreamModelEnabled(upstream string) bool {
upstream = strings.TrimSpace(upstream)
if !accountHasPrismBrowser(a) || !isPrismBrowserModel(upstream) {
return false
}
raw, configured := a.Extra[PrismBrowserModelsKey]
if !configured {
return true // Legacy enabled accounts inherit only the four known models.
}
switch models := raw.(type) {
case []string:
for _, model := range models {
if strings.TrimSpace(model) == upstream {
return true
}
}
case []any:
for _, value := range models {
if model, ok := value.(string); ok && strings.TrimSpace(model) == upstream {
return true
}
}
}
return false // Explicit empty or malformed scopes never widen routing.
}
57 changes: 57 additions & 0 deletions backend/internal/service/account_prism_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package service

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestPrismBrowserModelScope(t *testing.T) {
for _, tc := range []struct {
name string
scope any
configured bool
model string
want bool
}{
{"legacy Sol", nil, false, "gpt-6.1-sol", true},
{"legacy audio stays native", nil, false, "gpt-4o-audio-preview", false},
{"selected", []any{"gpt-6.1-sol"}, true, "gpt-6.1-sol", true},
{"unselected", []any{"gpt-6.1-sol"}, true, "gpt-5.6-sol", false},
{"empty", []string{}, true, "gpt-6.1-sol", false},
{"null", nil, true, "gpt-6.1-sol", false},
{"malformed", "gpt-6.1-sol", true, "gpt-6.1-sol", false},
{"wildcard never widens", []string{"*"}, true, "gpt-6.1-sol", false},
{"unsupported cannot be selected", []string{"gpt-4o-audio-preview"}, true, "gpt-4o-audio-preview", false},
{"explicit alias", []string{"gpt-6.1-sol"}, true, "my-sol", true},
} {
t.Run(tc.name, func(t *testing.T) {
_, a := prismTestService("")
a.Credentials["model_mapping"] = map[string]any{"my-sol": "gpt-6.1-sol"}
if tc.configured {
a.Extra[PrismBrowserModelsKey] = tc.scope
}
require.Equal(t, tc.want, a.IsPrismBrowserEnabledForModel(tc.model))
a.Extra["openai_prism_browser"] = false
require.False(t, a.IsPrismBrowserEnabledForModel(tc.model))
})
}
_, a := prismTestService("")
for _, model := range PrismBrowserSupportedModels() {
require.True(t, a.IsPrismBrowserEnabledForModel(model))
}
}

func TestPrismScopePreservesNativeWebSocketModels(t *testing.T) {
s, a := prismTestService("")
s.cfg = newSchedulerTestOpenAIWSV2Config()
a.Extra["openai_oauth_responses_websockets_v2_enabled"] = true
a.Extra[PrismBrowserModelsKey] = []string{"gpt-6.1-sol"}
for _, model := range []string{"gpt-4o-audio-preview", "gpt-5.6-sol"} {
require.True(t, s.isOpenAIAccountTransportCompatible(a, OpenAIUpstreamTransportResponsesWebsocketV2Ingress, model))
}
require.False(t, s.isOpenAIAccountTransportCompatible(a, OpenAIUpstreamTransportResponsesWebsocketV2Ingress, "gpt-6.1-sol"))
before := openAITurnRouteFingerprint(a)
a.Extra[PrismBrowserModelsKey] = []string{}
require.NotEqual(t, before, openAITurnRouteFingerprint(a))
}
3 changes: 2 additions & 1 deletion backend/internal/service/account_test_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
}

if account.IsOpenAI() {
if accountHasPrismBrowser(account) {
if account.IsPrismBrowserEnabledForModel(modelID) {
if normalizeAccountTestMode(mode) != AccountTestModeDefault || testOpts.ImageDataURL != "" || testOpts.AudioDataURL != "" {
return s.sendErrorAndEnd(c, "Prism supports the default text test only")
}
Expand Down Expand Up @@ -451,6 +451,7 @@ func (s *AccountTestService) testPrismBrowserConnection(c *gin.Context, account
if modelID == "" {
modelID = "gpt-5.6-sol"
}
modelID = account.GetMappedModel(modelID)
if prompt == "" {
prompt = "hi"
}
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/openai_account_scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2762,7 +2762,7 @@ func (s *OpenAIGatewayService) isOpenAIAccountTransportCompatible(account *Accou
}
// Prism runs one HTTP turn per request; the WS entry would only close the
// session after selection, so keep WS clients on the other accounts.
if accountHasPrismBrowser(account) {
if len(requestedModels) > 0 && account.IsPrismBrowserEnabledForModel(requestedModels[0]) {
return false
}
if requiredTransport == OpenAIUpstreamTransportResponsesWebsocketV2Ingress {
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/openai_codex_ticket.go
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ func (s *OpenAIGatewayService) openAICodexTicketOutboundModel(account *Account,
// outboundModel 必须是真正会发给上游的模型名(openAICodexTicketOutboundModel),
// 不是客户端原始模型:注入侧读的是出站 body.model,两侧口径必须一致。
func (s *OpenAIGatewayService) openAICodexTicketBlocksAccount(account *Account, outboundModel string) bool {
if accountHasPrismBrowser(account) {
if account.isPrismBrowserUpstreamModelEnabled(outboundModel) {
return false
}
if s == nil || !isOpenAICodexTicketAccount(account, outboundModel) || !s.openAICodexTicketEnabled() {
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/openai_gateway_forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}

modelForBPS := gjson.GetBytes(body, "model").String()
if accountHasPrismBrowser(account) {
if account.IsPrismBrowserEnabledForModel(modelForBPS) {
return s.forwardPrismBrowser(ctx, c, account, body, startTime)
}
if c.GetBool(bpsAccountProbeRequiredContextKey) &&
Expand Down
Loading
Loading