Skip to content
Merged
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: 1 addition & 2 deletions cmd/root/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/model/provider"
"github.com/docker/docker-agent/pkg/modelsdev"
"github.com/docker/docker-agent/pkg/telemetry"
)

Expand Down Expand Up @@ -161,7 +160,7 @@ func (f *modelsListFlags) collectModels(ctx context.Context, availableProviders
}

// Fetch catalog and add all text-capable models.
store, err := modelsdev.NewStore()
store, err := f.runConfig.ModelsDevStore()
if err != nil {
return rows
}
Expand Down
51 changes: 12 additions & 39 deletions pkg/attachment/modelcaps/modelcaps.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,31 +83,27 @@ func (mc ModelCapabilities) Supports(mimeType string) bool {
const loadTimeout = 10 * time.Second

// Load fetches (or returns from cache) the capability record for the given
// model ID. The model ID should be in "provider/model" format as used by
// models.dev (e.g. "anthropic/claude-3-5-sonnet-20241022").
// model ID using the provided store. The model ID should be in
// "provider/model" format as used by models.dev
// (e.g. "anthropic/claude-3-5-sonnet-20241022").
//
// When the model is not found in the models.dev database, Load returns a
// conservative capability set that only allows text MIME types. The returned
// error is always nil; capability detection failures are silent and safe.
func Load(modelID string) (ModelCapabilities, error) {
// When the store is nil or the model is not found, Load returns a
// conservative capability set that only allows text MIME types.
func Load(store *modelsdev.Store, modelID string) ModelCapabilities {
if store == nil {
return ModelCapabilities{modelFound: false}
}

ctx, cancel := context.WithTimeout(context.Background(), loadTimeout)
defer cancel()

store, err := modelsdev.NewStore()
if err != nil {
slog.WarnContext(ctx, "modelcaps: failed to load models.dev store, using conservative caps",
"error", err, "model", modelID)
return ModelCapabilities{modelFound: false}, nil
}

model, err := store.GetModel(ctx, modelID)
if err != nil {
if ctx.Err() != nil {
slog.WarnContext(ctx, "modelcaps: models.dev lookup timed out, using conservative caps",
"model", modelID, "timeout", loadTimeout)
}
// Model not found or context cancelled — conservative: text-only.
return ModelCapabilities{modelFound: false}, nil
return ModelCapabilities{modelFound: false}
}

mc := ModelCapabilities{modelFound: true}
Expand All @@ -119,7 +115,7 @@ func Load(modelID string) (ModelCapabilities, error) {
mc.supportsPDF = true
}
}
return mc, nil
return mc
}

// CapsWith constructs a ModelCapabilities value directly from booleans. This is
Expand All @@ -132,26 +128,3 @@ func CapsWith(supportsImage, supportsPDF bool) ModelCapabilities {
modelFound: true,
}
}

// LoadFromStore is like Load but accepts an explicit *modelsdev.Store, making
// it convenient for tests that inject a pre-populated in-memory store.
func LoadFromStore(store *modelsdev.Store, modelID string) ModelCapabilities {
ctx, cancel := context.WithTimeout(context.Background(), loadTimeout)
defer cancel()

model, err := store.GetModel(ctx, modelID)
if err != nil {
return ModelCapabilities{modelFound: false}
}

mc := ModelCapabilities{modelFound: true}
for _, input := range model.Modalities.Input {
switch strings.ToLower(input) {
case "image":
mc.supportsImage = true
case "pdf":
mc.supportsPDF = true
}
}
return mc
}
26 changes: 13 additions & 13 deletions pkg/attachment/modelcaps/modelcaps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ func buildStore(providers map[string]modelsdev.Provider) *modelsdev.Store {
return modelsdev.NewDatabaseStore(db)
}

