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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/scripts/dispatch-pr-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ custom_preserve_paths=(
frontend/src/components/Layout.tsx
frontend/src/components/ui/tooltip.tsx
frontend/src/lib/accountQuickConfig.ts
frontend/src/lib/usageTiming.ts
frontend/src/locales/en.json
frontend/src/locales/zh.json
frontend/src/pages/APIKeyUsagePortal.tsx
Expand Down
2 changes: 1 addition & 1 deletion .upstream-release
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v3.1.3
v3.1.4
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## v3.1.4 - 2026-11-05

### Fixes

- **Image generation and edits no longer send an explicit reasoning effort (#855, reported by @caoyanyi; #854, @cuiks).** The shared Codex request builder used by `/v1/images/generations`, `/v1/images/edits` and the admin Image Studio always sent `reasoning.effort=medium`, which the upstream now rejects as an unsupported image complexity (`Complexity 'medium' is not supported for this image model`) regardless of the requested `quality`. The builder now omits `reasoning.effort` and lets the upstream choose its default, while keeping `reasoning.summary`, encrypted reasoning content and the image tool's `model` / `quality` unchanged. No automatic retry or account switching is added. A regression test with a simulated upstream that rejects explicit effort covers generation, JSON and multipart edits, streaming and admin generation/edit paths.

- **`/v1/models` no longer hides image models behind text-only account allowlists (#826, reported by @depress77; #852).** When a Codex account saved a non-empty `credentials.models` allowlist containing only text models, model discovery filtered out every `gpt-image-*` entry even though the Images endpoints, which schedule by image capability and ignore that allowlist, could still generate images. Image models are now treated as a separate capability in discovery as well: catalog eligibility, enabled state and Pro-only plan gating still apply, and the text allowlist continues to filter text models. Key-level `ModelAllow` / `ModelDeny` and account visibility rules still hide image models as before.

- **Account detail sheet arrow keys no longer hijack text cursors or overlays (#851, @yanhao98).** The sheet's window-level Left/Right handler called `preventDefault` and switched accounts even while focus was in an input, textarea, select or contenteditable element, or while an edit dialog, dropdown or stacked sheet was open. Navigation now ignores modified arrow keys, editable targets and blocking overlays, and only switches accounts from the standalone sheet.

## v3.1.3 - 2026-11-04

### Features
Expand Down
2 changes: 2 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ OpenAI Responses 中转账号默认仍用 HTTP POST `/v1/responses`。账号设

**说明:** OpenAI Images 兼容入口。外部请求使用 `gpt-image-2`、`gpt-image-2.5-flare` 或 `gpt-image-2.5-sunburst`(支持日期快照及 `-2k` / `-4k` 档位后缀),内部按 `CLIProxyAPI/` 与 `sub2api/` 的链路转换为 Codex `/responses`:主模型默认 `gpt-5.6-luna`(优先使用「系统设置 → Codex → 生图设置」中的文本模型,未配置时沿用环境变量 `CODEX_IMAGES_MAIN_MODEL`;被上游拒绝时按 `gpt-5.5` → `gpt-5.6-terra` → `gpt-5.6-sol` → `gpt-6-astra` 顺序换驱动重试),图像模型写入 `tools[0].model`。

Images 入口及后台生图自动构造的 Codex 请求不强制 `reasoning.effort`,由上游选择默认值,避免显式 effort 被作为图片 complexity 拒绝。`reasoning.summary`、加密 reasoning 元数据及图片工具的 `quality` 参数保持原有语义。

Codex/GPT 图片生成与编辑在 `stream=false` 或省略 `stream` 时,只返回最终 JSON,不发送 HTTP `102 Processing` 或提前提交 HTTP 200;`stream=true` 仍使用原有 SSE 心跳。`DOWNSTREAM_HTTP_KEEPALIVE_INTERVAL` 不再为这两个入口的 Codex/GPT 非流式分支提供保活。客户端和反向代理仍需给完整生成时长足够的等待/空闲超时;需要异步调用时可使用下文的图片任务接口。Grok 图片分支不受此例外影响,仍保留既有 102 行为。

GPT Image 2.5 支持 `auto`、`low`、`medium`、`high`、`xhigh`、`max`。质量参数原样传给图片工具;工作台切回旧型号时会将 `xhigh` / `max` 调整为 `high`。省略模型仍默认使用 `gpt-image-2`。`-2k` / `-4k` 是本项目的尺寸与超分别名,发往上游前会剥掉该后缀。
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/components/AccountDetailSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "@/components/ui/sheet";
import { formatBeijingTime, formatRelativeTime } from "../utils/time";
import { formatLongUsageWindowLabel, getAccountStatusBadgeStatus } from "../lib/usageFormat";
import { shouldHandleAccountDetailKeyNavigation } from "../lib/accountDetailSheetKeyboard";

function isFutureTime(value?: string): boolean {
if (!value) return false;
Expand Down Expand Up @@ -275,7 +276,13 @@ export default function AccountDetailSheet({

useEffect(() => {
if (!open) return;
/**
* Keyboard navigation handler for switching accounts via ArrowLeft / ArrowRight.
*/
const onKeyDown = (event: KeyboardEvent) => {
if (!shouldHandleAccountDetailKeyNavigation(event)) {
return;
}
if (event.key === "ArrowLeft" && canGoPrev) {
event.preventDefault();
onPrev?.();
Expand Down
175 changes: 175 additions & 0 deletions frontend/src/lib/accountDetailSheetKeyboard.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'

import {
hasBlockingModalOrOverlay,
isEditableKeyboardTarget,
shouldHandleAccountDetailKeyNavigation,
} from './accountDetailSheetKeyboard.ts'

const detailSheetSource = readFileSync(
new URL('../components/AccountDetailSheet.tsx', import.meta.url),
'utf8',
)
const keyboardHelperSource = readFileSync(
new URL('./accountDetailSheetKeyboard.ts', import.meta.url),
'utf8',
)

/**
* Creates a mock document for querying overlays.
*
* @param {object} options
* @param {string[]} [options.matchingSelectors]
* @param {number} [options.sheetCount]
* @returns {object}
*/
function createMockDocument({ matchingSelectors = [], sheetCount = 1 } = {}) {
return {
querySelector: (selector) => {
const match = matchingSelectors.some((sel) => selector.includes(sel))
return match ? {} : null
},
querySelectorAll: (selector) => {
if (selector.includes('[data-slot="sheet-content"]')) {
return { length: sheetCount }
}
return { length: 0 }
},
}
}

/**
* Creates a mock DOM element with a closest() matcher.
*
* @param {string[]} matchingTags
* @returns {object}
*/
function createMockElement(matchingTags = []) {
return {
closest: (selector) => {
const selectorParts = selector.split(',').map((part) => part.trim())
const match = matchingTags.some((tag) => selectorParts.includes(tag))
return match ? {} : null
},
}
}

test('isEditableKeyboardTarget identifies inputs, textareas, selects, and contenteditable', () => {
assert.equal(isEditableKeyboardTarget(createMockElement(['input'])), true)
assert.equal(isEditableKeyboardTarget(createMockElement(['textarea'])), true)
assert.equal(isEditableKeyboardTarget(createMockElement(['select'])), true)
assert.equal(isEditableKeyboardTarget(createMockElement(['[contenteditable="true"]'])), true)
assert.equal(isEditableKeyboardTarget(createMockElement(['[contenteditable="false"]'])), false)
assert.equal(isEditableKeyboardTarget(createMockElement([])), false)
assert.equal(isEditableKeyboardTarget(null), false)
assert.equal(isEditableKeyboardTarget({}), false)
})

test('hasBlockingModalOrOverlay recognizes dialogs, alert dialogs, dropdowns, and multiple sheets', () => {
const dialogDoc = createMockDocument({ matchingSelectors: ['[data-slot="dialog-content"]'] })
assert.equal(hasBlockingModalOrOverlay(dialogDoc), true)

const alertDoc = createMockDocument({ matchingSelectors: ['[role="alertdialog"]'] })
assert.equal(hasBlockingModalOrOverlay(alertDoc), true)

const selectDoc = createMockDocument({ matchingSelectors: ['[data-select-dropdown="true"]'] })
assert.equal(hasBlockingModalOrOverlay(selectDoc), true)

const multiSheetDoc = createMockDocument({ sheetCount: 2 })
assert.equal(hasBlockingModalOrOverlay(multiSheetDoc), true)

const singleSheetDoc = createMockDocument({ sheetCount: 1 })
assert.equal(hasBlockingModalOrOverlay(singleSheetDoc), false)

assert.equal(hasBlockingModalOrOverlay(null), false)
})

test('shouldHandleAccountDetailKeyNavigation gates keyboard navigation', () => {
const normalDoc = createMockDocument({ sheetCount: 1 })
const plainTarget = createMockElement([])
const inputTarget = createMockElement(['input'])
const blockingDoc = createMockDocument({ matchingSelectors: ['[data-slot="dialog-content"]'] })
const multiSheetDoc = createMockDocument({ sheetCount: 2 })

assert.equal(
shouldHandleAccountDetailKeyNavigation({ key: 'ArrowLeft', target: plainTarget }, normalDoc),
true,
)
assert.equal(
shouldHandleAccountDetailKeyNavigation({ key: 'ArrowRight', target: plainTarget }, normalDoc),
true,
)

assert.equal(
shouldHandleAccountDetailKeyNavigation({ key: 'Enter', target: plainTarget }, normalDoc),
false,
)

assert.equal(
shouldHandleAccountDetailKeyNavigation(
{ key: 'ArrowLeft', shiftKey: true, target: plainTarget },
normalDoc,
),
false,
)
assert.equal(
shouldHandleAccountDetailKeyNavigation(
{ key: 'ArrowLeft', ctrlKey: true, target: plainTarget },
normalDoc,
),
false,
)
assert.equal(
shouldHandleAccountDetailKeyNavigation(
{ key: 'ArrowRight', altKey: true, target: plainTarget },
normalDoc,
),
false,
)
assert.equal(
shouldHandleAccountDetailKeyNavigation(
{ key: 'ArrowRight', metaKey: true, target: plainTarget },
normalDoc,
),
false,
)

assert.equal(
shouldHandleAccountDetailKeyNavigation({ key: 'ArrowLeft', target: inputTarget }, normalDoc),
false,
)
assert.equal(
shouldHandleAccountDetailKeyNavigation({ key: 'ArrowRight', target: inputTarget }, normalDoc),
false,
)

assert.equal(
shouldHandleAccountDetailKeyNavigation({ key: 'ArrowLeft', target: plainTarget }, blockingDoc),
false,
)
assert.equal(
shouldHandleAccountDetailKeyNavigation({ key: 'ArrowRight', target: plainTarget }, multiSheetDoc),
false,
)
})

test('AccountDetailSheet integrates keyboard navigation guard', () => {
assert.match(
detailSheetSource,
/shouldHandleAccountDetailKeyNavigation\(\s*event\s*\)/,
)
assert.match(
detailSheetSource,
/window\.addEventListener\(\s*['"]keydown['"]\s*,\s*onKeyDown\s*\)/,
)
})

test('accountDetailSheetKeyboard source declares required overlay and sheet count selectors', () => {
assert.match(keyboardHelperSource, /\[data-slot="dialog-content"\]/)
assert.match(
keyboardHelperSource,
/querySelectorAll\(\s*['"]\[data-slot="sheet-content"\]['"]\s*\)\.length\s*>\s*1/,
)
})
91 changes: 91 additions & 0 deletions frontend/src/lib/accountDetailSheetKeyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Account detail sheet keyboard navigation guards.
*
* Provides predicates for filtering ArrowLeft and ArrowRight navigation
* to avoid hijacking text input cursor movement and conflicting with active modals.
*/

export interface DocumentOverlayQueryTarget {
querySelector(selectors: string): unknown;
querySelectorAll(selectors: string): { length: number };
}

export interface AccountDetailKeyboardEventLike {
key: string;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
target?: EventTarget | null;
}

/**
* Determines whether an event target is an interactive or editable element
* (input, textarea, select, or contenteditable) where arrow keys should control native cursor movement.
*
* @param target - The event target to inspect.
* @returns True if the target or its ancestor is an editable element.
*/
export function isEditableKeyboardTarget(target: EventTarget | null): boolean {
if (!target || typeof (target as HTMLElement).closest !== "function") {
return false;
}
return Boolean(
(target as HTMLElement).closest(
'input, textarea, select, [contenteditable="true"]',
),
);
}

/**
* Checks whether an active dialog, alert dialog, select dropdown,
* or stacked secondary sheet is present in the document.
*
* @param doc - Document or mock document containing querySelector and querySelectorAll.
* @returns True if a blocking modal or overlay is active.
*/
export function hasBlockingModalOrOverlay(
doc: DocumentOverlayQueryTarget = document,
): boolean {
if (typeof doc?.querySelector !== "function") {
return false;
}
if (
doc.querySelector(
'[data-slot="dialog-content"], [role="dialog"]:not([data-slot="sheet-content"]), [role="alertdialog"], [data-select-dropdown="true"]',
)
) {
return true;
}
if (typeof doc?.querySelectorAll === "function") {
return doc.querySelectorAll('[data-slot="sheet-content"]').length > 1;
}
return false;
}

/**
* Evaluates whether an incoming keyboard event should navigate account detail sheets.
* Filters out modified keys, editable inputs, and events occurring while dialogs or overlays are active.
*
* @param event - The keyboard event or compatible object to evaluate.
* @param doc - Optional document-like target for overlay queries.
* @returns True if arrow navigation should proceed.
*/
export function shouldHandleAccountDetailKeyNavigation(
event: AccountDetailKeyboardEventLike,
doc: DocumentOverlayQueryTarget = document,
): boolean {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return false;
}
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return false;
}
if (isEditableKeyboardTarget(event.target ?? null)) {
return false;
}
if (hasBlockingModalOrOverlay(doc)) {
return false;
}
return true;
}
6 changes: 5 additions & 1 deletion proxy/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -1549,8 +1549,12 @@ func (h *Handler) imagesEditsFromJSON(c *gin.Context) {
h.forwardImagesRequest(c, "/v1/images/edits", imageModel, requestModel, logEffectiveModel, responsesBody, responseFormat, "image_edit", stream)
}

// buildImagesResponsesRequest builds the shared Images envelope, leaving reasoning
// effort unset so the upstream can choose a supported image complexity.
func buildImagesResponsesRequest(prompt string, images []string, toolJSON []byte) []byte {
req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)
// Explicit reasoning effort is rejected as unsupported image complexity.
// Let the upstream choose its default while retaining reasoning metadata.
req := []byte(`{"instructions":"","stream":true,"reasoning":{"summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)
req, _ = sjson.SetBytes(req, "model", imagesMainModel())

input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
Expand Down
19 changes: 17 additions & 2 deletions proxy/images25_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import (
"github.com/tidwall/gjson"
)

func TestGPTImage25ImagesIngressToUpstream(t *testing.T) {
// TestImagesIngressToUpstream covers Images and Image Studio against an upstream
// that rejects explicit reasoning effort while preserving image quality and inputs.
func TestImagesIngressToUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
previousRuntime, previousResin := CurrentRuntimeSettings(), resinCfg.Load()
t.Cleanup(func() { ApplyRuntimeSettings(previousRuntime); resinCfg.Store(previousResin) })
Expand All @@ -29,8 +31,16 @@ func TestGPTImage25ImagesIngressToUpstream(t *testing.T) {
observed := make(chan []byte, 1)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
forwarded := readUpstreamRequestBody(r)
observed <- forwarded
w.Header().Set("Content-Type", "text/event-stream")
// The Codex backend rejects explicit reasoning effort on image turns,
// independently of the image tool's quality setting.
if effort := gjson.GetBytes(forwarded, "reasoning.effort"); effort.Exists() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":{"type":"invalid_request_error","code":"invalid_value","message":"Complexity '%s' is not supported for this image model."}}`, effort.String())
return
}
observed <- forwarded
fmt.Fprint(w, `data: {"type":"response.completed","response":{"tool_usage":{"image_gen":{"input_tokens":1550,"input_tokens_details":{"text_tokens":29,"image_tokens":1521},"output_tokens":515,"output_tokens_details":{"image_tokens":515}}},"tools":[{"type":"image_generation","model":"gpt-image-2-codex"}],"output":[{"type":"image_generation_call","result":"`+tinyPNGBase64+`","output_format":"png"}]}}`+"\n\n")
}))
defer upstream.Close()
Expand All @@ -43,6 +53,8 @@ func TestGPTImage25ImagesIngressToUpstream(t *testing.T) {
model, quality, mode, driver string
stream bool
}{
{"gpt-image-2", "auto", "generate", "gpt-6.1-terra", false},
{"gpt-image-2", "medium", "json", "gpt-6.1-terra", false},
{"gpt-image-2.5-flare", "xhigh", "generate", "gpt-5.6-sol", false},
{"gpt-image-2.5-sunburst", "max", "generate", "gpt-5.6-terra", true},
{"gpt-image-2.5-sunburst-2026-09-08", "max", "json", "gpt-5.6-sol", false},
Expand Down Expand Up @@ -109,6 +121,9 @@ func TestGPTImage25ImagesIngressToUpstream(t *testing.T) {
t.Fatalf("image request failed: %d %s", recorder.Code, recorder.Body.String())
}
forwarded := <-observed
if gjson.GetBytes(forwarded, "reasoning.summary").String() != "auto" || gjson.GetBytes(forwarded, "include.0").String() != "reasoning.encrypted_content" {
t.Fatalf("reasoning metadata changed: %s", forwarded)
}
if gjson.GetBytes(forwarded, "model").String() != tc.driver || gjson.GetBytes(forwarded, "tools.0.model").String() != tc.model || gjson.GetBytes(forwarded, "tools.0.quality").String() != tc.quality {
t.Fatalf("outbound model/quality changed: %s", forwarded)
}
Expand Down
Loading
Loading