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
15 changes: 15 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1740,6 +1740,10 @@
"$ref": "#/definitions/CapabilitiesConfig",
"description": "Explicit attachment capability override for models the models.dev catalogue does not describe correctly (custom OpenAI-compatible providers, local models like Ollama, or dropped model versions). When set, the declared flags are authoritative and no models.dev lookup is performed; when omitted, capabilities are detected automatically. Without it, such models fall back to text-only and their image/PDF/audio/video attachments are silently dropped."
},
"output_capabilities": {
"$ref": "#/definitions/OutputCapabilitiesConfig",
"description": "Optional generative output capability override for this model. When omitted, Docker Agent detects output modalities from the models.dev catalogue. An explicit flag is authoritative, including false. Cannot be combined with first_available (set output_capabilities.image on the candidate models instead)."
},
"cost": {
"$ref": "#/definitions/CostConfig",
"description": "Explicit token pricing (USD per 1M tokens), overriding the models.dev catalogue. Used for per-turn cost computation, session cost tracking, the /model picker, and the after_llm_call hook's cost field. Makes an uncatalogued model (custom base_url provider, local or private deployment) 'priced' instead of billing $0. Prices must not be negative; an all-zero table means 'priced, free'. Cannot be combined with first_available."
Expand Down Expand Up @@ -1770,6 +1774,17 @@
},
"additionalProperties": false
},
"OutputCapabilitiesConfig": {
"type": "object",
"description": "Generative output capability overrides for a model. When omitted, Docker Agent detects output modalities from the models.dev catalogue. Explicit flags are authoritative, including false, and are useful for custom models or correcting catalogue metadata.",
"properties": {
"image": {
"type": "boolean",
"description": "Whether the model can generate image output. When omitted, Docker Agent uses models.dev output modalities; an explicit value overrides the catalogue."
}
},
"additionalProperties": false
},
"CostConfig": {
"type": "object",
"description": "Explicit token pricing for a model, in USD per one million tokens. Takes precedence over the models.dev catalogue and prices models the catalogue does not know.",
Expand Down
42 changes: 40 additions & 2 deletions docs/configuration/models/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ models:
parallel_tool_calls: boolean # Optional: allow parallel tool calls
track_usage: boolean # Optional: track token usage
routing: [list] # Optional: rule-based model routing
capabilities: # Optional: override attachment capabilities
capabilities: # Optional: override attachment (input) capabilities
image: boolean # Optional: whether the model accepts image attachments
pdf: boolean # Optional: whether the model accepts PDF attachments
audio: boolean # Optional: whether the model accepts audio attachments
video: boolean # Optional: whether the model accepts video attachments
output_capabilities: # Optional: owner-declared generative output capabilities (never inferred)
image: boolean # Optional: whether the model is declared able to generate image output
cost: # Optional: explicit token pricing (USD per 1M tokens)
input: float # Optional: price per 1M input tokens
output: float # Optional: price per 1M output tokens
Expand Down Expand Up @@ -73,7 +75,8 @@ models:
| `parallel_tool_calls` | boolean | ✗ | Allow model to call multiple tools at once |
| `track_usage` | boolean | ✗ | Track and report token usage for this model |
| `routing` | array | ✗ | Rule-based routing to different models. See [Model Routing](../routing/index.md). |
| `capabilities` | object | ✗ | Override attachment capabilities for this model. See [Attachment Capability Overrides](#attachment-capability-overrides). |
| `capabilities` | object | ✗ | Override attachment (input) capabilities for this model. See [Attachment Capability Overrides](#attachment-capability-overrides). |
| `output_capabilities` | object | ✗ | Owner-declared generative output capabilities for this model, e.g. image generation. Never inferred. Cannot be combined with `first_available`. See [Output Capabilities](#output-capabilities). |
| `cost` | object | ✗ | Explicit token pricing in USD per 1M tokens, overriding the built-in catalogue. See [Custom Token Pricing](#custom-token-pricing). |
| `provider_opts` | object | ✗ | Provider-specific options (see provider pages) |
| `title_model` | string | ✗ | Model used for session-title generation. Can be a named model from the `models:` section or an inline `provider/model` string. When omitted, the agent's primary model generates titles. Cannot be combined with `first_available`. |
Expand Down Expand Up @@ -148,6 +151,41 @@ See [`examples/capability-overrides.yaml`](https://github.com/docker/docker-agen
[`examples/strip-unsupported-media.yaml`](https://github.com/docker/docker-agent/blob/main/examples/strip-unsupported-media.yaml) for a fixture demonstrating the
stripping behaviour with and without an override.

## Output Capabilities

`output_capabilities` declares what a model can generate, as opposed to
`capabilities`, which declares what it accepts as input. There is no
automatic detection for output capabilities: no catalogue of
output-capable models exists, and matching on the model name string is
deliberately avoided as unreliable. A model's output capabilities are
therefore always unknown/off unless the owner declares them.

```yaml
models:
gemini-image:
provider: google
model: gemini-2.5-flash-image
output_capabilities:
image: true # this model is declared able to generate image output
```

| Field | Type | Description |
| --------------------------- | ------- | -------------------------------------------------------------|
| `output_capabilities.image` | boolean | Whether the model is declared able to generate image output |

Omitting `output_capabilities`, or leaving `image` unset or `false`, always
preserves existing behavior. Setting it to `true` only opts the model into
behavior that specifically keys off a declared image-output capability (for
example, a provider-specific request-shape guard); it does not by itself
change what Docker Agent sends to or renders from the model.

> [!WARNING]
> **Constraint**
>
> `output_capabilities` cannot be combined with `first_available` model selection — the combination is rejected at validation time. Declare it on the concrete candidate models instead.

See [`examples/gemini_image_output.yaml`](https://github.com/docker/docker-agent/blob/main/examples/gemini_image_output.yaml) for a complete example.

## Custom Token Pricing

Docker Agent prices each model call from the [models.dev](https://models.dev/)
Expand Down
30 changes: 30 additions & 0 deletions examples/gemini_image_output.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Gemini image-output model: the model can generate an image directly as
# part of its reply, instead of only describing one.
#
# The `gemini-image` model below declares `output_capabilities.image: true` —
# an explicit, owner-provided statement that this model can generate image
# output. It is never inferred from the model name or any catalogue; omit
# it, or leave it false, and behavior is unchanged.
#
# Native generated images aren't currently presented inline in the
# terminal UI, so the model's text reply is what you'll see today.
#
# Try it out:
# docker agent run examples/gemini_image_output.yaml \
# "Generate an image of a red panda working at a terminal"
# docker agent run examples/gemini_image_output.yaml \
# "Generate an image of a lighthouse at sunset, and describe the color palette you used"
models:
gemini-image:
provider: google
model: gemini-2.5-flash-image
output_capabilities:
image: true

agents:
root:
model: gemini-image
description: Minimal agent for exercising Gemini native image-output generation.
instruction: |
When asked to draw or generate an image, do so directly using your
native image-generation capability. Briefly describe what you made.
46 changes: 46 additions & 0 deletions pkg/config/first_available_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,42 @@ func TestResolveFirstAvailableModels_NamedCandidate(t *testing.T) {
assert.Equal(t, "claude-sonnet-4-6", got.Model)
}

// TestResolveFirstAvailableModels_PreservesConcreteCandidateOutputCapabilities is
// the regression for a selected candidate's owner-declared
// output_capabilities.image: resolveCandidate returns a named model's
// ModelConfig as-is (see resolveCandidate in first_available.go), but a
// future change to that path (e.g. rebuilding the struct field-by-field, or
// routing through ParseModelRef) could silently drop it. A selector itself
// can never carry output_capabilities (see TestValidateFirstAvailable's
// "combined with output_capabilities" cases) — only a concrete candidate can
// — so this is the only place that guarantee can be pinned end to end.
func TestResolveFirstAvailableModels_PreservesConcreteCandidateOutputCapabilities(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
Models: map[string]latest.ModelConfig{
"gemini_image": {
Provider: "google",
Model: "gemini-2.5-flash-image",
OutputCapabilities: &latest.OutputCapabilitiesConfig{Image: new(true)},
},
"smart": {FirstAvailable: []string{"gemini_image", "dmr/ai/qwen3"}},
},
}

env := environment.NewMapEnvProvider(map[string]string{"GEMINI_API_KEY": "test-key"})

require.NoError(t, ResolveFirstAvailableModels(t.Context(), cfg, "", env))

got := cfg.Models["smart"]
assert.Equal(t, "google", got.Provider)
assert.Equal(t, "gemini-2.5-flash-image", got.Model)
require.NotNil(t, got.OutputCapabilities,
"the selected concrete candidate's output_capabilities block must survive selection/resolution")
require.NotNil(t, got.OutputCapabilities.Image)
assert.True(t, *got.OutputCapabilities.Image)
}

func TestResolveFirstAvailableModels_SkipsRoutingCandidateWithMissingCredentials(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -373,6 +409,16 @@ func TestValidateFirstAvailable(t *testing.T) {
model: latest.ModelConfig{FirstAvailable: []string{"anthropic/claude-sonnet-4-6"}, Auth: &latest.AuthConfig{Type: "anthropic_wif"}},
wantErr: "cannot be combined with auth",
},
{
name: "combined with output_capabilities",
model: latest.ModelConfig{FirstAvailable: []string{"google/gemini-2.5-flash-image"}, OutputCapabilities: &latest.OutputCapabilitiesConfig{Image: new(true)}},
wantErr: "cannot be combined with output_capabilities",
},
{
name: "combined with output_capabilities false",
model: latest.ModelConfig{FirstAvailable: []string{"google/gemini-2.5-flash-image"}, OutputCapabilities: &latest.OutputCapabilitiesConfig{Image: new(false)}},
wantErr: "cannot be combined with output_capabilities",
},
{
name: "empty candidate",
model: latest.ModelConfig{FirstAvailable: []string{" "}},
Expand Down
121 changes: 121 additions & 0 deletions pkg/config/latest/output_capabilities_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package latest

import (
"testing"

"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestModelConfigOutputCapabilitiesYAMLRoundTrip pins that an explicit
// output_capabilities.image declaration survives parse and re-marshal, and
// defeats the provider/model shorthand collapse the same way capabilities does.
func TestModelConfigOutputCapabilitiesYAMLRoundTrip(t *testing.T) {
t.Parallel()

const in = `provider: google
model: gemini-2.5-flash-image
output_capabilities:
image: true
`
var f FlexibleModelConfig
require.NoError(t, yaml.Unmarshal([]byte(in), &f))

require.NotNil(t, f.OutputCapabilities, "output_capabilities should be parsed")
require.NotNil(t, f.OutputCapabilities.Image)
assert.True(t, *f.OutputCapabilities.Image)

assert.False(t, f.isShorthandOnly(), "output_capabilities override must defeat shorthand marshalling")

out, err := yaml.Marshal(f)
require.NoError(t, err)

var rt FlexibleModelConfig
require.NoError(t, yaml.Unmarshal(out, &rt))
require.NotNil(t, rt.OutputCapabilities, "output_capabilities should survive a marshal round-trip; got:\n%s", out)
require.NotNil(t, rt.OutputCapabilities.Image)
assert.True(t, *rt.OutputCapabilities.Image)
}

// TestModelConfigOutputCapabilitiesFalseYAMLRoundTrip pins that an explicit
// `image: false` is distinguishable from an omitted block: OutputCapabilities
// itself is non-nil (the owner declared the model, and declared it
// image-output-incapable), even though the Image flag is false.
func TestModelConfigOutputCapabilitiesFalseYAMLRoundTrip(t *testing.T) {
t.Parallel()

const in = `provider: google
model: gemini-2.5-flash
output_capabilities:
image: false
`
var f FlexibleModelConfig
require.NoError(t, yaml.Unmarshal([]byte(in), &f))

require.NotNil(t, f.OutputCapabilities, "an explicit false block should still be parsed as present")
require.NotNil(t, f.OutputCapabilities.Image)
assert.False(t, *f.OutputCapabilities.Image)
}

// TestModelConfigShorthandOnlyWithoutOutputCapabilities pins that a bare
// provider/model with no output_capabilities block still collapses to the
// shorthand form on marshal.
func TestModelConfigShorthandOnlyWithoutOutputCapabilities(t *testing.T) {
t.Parallel()

const in = `provider: openai
model: gpt-4o
`
var f FlexibleModelConfig
require.NoError(t, yaml.Unmarshal([]byte(in), &f))

assert.Nil(t, f.OutputCapabilities)
assert.True(t, f.isShorthandOnly(), "a bare provider/model must still marshal as shorthand")
}

// TestModelConfigOutputCapabilitiesOmittedStaysNil pins the default,
// missing-declaration case: OutputCapabilities stays nil, distinct from an
// explicit false block.
func TestModelConfigOutputCapabilitiesOmittedStaysNil(t *testing.T) {
t.Parallel()

const in = `provider: google
model: gemini-2.5-flash
`
var f FlexibleModelConfig
require.NoError(t, yaml.Unmarshal([]byte(in), &f))

assert.Nil(t, f.OutputCapabilities)
}

func TestModelConfigCloneCopiesOutputCapabilities(t *testing.T) {
t.Parallel()

orig := &ModelConfig{
Provider: "google",
Model: "gemini-2.5-flash-image",
OutputCapabilities: &OutputCapabilitiesConfig{Image: new(true)},
}

clone := orig.Clone()
require.NotNil(t, clone.OutputCapabilities)
require.NotNil(t, clone.OutputCapabilities.Image)
assert.True(t, *clone.OutputCapabilities.Image)

// Mutating the clone must not affect the original (deep copy).
*clone.OutputCapabilities.Image = false
assert.True(t, *orig.OutputCapabilities.Image, "clone must not share the OutputCapabilities pointer with the original")
}

// TestModelConfigCloneNilOutputCapabilities pins that a model with no
// declaration clones to nil, not a zero-value struct — the "unknown" state
// must not be silently upgraded to an authoritative false on clone.
func TestModelConfigCloneNilOutputCapabilities(t *testing.T) {
t.Parallel()

orig := &ModelConfig{Provider: "google", Model: "gemini-2.5-flash"}

clone := orig.Clone()
assert.Nil(t, clone.OutputCapabilities)
}
29 changes: 27 additions & 2 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ func (b *BudgetConfig) validate() error {
return nil
}

// Bool returns a pointer to value. It is primarily useful in configuration
// literals where a nil pointer means no override.
//
// Deprecated: use new(value) in new code.
//
//nolint:modernize // Compatibility API for configuration literals.
func Bool(value bool) *bool {
return &value
}

// SafetyMode is a declarative safety-mode default that agent authors
// (runtime.safety, agents.<name>.safety) and users (settings.safety,
// alias safety) can put in YAML. Only the four canonical session modes
Expand Down Expand Up @@ -1154,9 +1164,14 @@ type ModelConfig struct {
// of 0.9 applies. Useful next to CompactionModel: a model that compacts
// with a slower/smaller summarizer may want to trigger earlier.
CompactionThreshold *float64 `json:"compaction_threshold,omitempty"`
// Capabilities optionally declares the model's attachment capabilities,
// overriding the automatic models.dev-based detection. See [CapabilitiesConfig].
// Capabilities optionally declares the model's attachment (input)
// capabilities, overriding the automatic models.dev-based detection. See
// [CapabilitiesConfig].
Capabilities *CapabilitiesConfig `json:"capabilities,omitempty"`
// OutputCapabilities optionally overrides the model's generative *output*
// capabilities. An omitted flag is resolved from the models.dev catalogue;
// an explicit value takes precedence. See [OutputCapabilitiesConfig].
OutputCapabilities *OutputCapabilitiesConfig `json:"output_capabilities,omitempty"`
// Cost optionally declares the model's token pricing explicitly,
// overriding the models.dev catalogue. See [CostConfig].
Cost *CostConfig `json:"cost,omitempty"`
Expand Down Expand Up @@ -1224,6 +1239,15 @@ type CapabilitiesConfig struct {
Video bool `json:"video,omitempty"`
}

// OutputCapabilitiesConfig overrides model generative *output* capabilities.
// A nil flag defers to models.dev, while an explicit true or false is
// authoritative. Custom models not found in the catalogue conservatively
// resolve as unable to generate image output.
type OutputCapabilitiesConfig struct {
// Image reports whether the model can generate image output.
Image *bool `json:"image,omitempty"`
}

// IsFirstAvailable reports whether this model is a first-available selector
// (i.e. it picks the first candidate with configured credentials).
func (m *ModelConfig) IsFirstAvailable() bool {
Expand Down Expand Up @@ -1401,6 +1425,7 @@ func (f *FlexibleModelConfig) isShorthandOnly() bool {
f.CompactionModel == "" &&
f.CompactionThreshold == nil &&
f.Capabilities == nil &&
f.OutputCapabilities == nil &&
f.Cost == nil
}

Expand Down
3 changes: 3 additions & 0 deletions pkg/config/latest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ func (m *ModelConfig) validateFirstAvailable() error {
if m.Cost != nil {
return errors.New("first_available cannot be combined with cost (set it on the candidate models instead)")
}
if m.OutputCapabilities != nil {
return errors.New("first_available cannot be combined with output_capabilities (set output_capabilities.image on the candidate models instead)")
}
for i, ref := range m.FirstAvailable {
if strings.TrimSpace(ref) == "" {
return fmt.Errorf("first_available[%d] must not be empty", i)
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/latest/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ func TestModelConfigValidateFirstAvailable(t *testing.T) {
{name: "with compaction_model", model: ModelConfig{FirstAvailable: candidates, CompactionModel: "small"}, wantErr: "first_available cannot be combined with compaction_model"},
{name: "with compaction_threshold", model: ModelConfig{FirstAvailable: candidates, CompactionThreshold: new(0.5)}, wantErr: "first_available cannot be combined with compaction_threshold"},
{name: "with cost", model: ModelConfig{FirstAvailable: candidates, Cost: &CostConfig{Input: 1}}, wantErr: "first_available cannot be combined with cost"},
{name: "with output_capabilities", model: ModelConfig{FirstAvailable: candidates, OutputCapabilities: &OutputCapabilitiesConfig{Image: new(true)}}, wantErr: "first_available cannot be combined with output_capabilities"},
{name: "with output_capabilities false", model: ModelConfig{FirstAvailable: candidates, OutputCapabilities: &OutputCapabilitiesConfig{Image: new(false)}}, wantErr: "first_available cannot be combined with output_capabilities"},
}

for _, tt := range tests {
Expand Down
Loading
Loading