diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index ba74a5869..7636fa5fd 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -49,6 +49,7 @@ type codexModelsFailoverHTTPUpstream struct { accountIDs []int64 firstErr error firstStatus int + firstBody string statuses map[int64]int } @@ -62,6 +63,14 @@ func (u *codexModelsFailoverHTTPUpstream) Do(_ *http.Request, _ string, accountI if u.firstErr != nil { return nil, u.firstErr } + if u.firstBody != "" && !hasStatus { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(u.firstBody)), + }, nil + } if !hasStatus { status = u.firstStatus } @@ -150,6 +159,22 @@ func TestCodexModelsFailsOverFromUpstreamTransportError(t *testing.T) { } } +func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) { + handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusOK) + upstream.firstBody = `{"object":"list","data":[]}` + recorder := performCodexModelsRequest(t, handler, groupID) + + if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) { + t.Fatalf("upstream account calls: got %v, want %v", got, want) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want { + t.Fatalf("body: got %q, want %q", got, want) + } +} + func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) { statuses := []int{ http.StatusBadRequest, diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 1ad532e9e..32a9bd57d 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1,8 +1,10 @@ package service import ( + "bytes" "context" "crypto/sha256" + "encoding/json" "errors" "fmt" "io" @@ -219,10 +221,10 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest // FetchCodexModelsManifest fetches the live Codex models manifest from either // the ChatGPT backend for OAuth accounts or a custom upstream for API key accounts. // -// The response body is passed through verbatim: the manifest schema evolves -// with Codex client releases, and interpreting it here would force the gateway -// to chase upstream changes. Passing it through keeps the gateway -// schema-agnostic and always reflects the account's real entitlements. +// After validating the stable top-level envelope, the response body is passed +// through verbatim. Model entries evolve with Codex client releases, so the +// gateway deliberately avoids interpreting their fields and reflects the +// account's real entitlements without chasing upstream schema changes. func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) { if account == nil { return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required") @@ -461,9 +463,43 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont retryable: isRetryableCodexModelsManifestTransportError(err), } } + if err := validateCodexModelsManifestEnvelope(body); err != nil { + return nil, &codexModelsManifestUpstreamError{ + err: infraerrors.Newf( + http.StatusBadGateway, + "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST", + "codex models manifest upstream returned an invalid envelope: %v", + err, + ), + retryable: true, + } + } return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, nil } +func validateCodexModelsManifestEnvelope(body []byte) error { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("decode JSON object: %w", err) + } + if envelope == nil { + return errors.New("expected a JSON object") + } + models, ok := envelope["models"] + if !ok { + return errors.New("missing top-level models array") + } + models = bytes.TrimSpace(models) + var entries []json.RawMessage + if len(models) == 0 || models[0] != '[' { + return errors.New("top-level models field is not an array") + } + if err := json.Unmarshal(models, &entries); err != nil { + return fmt.Errorf("decode top-level models array: %w", err) + } + return nil +} + func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string { hasher := sha256.New() _, _ = fmt.Fprintf(hasher, "%d\n%d\n%s\n%s\n", request.accountID, request.credentialAccountID, request.proxyURL, request.url) diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 8554c0725..307599368 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -470,6 +470,81 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { } } +func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "OpenAI models list", body: `{"object":"list","data":[]}`}, + {name: "invalid JSON", body: `{"models":`}, + {name: "non-object", body: `[]`}, + {name: "null object", body: `null`}, + {name: "missing models", body: `{}`}, + {name: "models object", body: `{"models":{}}`}, + {name: "models null", body: `{"models":null}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(tt.body)), + }, nil + }} + + s := newCodexModelsAPIKeyTestService(upstream) + _, err := s.FetchCodexModelsManifest( + context.Background(), + newCodexModelsAPIKeyTestAccount("https://upstream.example"), + "0.144.0", + "", + ) + if err == nil { + t.Fatal("expected invalid manifest error, got nil") + } + if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST" { + t.Errorf("error reason: got %q", infraerrors.Reason(err)) + } + if !IsRetryableCodexModelsManifestError(err) { + t.Error("invalid upstream manifest must be retryable") + } + }) + } +} + +func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) { + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + body := `{"object":"list","data":[]}` + if calls.Add(1) > 1 { + body = `{"models":[{"slug":"gpt-5.6"}]}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }} + + s := newCodexModelsAPIKeyTestService(upstream) + account := newCodexModelsAPIKeyTestAccount("https://upstream.example") + if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err == nil { + t.Fatal("expected invalid manifest error on first fetch") + } + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + if err != nil { + t.Fatalf("second fetch returned error: %v", err) + } + if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"}]}`; got != want { + t.Errorf("body: got %q, want %q", got, want) + } + if got := calls.Load(); got != 2 { + t.Errorf("upstream calls: got %d, want 2", got) + } +} + func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t *testing.T) { const manifestBody = `{"models":[{"slug":"gpt-5.6"}]}` var calls atomic.Int32 @@ -693,7 +768,7 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. calls.Add(1) body := `{"models":[]}` if strings.Contains(req.URL.Host, "large") { - body = strings.Repeat("x", (1<<20)+1) + body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}` } return &http.Response{ StatusCode: http.StatusOK, diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index de2446eb7..300bea376 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -649,6 +649,12 @@ func TestNormalizeCompletedImageGenerationStatus(t *testing.T) { want: `{"type":"response.output_item.added","item":{"type":"image_generation_call","status":"generating","result":"image-data"}}`, wantChanged: false, }, + { + name: "done preserves base64 result", + input: `{"type":"response.done","response":{"output":[{"type":"image_generation_call","status":"generating","result":"iVBORw0KGgoAAAANSUhEUg/+=="}]}}`, + want: `{"type":"response.done","response":{"output":[{"type":"image_generation_call","status":"completed","result":"iVBORw0KGgoAAAANSUhEUg/+=="}]}}`, + wantChanged: true, + }, } for _, tt := range tests { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index f6a7dca25..67bffe6ce 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -803,6 +803,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( wroteDownstream, ) } + if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed { + upstreamMessage = normalized + } eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) if responseID == "" && eventResponseID != "" { diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 8fbf168f6..c90a1ebc0 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -38,6 +38,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT captureConn := &openAIWSCaptureConn{ events: [][]byte{ + []byte(`{"type":"response.output_item.done","item":{"id":"ig_ingress_1","type":"image_generation_call","status":"generating","result":"iVBORw0KGgoAAAANSUhEUg/+=="}}`), []byte(`{"type":"response.completed","response":{"id":"resp_ingress_turn_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`), []byte(`{"type":"response.completed","response":{"id":"resp_ingress_turn_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`), }, @@ -138,6 +139,10 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT } writeMessage(`{"type":"response.create","model":"gpt-5.1","stream":false}`) + firstTurnImageEvent := readMessage() + require.Equal(t, "response.output_item.done", gjson.GetBytes(firstTurnImageEvent, "type").String()) + require.Equal(t, "completed", gjson.GetBytes(firstTurnImageEvent, "item.status").String()) + require.Equal(t, "iVBORw0KGgoAAAANSUhEUg/+==", gjson.GetBytes(firstTurnImageEvent, "item.result").String()) firstTurnEvent := readMessage() require.Equal(t, "response.completed", gjson.GetBytes(firstTurnEvent, "type").String()) require.Equal(t, "resp_ingress_turn_1", gjson.GetBytes(firstTurnEvent, "response.id").String()) @@ -765,7 +770,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR upstreamConn := &openAIWSCaptureConn{ events: [][]byte{ - []byte(`{"type":"response.completed","response":{"id":"resp_passthrough_turn_1","model":"gpt-5.1","usage":{"input_tokens":2,"output_tokens":3}}}`), + []byte(`{"type":"response.completed","response":{"id":"resp_passthrough_turn_1","model":"gpt-5.1","output":[{"id":"ig_passthrough_1","type":"image_generation_call","status":"generating","result":"final-image"}],"usage":{"input_tokens":2,"output_tokens":3}}}`), }, } captureDialer := &openAIWSCaptureDialer{conn: upstreamConn} @@ -858,6 +863,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR require.NoError(t, readErr) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) require.Equal(t, "resp_passthrough_turn_1", gjson.GetBytes(event, "response.id").String()) + require.Equal(t, "completed", gjson.GetBytes(event, "response.output.0.status").String()) _ = clientConn.Close(coderws.StatusNormalClosure, "done") select { @@ -1047,7 +1053,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_bridge_1"}}, Body: io.NopCloser(strings.NewReader( "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n" + - "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_http_bridge_1\",\"usage\":{\"input_tokens\":2,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n" + + "data: {\"type\":\"response.done\",\"response\":{\"id\":\"resp_http_bridge_1\",\"output\":[{\"id\":\"ig_bridge_1\",\"type\":\"image_generation_call\",\"status\":\"in_progress\",\"result\":\"final-image\"}],\"usage\":{\"input_tokens\":2,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n" + "data: [DONE]\n\n", )), }, @@ -1145,8 +1151,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe _, event2, readErr2 := clientConn.Read(readCtx2) cancelRead2() require.NoError(t, readErr2) - require.Equal(t, "response.completed", gjson.GetBytes(event2, "type").String()) + require.Equal(t, "response.done", gjson.GetBytes(event2, "type").String()) require.Equal(t, "resp_http_bridge_1", gjson.GetBytes(event2, "response.id").String()) + require.Equal(t, "completed", gjson.GetBytes(event2, "response.output.0.status").String()) _ = clientConn.Close(coderws.StatusNormalClosure, "done") diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 37c68a412..0d031de18 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -281,6 +281,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi "item": map[string]any{ "id": "ig_ws_1", "type": "image_generation_call", + "status": "generating", "result": "final-image", }, }); err != nil { @@ -296,6 +297,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi map[string]any{ "id": "ig_ws_1", "type": "image_generation_call", + "status": "in_progress", "result": "final-image", }, }, @@ -375,6 +377,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi require.Equal(t, 4, result.Usage.OutputTokens) require.True(t, result.OpenAIWSMode) require.Equal(t, "resp_ws_image_1", gjson.GetBytes(rec.Body.Bytes(), "id").String()) + require.Equal(t, "completed", gjson.GetBytes(rec.Body.Bytes(), "output.0.status").String()) } func requestToJSONString(payload map[string]any) string { diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index c406b6b33..62aee29c2 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -504,6 +504,9 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "") return nil, fmt.Errorf("openai ws read event: %w", readErr) } + if normalized, changed := normalizeCompletedImageGenerationStatus(message); changed { + message = normalized + } eventType, eventResponseID, responseField := parseOpenAIWSEventEnvelope(message) if eventType == "" { diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 9140f6144..09b3dc062 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -331,6 +331,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } upstreamMessage := []byte(trimmedData) + if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed { + upstreamMessage = normalized + } eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) if responseID == "" && eventResponseID != "" { responseID = eventResponseID diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 4505ffb66..af1bf7456 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -212,6 +212,11 @@ func (c *openAIWSClientFrameConn) WriteFrame(ctx context.Context, msgType coderw if ctx == nil { ctx = context.Background() } + if msgType == coderws.MessageText { + if normalized, changed := normalizeCompletedImageGenerationStatus(payload); changed { + payload = normalized + } + } return c.conn.Write(ctx, msgType, payload) } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 228d7de2a..c61ebf605 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1461,7 +1461,7 @@ - +
{ const wrapper = mountModal(account) + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageTool') + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolDesc') + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolEnabledDesc') + await wrapper.get('button[data-testid="codex-image-tool-enabled"]').trigger('click') await wrapper.get('form#edit-account-form').trigger('submit.prevent') @@ -809,6 +813,9 @@ describe('EditAccountModal', () => { const wrapper = mountModal(account) + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolBlock') + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolBlockDesc') + await wrapper.get('button[data-testid="codex-image-tool-block"]').trigger('click') await wrapper.get('form#edit-account-form').trigger('submit.prevent') diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index e41d44f74..67bb3f980 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -100,6 +100,14 @@ {{ t('keys.useKeyModal.openai.authModeApiKey') }}
+
+ +

{{ t('keys.useKeyModal.openai.authModeApiKeyRestartNotice') }}

+
diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index b147099ed..d558f35b3 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -268,6 +268,7 @@ describe('UseKeyModal', () => { expect(configToml).toContain('[features]\ngoals = true') expect(codeBlocks).toContain('{\n "OPENAI_API_KEY": "sk-test"\n}') expect(wrapper.text()).toContain('auth.json') + expect(wrapper.find('[data-testid="codex-api-key-restart-notice"]').exists()).toBe(false) }) it('renders API Key Mode authorization in OpenAI Codex config', async () => { @@ -305,6 +306,19 @@ describe('UseKeyModal', () => { expect(configToml).not.toContain('image_generation') expect(codeBlocks).toContain('{\n "OPENAI_API_KEY": "sk-test"\n}') expect(wrapper.text()).toContain('auth.json') + + const restartNotice = wrapper.get('[data-testid="codex-api-key-restart-notice"]') + expect(restartNotice.text()).toContain( + 'keys.useKeyModal.openai.authModeApiKeyRestartNotice' + ) + + await wrapper.get('[data-testid="codex-auth-mode-legacy"]').trigger('click') + await nextTick() + + expect(wrapper.find('[data-testid="codex-api-key-restart-notice"]').exists()).toBe(false) + expect(wrapper.findAll('pre code').map((code) => code.text()).join('\n')).not.toContain( + 'x-openai-actor-authorization' + ) }) it('keeps legacy OpenAI Codex WebSocket config as the default', async () => { diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 950ea80af..1d38b2f1c 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -489,21 +489,21 @@ export default { codexCLIOnlyAppServer: 'Allow Codex app-server clients', codexCLIOnlyAppServerDesc: "Effective only when the switch above is on. When enabled, this account also allows third-party clients that embed the Codex engine over the app-server protocol (e.g. Claude Code's codex plugin); they still pass the global engine-fingerprint gate. OR-combined with the global app-server toggle.", - codexImageTool: 'Codex image tool', + codexImageTool: 'Codex image bridge policy', codexImageToolDesc: - 'One policy for the image_generation tool on Codex /responses text requests: whether it is auto-injected, and whether client-provided tools pass through. Account policy takes precedence over channel and global settings; standalone image-generation endpoints are unaffected.', + 'Controls the hosted image_generation bridge and client-declared image tools on Codex /responses text requests. Hosted auto-injection applies only to non-Responses Lite requests. Account policy takes precedence over channel and global settings; standalone image-generation endpoints are unaffected.', codexImageToolInherit: 'Follow channel', - codexImageToolInheritDesc: 'No account override; injection follows the channel or global policy, and client-provided image tools pass through.', - codexImageToolEnabled: 'Force inject', - codexImageToolEnabledDesc: 'Always inject the image tool for Codex /responses requests.', - codexImageToolDisabled: 'No injection', - codexImageToolDisabledDesc: 'Never auto-inject; client-provided image tools still pass through.', - codexImageToolBlock: 'Block all', - codexImageToolBlockDesc: 'No injection, and client-provided image tools plus matching tool_choice are removed.', + codexImageToolInheritDesc: 'No account override; hosted injection for non-Lite requests follows the channel or global policy, while client-provided hosted tools and local image_gen declarations pass through.', + codexImageToolEnabled: 'Enable hosted bridge', + codexImageToolEnabledDesc: 'Inject the hosted image_generation tool only for non-Responses Lite requests; client-provided image tools still pass through.', + codexImageToolDisabled: 'No hosted injection', + codexImageToolDisabledDesc: 'Do not inject the hosted tool; client-provided hosted tools and local image_gen declarations still pass through.', + codexImageToolBlock: 'Strip client image tools', + codexImageToolBlockDesc: 'Do not auto-inject through the bridge, and remove client-provided hosted image_generation tools, local image_gen declarations, and matching tool_choice. Image-only model routing remains unaffected.', codexImageToolBadgeInherit: 'Channel policy', - codexImageToolBadgeEnabled: 'Force inject', - codexImageToolBadgeDisabled: 'No injection', - codexImageToolBadgeBlock: 'Blocked', + codexImageToolBadgeEnabled: 'Hosted bridge on', + codexImageToolBadgeDisabled: 'No hosted injection', + codexImageToolBadgeBlock: 'Client image tools stripped', compactMode: 'Compact mode', compactModeDesc: 'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.', diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index ce6327770..96c0a6cbd 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -156,7 +156,7 @@ export default { webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.', webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation', codexImageGenerationBridge: 'Codex Image Generation Bridge', - codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.', + codexImageGenerationBridgeHint: 'When enabled, only non-Responses Lite Codex /responses text requests in OpenAI groups receive the hosted image_generation tool. The bridge does not inject tools for Responses Lite; local image_gen handling follows the client and account policy. Leave this off unless routed accounts support image generation.', bedrockCCCompat: 'Bedrock CC Compatibility', bedrockCCCompatHint: '⚠️ When enabled, requests to Bedrock accounts in this channel will be transformed for Claude Code compatibility (thinking type conversion, tool_use ID sanitization).', basicSettings: 'Basic Settings', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 77c1d29ca..06d15b921 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -140,9 +140,10 @@ export default { openai: { description: 'Add the following configuration files to your Codex CLI config directory.', authModeTitle: 'Codex authentication mode', - authModeDescription: 'Compatibility mode keeps the existing setup for older Codex clients. API Key Mode enables the client-side image executor.', + authModeDescription: 'Compatibility mode keeps the existing setup for older Codex clients. API Key Mode authorizes the client-side image executor.', authModeLegacy: 'Compatibility mode', authModeApiKey: 'API Key Mode', + authModeApiKeyRestartNotice: 'After saving this configuration, completely quit and restart Codex Desktop or CLI, then create a new task so the client can rebuild its tool registry.', configTomlHint: 'Make sure the following content is at the beginning of the config.toml file', note: 'Make sure the config directory exists. macOS/Linux users can run mkdir -p ~/.codex to create it.', noteWindows: 'Press Win+R and enter %userprofile%\\.codex to open the config directory. Create it manually if it does not exist.', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 917a4ea20..f72582c54 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -587,21 +587,21 @@ export default { codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。', codexCLIOnlyAppServer: '允许 Codex app-server 客户端', codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。', - codexImageTool: 'Codex 图片工具', + codexImageTool: 'Codex 图片桥接策略', codexImageToolDesc: - '统一控制 Codex /responses 文本请求的 image_generation 图片工具:是否自动注入,以及客户端自带该工具时是否放行。账号级策略优先于渠道和全局配置,不影响独立图片生成接口。', + '统一控制 Codex /responses 文本请求的 hosted image_generation 桥接和客户端图片工具声明。hosted 工具自动注入仅适用于非 Responses Lite 请求;账号级策略优先于渠道和全局配置,不影响独立图片生成接口。', codexImageToolInherit: '跟随渠道', - codexImageToolInheritDesc: '不写入账号覆盖,是否注入由渠道或全局策略决定;客户端自带的图片工具照常放行。', - codexImageToolEnabled: '强制注入', - codexImageToolEnabledDesc: '始终为 Codex /responses 请求注入图片工具。', - codexImageToolDisabled: '关闭注入', - codexImageToolDisabledDesc: '不自动注入;客户端自带的图片工具仍会放行。', - codexImageToolBlock: '完全阻断', - codexImageToolBlockDesc: '不注入,并移除客户端自带的图片工具及指向它的 tool_choice。', + codexImageToolInheritDesc: '不写入账号覆盖;非 Lite 请求是否注入 hosted 工具由渠道或全局策略决定,客户端显式携带的 hosted 工具和本地 image_gen 声明照常放行。', + codexImageToolEnabled: '启用 Hosted 桥接', + codexImageToolEnabledDesc: '仅为非 Responses Lite 请求注入 hosted image_generation 工具;客户端显式携带的图片工具仍会放行。', + codexImageToolDisabled: '不注入 Hosted 工具', + codexImageToolDisabledDesc: '不注入 hosted 工具;客户端显式携带的 hosted 工具和本地 image_gen 声明仍会放行。', + codexImageToolBlock: '移除客户端图片工具', + codexImageToolBlockDesc: '不通过桥接自动注入 hosted 工具,并移除客户端显式携带的 hosted image_generation 工具、本地 image_gen 声明及相关 tool_choice;image-only 模型路由不受影响。', codexImageToolBadgeInherit: '渠道策略', - codexImageToolBadgeEnabled: '强制注入', - codexImageToolBadgeDisabled: '关闭注入', - codexImageToolBadgeBlock: '完全阻断', + codexImageToolBadgeEnabled: 'Hosted 桥接已开启', + codexImageToolBadgeDisabled: '不注入 Hosted 工具', + codexImageToolBadgeBlock: '客户端图片工具已移除', compactMode: 'Compact 模式', compactModeDesc: '控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。', diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index 56ffbb60c..cdfb185c8 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -156,7 +156,7 @@ export default { webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作', webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关', codexImageGenerationBridge: 'Codex 图片生成桥接', - codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。', + codexImageGenerationBridgeHint: '开启后,OpenAI 分组仅会为非 Responses Lite 的 Codex /responses 文本请求自动注入 hosted image_generation 工具。桥接不会为 Responses Lite 注入工具;本地 image_gen 的处理由客户端和账号策略决定。仅在路由账号支持图片生成时开启。', bedrockCCCompat: 'Bedrock CC 兼容', bedrockCCCompatHint: '⚠️ 开启后,该渠道下 Bedrock 账号的请求将进行 Claude Code 兼容处理(thinking 类型转换、tool_use ID 清理)', basicSettings: '基础设置', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 500bec422..eaf000408 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -140,9 +140,10 @@ export default { openai: { description: '将以下配置文件添加到 Codex CLI 配置目录中。', authModeTitle: 'Codex 认证模式', - authModeDescription: '兼容模式保留旧版 Codex 配置;API Key Mode 用于启用客户端图片执行器。', + authModeDescription: '兼容模式保留旧版 Codex 配置;API Key Mode 用于授权客户端图片执行器。', authModeLegacy: '兼容模式', authModeApiKey: 'API Key Mode', + authModeApiKeyRestartNotice: '保存此配置后,必须完全退出并重启 Codex Desktop 或 CLI,然后新建 task,让客户端重新构建工具注册表。', configTomlHint: '请确保以下内容位于 config.toml 文件的开头部分', note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。', noteWindows: