mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
Merge pull request #4431 from linyqh/codex/fix-codex-image-handoff
fix(openai): 修复 Codex 生图网关侧交接问题
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '基础设置',
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user