Files
thotamandClaude Opus 5 c2c5124a0c 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>
2026-09-10 10:31:48 +07:00

214 lines
5.9 KiB
Go

package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
)
// invalidFcIDChars matches characters not allowed in Responses API tool call IDs.
var invalidFcIDChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
// buildRequestBody converts internal ChatRequest to Responses API format.
func (p *CodexProvider) buildRequestBody(req ChatRequest, stream bool) map[string]any {
model := req.Model
if model == "" {
model = p.defaultModel
}
var instructions string
var input []any
for _, m := range req.Messages {
switch m.Role {
case "system":
if instructions == "" {
instructions = m.Content
} else {
instructions += "\n\n" + m.Content
}
case "user":
if len(m.Images) > 0 || len(m.Videos) > 0 {
var parts []map[string]any
for _, img := range m.Images {
parts = append(parts, map[string]any{
"type": "input_image",
"image_url": fmt.Sprintf("data:%s;base64,%s", img.MimeType, img.Data),
})
}
// Videos are not supported by Codex, they are omitted here.
if m.Content != "" {
parts = append(parts, map[string]any{
"type": "input_text",
"text": m.Content,
})
}
input = append(input, map[string]any{
"role": "user",
"content": parts,
})
} else {
input = append(input, map[string]any{
"role": "user",
"content": m.Content,
})
}
case "assistant":
if len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
callID := toFcID(tc.ID)
input = append(input, map[string]any{
"type": "function_call",
"id": callID,
"call_id": callID,
"name": tc.Name,
"arguments": string(argsJSON),
})
}
}
if m.Content != "" {
item := map[string]any{
"type": "message",
"role": "assistant",
"content": []map[string]any{
{"type": "output_text", "text": m.Content},
},
}
if m.Phase != "" {
item["phase"] = m.Phase
}
input = append(input, item)
}
case "tool":
input = append(input, map[string]any{
"type": "function_call_output",
"call_id": toFcID(m.ToolCallID),
"output": m.Content,
})
}
}
body := map[string]any{
"model": model,
"input": input,
"stream": stream,
"store": false,
}
if instructions == "" {
instructions = "You are a helpful assistant."
}
body["instructions"] = instructions
if len(req.Tools) > 0 {
var tools []map[string]any
for _, t := range req.Tools {
if t.Type == "image_generation" {
// Pass native image_generation tool object as-is — Responses API first-class tool.
// Defaults chosen for Phase 1b; per-agent overrides are Phase 4.
tools = append(tools, map[string]any{
"type": "image_generation",
"action": "generate",
"model": DefaultImageModel,
"output_format": "png",
"partial_images": 1,
})
} else if t.Function != nil {
// Function tool path (default). Works with both value-type and pointer Function
// fields — we only access t.Function when type is not "image_generation".
tools = append(tools, map[string]any{
"type": "function",
"name": t.Function.Name,
"description": t.Function.Description,
"parameters": NormalizeSchema("codex", t.Function.Parameters),
})
}
}
body["tools"] = tools
}
if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" {
body["reasoning"] = map[string]any{"effort": level}
}
// Prompt caching params (prompt_cache_key, prompt_cache_retention) are accepted
// only by native OpenAI endpoints. The ChatGPT subscription OAuth backend
// (chatgpt.com/backend-api) rejects them with HTTP 400, so gate on the endpoint —
// the same native-only policy CacheMiddleware already applies. Server-side prefix
// caching still works on the OAuth backend without these params.
if isOpenAINativeEndpoint(p.apiBase) {
if cacheKey, ok := req.Options[OptPromptCacheKey]; ok {
body["prompt_cache_key"] = cacheKey
}
if retention, ok := req.Options[OptPromptCacheRetention]; ok {
body["prompt_cache_retention"] = retention
}
}
return body
}
func (p *CodexProvider) doRequest(ctx context.Context, body any) (io.ReadCloser, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("%s: marshal request: %w", p.name, err)
}
endpoint := p.apiBase + "/codex/responses"
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("%s: create request: %w", p.name, err)
}
httpReq.Header.Set("Content-Type", "application/json")
token, err := p.tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("%s: get auth token: %w", p.name, err)
}
httpReq.Header.Set("Authorization", "Bearer "+token)
httpReq.Header.Set("OpenAI-Beta", "responses=v1")
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("%s: request failed: %w", p.name, err)
}
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
retryAfter := ParseRetryAfter(resp.Header.Get("Retry-After"))
return nil, &HTTPError{
Status: resp.StatusCode,
Body: fmt.Sprintf("%s: %s", p.name, string(respBody)),
RetryAfter: retryAfter,
}
}
return resp.Body, nil
}
// toFcID ensures a tool call ID starts with "fc_" and contains only
// letters, numbers, underscores, or dashes as required by the Responses API.
func toFcID(id string) string {
if strings.HasPrefix(id, "tool_") {
id = id[len("tool_"):]
} else if strings.HasPrefix(id, "call_") {
id = id[len("call_"):]
} else if strings.HasPrefix(id, "fc_") {
id = id[len("fc_"):]
}
// Replace invalid characters (e.g. colons from session keys) with underscores.
id = invalidFcIDChars.ReplaceAllString(id, "_")
return "fc_" + id
}