Skip to content

Commit 65cfdea

Browse files
committed
feat(#3996): reject incompatible image-output Google requests
Gemini image-output requests fail with opaque HTTP 400 responses when they also carry custom tools, built-in tools, or structured output. Resolve the model capability first, then reject those unsupported shapes locally before network dispatch on the gateway, direct Gemini API, and Vertex AI surfaces with a bounded error naming only fixed safe categories. Keep ordinary text requests and compatible image-output requests unchanged. Request-shape diagnostics report the resolved capability and separately note whether configuration explicitly overrode catalogue metadata. Tests cover the gate, no-dispatch behavior, safe errors, and the runtime/TUI error seam.
1 parent 5bb315a commit 65cfdea

9 files changed

Lines changed: 853 additions & 18 deletions

File tree

docs/providers/google/index.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ models:
6060
| `gemini-2.5-flash` | Fast inference, cost-effective |
6161
| `gemini-2.5-pro` | Strong reasoning, large context |
6262

63+
## Generated Images
64+
65+
Some Gemini models (e.g. `gemini-2.5-flash-image`) are designed to generate
66+
an image directly as part of their reply, not just describe one. Docker
67+
Agent's Gemini request path doesn't yet ask for that image output — that
68+
support is still being completed — so today a request like this gets a
69+
text-only reply. See
70+
[Generated Media](../../features/tui/index.md#generated-media) for the
71+
current, verified state.
72+
73+
```yaml
74+
agents:
75+
root:
76+
model: google/gemini-2.5-flash-image
77+
```
78+
79+
When the model is accessed through a Docker AI Gateway and explicitly
80+
declared image-output-capable with
81+
[`output_capabilities.image: true`](../../configuration/models/index.md#output-capabilities),
82+
Docker Agent has verified that request combined with custom function tools,
83+
a built-in tool (e.g. `google_search`), or structured output gets rejected
84+
by the gateway with an opaque, empty-body HTTP 400. To avoid that, Docker
85+
Agent rejects such a combination itself, before any request is sent, with a
86+
clear error naming which feature is incompatible. Plain text requests to
87+
that model (no tools, no structured output) are unaffected, as is every
88+
other route: direct Gemini API/Vertex AI calls, and gateway calls to a model
89+
without the declaration.
90+
6391
## Thinking Budget
6492

6593
Gemini supports two approaches depending on the model version:

pkg/model/provider/gemini/client.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,17 @@ func stringifyEnumValues(values []any) []string {
729729
return out
730730
}
731731

732+
// wantsImageResponseModalities reports whether this ordinary chat request
733+
// should ask Gemini for TEXT+IMAGE output.
734+
func (c *Client) wantsImageResponseModalities(imageOutputEnabled bool) bool {
735+
switch c.apiSurface {
736+
case apiSurfaceGateway, apiSurfaceGeminiAPI, apiSurfaceVertexAI:
737+
default:
738+
return false
739+
}
740+
return imageOutputEnabled && !c.ModelOptions.GeneratingTitle() && !c.ModelOptions.Compacting()
741+
}
742+
732743
// CreateChatCompletionStream creates a streaming chat completion request
733744
func (c *Client) CreateChatCompletionStream(
734745
ctx context.Context,
@@ -740,9 +751,15 @@ func (c *Client) CreateChatCompletionStream(
740751
}
741752

742753
config := c.buildConfig()
754+
imageOutputEnabled := c.ImageOutputEnabled(ctx)
755+
756+
if c.wantsImageResponseModalities(imageOutputEnabled) {
757+
config.ResponseModalities = []string{string(genai.ModalityText), string(genai.ModalityImage)}
758+
}
743759

744760
// Start with Google built-in tools (search, maps, code execution) from provider_opts
745-
config.Tools = c.builtInTools()
761+
builtInTools := c.builtInTools()
762+
config.Tools = builtInTools
746763

747764
// Add tools to config if provided
748765
if len(requestTools) > 0 {
@@ -767,7 +784,11 @@ func (c *Client) CreateChatCompletionStream(
767784
}
768785
}
769786

770-
shape := newRequestShape(c, config, len(requestTools))
787+
if err := c.checkImageOutputRequestCompatibility(imageOutputEnabled, config, builtInTools, len(requestTools)); err != nil {
788+
return nil, err
789+
}
790+
791+
shape := newRequestShape(c, config, len(requestTools), imageOutputEnabled)
771792
slog.DebugContext(ctx, "Gemini request shape", shape.LogAttrs()...)
772793

773794
contents := convertMessagesToGemini(ctx, messages, c.ID(), c.ModelOptions.ModelsDevStore(), c.CapsOverride())

pkg/model/provider/gemini/diagnostics.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,16 @@ type RequestShape struct {
5858
// or "gateway".
5959
APISurface string
6060

61-
// OutputCapabilityKnown and OutputCapabilityEnabled report whether an
62-
// authoritative source for the model's image/media *output* capability
63-
// was consulted for this request. No such source exists yet — it is the
64-
// subject of a later step — so both fields are always false today,
65-
// deliberately reporting "unknown" rather than guessing from the model
66-
// ID string.
61+
// OutputCapabilityKnown records whether the capability was resolved from an
62+
// explicit configuration override instead of the catalogue.
6763
OutputCapabilityKnown bool
6864
OutputCapabilityEnabled bool
6965
}
7066

7167
// newRequestShape captures a [RequestShape] from a fully-built
7268
// genai.GenerateContentConfig (i.e. after tools/ToolConfig have been
7369
// attached) and the client that built it.
74-
func newRequestShape(c *Client, config *genai.GenerateContentConfig, functionToolCount int) RequestShape {
70+
func newRequestShape(c *Client, config *genai.GenerateContentConfig, functionToolCount int, imageOutputEnabled bool) RequestShape {
7571
modalities := normalizeResponseModalities(config.ResponseModalities)
7672
kinds := builtInToolKinds(config.Tools)
7773

@@ -86,6 +82,8 @@ func newRequestShape(c *Client, config *genai.GenerateContentConfig, functionToo
8682
ThinkingConfigSet: config.ThinkingConfig != nil,
8783
NoThinkingRequested: c.ModelOptions.NoThinking(),
8884
APISurface: c.apiSurface,
85+
OutputCapabilityKnown: c.ModelConfig.OutputCapabilities != nil && c.ModelConfig.OutputCapabilities.Image != nil,
86+
OutputCapabilityEnabled: imageOutputEnabled,
8987
}
9088

9189
if config.ToolConfig != nil {

pkg/model/provider/gemini/diagnostics_test.go

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func TestNewRequestShape_Minimal(t *testing.T) {
4040
config := client.buildConfig()
4141
config.Tools = client.builtInTools()
4242

43-
shape := newRequestShape(client, config, 0)
43+
shape := newRequestShape(client, config, 0, false)
4444

4545
assert.False(t, shape.ResponseModalitiesSet)
4646
assert.Empty(t, shape.ResponseModalities)
@@ -52,9 +52,8 @@ func TestNewRequestShape_Minimal(t *testing.T) {
5252
assert.False(t, shape.ThinkingConfigSet)
5353
assert.False(t, shape.NoThinkingRequested)
5454
assert.Equal(t, apiSurfaceGeminiAPI, shape.APISurface)
55-
// No authoritative output-capability source exists yet: diagnostics must
56-
// report "unknown", never guess from the model ID.
57-
assert.False(t, shape.OutputCapabilityKnown)
55+
// No output_capabilities declaration on this ModelConfig: diagnostics
56+
// must report "unknown", never guess from the model ID.
5857
assert.False(t, shape.OutputCapabilityEnabled)
5958
}
6059

@@ -93,7 +92,7 @@ func TestNewRequestShape_ToolsAndBuiltIns(t *testing.T) {
9392
config.ToolConfig.IncludeServerSideToolInvocations = new(true)
9493
}
9594

96-
shape := newRequestShape(client, config, len(requestTools))
95+
shape := newRequestShape(client, config, len(requestTools), false)
9796

9897
assert.Equal(t, 2, shape.BuiltInToolCount)
9998
assert.ElementsMatch(t, []string{"google_search", "google_maps"}, shape.BuiltInToolKinds)
@@ -114,7 +113,7 @@ func TestNewRequestShape_ResponseModalitiesNormalized(t *testing.T) {
114113
config := client.buildConfig()
115114
config.ResponseModalities = []string{" text ", "IMAGE", "text", ""}
116115

117-
shape := newRequestShape(client, config, 0)
116+
shape := newRequestShape(client, config, 0, false)
118117

119118
assert.True(t, shape.ResponseModalitiesSet)
120119
assert.Equal(t, []string{"TEXT", "IMAGE"}, shape.ResponseModalities)
@@ -135,7 +134,7 @@ func TestNewRequestShape_NoThinkingRequested(t *testing.T) {
135134
}
136135
config := client.buildConfig()
137136

138-
shape := newRequestShape(client, config, 0)
137+
shape := newRequestShape(client, config, 0, false)
139138

140139
assert.True(t, shape.ThinkingConfigSet)
141140
assert.True(t, shape.NoThinkingRequested)
@@ -156,7 +155,7 @@ func TestNewRequestShape_StructuredOutputPresent(t *testing.T) {
156155
}
157156
config := client.buildConfig()
158157

159-
shape := newRequestShape(client, config, 0)
158+
shape := newRequestShape(client, config, 0, false)
160159

161160
require.True(t, shape.StructuredOutputPresent)
162161

@@ -168,6 +167,46 @@ func TestNewRequestShape_StructuredOutputPresent(t *testing.T) {
168167
}
169168
}
170169

170+
// TestNewRequestShape_OutputCapabilityUsesResolvedValue pins that diagnostics
171+
// distinguish an explicit override while reporting the resolved capability.
172+
func TestNewRequestShape_OutputCapabilityUsesResolvedValue(t *testing.T) {
173+
t.Parallel()
174+
175+
tests := []struct {
176+
name string
177+
outputCapabilities *latest.OutputCapabilitiesConfig
178+
resolvedEnabled bool
179+
wantKnown bool
180+
wantEnabled bool
181+
}{
182+
{name: "catalogue enabled", outputCapabilities: nil, resolvedEnabled: true, wantKnown: false, wantEnabled: true},
183+
{name: "declared false", outputCapabilities: &latest.OutputCapabilitiesConfig{Image: new(false)}, wantKnown: true, wantEnabled: false},
184+
{name: "declared true", outputCapabilities: &latest.OutputCapabilitiesConfig{Image: new(true)}, resolvedEnabled: true, wantKnown: true, wantEnabled: true},
185+
}
186+
187+
for _, tt := range tests {
188+
t.Run(tt.name, func(t *testing.T) {
189+
t.Parallel()
190+
191+
client := &Client{
192+
Config: base.Config{
193+
ModelConfig: latest.ModelConfig{
194+
Provider: "google",
195+
Model: "gemini-2.5-flash-image",
196+
OutputCapabilities: tt.outputCapabilities,
197+
},
198+
},
199+
apiSurface: apiSurfaceGeminiAPI,
200+
}
201+
config := client.buildConfig()
202+
203+
shape := newRequestShape(client, config, 0, tt.resolvedEnabled)
204+
assert.Equal(t, tt.wantKnown, shape.OutputCapabilityKnown)
205+
assert.Equal(t, tt.wantEnabled, shape.OutputCapabilityEnabled)
206+
})
207+
}
208+
}
209+
171210
// TestRequestShape_LogAttrsNeverLeaksToolSchemas is the core safety
172211
// regression for this diagnostic: it builds a request with a function tool
173212
// carrying a marker description and parameter schema, then verifies that
@@ -189,7 +228,7 @@ func TestRequestShape_LogAttrsNeverLeaksToolSchemas(t *testing.T) {
189228
require.NoError(t, err)
190229
config.Tools = allTools
191230

192-
shape := newRequestShape(client, config, len(requestTools))
231+
shape := newRequestShape(client, config, len(requestTools), false)
193232

194233
for _, attr := range flattenAttrs(shape.LogAttrs()) {
195234
assert.NotContains(t, attr, marker, "RequestShape must never carry tool descriptions or schemas")
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package gemini
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"google.golang.org/genai"
8+
)
9+
10+
// imageOutputIncompatibility names a fixed, safe request-feature class
11+
// rejected by the image-output request guard. Values are display-safe:
12+
// never provider text, tool names, schema contents, or prompts.
13+
type imageOutputIncompatibility string
14+
15+
const (
16+
imageOutputIncompatibleTools imageOutputIncompatibility = "tools"
17+
imageOutputIncompatibleBuiltInTools imageOutputIncompatibility = "built-in tools"
18+
imageOutputIncompatibleStructuredOutput imageOutputIncompatibility = "structured output"
19+
)
20+
21+
// ImageOutputRequestIncompatibleError is returned before any provider
22+
// dispatch when a request to an image-output-capable model
23+
// (output_capabilities.image: true) combines custom function tools (with
24+
// their required ToolConfig), a built-in tool, or structured output. The
25+
// request shape is intentionally unsupported until live direct Gemini API and
26+
// Vertex AI verification proves it is accepted there; gateway probing has
27+
// already shown opaque, empty-body HTTP 400 responses. Rejecting locally keeps
28+
// the verified minimal request byte-for-byte and gives the caller a specific,
29+
// actionable error instead.
30+
type ImageOutputRequestIncompatibleError struct {
31+
// Incompatibilities is always non-empty. Its values are the fixed enum
32+
// above — never provider text, tool names/schemas, or prompt content.
33+
Incompatibilities []imageOutputIncompatibility
34+
}
35+
36+
func (e *ImageOutputRequestIncompatibleError) Error() string {
37+
names := make([]string, len(e.Incompatibilities))
38+
for i, c := range e.Incompatibilities {
39+
names[i] = string(c)
40+
}
41+
return fmt.Sprintf(
42+
"this model is configured for image output (output_capabilities.image) and does not support %s in the same request; use a separate model or request for that combination",
43+
strings.Join(names, ", "),
44+
)
45+
}
46+
47+
// checkImageOutputRequestCompatibility rejects, before any provider dispatch,
48+
// an incompatible request when image output is enabled by configuration or the
49+
// models.dev catalogue.
50+
func (c *Client) checkImageOutputRequestCompatibility(imageOutputEnabled bool, config *genai.GenerateContentConfig, builtInTools []*genai.Tool, requestTools int) error {
51+
if !imageOutputEnabled {
52+
return nil
53+
}
54+
55+
var incompatibilities []imageOutputIncompatibility
56+
if requestTools > 0 {
57+
incompatibilities = append(incompatibilities, imageOutputIncompatibleTools)
58+
}
59+
if len(builtInTools) > 0 {
60+
incompatibilities = append(incompatibilities, imageOutputIncompatibleBuiltInTools)
61+
}
62+
if config.ResponseMIMEType != "" || config.ResponseJsonSchema != nil {
63+
incompatibilities = append(incompatibilities, imageOutputIncompatibleStructuredOutput)
64+
}
65+
if len(incompatibilities) == 0 {
66+
return nil
67+
}
68+
return &ImageOutputRequestIncompatibleError{Incompatibilities: incompatibilities}
69+
}

0 commit comments

Comments
 (0)