Skip to content
Open
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
3 changes: 3 additions & 0 deletions docs/features/api-server/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ All endpoints are under the `/api` prefix.
| `POST` | `/api/sessions/:id/elicitation` | Respond to an MCP tool elicitation request |
| `POST` | `/api/sessions/:id/steer` | Inject messages into a running turn (pre-empts current) |
| `POST` | `/api/sessions/:id/followup` | Enqueue messages to run after the current turn finishes |
| `GET` | `/api/sessions/:id/models` | List available models for the session's current agent |
| `PATCH` | `/api/sessions/:id/model` | Set or clear the agent's model override |
| `POST` | `/api/sessions/:id/model` | Set or clear the agent's model override (backward compat with RemoteRuntime) |

### Agent Execution

Expand Down
12 changes: 12 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,15 @@ type SessionStatusResponse struct {
OutputTokens int64 `json:"output_tokens"`
NumMessages int `json:"num_messages"`
}

// SetSessionModelRequest is the body of PATCH /api/sessions/:id/model.
// An empty Model clears the override and reverts to the agent's default.
type SetSessionModelRequest struct {
Model string `json:"model"`
Comment thread
dgageot marked this conversation as resolved.
}

// SetSessionModelResponse is the response from PATCH /api/sessions/:id/model.
type SetSessionModelResponse struct {
Agent string `json:"agent"`
Model string `json:"model,omitempty"`
}
71 changes: 5 additions & 66 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -777,76 +777,15 @@ func (a *App) AvailableModels(ctx context.Context) []runtime.ModelChoice {
if !a.runtime.SupportsModelSwitching() {
return nil
}
models := a.runtime.AvailableModels(ctx)

// Determine the currently active model for this agent
agentName := a.runtime.CurrentAgentName()
Comment thread
dgageot marked this conversation as resolved.
currentModelRef := ""
if a.session != nil && a.session.AgentModelOverrides != nil {
currentModelRef = a.session.AgentModelOverrides[agentName]
}

// Build a set of model refs already in the list
existingRefs := make(map[string]bool)
for _, m := range models {
existingRefs[m.Ref] = true
}

// Check if current model is in the list and mark it
currentFound := currentModelRef == ""
for i := range models {
if currentModelRef != "" {
// An override is set - mark the override as current
if models[i].Ref == currentModelRef {
models[i].IsCurrent = true
currentFound = true
}
} else {
// No override - the default model is current
models[i].IsCurrent = models[i].IsDefault
}
}

// Add custom models from the session that aren't already in the list
currentRef := ""
var customRefs []string
if a.session != nil {
for _, customRef := range a.session.CustomModelsUsed {
if existingRefs[customRef] {
continue // Already in the list
}
existingRefs[customRef] = true

providerName, modelName, _ := strings.Cut(customRef, "/")
isCurrent := customRef == currentModelRef
if isCurrent {
currentFound = true
}
models = append(models, runtime.ModelChoice{
Name: customRef,
Ref: customRef,
Provider: providerName,
Model: modelName,
IsDefault: false,
IsCurrent: isCurrent,
IsCustom: true,
})
}
currentRef = a.session.AgentModelOverrides[agentName]
customRefs = a.session.CustomModelsUsed
}

// If current model is a custom model not in the list, add it
if !currentFound && strings.Contains(currentModelRef, "/") {
providerName, modelName, _ := strings.Cut(currentModelRef, "/")
models = append(models, runtime.ModelChoice{
Name: currentModelRef,
Ref: currentModelRef,
Provider: providerName,
Model: modelName,
IsDefault: false,
IsCurrent: true,
IsCustom: true,
})
}

return models
return runtime.DecorateModelChoices(a.runtime.AvailableModels(ctx), currentRef, customRefs)
}

// trackCustomModel adds a custom model to the session's history if not already present.
Expand Down
123 changes: 105 additions & 18 deletions pkg/runtime/model_switcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,49 +16,136 @@ import (
"github.com/docker/docker-agent/pkg/modelsdev"
)

// ModelChoice represents a model available for selection in the TUI picker.
// ModelChoice represents a model available for selection in the model picker.
//
// JSON tags are part of the public wire format used by
// GET /api/sessions/:id/models; renaming a tag is a breaking change.
type ModelChoice struct {
// Name is the display name (config key)
Name string
Name string `json:"name"`
// Ref is the model reference used internally (e.g., "my_model" or "openai/gpt-4o")
Ref string
Ref string `json:"ref"`
// Provider is the provider name (e.g., "openai", "anthropic")
Provider string
Provider string `json:"provider,omitempty"`
// Model is the specific model name (e.g., "gpt-4o", "claude-sonnet-4-0")
Model string
Model string `json:"model,omitempty"`
// IsDefault indicates this is the agent's configured default model
IsDefault bool
IsDefault bool `json:"is_default,omitempty"`
// IsCurrent indicates this is the currently active model for the agent
IsCurrent bool
IsCurrent bool `json:"is_current,omitempty"`
// IsCustom indicates this is a custom model from the session history (not from config)
IsCustom bool
IsCustom bool `json:"is_custom,omitempty"`
// IsCatalog indicates this is a model from the models.dev catalog
IsCatalog bool
IsCatalog bool `json:"is_catalog,omitempty"`

// The fields below are populated (best-effort) from the models.dev
// catalog. They are optional and may all be zero/empty when no
// catalog entry is found for the model.

// Family is the model family (e.g., "claude", "gpt").
Family string
Family string `json:"family,omitempty"`
// InputCost is the price (in USD) per 1M input tokens.
InputCost float64
InputCost float64 `json:"input_cost,omitempty"`
// OutputCost is the price (in USD) per 1M output tokens.
OutputCost float64
OutputCost float64 `json:"output_cost,omitempty"`
// CacheReadCost is the price (in USD) per 1M cached input tokens.
CacheReadCost float64
CacheReadCost float64 `json:"cache_read_cost,omitempty"`
// CacheWriteCost is the price (in USD) per 1M cache-write tokens.
CacheWriteCost float64
CacheWriteCost float64 `json:"cache_write_cost,omitempty"`
// ContextLimit is the maximum context window size in tokens.
ContextLimit int
ContextLimit int `json:"context_limit,omitempty"`
// OutputLimit is the maximum number of tokens the model can produce
// in a single response.
OutputLimit int64
OutputLimit int64 `json:"output_limit,omitempty"`
// InputModalities lists the input modalities supported by the model
// (e.g., "text", "image", "audio").
InputModalities []string
InputModalities []string `json:"input_modalities,omitempty"`
// OutputModalities lists the output modalities the model can produce.
OutputModalities []string
OutputModalities []string `json:"output_modalities,omitempty"`
}

Comment thread
dgageot marked this conversation as resolved.
// SessionModelsResponse is the response returned by
// GET /api/sessions/:id/models. CurrentModelRef is the active override for
// the named agent (empty when the agent is using its configured default).
type SessionModelsResponse struct {
Agent string `json:"agent"`
CurrentModelRef string `json:"current_model_ref,omitempty"`
Models []ModelChoice `json:"models"`
Comment thread
dgageot marked this conversation as resolved.
}

// DecorateModelChoices marks the active selection with IsCurrent and
// appends any custom (provider/model) refs from the session history that
// the runtime does not already expose. It is used by every consumer that
// wants to render a model picker (the TUI App, the HTTP /sessions/:id/models
// endpoint, …) so they all agree on which entry is current and what the
// final list looks like.
//
// currentRef is the model override active for the agent ("" when none),
// and customRefs is the session's CustomModelsUsed history.
//
// The input slice is never mutated: callers can safely pass a slice that
// is shared with or backed by an internal cache.
func DecorateModelChoices(models []ModelChoice, currentRef string, customRefs []string) []ModelChoice {
// Defensive copy: AvailableModels implementations may return a slice
// backed by an internal cache. Mutating its IsCurrent flag in place
// would leak picker state across sessions/agents.
result := make([]ModelChoice, len(models), len(models)+len(customRefs)+1)
copy(result, models)

existingRefs := make(map[string]bool, len(result))
for _, m := range result {
existingRefs[m.Ref] = true
}

currentFound := currentRef == ""
for i := range result {
if currentRef != "" {
if result[i].Ref == currentRef {
result[i].IsCurrent = true
currentFound = true
}
} else {
result[i].IsCurrent = result[i].IsDefault
}
}

for _, ref := range customRefs {
if existingRefs[ref] {
continue
}
existingRefs[ref] = true

prov, name, _ := strings.Cut(ref, "/")
isCurrent := ref == currentRef
if isCurrent {
currentFound = true
}
result = append(result, ModelChoice{
Name: ref,
Ref: ref,
Provider: prov,
Model: name,
IsCurrent: isCurrent,
IsCustom: true,
})
}

// If the override points at an inline provider/model not in the
// runtime's list nor in the session's history, fabricate a synthetic
// choice so the picker can still highlight the active selection.
if !currentFound && strings.Contains(currentRef, "/") {
prov, name, _ := strings.Cut(currentRef, "/")
result = append(result, ModelChoice{
Name: currentRef,
Ref: currentRef,
Provider: prov,
Model: name,
IsCurrent: true,
IsCustom: true,
Comment thread
dgageot marked this conversation as resolved.
})
}

return result
}