// TestLoadFromStore_QualifiedIDRequired is the regression test for the bug
// TestLoad_QualifiedIDRequired is the regression test for the bug
// fixed by pass-fully-qualified-provider-model-ID: modelcaps.Load (and
// LoadFromStore) requires a "provider/model" key to find a model in the
// Load) requires a "provider/model" key to find a model in the
// models.dev database. A bare model name without the provider prefix must
// NOT resolve to vision capabilities — it falls back to text-only.
//
// Before the fix, callers passed c.ModelConfig.Model (e.g. "claude-sonnet-4-6")
// instead of c.ModelConfig.Provider+"/"+c.ModelConfig.Model; the lookup always
// missed and all image / PDF attachments were silently dropped.
func TestLoadFromStore_QualifiedIDRequired(t *testing.T) {
func TestLoad_QualifiedIDRequired(t *testing.T) {
store := buildStore(map[string]modelsdev.Provider{
"anthropic": {
Models: map[string]modelsdev.Model{
Expand All @@ -39,7 +39,7 @@ func TestLoadFromStore_QualifiedIDRequired(t *testing.T) {

// Bare model name (the original bug): must fall back to conservative text-only caps.
bareID := "claude-sonnet-4-6"
mcBare := modelcaps.LoadFromStore(store, bareID)
mcBare := modelcaps.Load(store, bareID)
if mcBare.Supports("image/jpeg") {
t.Errorf("bare model name %q must NOT resolve to vision caps: image/jpeg should be dropped", bareID)
}
Expand All @@ -49,7 +49,7 @@ func TestLoadFromStore_QualifiedIDRequired(t *testing.T) {

// Fully-qualified ID (the fix): must resolve to vision+pdf caps.
qualifiedID := "anthropic/claude-sonnet-4-6"
mcQualified := modelcaps.LoadFromStore(store, qualifiedID)
mcQualified := modelcaps.Load(store, qualifiedID)
if !mcQualified.Supports("image/jpeg") {
t.Errorf("qualified ID %q must resolve to vision caps: image/jpeg should be passed through", qualifiedID)
}
Expand All @@ -58,7 +58,7 @@ func TestLoadFromStore_QualifiedIDRequired(t *testing.T) {
}
}

func TestLoadFromStore_VisionModel(t *testing.T) {
func TestLoad_VisionModel(t *testing.T) {
store := buildStore(map[string]modelsdev.Provider{
"anthropic": {
Models: map[string]modelsdev.Model{
Expand All @@ -73,7 +73,7 @@ func TestLoadFromStore_VisionModel(t *testing.T) {
},
})

mc := modelcaps.LoadFromStore(store, "anthropic/claude-3-5-sonnet")
mc := modelcaps.Load(store, "anthropic/claude-3-5-sonnet")

if !mc.Supports("image/jpeg") {
t.Error("expected image/jpeg to be supported for vision model")
Expand All @@ -89,7 +89,7 @@ func TestLoadFromStore_VisionModel(t *testing.T) {
}
}

func TestLoadFromStore_TextOnlyModel(t *testing.T) {
func TestLoad_TextOnlyModel(t *testing.T) {
store := buildStore(map[string]modelsdev.Provider{
"openai": {
Models: map[string]modelsdev.Model{
Expand All @@ -104,7 +104,7 @@ func TestLoadFromStore_TextOnlyModel(t *testing.T) {
},
})

mc := modelcaps.LoadFromStore(store, "openai/gpt-3.5-turbo")
mc := modelcaps.Load(store, "openai/gpt-3.5-turbo")

if mc.Supports("image/jpeg") {
t.Error("expected image/jpeg NOT to be supported for text-only model")
Expand All @@ -121,10 +121,10 @@ func TestLoadFromStore_TextOnlyModel(t *testing.T) {
}
}

func TestLoadFromStore_ModelNotFound(t *testing.T) {
func TestLoad_ModelNotFound(t *testing.T) {
store := buildStore(map[string]modelsdev.Provider{})

mc := modelcaps.LoadFromStore(store, "unknown/nonexistent-model")
mc := modelcaps.Load(store, "unknown/nonexistent-model")

// Conservative fallback: only text is allowed
if mc.Supports("image/jpeg") {
Expand All @@ -138,7 +138,7 @@ func TestLoadFromStore_ModelNotFound(t *testing.T) {
}
}

func TestLoadFromStore_OfficeDocsNotAllowed(t *testing.T) {
func TestLoad_OfficeDocsNotAllowed(t *testing.T) {
// Office document MIMEs (DOCX, XLSX, etc.) are ZIP-based binaries and
// cannot be naively TXT-enveloped. models.dev has no "office" or
// "document" modality, so they must return false for all models.
Expand All @@ -156,7 +156,7 @@ func TestLoadFromStore_OfficeDocsNotAllowed(t *testing.T) {
},
})

mc := modelcaps.LoadFromStore(store, "openai/gpt-4o")
mc := modelcaps.Load(store, "openai/gpt-4o")

for _, officeMIME := range []string{
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Expand Down
26 changes: 25 additions & 1 deletion pkg/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/modelsdev"
)

type RuntimeConfig struct {
Expand All @@ -17,6 +18,11 @@ type RuntimeConfig struct {
EnvProviderForTests environment.Provider
envProvider environment.Provider
envProviderLock sync.Mutex

ModelsDevStoreOverride *modelsdev.Store
modelsDevStore *modelsdev.Store
modelsDevStoreErr error
modelsDevStoreOnce sync.Once
}

type Config struct {
Expand All @@ -41,9 +47,14 @@ type Config struct {
}

func (runConfig *RuntimeConfig) Clone() *RuntimeConfig {
store, storeErr := runConfig.ModelsDevStore()
clone := &RuntimeConfig{
Config: runConfig.Config,
Config: runConfig.Config,
ModelsDevStoreOverride: runConfig.ModelsDevStoreOverride,
modelsDevStore: store,
modelsDevStoreErr: storeErr,
}
clone.modelsDevStoreOnce.Do(func() {}) // mark as resolved
clone.EnvFiles = slices.Clone(runConfig.EnvFiles)
clone.Models = maps.Clone(runConfig.Models)
clone.Providers = maps.Clone(runConfig.Providers)
Expand All @@ -57,6 +68,19 @@ func (runConfig *RuntimeConfig) Clone() *RuntimeConfig {
return clone
}

// ModelsDevStore returns the lazily-initialized models.dev store.
// The store is created on first access and shared across clones.
// If ModelsDevStoreOverride is set, it is returned directly.
func (runConfig *RuntimeConfig) ModelsDevStore() (*modelsdev.Store, error) {
if runConfig.ModelsDevStoreOverride != nil {
return runConfig.ModelsDevStoreOverride, nil
}
runConfig.modelsDevStoreOnce.Do(func() {
runConfig.modelsDevStore, runConfig.modelsDevStoreErr = modelsdev.NewStore()
})
return runConfig.modelsDevStore, runConfig.modelsDevStoreErr
}

func (runConfig *RuntimeConfig) EnvProvider() environment.Provider {
if runConfig.EnvProviderForTests != nil {
return runConfig.EnvProviderForTests
Expand Down
6 changes: 3 additions & 3 deletions pkg/model/provider/anthropic/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ import (
"github.com/docker/docker-agent/pkg/modelsdev"
)

// convertDocumentFromStore converts a chat.Document to standard Anthropic SDK content blocks
// convertDocument converts a chat.Document to standard Anthropic SDK content blocks
// using an explicit modelsdev.Store for capability lookup.
//
// Routing:
// - image/* with InlineData → ImageBlockParam (base64 source)
// - application/pdf with InlineData → DocumentBlockParam (base64)
// - text with InlineText → TextBlockParam with TXTEnvelope
// - unsupported / no content → nil (logged as warning)
func convertDocumentFromStore(ctx context.Context, doc chat.Document, modelID string, store *modelsdev.Store) ([]anthropic.ContentBlockParamUnion, error) {
mc := modelcaps.LoadFromStore(store, modelID)
func convertDocument(ctx context.Context, doc chat.Document, modelID string, store *modelsdev.Store) ([]anthropic.ContentBlockParamUnion, error) {
mc := modelcaps.Load(store, modelID)
return convertDocumentWithCaps(ctx, doc, mc)
}

Expand Down
6 changes: 5 additions & 1 deletion pkg/model/provider/anthropic/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/model/provider/base"
"github.com/docker/docker-agent/pkg/model/provider/options"
"github.com/docker/docker-agent/pkg/modelsdev"
)

Expand Down Expand Up @@ -79,14 +80,17 @@ func TestConvertDocumentAnthropic_QualifiedIDRequired(t *testing.T) {
},
})

var modelOpts options.ModelOptions
options.WithModelsDevStore(store)(&modelOpts)

c := &Client{
Config: base.Config{
ModelConfig: latest.ModelConfig{
Provider: "anthropic",
Model: "claude-sonnet-4-6",
},
ModelOptions: modelOpts,
},
modelsStore: store,
}

parts := []chat.MessagePart{
Expand Down
11 changes: 1 addition & 10 deletions pkg/model/provider/anthropic/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"github.com/docker/docker-agent/pkg/model/provider/base"
"github.com/docker/docker-agent/pkg/model/provider/options"
"github.com/docker/docker-agent/pkg/model/provider/providerutil"
"github.com/docker/docker-agent/pkg/modelsdev"
"github.com/docker/docker-agent/pkg/tools"
)

Expand All @@ -34,7 +33,6 @@ type Client struct {

clientFn func(context.Context) (anthropic.Client, error)
fileManager *FileManager
modelsStore *modelsdev.Store // initialised in NewClient; overrideable in tests
}

// NewClient creates a new Anthropic client from the provided configuration
Expand Down Expand Up @@ -69,13 +67,6 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro
},
}

store, err := modelsdev.NewStore()
if err != nil {
slog.WarnContext(ctx, "anthropic: failed to load models.dev store, attachments will use conservative caps", "error", err)
store = modelsdev.NewDatabaseStore(&modelsdev.Database{}) // empty: conservative text-only
}
anthropicClient.modelsStore = store

if gateway := globalOptions.Gateway(); gateway == "" {
authOpts, err := buildDirectAuthOptions(ctx, cfg, env)
if err != nil {
Expand Down Expand Up @@ -325,7 +316,7 @@ func (c *Client) CreateChatCompletionStream(
// convertDoc converts a document attachment using the client's model ID
// and the store initialized at construction time.
func (c *Client) convertDoc(ctx context.Context, doc chat.Document) ([]anthropic.ContentBlockParamUnion, error) {
return convertDocumentFromStore(ctx, doc, c.ID(), c.modelsStore)
return convertDocument(ctx, doc, c.ID(), c.ModelOptions.ModelsDevStore())
}

func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) ([]anthropic.MessageParam, error) {
Expand Down
6 changes: 3 additions & 3 deletions pkg/model/provider/bedrock/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ func imageFormatFromMIME(mimeType string) (types.ImageFormat, bool) {
}
}

// convertDocumentFromStore converts a chat.Document to zero or more Bedrock ContentBlocks
// convertDocument converts a chat.Document to zero or more Bedrock ContentBlocks
// using the provided modelsdev.Store for capability lookup.
//
// Routing:
// - image/* with InlineData → ContentBlockMemberImage
// - application/pdf with InlineData → ContentBlockMemberDocument (PDF)
// - text/* with InlineText → ContentBlockMemberText with TXTEnvelope
// - unsupported / no content → nil (logged as warning)
func convertDocumentFromStore(ctx context.Context, doc chat.Document, modelID string, store *modelsdev.Store) ([]types.ContentBlock, error) {
mc := modelcaps.LoadFromStore(store, modelID)
func convertDocument(ctx context.Context, doc chat.Document, modelID string, store *modelsdev.Store) ([]types.ContentBlock, error) {
mc := modelcaps.Load(store, modelID)
return convertDocumentWithCaps(ctx, doc, mc)
}

Expand Down
Loading
Loading