Merge pull request #4431 from linyqh/codex/fix-codex-image-handoff

fix(openai): 修复 Codex 生图网关侧交接问题
This commit is contained in:
Wesley Liddick
2026-07-16 19:32:33 +08:00
committed by GitHub
20 changed files with 234 additions and 37 deletions
@@ -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,
@@ -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)
@@ -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,
@@ -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 {
@@ -803,6 +803,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
wroteDownstream,
)
}
if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed {
upstreamMessage = normalized
}
eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage)
if responseID == "" && eventResponseID != "" {
@@ -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")
@@ -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 {
@@ -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 == "" {
@@ -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
@@ -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)
}
@@ -1461,7 +1461,7 @@
</div>
</div>
<!-- OpenAI Codex 图片工具统一策略(自动注入 + 客户端显式携带) -->
<!-- OpenAI Codex hosted image_generation bridge policy -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
@@ -771,6 +771,10 @@ describe('EditAccountModal', () => {
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')
@@ -100,6 +100,14 @@
{{ t('keys.useKeyModal.openai.authModeApiKey') }}
</button>
</div>
<div
v-if="codexAuthMode === 'api-key'"
data-testid="codex-api-key-restart-notice"
class="mt-3 flex items-start gap-2 border-l-2 border-amber-400 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500 dark:bg-amber-950/30 dark:text-amber-200"
>
<Icon name="exclamationCircle" size="sm" class="mt-0.5 flex-shrink-0" />
<p>{{ t('keys.useKeyModal.openai.authModeApiKeyRestartNotice') }}</p>
</div>
</div>
<!-- OS/Shell Tabs -->
@@ -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 () => {
+12 -12
View File
@@ -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.',
@@ -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',
+2 -1
View File
@@ -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.',
+12 -12
View File
@@ -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 强制排除。',
@@ -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: '基础设置',
+2 -1
View File
@@ -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: