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<string, unknown>.
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) <noreply@anthropic.com>
This commit is contained in:
thotamandClaude Opus 5 authored and GitHub committed 2026-09-10 10:31:48 +07:00
1 parent 53414903c7
commit c2c5124a0c
14 files changed
+141 -32

No files matched your search

+1 -1
View File
@@ -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,
})
@@ -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)
}
}
+4 -4
View File
@@ -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 {
+22 -9
View File
@@ -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
+53
View File
@@ -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)
}
}
+3 -1
View File
@@ -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" ||
@@ -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",
+3 -1
View File
@@ -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": {
+6 -1
View File
@@ -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": "도구 업데이트됨",
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": "请尝试其他搜索词。",
@@ -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 (
<div className="space-y-1">
<Label className="text-xs">{field.label}</Label>
<Label className="text-xs">{label(field)}</Label>
{field.type === "select" && field.options && (
<Select value={String(value ?? "")} onValueChange={onChange}>
<SelectTrigger className="h-8 text-sm">
@@ -31,7 +37,7 @@ export function ParamFieldControl({
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{label(opt)}
</SelectItem>
))}
</SelectContent>
@@ -1,8 +1,10 @@
export type ParamField = {
key: string;
label: string;
/** i18n key in the "tools" namespace; falls back to `label` when absent. */
labelKey?: string;
type: "select" | "toggle" | "number" | "text";
options?: { value: string; label: string }[];
options?: { value: string; label: string; labelKey?: string }[];
default?: unknown;
min?: number;
max?: number;
@@ -16,11 +18,31 @@ export const MEDIA_PARAMS_SCHEMA: Record<string, Record<string, ParamField[]>> =
{
key: "image_model",
label: "Image model",
labelKey: "builtin.mediaChain.imageModelLabel",
type: "select",
default: "gpt-image-2",
// Keep in sync with allowedImageModels in internal/providers/native_image.go.
default: "gpt-image-2.5-flare",
options: [
{ value: "gpt-image-2", label: "Default · gpt-image-2 (recommended)" },
{ value: "gpt-image-1.5", label: "Legacy · gpt-image-1.5" },
{
value: "gpt-image-2.5-flare",
label: "Default · gpt-image-2.5-flare (recommended)",
labelKey: "builtin.mediaChain.imageModelFlareOption",
},
{
value: "gpt-image-2.5-sunburst",
label: "Precise · gpt-image-2.5-sunburst",
labelKey: "builtin.mediaChain.imageModelSunburstOption",
},
{
value: "gpt-image-2",
label: "Previous · gpt-image-2",
labelKey: "builtin.mediaChain.imageModelPreviousOption",
},
{
value: "gpt-image-1.5",
label: "Legacy · gpt-image-1.5",
labelKey: "builtin.mediaChain.imageModelLegacyOption",
},
],
},
],