From c2c5124a0ccf3138981e0564d019fde88d4c76ab Mon Sep 17 00:00:00 2001 From: thotam <78630736+thotam@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:31:48 +0700 Subject: [PATCH] feat(image): add GPT Image 2.5 and default to flare (#1562) OpenAI shipped GPT Image 2.5 on 2026-09-08 as two model IDs rather than one: flare is tuned for speed, sunburst for editing precision. Both accept text and image input and run on /images/generations, /images/edits and the Responses API, which is the path the native image_generation tool already uses. Flare replaces gpt-image-2 as the default because OpenAI reports higher quality at roughly half the latency, which suits create_image's one-shot call. Both gpt-image-2 and gpt-image-1.5 stay on the whitelist, so a saved image_model keeps working untouched. Also in this change: - The rejection message is now built from the whitelist itself instead of a hand-written string that drifts whenever a model is added. - codex_build.go uses the DefaultImageModel constant instead of hardcoding "gpt-image-2", so the inline chat path cannot drift from the shared default. - Both new models join the isEditModel branch so reference images route to /images/edits. - ParamField gains an optional labelKey, letting the dropdown read its label from i18n instead of showing English inside a translated UI. It passes a defaultValue, so a missing key degrades to the literal rather than breaking. - Locale keys added to all five catalogs; ko was missing the whole mediaChain.imageModel* group. Surface parity: the desktop UI is unchanged because sortable-provider-card renders no param form and passes params through as Record. The CLI is unchanged because cmd/ exposes no image-model flag. The API contract is unchanged because image_model lives in the free-form params map; only the backend whitelist widened. Co-authored-by: Claude Opus 5 (1M context) --- internal/providers/codex_build.go | 2 +- internal/providers/codex_native_image_test.go | 10 ++-- internal/providers/codex_test.go | 8 +-- internal/providers/native_image.go | 31 +++++++---- internal/providers/native_image_test.go | 53 +++++++++++++++++++ internal/tools/create_image.go | 4 +- .../tools/create_image_native_path_test.go | 2 +- ui/web/src/i18n/locales/en/tools.json | 4 +- ui/web/src/i18n/locales/ko/tools.json | 7 ++- ui/web/src/i18n/locales/ru/tools.json | 4 +- ui/web/src/i18n/locales/vi/tools.json | 4 +- ui/web/src/i18n/locales/zh/tools.json | 4 +- .../media-param-field-control.tsx | 10 +++- .../media-provider-params-schema.ts | 30 +++++++++-- 14 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 internal/providers/native_image_test.go diff --git a/internal/providers/codex_build.go b/internal/providers/codex_build.go index d538d046..3ef4dc26 100644 --- a/internal/providers/codex_build.go +++ b/internal/providers/codex_build.go @@ -118,7 +118,7 @@ func (p *CodexProvider) buildRequestBody(req ChatRequest, stream bool) map[strin tools = append(tools, map[string]any{ "type": "image_generation", "action": "generate", - "model": "gpt-image-2", + "model": DefaultImageModel, "output_format": "png", "partial_images": 1, }) diff --git a/internal/providers/codex_native_image_test.go b/internal/providers/codex_native_image_test.go index cbb30c8b..aeccdb0b 100644 --- a/internal/providers/codex_native_image_test.go +++ b/internal/providers/codex_native_image_test.go @@ -53,7 +53,7 @@ func mockImageServer(t *testing.T, captured *[]byte) *httptest.Server { // asserts each required field is present and well-formed. // // Sub-cases: -// - Default (empty ImageModel) → tools[0].model == "gpt-image-2" +// - Default (empty ImageModel) → tools[0].model == DefaultImageModel // - Legacy (ImageModel: "gpt-image-1.5") → tools[0].model == "gpt-image-1.5" // - Rejected (ImageModel: "dall-e-3") → GenerateImage returns error containing "unsupported image model" func TestCodexGenerateImage_BuildsNativeRequest(t *testing.T) { @@ -163,7 +163,7 @@ func TestCodexGenerateImage_BuildsNativeRequest(t *testing.T) { } // TestCodexGenerateImage_ImageModelDefault verifies that an empty ImageModel results -// in the default gpt-image-2 model in the outbound tools[0].model field. +// in DefaultImageModel in the outbound tools[0].model field. func TestCodexGenerateImage_ImageModelDefault(t *testing.T) { var captured []byte server := mockImageServer(t, &captured) @@ -174,7 +174,7 @@ func TestCodexGenerateImage_ImageModelDefault(t *testing.T) { _, err := p.GenerateImage(context.Background(), NativeImageRequest{ Prompt: "test", - ImageModel: "", // explicitly empty — should default to gpt-image-2 + ImageModel: "", // explicitly empty - should fall back to DefaultImageModel AspectRatio: "1:1", }) if err != nil { @@ -190,8 +190,8 @@ func TestCodexGenerateImage_ImageModelDefault(t *testing.T) { t.Fatal("tools array is empty") } tool, _ := tools[0].(map[string]any) - if imgModel, _ := tool["model"].(string); imgModel != "gpt-image-2" { - t.Errorf("tools[0].model = %q, want gpt-image-2 (default)", imgModel) + if imgModel, _ := tool["model"].(string); imgModel != DefaultImageModel { + t.Errorf("tools[0].model = %q, want %q (default)", imgModel, DefaultImageModel) } } diff --git a/internal/providers/codex_test.go b/internal/providers/codex_test.go index 6e3ea6d2..b9efe8cb 100644 --- a/internal/providers/codex_test.go +++ b/internal/providers/codex_test.go @@ -970,8 +970,8 @@ func TestCodexBuildRequestBodyImageGenerationTool(t *testing.T) { if tool["action"] != "generate" { t.Errorf("tool[action] = %v, want generate", tool["action"]) } - if tool["model"] != "gpt-image-2" { - t.Errorf("tool[model] = %v, want gpt-image-2", tool["model"]) + if tool["model"] != DefaultImageModel { + t.Errorf("tool[model] = %v, want %v", tool["model"], DefaultImageModel) } if tool["output_format"] != "png" { t.Errorf("tool[output_format] = %v, want png", tool["output_format"]) @@ -1039,8 +1039,8 @@ func TestCodexBuildRequestBodyMixedTools(t *testing.T) { if img["action"] != "generate" { t.Errorf("tools[1] action = %v, want generate", img["action"]) } - if img["model"] != "gpt-image-2" { - t.Errorf("tools[1] model = %v, want gpt-image-2", img["model"]) + if img["model"] != DefaultImageModel { + t.Errorf("tools[1] model = %v, want %v", img["model"], DefaultImageModel) } // Function field must not bleed into image tool. if _, has := img["name"]; has { diff --git a/internal/providers/native_image.go b/internal/providers/native_image.go index 976ebf92..b526a627 100644 --- a/internal/providers/native_image.go +++ b/internal/providers/native_image.go @@ -3,6 +3,7 @@ package providers import ( "context" "fmt" + "strings" ) // NativeImageProvider is implemented by OAuth-backed providers whose upstream @@ -14,17 +15,29 @@ type NativeImageProvider interface { } // DefaultImageModel is the image model used by the Responses API image_generation -// tool when the caller does not specify one. gpt-image-2 is the current (2026-Q2) -// quality baseline; gpt-image-1.5 is available as a legacy fallback. -const DefaultImageModel = "gpt-image-2" +// tool when the caller does not specify one. GPT Image 2.5 (2026-09-08) ships as +// two model IDs instead of one: "flare" tuned for speed, "sunburst" tuned for +// editing precision. Flare is the default because it beats gpt-image-2 on quality +// at roughly half the latency, which suits the one-shot create_image path. +const DefaultImageModel = "gpt-image-2.5-flare" // allowedImageModels enumerates the image models the native ChatGPT Responses API // image_generation tool will accept. Constraining to this whitelist prevents -// silent upstream rejections from arbitrary model names (e.g. "dall-e-3") and -// keeps the PR's motivation — gpt-image-2 quality — as the default everywhere. +// silent upstream rejections from arbitrary model names (e.g. "dall-e-3"). var allowedImageModels = map[string]bool{ - "gpt-image-2": true, // default — latest quality - "gpt-image-1.5": true, // legacy fallback + "gpt-image-2.5-flare": true, // default - fastest at high quality + "gpt-image-2.5-sunburst": true, // most capable at editing precision + "gpt-image-2": true, // previous generation + "gpt-image-1.5": true, // legacy fallback +} + +// allowedImageModelList is the whitelist rendered for error messages, ordered +// newest first so the suggestion a caller sees leads with the recommended model. +var allowedImageModelList = []string{ + "gpt-image-2.5-flare (default)", + "gpt-image-2.5-sunburst", + "gpt-image-2", + "gpt-image-1.5 (legacy)", } // ValidateImageModel returns the model to use, or an error if the caller @@ -34,7 +47,7 @@ func ValidateImageModel(model string) (string, error) { return DefaultImageModel, nil } if !allowedImageModels[model] { - return "", fmt.Errorf("unsupported image model %q; allowed: gpt-image-2 (default), gpt-image-1.5 (legacy)", model) + return "", fmt.Errorf("unsupported image model %q; allowed: %s", model, strings.Join(allowedImageModelList, ", ")) } return model, nil } @@ -47,7 +60,7 @@ type NativeImageRequest struct { Model string // ImageModel is the image-generation model attached to the image_generation - // tool (e.g. "gpt-image-2"). Must be a value accepted by ValidateImageModel; + // tool (e.g. "gpt-image-2.5-flare"). Must be a value accepted by ValidateImageModel; // empty falls back to DefaultImageModel. ImageModel string diff --git a/internal/providers/native_image_test.go b/internal/providers/native_image_test.go new file mode 100644 index 00000000..6aebeb34 --- /dev/null +++ b/internal/providers/native_image_test.go @@ -0,0 +1,53 @@ +package providers + +import ( + "strings" + "testing" +) + +func TestValidateImageModel(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "empty falls back to default", input: "", want: DefaultImageModel}, + {name: "flare", input: "gpt-image-2.5-flare", want: "gpt-image-2.5-flare"}, + {name: "sunburst", input: "gpt-image-2.5-sunburst", want: "gpt-image-2.5-sunburst"}, + {name: "previous generation", input: "gpt-image-2", want: "gpt-image-2"}, + {name: "legacy", input: "gpt-image-1.5", want: "gpt-image-1.5"}, + {name: "unversioned 2.5 is not a real model id", input: "gpt-image-2.5", wantErr: true}, + {name: "unrelated model", input: "dall-e-3", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateImageModel(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("ValidateImageModel(%q) = %q, want error", tc.input, got) + } + // The error must list the allowed models so the caller can self-correct. + if !strings.Contains(err.Error(), DefaultImageModel) { + t.Errorf("error %q does not mention the default model %q", err, DefaultImageModel) + } + return + } + if err != nil { + t.Fatalf("ValidateImageModel(%q) returned error: %v", tc.input, err) + } + if got != tc.want { + t.Errorf("ValidateImageModel(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +// TestDefaultImageModelIsAllowed guards against the default drifting out of the +// whitelist, which would make every create_image call with no explicit model fail. +func TestDefaultImageModelIsAllowed(t *testing.T) { + if !allowedImageModels[DefaultImageModel] { + t.Fatalf("DefaultImageModel %q is missing from allowedImageModels", DefaultImageModel) + } +} diff --git a/internal/tools/create_image.go b/internal/tools/create_image.go index db6ee3a8..484516a5 100644 --- a/internal/tools/create_image.go +++ b/internal/tools/create_image.go @@ -395,7 +395,9 @@ func (t *CreateImageTool) callProvider(ctx context.Context, cp credentialProvide // OpenAI image-to-image (Edits) if len(refImgs) > 0 && (ptype == "openai" || providerName == "openai" || ptype == "openai_compat") { - isEditModel := model == "gpt-image-2" || + isEditModel := model == "gpt-image-2.5-flare" || + model == "gpt-image-2.5-sunburst" || + model == "gpt-image-2" || model == "gpt-image-1.5" || model == "gpt-image-1" || model == "gpt-image-1-mini" || diff --git a/internal/tools/create_image_native_path_test.go b/internal/tools/create_image_native_path_test.go index a0958e40..facbc52e 100644 --- a/internal/tools/create_image_native_path_test.go +++ b/internal/tools/create_image_native_path_test.go @@ -201,7 +201,7 @@ func TestCreateImageTool_ThreadsImageModel(t *testing.T) { { name: "default (empty params.image_model)", chainImageModel: "", - wantImageModel: "", // provider validator defaults to gpt-image-2 + wantImageModel: "", // passed through empty; the provider validator applies the default }, { name: "legacy gpt-image-1.5", diff --git a/ui/web/src/i18n/locales/en/tools.json b/ui/web/src/i18n/locales/en/tools.json index 29aa8251..3a1d5bc6 100644 --- a/ui/web/src/i18n/locales/en/tools.json +++ b/ui/web/src/i18n/locales/en/tools.json @@ -246,7 +246,9 @@ "save": "Save", "saving": "Saving...", "imageModelLabel": "Image model", - "imageModelDefaultOption": "Default · gpt-image-2 (recommended)", + "imageModelFlareOption": "Default · gpt-image-2.5-flare (recommended)", + "imageModelSunburstOption": "Precise · gpt-image-2.5-sunburst", + "imageModelPreviousOption": "Previous · gpt-image-2", "imageModelLegacyOption": "Legacy · gpt-image-1.5" }, "toast": { diff --git a/ui/web/src/i18n/locales/ko/tools.json b/ui/web/src/i18n/locales/ko/tools.json index 5de8bf4a..7a62f3da 100644 --- a/ui/web/src/i18n/locales/ko/tools.json +++ b/ui/web/src/i18n/locales/ko/tools.json @@ -171,7 +171,12 @@ "addProvider": "공급자 추가", "cancel": "취소", "save": "저장", - "saving": "저장 중..." + "saving": "저장 중...", + "imageModelLabel": "이미지 모델", + "imageModelFlareOption": "기본 · gpt-image-2.5-flare (권장)", + "imageModelSunburstOption": "정밀 · gpt-image-2.5-sunburst", + "imageModelPreviousOption": "이전 · gpt-image-2", + "imageModelLegacyOption": "레거시 · gpt-image-1.5" }, "toast": { "toggled": "도구 업데이트됨", diff --git a/ui/web/src/i18n/locales/ru/tools.json b/ui/web/src/i18n/locales/ru/tools.json index 92a84b34..51053dad 100644 --- a/ui/web/src/i18n/locales/ru/tools.json +++ b/ui/web/src/i18n/locales/ru/tools.json @@ -245,7 +245,9 @@ "save": "Сохранить", "saving": "Сохранение...", "imageModelLabel": "Модель изображений", - "imageModelDefaultOption": "По умолчанию · gpt-image-2 (рекомендуется)", + "imageModelFlareOption": "По умолчанию · gpt-image-2.5-flare (рекомендуется)", + "imageModelSunburstOption": "Точная · gpt-image-2.5-sunburst", + "imageModelPreviousOption": "Предыдущая · gpt-image-2", "imageModelLegacyOption": "Устаревшая · gpt-image-1.5" }, "toast": { diff --git a/ui/web/src/i18n/locales/vi/tools.json b/ui/web/src/i18n/locales/vi/tools.json index 1ee07c1a..759813d0 100644 --- a/ui/web/src/i18n/locales/vi/tools.json +++ b/ui/web/src/i18n/locales/vi/tools.json @@ -246,7 +246,9 @@ "save": "Lưu", "saving": "Đang lưu...", "imageModelLabel": "Mô hình ảnh", - "imageModelDefaultOption": "Mặc định · gpt-image-2 (khuyến nghị)", + "imageModelFlareOption": "Mặc định · gpt-image-2.5-flare (khuyến nghị)", + "imageModelSunburstOption": "Chính xác · gpt-image-2.5-sunburst", + "imageModelPreviousOption": "Đời trước · gpt-image-2", "imageModelLegacyOption": "Cũ · gpt-image-1.5" }, "toast": { diff --git a/ui/web/src/i18n/locales/zh/tools.json b/ui/web/src/i18n/locales/zh/tools.json index 621ffdef..1e824272 100644 --- a/ui/web/src/i18n/locales/zh/tools.json +++ b/ui/web/src/i18n/locales/zh/tools.json @@ -160,7 +160,9 @@ "settings": "设置", "timeout": "超时", "imageModelLabel": "图像模型", - "imageModelDefaultOption": "默认 · gpt-image-2(推荐)", + "imageModelFlareOption": "默认 · gpt-image-2.5-flare(推荐)", + "imageModelSunburstOption": "精细 · gpt-image-2.5-sunburst", + "imageModelPreviousOption": "上一代 · gpt-image-2", "imageModelLegacyOption": "旧版 · gpt-image-1.5" }, "noMatchDescription": "请尝试其他搜索词。", diff --git a/ui/web/src/pages/builtin-tools/media-param-field-control.tsx b/ui/web/src/pages/builtin-tools/media-param-field-control.tsx index 82e72481..e49713bf 100644 --- a/ui/web/src/pages/builtin-tools/media-param-field-control.tsx +++ b/ui/web/src/pages/builtin-tools/media-param-field-control.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -20,9 +21,14 @@ export function ParamFieldControl({ value: unknown; onChange: (v: unknown) => void; }) { + const { t } = useTranslation("tools"); + // Schema labels are English literals; labelKey opts a field into i18n. + const label = (f: { label: string; labelKey?: string }) => + f.labelKey ? t(f.labelKey, { defaultValue: f.label }) : f.label; + return (
- + {field.type === "select" && field.options && (