Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/providers/google/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,34 @@ models:
| `gemini-2.5-flash` | Fast inference, cost-effective |
| `gemini-2.5-pro` | Strong reasoning, large context |

## Generated Images

Some Gemini models (e.g. `gemini-2.5-flash-image`) are designed to generate
an image directly as part of their reply, not just describe one. Docker
Agent's Gemini request path doesn't yet ask for that image output — that
support is still being completed — so today a request like this gets a
text-only reply. See
[Generated Media](../../features/tui/index.md#generated-media) for the
current, verified state.

```yaml
agents:
root:
model: google/gemini-2.5-flash-image
```

When the model is accessed through a Docker AI Gateway and explicitly
declared image-output-capable with
[`output_capabilities.image: true`](../../configuration/models/index.md#output-capabilities),
Docker Agent has verified that request combined with custom function tools,
a built-in tool (e.g. `google_search`), or structured output gets rejected
by the gateway with an opaque, empty-body HTTP 400. To avoid that, Docker
Agent rejects such a combination itself, before any request is sent, with a
clear error naming which feature is incompatible. Plain text requests to
that model (no tools, no structured output) are unaffected, as is every
other route: direct Gemini API/Vertex AI calls, and gateway calls to a model
without the declaration.

## Thinking Budget

Gemini supports two approaches depending on the model version:
Expand Down
25 changes: 23 additions & 2 deletions pkg/model/provider/gemini/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,17 @@ func stringifyEnumValues(values []any) []string {
return out
}

// wantsImageResponseModalities reports whether this ordinary chat request
// should ask Gemini for TEXT+IMAGE output.
func (c *Client) wantsImageResponseModalities(imageOutputEnabled bool) bool {
switch c.apiSurface {
case apiSurfaceGateway, apiSurfaceGeminiAPI, apiSurfaceVertexAI:
default:
return false
}
return imageOutputEnabled && !c.ModelOptions.GeneratingTitle() && !c.ModelOptions.Compacting()
}

// CreateChatCompletionStream creates a streaming chat completion request
func (c *Client) CreateChatCompletionStream(
ctx context.Context,
Expand All @@ -740,9 +751,15 @@ func (c *Client) CreateChatCompletionStream(
}

config := c.buildConfig()
imageOutputEnabled := c.ImageOutputEnabled(ctx)

if c.wantsImageResponseModalities(imageOutputEnabled) {
config.ResponseModalities = []string{string(genai.ModalityText), string(genai.ModalityImage)}
}

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

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

shape := newRequestShape(c, config, len(requestTools))
if err := c.checkImageOutputRequestCompatibility(imageOutputEnabled, config, builtInTools, len(requestTools)); err != nil {
return nil, err
}

shape := newRequestShape(c, config, len(requestTools), imageOutputEnabled)
slog.DebugContext(ctx, "Gemini request shape", shape.LogAttrs()...)

contents := convertMessagesToGemini(ctx, messages, c.ID(), c.ModelOptions.ModelsDevStore(), c.CapsOverride())
Expand Down
12 changes: 5 additions & 7 deletions pkg/model/provider/gemini/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,16 @@ type RequestShape struct {
// or "gateway".
APISurface string

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

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

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

if config.ToolConfig != nil {
Expand Down
57 changes: 48 additions & 9 deletions pkg/model/provider/gemini/diagnostics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestNewRequestShape_Minimal(t *testing.T) {
config := client.buildConfig()
config.Tools = client.builtInTools()

shape := newRequestShape(client, config, 0)
shape := newRequestShape(client, config, 0, false)

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

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

shape := newRequestShape(client, config, len(requestTools))
shape := newRequestShape(client, config, len(requestTools), false)

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

shape := newRequestShape(client, config, 0)
shape := newRequestShape(client, config, 0, false)

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

shape := newRequestShape(client, config, 0)
shape := newRequestShape(client, config, 0, false)

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

shape := newRequestShape(client, config, 0)
shape := newRequestShape(client, config, 0, false)

require.True(t, shape.StructuredOutputPresent)

Expand All @@ -168,6 +167,46 @@ func TestNewRequestShape_StructuredOutputPresent(t *testing.T) {
}
}

// TestNewRequestShape_OutputCapabilityUsesResolvedValue pins that diagnostics
// distinguish an explicit override while reporting the resolved capability.
func TestNewRequestShape_OutputCapabilityUsesResolvedValue(t *testing.T) {
t.Parallel()

tests := []struct {
name string
outputCapabilities *latest.OutputCapabilitiesConfig
resolvedEnabled bool
wantKnown bool
wantEnabled bool
}{
{name: "catalogue enabled", outputCapabilities: nil, resolvedEnabled: true, wantKnown: false, wantEnabled: true},
{name: "declared false", outputCapabilities: &latest.OutputCapabilitiesConfig{Image: new(false)}, wantKnown: true, wantEnabled: false},
{name: "declared true", outputCapabilities: &latest.OutputCapabilitiesConfig{Image: new(true)}, resolvedEnabled: true, wantKnown: true, wantEnabled: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

client := &Client{
Config: base.Config{
ModelConfig: latest.ModelConfig{
Provider: "google",
Model: "gemini-2.5-flash-image",
OutputCapabilities: tt.outputCapabilities,
},
},
apiSurface: apiSurfaceGeminiAPI,
}
config := client.buildConfig()

shape := newRequestShape(client, config, 0, tt.resolvedEnabled)
assert.Equal(t, tt.wantKnown, shape.OutputCapabilityKnown)
assert.Equal(t, tt.wantEnabled, shape.OutputCapabilityEnabled)
})
}
}

// TestRequestShape_LogAttrsNeverLeaksToolSchemas is the core safety
// regression for this diagnostic: it builds a request with a function tool
// carrying a marker description and parameter schema, then verifies that
Expand All @@ -189,7 +228,7 @@ func TestRequestShape_LogAttrsNeverLeaksToolSchemas(t *testing.T) {
require.NoError(t, err)
config.Tools = allTools

shape := newRequestShape(client, config, len(requestTools))
shape := newRequestShape(client, config, len(requestTools), false)

for _, attr := range flattenAttrs(shape.LogAttrs()) {
assert.NotContains(t, attr, marker, "RequestShape must never carry tool descriptions or schemas")
Expand Down
69 changes: 69 additions & 0 deletions pkg/model/provider/gemini/image_output_guard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package gemini

import (
"fmt"
"strings"

"google.golang.org/genai"
)

// imageOutputIncompatibility names a fixed, safe request-feature class
// rejected by the image-output request guard. Values are display-safe:
// never provider text, tool names, schema contents, or prompts.
type imageOutputIncompatibility string

const (
imageOutputIncompatibleTools imageOutputIncompatibility = "tools"
imageOutputIncompatibleBuiltInTools imageOutputIncompatibility = "built-in tools"
imageOutputIncompatibleStructuredOutput imageOutputIncompatibility = "structured output"
)

// ImageOutputRequestIncompatibleError is returned before any provider
// dispatch when a request to an image-output-capable model
// (output_capabilities.image: true) combines custom function tools (with
// their required ToolConfig), a built-in tool, or structured output. The
// request shape is intentionally unsupported until live direct Gemini API and
// Vertex AI verification proves it is accepted there; gateway probing has
// already shown opaque, empty-body HTTP 400 responses. Rejecting locally keeps
// the verified minimal request byte-for-byte and gives the caller a specific,
// actionable error instead.
type ImageOutputRequestIncompatibleError struct {
// Incompatibilities is always non-empty. Its values are the fixed enum
// above — never provider text, tool names/schemas, or prompt content.
Incompatibilities []imageOutputIncompatibility
}

func (e *ImageOutputRequestIncompatibleError) Error() string {
names := make([]string, len(e.Incompatibilities))
for i, c := range e.Incompatibilities {
names[i] = string(c)
}
return fmt.Sprintf(
"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",
strings.Join(names, ", "),
)
}

// checkImageOutputRequestCompatibility rejects, before any provider dispatch,
// an incompatible request when image output is enabled by configuration or the
// models.dev catalogue.
func (c *Client) checkImageOutputRequestCompatibility(imageOutputEnabled bool, config *genai.GenerateContentConfig, builtInTools []*genai.Tool, requestTools int) error {
if !imageOutputEnabled {
return nil
}

var incompatibilities []imageOutputIncompatibility
if requestTools > 0 {
incompatibilities = append(incompatibilities, imageOutputIncompatibleTools)
}
if len(builtInTools) > 0 {
incompatibilities = append(incompatibilities, imageOutputIncompatibleBuiltInTools)
}
if config.ResponseMIMEType != "" || config.ResponseJsonSchema != nil {
incompatibilities = append(incompatibilities, imageOutputIncompatibleStructuredOutput)
}
if len(incompatibilities) == 0 {
return nil
}
return &ImageOutputRequestIncompatibleError{Incompatibilities: incompatibilities}
}
Loading
Loading