diff --git a/backend/internal/handler/account_auto_config_outcome.go b/backend/internal/handler/account_auto_config_outcome.go index 687b3f92d93..2a7848ddd20 100644 --- a/backend/internal/handler/account_auto_config_outcome.go +++ b/backend/internal/handler/account_auto_config_outcome.go @@ -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 { diff --git a/backend/internal/handler/account_auto_config_outcome_test.go b/backend/internal/handler/account_auto_config_outcome_test.go index c1e6c80d240..3ed71f939d5 100644 --- a/backend/internal/handler/account_auto_config_outcome_test.go +++ b/backend/internal/handler/account_auto_config_outcome_test.go @@ -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) + } +} diff --git a/backend/internal/handler/openai_prism_error_test.go b/backend/internal/handler/openai_prism_error_test.go new file mode 100644 index 00000000000..b7d62f27bdd --- /dev/null +++ b/backend/internal/handler/openai_prism_error_test.go @@ -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)) + }) + } +} diff --git a/backend/internal/service/account_prism.go b/backend/internal/service/account_prism.go new file mode 100644 index 00000000000..f70bd31182d --- /dev/null +++ b/backend/internal/service/account_prism.go @@ -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. +} diff --git a/backend/internal/service/account_prism_test.go b/backend/internal/service/account_prism_test.go new file mode 100644 index 00000000000..ab56caaef2a --- /dev/null +++ b/backend/internal/service/account_prism_test.go @@ -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)) +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 31259fbdf90..82b9639cfea 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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") } @@ -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" } diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index beb3efed23a..884d0c3d32c 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -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 { diff --git a/backend/internal/service/openai_codex_ticket.go b/backend/internal/service/openai_codex_ticket.go index 45cd5fc9894..18bfd9afb0f 100644 --- a/backend/internal/service/openai_codex_ticket.go +++ b/backend/internal/service/openai_codex_ticket.go @@ -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() { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 3ce8c3498c3..74dedd799dc 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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) && diff --git a/backend/internal/service/openai_prism_browser.go b/backend/internal/service/openai_prism_browser.go index 7bdafaf5da7..220058d530e 100644 --- a/backend/internal/service/openai_prism_browser.go +++ b/backend/internal/service/openai_prism_browser.go @@ -18,10 +18,31 @@ import ( "github.com/gin-gonic/gin" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) const prismBrowserMaxResponseBytes = 2 << 20 +const prismBrowserAttemptsKey = "prism_browser_attempts" + +// MarkPrismBrowserAttempt separates adapter admission/outcomes from the native +// account concurrency ramp. Keep all attempts so failover cannot lose the mark. +func MarkPrismBrowserAttempt(c *gin.Context, accountID int64) { + value, _ := c.Get(prismBrowserAttemptsKey) + ids, _ := value.(map[int64]bool) + if ids == nil { + ids = make(map[int64]bool) + } + ids[accountID] = true + c.Set(prismBrowserAttemptsKey, ids) +} + +func IsPrismBrowserAttempt(c *gin.Context, accountID int64) bool { + value, _ := c.Get(prismBrowserAttemptsKey) + ids, _ := value.(map[int64]bool) + return ids[accountID] +} + func prismBrowserTerminal(body []byte, model string, stream bool) (string, error) { terminal := body var itemEvents []gjson.Result @@ -213,42 +234,88 @@ func prismBrowserAdapterErrorMessage(status int, body []byte) string { } } +// Only log protocol codes we own. Never copy arbitrary adapter messages or +// reflected input into gateway logs when diagnosing fast 422 refusals. +func prismBrowserForwardError(status int, body []byte) error { + code := gjson.GetBytes(body, "error.type").String() + switch code { + case "tools_disabled", "unsupported_model", "unsupported_request", "unsupported_reasoning", + "unsupported_input", "unsupported_tool_model", "unsupported_tool", "invalid_tools", + "invalid_tool_choice", "invalid_tool_payload", "unsupported_reasoning_history", + "model_unavailable", "reasoning_unavailable", "pending_turn", "prism_busy": + return fmt.Errorf("prism adapter returned HTTP %d (%s)", status, code) + default: + return fmt.Errorf("prism adapter returned HTTP %d", status) + } +} + func (s *OpenAIGatewayService) forwardPrismBrowser(ctx context.Context, c *gin.Context, account *Account, body []byte, started time.Time) (*OpenAIForwardResult, error) { + MarkPrismBrowserAttempt(c, account.ID) + // This path buffers and writes a complete JSON or SSE response, including + // errors. The handler must never append another response.failed envelope. + defer func() { + if c.Writer.Written() { + MarkResponseCommitted(c) + } + }() + writeError := func(status int, raw []byte) { + committed := StopOpenAICompactSSEKeepaliveCommitted(c) + if committed || c.Writer.Written() { + code := gjson.GetBytes(raw, "error.type").String() + message := gjson.GetBytes(raw, "error.message").String() + writeOpenAICompactSSEFailureMessage(c, status, code, message) + return + } + c.Data(status, "application/json", raw) + } + fail := func(status int, code, message string) { + raw, _ := json.Marshal(gin.H{"error": gin.H{"type": code, "message": message}}) + writeError(status, raw) + } if isOpenAIResponsesCompactPath(c) { - c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": "Prism adapter does not support responses/compact"}}) + fail(http.StatusBadRequest, "invalid_request_error", "Prism adapter does not support responses/compact") return nil, errors.New("prism adapter does not support responses/compact") } model := strings.TrimSpace(gjson.GetBytes(body, "model").String()) stream := gjson.GetBytes(body, "stream").Bool() if model == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": "model is required"}}) + fail(http.StatusBadRequest, "invalid_request_error", "model is required") return nil, errors.New("prism adapter model is required") } + upstreamModel := account.GetMappedModel(model) + if upstreamModel != model { + mapped, mapErr := sjson.SetBytes(body, "model", upstreamModel) + if mapErr != nil { + fail(http.StatusBadRequest, "invalid_request_error", "invalid Prism request") + return nil, mapErr + } + body = mapped + } sessionID, err := prismBrowserSessionID(c, account.ID, body) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error()}}) + fail(http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } responseBody, upstreamHeaders, status, err := s.callPrismBrowserForCaller(ctx, account, body, sessionID, prismBrowserCallerID(c, account.ID)) if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "prism_unavailable", "message": "Prism adapter unavailable; request was not replayed"}}) + fail(http.StatusBadGateway, "prism_unavailable", "Prism adapter unavailable; request was not replayed") return nil, err } if status != http.StatusOK { if prismBrowserAdapterMisconfigured(status) { - c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "prism_unavailable", "message": "Prism adapter rejected the gateway; check the adapter key and endpoint"}}) + fail(http.StatusBadGateway, "prism_unavailable", "Prism adapter rejected the gateway; check the adapter key and endpoint") return nil, fmt.Errorf("prism adapter returned HTTP %d", status) } - c.Data(status, "application/json", responseBody) - return nil, fmt.Errorf("prism adapter returned HTTP %d", status) + writeError(status, responseBody) + return nil, prismBrowserForwardError(status, responseBody) } - responseID, err := prismBrowserTerminal(responseBody, model, stream) + responseID, err := prismBrowserTerminal(responseBody, upstreamModel, stream) if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "invalid_prism_response", "message": "Prism adapter returned no valid terminal response"}}) + fail(http.StatusBadGateway, "invalid_prism_response", "Prism adapter returned no valid terminal response") return nil, err } if err := prismBrowserValidateToolCatalog(body, responseBody, stream); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "invalid_prism_response", "message": "Prism adapter returned an undeclared client tool"}}) + fail(http.StatusBadGateway, "invalid_prism_response", "Prism adapter returned an undeclared client tool") return nil, err } contentType := "application/json" @@ -263,7 +330,7 @@ func (s *OpenAIGatewayService) forwardPrismBrowser(ctx context.Context, c *gin.C ResponseID: responseID, UpstreamHeaders: upstreamHeaders, Model: model, - UpstreamModel: model, + UpstreamModel: upstreamModel, Stream: stream, Duration: time.Since(started), UsageUnavailable: true, diff --git a/backend/internal/service/openai_turn_admission.go b/backend/internal/service/openai_turn_admission.go index 01800c7d475..2be15178c17 100644 --- a/backend/internal/service/openai_turn_admission.go +++ b/backend/internal/service/openai_turn_admission.go @@ -188,7 +188,7 @@ func openAITurnRouteFingerprint(a *Account) [32]byte { for _, key := range []string{ codexFingerprintSeedExtraKey, codexFingerprintModeExtraKey, "openai_passthrough", "openai_oauth_passthrough", "openai_excel_bps", "openai_excel_bps_models", "openai_excel_bps_mihomo", - "openai_prism_browser", + "openai_prism_browser", PrismBrowserModelsKey, "openai_oauth_responses_websockets_v2_mode", "openai_apikey_responses_websockets_v2_mode", "openai_oauth_responses_websockets_v2_enabled", "openai_apikey_responses_websockets_v2_enabled", "responses_websockets_v2_enabled", "openai_ws_enabled", "openai_ws_force_http", @@ -397,7 +397,7 @@ func (s *OpenAIGatewayService) bindOpenAIWSHandshake(account *Account, model str } func (s *OpenAIGatewayService) checkOpenAIWSBinding(account *Account, model string, b *openAIWSTurnBinding) error { - if accountHasPrismBrowser(account) { + if account.isPrismBrowserUpstreamModelEnabled(model) { return denyOpenAITurn("prism_requires_http") } if account.isExcelBPSUpstreamModelEnabled(model) { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index ce2affcf04e..5646521c61c 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -599,7 +599,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return admissionErr } account = latest - if accountHasPrismBrowser(account) { + if account.IsPrismBrowserEnabledForModel(extractOpenAICodexTicketModel(firstClientMessage)) { return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "Prism accounts require HTTP/SSE", nil) } if account.IsExcelBPSEnabledForModel(extractOpenAICodexTicketModel(firstClientMessage)) { diff --git a/backend/internal/service/prism_browser_routing_test.go b/backend/internal/service/prism_browser_routing_test.go index 5faddf827e1..1dca7af907f 100644 --- a/backend/internal/service/prism_browser_routing_test.go +++ b/backend/internal/service/prism_browser_routing_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -33,6 +34,13 @@ func TestPrismBrowserResponsesURL(t *testing.T) { } } +func TestPrismBrowserForwardErrorKeepsKnownCodeWithoutReflectingSecrets(t *testing.T) { + err := prismBrowserForwardError(422, []byte(`{"error":{"type":"tools_disabled","message":"private input fixture"}}`)) + require.EqualError(t, err, "prism adapter returned HTTP 422 (tools_disabled)") + err = prismBrowserForwardError(422, []byte(`{"error":{"type":"private input fixture","message":"private input fixture"}}`)) + require.EqualError(t, err, "prism adapter returned HTTP 422") +} + func TestPrismBrowserSessionIDUsesExistingCodexIdentity(t *testing.T) { identity := func(keyID, accountID int64, headers map[string]string, body string) (string, error) { c, _ := gin.CreateTestContext(httptest.NewRecorder()) @@ -147,6 +155,24 @@ func TestPrismBrowserInvalidSessionDoesNotDispatch(t *testing.T) { } } +func TestPrismBrowserExplicitMappingReachesAdapter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, "gpt-6.1-sol", body["model"]) + _, _ = io.WriteString(w, `{"id":"resp_fixture","status":"completed","model":"gpt-6.1-sol","usage":null,"output":[{"content":[{"text":"21"}]}]}`) + })) + defer server.Close() + s, account := prismTestService(server.URL) + account.Credentials["model_mapping"] = map[string]any{"my-sol": "gpt-6.1-sol"} + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + result, err := s.forwardPrismBrowser(context.Background(), c, account, []byte(`{"model":"my-sol","input":"hi"}`), time.Now()) + require.NoError(t, err) + require.Equal(t, "my-sol", result.Model) + require.Equal(t, "gpt-6.1-sol", result.UpstreamModel) +} + func prismTestService(endpoint string) (*OpenAIGatewayService, *Account) { cfg := &config.Config{} cfg.Gateway.PrismBrowser = config.GatewayPrismBrowserConfig{Enabled: true, BaseURL: endpoint + "/v1", APIKey: "fixture-bridge-key"} diff --git a/docs/screenshots/prism-model-scope/README.md b/docs/screenshots/prism-model-scope/README.md new file mode 100644 index 00000000000..65667aef2fa --- /dev/null +++ b/docs/screenshots/prism-model-scope/README.md @@ -0,0 +1,12 @@ +# Prism 专属模型选择 + +入口:账号管理 → 编辑 OpenAI OAuth 账号 → Prism。 + +截图来自本 PR 的实际 `EditAccountModal.vue`,使用本地 Vite、Playwright 和虚构账号 / mock API。验证了四模型默认勾选、仅保留 6.1 Sol 并提交保存、移动端深色布局;不是生产截图或整页导航验收。 + +- `before.png`:生产基线 `bc83ff9c367883e5b7d0140e6bb42e2e7cc5239c`,只有总开关。 +- `after.png`:默认勾选四个已支持模型。 +- `selected.png`:仅勾选 6.1 Sol,mock 更新接口收到 `openai_prism_browser_models: ["gpt-6.1-sol"]`。 +- `mobile-dark.png`:相同选择在 390px 深色视口下的布局。 + +源码改动与截图在同一提交;`manifest.json` 记录生成时的基线、修改状态和浏览器错误。可复用生成脚本在运维仓库 `.agents/skills/sub2api-pr-management/scripts/screenshot-prism-model-scope.mjs`。 diff --git a/docs/screenshots/prism-model-scope/after.png b/docs/screenshots/prism-model-scope/after.png new file mode 100644 index 00000000000..edd5e131cc3 Binary files /dev/null and b/docs/screenshots/prism-model-scope/after.png differ diff --git a/docs/screenshots/prism-model-scope/before.png b/docs/screenshots/prism-model-scope/before.png new file mode 100644 index 00000000000..f78814e3658 Binary files /dev/null and b/docs/screenshots/prism-model-scope/before.png differ diff --git a/docs/screenshots/prism-model-scope/manifest.json b/docs/screenshots/prism-model-scope/manifest.json new file mode 100644 index 00000000000..24efc7d798a --- /dev/null +++ b/docs/screenshots/prism-model-scope/manifest.json @@ -0,0 +1,10 @@ +{ + "baseline": "bc83ff9c367883e5b7d0140e6bb42e2e7cc5239c", + "sourceHead": "bc83ff9c367883e5b7d0140e6bb42e2e7cc5239c", + "sourceModified": "M frontend/src/components/account/EditAccountModal.vue\n M frontend/src/i18n/locales/zh/admin/accounts.ts", + "scope": "Actual EditAccountModal, local mock API, synthetic account; not production or full navigation", + "savedModels": [ + "gpt-6.1-sol" + ], + "errors": [] +} diff --git a/docs/screenshots/prism-model-scope/mobile-dark.png b/docs/screenshots/prism-model-scope/mobile-dark.png new file mode 100644 index 00000000000..fa96235453a Binary files /dev/null and b/docs/screenshots/prism-model-scope/mobile-dark.png differ diff --git a/docs/screenshots/prism-model-scope/selected.png b/docs/screenshots/prism-model-scope/selected.png new file mode 100644 index 00000000000..131e08442bd Binary files /dev/null and b/docs/screenshots/prism-model-scope/selected.png differ diff --git a/frontend/src/components/account/AccountConcurrencyProgress.vue b/frontend/src/components/account/AccountConcurrencyProgress.vue index 475f4869b7e..4ff39c6deb5 100644 --- a/frontend/src/components/account/AccountConcurrencyProgress.vue +++ b/frontend/src/components/account/AccountConcurrencyProgress.vue @@ -8,6 +8,7 @@ :aria-label="t('autoConfig.progress', { count: progress.successes, required: progress.required, next: Math.min(account.concurrency + progress.step, progress.maximum) })" />
{{ t('autoConfig.progress', { count: progress.successes, required: progress.required, next: Math.min(account.concurrency + progress.step, progress.maximum) }) }}
+

{{ t('admin.accounts.openai.prismBrowserConcurrencyHint') }}