diff --git a/.github/scripts/dispatch-pr-checks.sh b/.github/scripts/dispatch-pr-checks.sh index 760aa8085..6be336716 100644 --- a/.github/scripts/dispatch-pr-checks.sh +++ b/.github/scripts/dispatch-pr-checks.sh @@ -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 diff --git a/.upstream-release b/.upstream-release index 66cfae52b..6eef43b75 100644 --- a/.upstream-release +++ b/.upstream-release @@ -1 +1 @@ -v3.1.3 +v3.1.4 diff --git a/CHANGELOG.md b/CHANGELOG.md index d056a68bf..c5e15818d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 36e80d00c..fc2eea800 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` 是本项目的尺寸与超分别名,发往上游前会剥掉该后缀。 diff --git a/frontend/src/components/AccountDetailSheet.tsx b/frontend/src/components/AccountDetailSheet.tsx index aa5edd16d..d68a035ba 100644 --- a/frontend/src/components/AccountDetailSheet.tsx +++ b/frontend/src/components/AccountDetailSheet.tsx @@ -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; @@ -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?.(); diff --git a/frontend/src/lib/accountDetailSheetKeyboard.test.mjs b/frontend/src/lib/accountDetailSheetKeyboard.test.mjs new file mode 100644 index 000000000..af8f6a97d --- /dev/null +++ b/frontend/src/lib/accountDetailSheetKeyboard.test.mjs @@ -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/, + ) +}) diff --git a/frontend/src/lib/accountDetailSheetKeyboard.ts b/frontend/src/lib/accountDetailSheetKeyboard.ts new file mode 100644 index 000000000..df4450f51 --- /dev/null +++ b/frontend/src/lib/accountDetailSheetKeyboard.ts @@ -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; +} diff --git a/proxy/images.go b/proxy/images.go index 685c3f3e8..033938868 100644 --- a/proxy/images.go +++ b/proxy/images.go @@ -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":""}]}]`) diff --git a/proxy/images25_test.go b/proxy/images25_test.go index 05e9838b6..4675ef414 100644 --- a/proxy/images25_test.go +++ b/proxy/images25_test.go @@ -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) }) @@ -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() @@ -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}, @@ -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) } diff --git a/proxy/scoped_models.go b/proxy/scoped_models.go index 6c2a90b54..625f3ed85 100644 --- a/proxy/scoped_models.go +++ b/proxy/scoped_models.go @@ -235,7 +235,13 @@ func (h *Handler) scopedModelRecords(ctx context.Context, row *database.APIKeyRo default: for _, item := range catalog.Items { - if !account.ModelCatalogEligible() || !item.Enabled || !account.SupportsCodexModel(item.ID) { + if !account.ModelCatalogEligible() || !item.Enabled { + continue + } + // 图片模型是独立能力轴:Images 路径按 imageCapableAccountFilter 调度, + // 不看账号文本白名单(issue #826)。发现端点与之同源,否则纯文本白名单 + // 会隐藏实际可生图的型号,下游插件无法自动发现。 + if !isImageModelInfo(item) && !account.SupportsCodexModel(item.ID) { continue } if item.ProOnly && !isSparkPlanCandidate(account.GetPlanType()) { diff --git a/proxy/scoped_models_test.go b/proxy/scoped_models_test.go index 85c003644..168ec330b 100644 --- a/proxy/scoped_models_test.go +++ b/proxy/scoped_models_test.go @@ -272,3 +272,38 @@ func TestScopedModelsDeclaredListCannotOverrideCatalogVisibility(t *testing.T) { t.Fatal("request admission bypassed catalog visibility") } } + +// issue #826: 账号文本白名单不应隐藏图片模型。Images 路径按 imageCapableAccountFilter +// 调度、不看白名单,因此同一 Key 能生图却在 /v1/models 发现不到图片型号。 +func TestScopedModelsTextAllowlistDoesNotHideCodexImageModels(t *testing.T) { + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 1}) + codex := &auth.Account{DBID: 1, AccessToken: "codex", PlanType: "plus", Models: []string{"gpt-5.5"}} + store.AddAccount(codex) + handler := NewHandler(store, nil, nil, nil) + + models := listScopedModelsForTest(t, handler, &database.APIKeyRow{ID: 1}) + if _, _, ok := scopedModelByID(models, "gpt-5.5"); !ok { + t.Fatalf("allowlisted text model missing: %+v", models) + } + if _, _, ok := scopedModelByID(models, "gpt-5.5-codex"); ok { + t.Fatalf("text allowlist no longer narrows text models: %+v", models) + } + for _, id := range []string{"gpt-image-2", "gpt-image-2-2k", "gpt-image-2-4k"} { + if _, _, ok := scopedModelByID(models, id); !ok { + t.Fatalf("image model %q hidden by text-only allowlist: %+v", id, models) + } + } + + // Key 级 allow/deny 仍然生效:发现端点不能无条件向所有 Key 返回图片型号。 + denied := listScopedModelsForTest(t, handler, &database.APIKeyRow{ID: 1, Limits: database.APIKeyLimits{ModelDeny: []string{"gpt-image-2"}}}) + if _, _, ok := scopedModelByID(denied, "gpt-image-2"); ok { + t.Fatalf("key ModelDeny did not hide image model: %+v", denied) + } + if _, _, ok := scopedModelByID(denied, "gpt-5.5"); !ok { + t.Fatalf("ModelDeny on image model removed text model: %+v", denied) + } + allowOnly := listScopedModelsForTest(t, handler, &database.APIKeyRow{ID: 1, Limits: database.APIKeyLimits{ModelAllow: []string{"gpt-5.5"}}}) + if len(allowOnly) != 1 || allowOnly[0].ID != "gpt-5.5" { + t.Fatalf("key ModelAllow must still gate image models: %+v", allowOnly) + } +}