Skip to content

Commit bda5311

Browse files
refactor: use c.ID() at all modelcaps call sites; inject store for testability
Per reviewer request, replace all c.ModelConfig.Provider+"/"+c.ModelConfig.Model expressions with c.ID() at the modelcaps lookup call sites in every provider. Also wire store injection so regression tests can verify the full path: anthropic/client.go: - Add modelsStore *modelsdev.Store field (nil = use real store) - Add convertDoc() method routing through store when set, using c.ID() - convertUserMultiContent now calls c.convertDoc() anthropic/beta_converter.go: - convertBetaUserMultiContent calls c.convertDoc() openai/client.go: - Add modelsStore field; convertMessages routes through ConvertMessagesFromStore - Responses API inline call uses c.ID() oaistream/messages.go: - Add ConvertMessagesFromStore / ConvertMultiContentFromStore (store-injectable) - Internal convertMessagesWithStore / convertMultiContentWithStore helpers dmr, gemini, bedrock: - Use c.ID() directly Tests updated: - anthropic test constructs Client{modelsStore: store} and calls convertUserMultiContent — image present only if c.ID() is used - oaistream test calls ConvertMultiContentFromStore with qualified vs bare ID — image present only when provider prefix is included
1 parent 9e4db92 commit bda5311

9 files changed

Lines changed: 99 additions & 46 deletions

File tree

pkg/model/provider/anthropic/attachments_test.go

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99

1010
"github.com/docker/docker-agent/pkg/attachment/modelcaps"
1111
"github.com/docker/docker-agent/pkg/chat"
12+
"github.com/docker/docker-agent/pkg/config/latest"
13+
"github.com/docker/docker-agent/pkg/model/provider/base"
1214
"github.com/docker/docker-agent/pkg/modelsdev"
1315
)
1416

@@ -53,13 +55,15 @@ func TestConvertDocumentAnthropic_StrategyB64_PDF(t *testing.T) {
5355
}
5456