// ModelSwitcherConfig holds the configuration needed for model switching.
Expand Down
121 changes: 121 additions & 0 deletions pkg/runtime/model_switcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,124 @@ func TestResolveModelRef_InvalidFormat(t *testing.T) {
})
}
}

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

t.Run("default marked current when no override is set", func(t *testing.T) {
t.Parallel()
got := DecorateModelChoices(
[]ModelChoice{
{Name: "default", Ref: "openai/gpt-4o-mini", IsDefault: true},
{Name: "other", Ref: "openai/gpt-4o"},
},
"",
nil,
)
require.Len(t, got, 2)
assert.True(t, got[0].IsCurrent, "the IsDefault model must be marked IsCurrent when no override is set")
assert.False(t, got[1].IsCurrent)
})

t.Run("override matching a known choice marks it current", func(t *testing.T) {
t.Parallel()
got := DecorateModelChoices(
[]ModelChoice{
{Name: "default", Ref: "openai/gpt-4o-mini", IsDefault: true},
{Name: "other", Ref: "openai/gpt-4o"},
},
"openai/gpt-4o",
nil,
)
require.Len(t, got, 2)
assert.False(t, got[0].IsCurrent, "default must not be marked current when an override is active")
assert.True(t, got[1].IsCurrent)
})

t.Run("synthesizes choice for inline override not in list", func(t *testing.T) {
t.Parallel()
got := DecorateModelChoices(
[]ModelChoice{{Name: "default", Ref: "openai/gpt-4o-mini", IsDefault: true}},
"anthropic/claude-sonnet-4-0",
nil,
)
require.Len(t, got, 2)
assert.Equal(t, "anthropic/claude-sonnet-4-0", got[1].Ref)
assert.Equal(t, "anthropic", got[1].Provider)
assert.Equal(t, "claude-sonnet-4-0", got[1].Model)
assert.True(t, got[1].IsCurrent)
assert.True(t, got[1].IsCustom)
})

t.Run("appends custom refs from session history", func(t *testing.T) {
t.Parallel()
got := DecorateModelChoices(
[]ModelChoice{{Name: "default", Ref: "openai/gpt-4o-mini", IsDefault: true}},
"",
[]string{"openai/gpt-4o", "anthropic/claude-sonnet-4-0"},
)
require.Len(t, got, 3)
assert.Equal(t, "openai/gpt-4o-mini", got[0].Ref)
assert.Equal(t, "openai/gpt-4o", got[1].Ref)
assert.True(t, got[1].IsCustom)
assert.Equal(t, "anthropic/claude-sonnet-4-0", got[2].Ref)
assert.True(t, got[2].IsCustom)
})

t.Run("does not duplicate custom ref already in list", func(t *testing.T) {
t.Parallel()
got := DecorateModelChoices(
[]ModelChoice{{Name: "default", Ref: "openai/gpt-4o", IsDefault: true}},
"",
[]string{"openai/gpt-4o"},
)
require.Len(t, got, 1)
assert.Equal(t, "openai/gpt-4o", got[0].Ref)
})

t.Run("non-provider override does not synthesize a fabricated choice", func(t *testing.T) {
t.Parallel()
// "my_model" is a config key (no slash); when not in the runtime's
// list we should NOT fabricate a choice for it because we have no
// provider/model breakdown to display.
got := DecorateModelChoices(
[]ModelChoice{{Name: "default", Ref: "default", IsDefault: true}},
"my_model",
nil,
)
require.Len(t, got, 1)
assert.False(t, got[0].IsCurrent, "default must not be marked current when override is unknown")
})

t.Run("custom ref matching the active override is marked current", func(t *testing.T) {
t.Parallel()
got := DecorateModelChoices(
[]ModelChoice{{Name: "default", Ref: "default", IsDefault: true}},
"openai/gpt-4o",
[]string{"openai/gpt-4o"},
)
require.Len(t, got, 2)
assert.False(t, got[0].IsCurrent)
assert.Equal(t, "openai/gpt-4o", got[1].Ref)
assert.True(t, got[1].IsCurrent)
assert.True(t, got[1].IsCustom)
})

// AvailableModels implementations may return a slice backed by an
// internal cache; mutating its IsCurrent flag in place would leak
// state across sessions. The function must therefore never modify
// the input slice or its underlying array.
t.Run("does not mutate the input slice", func(t *testing.T) {
t.Parallel()
input := []ModelChoice{
{Name: "default", Ref: "openai/gpt-4o-mini", IsDefault: true},
{Name: "other", Ref: "openai/gpt-4o"},
}
orig := make([]ModelChoice, len(input))
copy(orig, input)

_ = DecorateModelChoices(input, "openai/gpt-4o", []string{"anthropic/claude-sonnet-4-0"})

assert.Equal(t, orig, input, "DecorateModelChoices must not modify the input slice")
})
}
Loading
Loading