mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 05:20:01 +00:00
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:
1 parent
53414903c7
commit
c2c5124a0c
14 files changed
+141
-32
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "도구 업데이트됨",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user