5557
// TestConvertDocumentAnthropic_QualifiedIDRequired is the regression test for
56-
// the bug where callers passed a bare model name to convertDocument instead of
57-
// a "provider/model" qualified identifier, causing modelcaps to miss the model
58-
// and silently drop all image/PDF attachments.
58+
// the bug where convertUserMultiContent passed only c.ModelConfig.Model (bare
59+
// model name) to convertDocument instead of c.ModelConfig.Provider+"/"+c.ModelConfig.Model.
60+
// When the bare name was used, modelcaps.Load always missed the model and all
61+
// image/PDF attachments were silently dropped.
5962
//
60-
// It calls convertDocumentFromStore directly with an injected fake store so
61-
// the test exercises the full modelID → LoadFromStore → caps → blocks path
62-
// without hitting the network.
63+
// The test constructs a Client with Provider="anthropic" and Model="claude-sonnet-4-6",
64+
// injects a fake modelsdev.Store, and calls convertUserMultiContent directly.
65+
// The image block must be present in the output — which only happens if the
66+
// fully-qualified "anthropic/claude-sonnet-4-6" was used for the caps lookup.
6367
func TestConvertDocumentAnthropic_QualifiedIDRequired(t *testing.T) {
6468
store := modelsdev.NewDatabaseStore(&modelsdev.Database{
6569
Providers: map[string]modelsdev.Provider{
@@ -75,24 +79,31 @@ func TestConvertDocumentAnthropic_QualifiedIDRequired(t *testing.T) {
7579
},
7680
})
7781

78-
doc := chat.Document{
79-
Name: "photo.jpg",
80-
MimeType: "image/jpeg",
81-
Source: chat.DocumentSource{InlineData: minJPEG},
82+
c := &Client{
83+
Config: base.Config{
84+
ModelConfig: latest.ModelConfig{
85+
Provider: "anthropic",
86+
Model: "claude-sonnet-4-6",
87+
},
88+
},
89+
modelsStore: store,
8290
}
8391

84-
// Bare model name (the original bug): LoadFromStore misses the model,
85-
// capabilities fall back to text-only, image must be dropped.
86-
blocksBare, err := convertDocumentFromStore(t.Context(), doc, "claude-sonnet-4-6", store)
87-
require.NoError(t, err)
88-
assert.Nil(t, blocksBare, "bare model name must not resolve caps: image should be dropped")
92+
parts := []chat.MessagePart{
93+
{
94+
Type: chat.MessagePartTypeDocument,
95+
Document: &chat.Document{
96+
Name: "photo.jpg",
97+
MimeType: "image/jpeg",
98+
Source: chat.DocumentSource{InlineData: minJPEG},
99+
},
100+
},
101+
}
89102

90-
// Qualified ID (the fix): LoadFromStore finds the model, vision caps
91-
// are set, image must be preserved as a native image block.
92-
blocksQualified, err := convertDocumentFromStore(t.Context(), doc, "anthropic/claude-sonnet-4-6", store)
103+
blocks, err := c.convertUserMultiContent(t.Context(), parts)
93104
require.NoError(t, err)
94-
require.Len(t, blocksQualified, 1, "qualified ID must resolve caps: image should be present")
95-
assert.NotNil(t, blocksQualified[0].OfImage, "expected native image block for qualified model ID")
105+
require.Len(t, blocks, 1, "image must not be dropped when provider+model ID is used for caps lookup")
106+
assert.NotNil(t, blocks[0].OfImage, "expected native image block")
96107
}
97108

98109
func TestConvertDocumentAnthropic_StrategyTXT(t *testing.T) {

pkg/model/provider/anthropic/beta_converter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ func (c *Client) convertBetaUserMultiContent(ctx context.Context, parts []chat.M
277277

278278
case chat.MessagePartTypeDocument:
279279
if part.Document != nil {
280-
stdBlocks, err := convertDocument(ctx, *part.Document, c.ModelConfig.Provider+"/"+c.ModelConfig.Model)
280+
stdBlocks, err := c.convertDoc(ctx, *part.Document)
281281
if err != nil {
282282
return nil, fmt.Errorf("failed to convert document attachment %q: %w", part.Document.Name, err)
283283
}

pkg/model/provider/anthropic/client.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/docker/docker-agent/pkg/model/provider/base"
2424
"github.com/docker/docker-agent/pkg/model/provider/options"
2525
"github.com/docker/docker-agent/pkg/model/provider/providerutil"
26+
"github.com/docker/docker-agent/pkg/modelsdev"
2627
"github.com/docker/docker-agent/pkg/tools"
2728
)
2829

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

3435
clientFn func(context.Context) (anthropic.Client, error)
3536
fileManager *FileManager
37+
modelsStore *modelsdev.Store // nil in production (uses modelsdev.NewStore()); set in tests
3638
}
3739

3840
// NewClient creates a new Anthropic client from the provided configuration
@@ -313,6 +315,15 @@ func (c *Client) CreateChatCompletionStream(
313315
return ad, nil
314316
}
315317

318+
// convertDoc converts a document attachment using the client's model ID and
319+
// the injected store (if set) or the real modelsdev store.
320+
func (c *Client) convertDoc(ctx context.Context, doc chat.Document) ([]anthropic.ContentBlockParamUnion, error) {
321+
if c.modelsStore != nil {
322+
return convertDocumentFromStore(ctx, doc, c.ID(), c.modelsStore)
323+
}
324+
return convertDocument(ctx, doc, c.ID())
325+
}
326+
316327
func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) ([]anthropic.MessageParam, error) {
317328
var anthropicMessages []anthropic.MessageParam
318329
// Track whether the last appended assistant message included tool_use blocks
@@ -553,7 +564,7 @@ func (c *Client) convertUserMultiContent(ctx context.Context, parts []chat.Messa
553564

554565
case chat.MessagePartTypeDocument:
555566
if part.Document != nil {
556-
docBlocks, err := convertDocument(ctx, *part.Document, c.ModelConfig.Provider+"/"+c.ModelConfig.Model)
567+
docBlocks, err := c.convertDoc(ctx, *part.Document)
557568
if err != nil {
558569
return nil, fmt.Errorf("failed to convert document attachment %q: %w", part.Document.Name, err)
559570
}

pkg/model/provider/bedrock/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ func (c *Client) buildConverseStreamInput(ctx context.Context, messages []chat.M
236236
enableCaching := c.promptCachingEnabled()
237237

238238
// Convert and set messages (excluding system)
239-
input.Messages, input.System = convertMessages(ctx, messages, c.ModelConfig.Provider+"/"+c.ModelConfig.Model, enableCaching)
239+
input.Messages, input.System = convertMessages(ctx, messages, c.ID(), enableCaching)
240240

241241
// Compute thinking fields first — its presence drives the inference config.
242242
additionalFields := c.buildAdditionalModelRequestFields()

pkg/model/provider/dmr/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, opts ...options.Opt
147147
// convertMessages converts chat messages to OpenAI format and merges consecutive
148148
// system/user messages, which is needed by some local models run by DMR.
149149
func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) []openai.ChatCompletionMessageParamUnion {
150-
openaiMessages := oaistream.ConvertMessages(ctx, messages, c.ModelConfig.Provider+"/"+c.ModelConfig.Model)
150+
openaiMessages := oaistream.ConvertMessages(ctx, messages, c.ID())
151151
return oaistream.MergeConsecutiveMessages(openaiMessages)
152152
}
153153

pkg/model/provider/gemini/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ func (c *Client) CreateChatCompletionStream(
601601
}
602602
}
603603

604-
contents := convertMessagesToGemini(ctx, messages, c.ModelConfig.Provider+"/"+c.ModelConfig.Model)
604+
contents := convertMessagesToGemini(ctx, messages, c.ID())
605605

606606
// Debug: Log the messages we're sending
607607
slog.DebugContext(ctx, "Gemini messages", "count", len(contents))

pkg/model/provider/oaistream/attachments_test.go

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,8 @@ func TestConvertDocument_StrategyB64_ImageDropped(t *testing.T) {
5858
// where callers passed a bare model name instead of a "provider/model" ID,
5959
// causing modelcaps to miss the model and silently drop image/PDF attachments.
6060
//
61-
// It calls convertDocumentFromStore directly with an injected fake store so
62-
// the test exercises the full modelID → LoadFromStore → caps → parts path
63-
// without hitting the network.
61+
// It calls ConvertMultiContentFromStore with an injected fake store, exercising
62+
// the same path as the production client (which calls ConvertMessages with c.ID()).
6463
func TestConvertDocument_QualifiedIDRequired(t *testing.T) {
6564
store := modelsdev.NewDatabaseStore(&modelsdev.Database{
6665
Providers: map[string]modelsdev.Provider{
@@ -76,22 +75,21 @@ func TestConvertDocument_QualifiedIDRequired(t *testing.T) {
7675
},
7776
})
7877

79-
doc := chat.Document{
80-
Name: "photo.jpg",
81-
MimeType: "image/jpeg",
82-
Source: chat.DocumentSource{InlineData: minJPEG},
83-
}
78+
msgParts := []chat.MessagePart{{
79+
Type: chat.MessagePartTypeDocument,
80+
Document: &chat.Document{
81+
Name: "photo.jpg",
82+
MimeType: "image/jpeg",
83+
Source: chat.DocumentSource{InlineData: minJPEG},
84+
},
85+
}}
8486

85-
// Bare model name (the original bug): LoadFromStore misses the model,
86-
// capabilities fall back to text-only, image must be dropped.
87-
partsBare, err := convertDocumentFromStore(t.Context(), doc, "gpt-4o", store)
88-
require.NoError(t, err)
89-
assert.Nil(t, partsBare, "bare model name must not resolve caps: image should be dropped")
87+
// Bare model name (the original bug): image must be dropped.
88+
partsBare := ConvertMultiContentFromStore(t.Context(), msgParts, "gpt-4o", store)
89+
assert.Empty(t, partsBare, "bare model name must not resolve caps: image should be dropped")
9090

91-
// Qualified ID (the fix): LoadFromStore finds the model, vision caps
92-
// are set, image must be preserved as a data-URI image part.
93-
partsQualified, err := convertDocumentFromStore(t.Context(), doc, "openai/gpt-4o", store)
94-
require.NoError(t, err)
91+
// Qualified ID (the fix, matching what c.ID() returns): image must be preserved.
92+
partsQualified := ConvertMultiContentFromStore(t.Context(), msgParts, "openai/gpt-4o", store)
9593
require.Len(t, partsQualified, 1, "qualified ID must resolve caps: image should be present")
9694
assert.NotNil(t, partsQualified[0].OfImageURL, "expected image URL part for qualified model ID")
9795
}

pkg/model/provider/oaistream/messages.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/openai/openai-go/v3/packages/param"
1515

1616
"github.com/docker/docker-agent/pkg/chat"
17+
"github.com/docker/docker-agent/pkg/modelsdev"
1718
)
1819

1920
// JSONSchema is a helper type that implements json.Marshaler for map[string]any.
@@ -30,6 +31,16 @@ func (j JSONSchema) MarshalJSON() ([]byte, error) {
3031
// modelID is used for attachment capability lookups; pass an empty string to
3132
// skip capability checks (all documents are attempted).
3233
func ConvertMultiContent(ctx context.Context, multiContent []chat.MessagePart, modelID string) []openai.ChatCompletionContentPartUnionParam {
34+
return convertMultiContentWithStore(ctx, multiContent, modelID, nil)
35+
}
36+
37+
// ConvertMultiContentFromStore is the store-injectable variant of ConvertMultiContent,
38+
// used by tests to inject a fake in-memory modelsdev.Store.
39+
func ConvertMultiContentFromStore(ctx context.Context, multiContent []chat.MessagePart, modelID string, store *modelsdev.Store) []openai.ChatCompletionContentPartUnionParam {
40+
return convertMultiContentWithStore(ctx, multiContent, modelID, store)
41+
}
42+
43+
func convertMultiContentWithStore(ctx context.Context, multiContent []chat.MessagePart, modelID string, store *modelsdev.Store) []openai.ChatCompletionContentPartUnionParam {
3344
parts := make([]openai.ChatCompletionContentPartUnionParam, 0, len(multiContent))
3445
for _, part := range multiContent {
3546
switch part.Type {
@@ -45,7 +56,13 @@ func ConvertMultiContent(ctx context.Context, multiContent []chat.MessagePart, m
4556
}
4657
case chat.MessagePartTypeDocument:
4758
if part.Document != nil {
48-
docParts, err := convertDocument(ctx, *part.Document, modelID)
59+
var docParts []openai.ChatCompletionContentPartUnionParam
60+
var err error
61+
if store != nil {
62+
docParts, err = convertDocumentFromStore(ctx, *part.Document, modelID, store)
63+
} else {
64+
docParts, err = convertDocument(ctx, *part.Document, modelID)
65+
}
4966
if err != nil {
5067
slog.WarnContext(ctx, "failed to convert document attachment", "error", err, "doc", part.Document.Name)
5168
continue
@@ -62,6 +79,16 @@ func ConvertMultiContent(ctx context.Context, multiContent []chat.MessagePart, m
6279
// modelID is forwarded to convertDocument for attachment capability lookups.
6380
// This is the base conversion without any provider-specific post-processing.
6481
func ConvertMessages(ctx context.Context, messages []chat.Message, modelID string) []openai.ChatCompletionMessageParamUnion {
82+
return convertMessagesWithStore(ctx, messages, modelID, nil)
83+
}
84+
85+
// ConvertMessagesFromStore is the store-injectable variant of ConvertMessages,
86+
// used by tests to inject a fake in-memory modelsdev.Store.
87+
func ConvertMessagesFromStore(ctx context.Context, messages []chat.Message, modelID string, store *modelsdev.Store) []openai.ChatCompletionMessageParamUnion {
88+
return convertMessagesWithStore(ctx, messages, modelID, store)
89+
}
90+
91+
func convertMessagesWithStore(ctx context.Context, messages []chat.Message, modelID string, store *modelsdev.Store) []openai.ChatCompletionMessageParamUnion {
6592
openaiMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages))
6693
for i := range messages {
6794
msg := &messages[i]
@@ -94,7 +121,7 @@ func ConvertMessages(ctx context.Context, messages []chat.Message, modelID strin
94121
if len(msg.MultiContent) == 0 {
95122
openaiMessage = openai.UserMessage(msg.Content)
96123
} else {
97-
openaiMessage = openai.UserMessage(ConvertMultiContent(ctx, msg.MultiContent, modelID))
124+
openaiMessage = openai.UserMessage(convertMultiContentWithStore(ctx, msg.MultiContent, modelID, store))
98125
}
99126

100127
case chat.MessageRoleAssistant:

pkg/model/provider/openai/client.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/docker/docker-agent/pkg/model/provider/oaistream"
2727
"github.com/docker/docker-agent/pkg/model/provider/options"
2828
"github.com/docker/docker-agent/pkg/modelinfo"
29+
"github.com/docker/docker-agent/pkg/modelsdev"
2930
"github.com/docker/docker-agent/pkg/rag/prompts"
3031
"github.com/docker/docker-agent/pkg/rag/types"
3132
"github.com/docker/docker-agent/pkg/tools"
@@ -41,6 +42,8 @@ type Client struct {
4142
// wsPool is initialized in NewClient when transport=websocket is configured.
4243
// It maintains a persistent WebSocket connection across requests.
4344
wsPool *wsPool
45+
46+
modelsStore *modelsdev.Store // nil in production (uses modelsdev.NewStore()); set in tests
4447
}
4548

4649
// NewClient creates a new OpenAI client from the provided configuration
@@ -181,7 +184,10 @@ func (c *Client) Close() {
181184
// convertMessages converts chat.Message to openai.ChatCompletionMessageParamUnion
182185
// using the shared oaistream implementation.
183186
func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) []openai.ChatCompletionMessageParamUnion {
184-
return oaistream.ConvertMessages(ctx, messages, c.ModelConfig.Provider+"/"+c.ModelConfig.Model)
187+
if c.modelsStore != nil {
188+
return oaistream.ConvertMessagesFromStore(ctx, messages, c.ID(), c.modelsStore)
189+
}
190+
return oaistream.ConvertMessages(ctx, messages, c.ID())
185191
}
186192

187193
// CreateChatCompletionStream creates a streaming chat completion request
@@ -613,7 +619,7 @@ func (c *Client) convertMessagesToResponseInput(ctx context.Context, messages []
613619
}
614620
case chat.MessagePartTypeDocument:
615621
if part.Document != nil {
616-
docParts, err := convertDocumentToResponseInput(ctx, *part.Document, c.ModelConfig.Provider+"/"+c.ModelConfig.Model)
622+
docParts, err := convertDocumentToResponseInput(ctx, *part.Document, c.ID())
617623
if err != nil {
618624
slog.WarnContext(ctx, "failed to convert document attachment", "error", err, "doc", part.Document.Name)
619625
continue

0 commit comments

Comments
 (0)