Skip to content

Commit 1d7328e

Browse files
committed
refactor(provider): split CloneWithOptions into a pure mergeCloneOptions helper
CloneWithOptions mixed two responsibilities: (1) merge base options with caller-supplied overrides into a (ModelConfig, []options.Opt) pair, and (2) actually call NewWithModels and gracefully fall back if it errors. The pure half was untestable in isolation, and two branches were uncovered: - the WithNoThinking() override that nils out ThinkingBudget, - the failure-fallback that returns the base provider unchanged. Extract the merging logic into a pure mergeCloneOptions(base.Config, []Opt) function. Tests can now table-drive every override path (no-overrides, MaxTokens override, NoThinking override, base-options preservation, later-wins precedence) without standing up an httptest.Server, and the fallback path is exercised with a fakeProvider whose zero-valued Config naturally fails the registry lookup. Behaviour is unchanged. CloneWithOptions and mergeCloneOptions both reach 100% coverage; package coverage 93.9% -> 95.8%. Assisted-By: docker-agent
1 parent 8adec31 commit 1d7328e

2 files changed

Lines changed: 158 additions & 13 deletions

File tree

pkg/model/provider/clone.go

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,48 @@ import (
44
"context"
55
"log/slog"
66

7+
"github.com/docker/docker-agent/pkg/config/latest"
8+
"github.com/docker/docker-agent/pkg/model/provider/base"
79
"github.com/docker/docker-agent/pkg/model/provider/options"
810
)
911

1012
// CloneWithOptions returns a new Provider instance using the same provider/model
1113
// as the base provider, applying the provided options. If cloning fails, the
1214
// original base provider is returned.
13-
func CloneWithOptions(ctx context.Context, base Provider, opts ...options.Opt) Provider {
14-
config := base.BaseConfig()
15+
func CloneWithOptions(ctx context.Context, baseProvider Provider, opts ...options.Opt) Provider {
16+
cfg := baseProvider.BaseConfig()
17+
modelConfig, mergedOpts := mergeCloneOptions(cfg, opts)
1518

19+
// Use NewWithModels to support cloning routers that reference other models.
20+
// cfg.Models is populated by routers; for other providers it's nil (which is fine).
21+
clone, err := NewWithModels(ctx, &modelConfig, cfg.Models, cfg.Env, mergedOpts...)
22+
if err != nil {
23+
slog.Debug("Failed to clone provider; using base provider", "error", err, "id", baseProvider.ID())
24+
return baseProvider
25+
}
26+
27+
return clone
28+
}
29+
30+
// mergeCloneOptions is the pure half of CloneWithOptions. Given the base
31+
// provider's configuration and the user-supplied overrides, it returns:
32+
//
33+
// - a copy of the base ModelConfig with explicit overrides applied (currently
34+
// MaxTokens and the no-thinking flag), and
35+
// - the full ordered slice of options that should be passed to NewWithModels
36+
// (existing options first, then user overrides; later opts win).
37+
//
38+
// Splitting this out from the impure NewWithModels call lets us table-test the
39+
// option-merging logic without spinning up an HTTP server.
40+
func mergeCloneOptions(cfg base.Config, opts []options.Opt) (latest.ModelConfig, []options.Opt) {
1641
// Preserve existing options, then apply overrides. Later opts take precedence.
17-
baseOpts := options.FromModelOptions(config.ModelOptions)
42+
baseOpts := options.FromModelOptions(cfg.ModelOptions)
1843
mergedOpts := append(baseOpts, opts...)
1944

2045
// Apply max_tokens override if present in options
2146
// We need to apply it to the ModelConfig itself since that's what providers use
2247
// Only update MaxTokens if an option explicitly sets it (non-zero value)
23-
modelConfig := config.ModelConfig
48+
modelConfig := cfg.ModelConfig
2449
for _, opt := range mergedOpts {
2550
tempOpts := &options.ModelOptions{}
2651
opt(tempOpts)
@@ -32,13 +57,5 @@ func CloneWithOptions(ctx context.Context, base Provider, opts ...options.Opt) P
3257
}
3358
}
3459

35-
// Use NewWithModels to support cloning routers that reference other models.
36-
// config.Models is populated by routers; for other providers it's nil (which is fine).
37-
clone, err := NewWithModels(ctx, &modelConfig, config.Models, config.Env, mergedOpts...)
38-
if err != nil {
39-
slog.Debug("Failed to clone provider; using base provider", "error", err, "id", base.ID())
40-
return base
41-
}
42-
43-
return clone
60+
return modelConfig, mergedOpts
4461
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package provider
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/docker/docker-agent/pkg/config/latest"
10+
"github.com/docker/docker-agent/pkg/model/provider/base"
11+
"github.com/docker/docker-agent/pkg/model/provider/options"
12+
)
13+
14+
func TestMergeCloneOptions_NoOverrides(t *testing.T) {
15+
t.Parallel()
16+
17+
originalMax := int64(8192)
18+
cfg := base.Config{
19+
ModelConfig: latest.ModelConfig{
20+
Provider: "openai",
21+
Model: "gpt-4o",
22+
MaxTokens: &originalMax,
23+
ThinkingBudget: &latest.ThinkingBudget{
24+
Effort: "high",
25+
},
26+
},
27+
}
28+
29+
got, mergedOpts := mergeCloneOptions(cfg, nil)
30+
31+
require.NotNil(t, got.MaxTokens)
32+
assert.Equal(t, originalMax, *got.MaxTokens, "MaxTokens should be untouched when no overrides")
33+
require.NotNil(t, got.ThinkingBudget)
34+
assert.Equal(t, "high", got.ThinkingBudget.Effort, "ThinkingBudget should be preserved")
35+
assert.Empty(t, mergedOpts, "no base opts and no overrides -> empty merged slice")
36+
}
37+
38+
func TestMergeCloneOptions_MaxTokensOverride(t *testing.T) {
39+
t.Parallel()
40+
41+
originalMax := int64(8192)
42+
cfg := base.Config{
43+
ModelConfig: latest.ModelConfig{MaxTokens: &originalMax},
44+
}
45+
46+
newMax := int64(2048)
47+
got, mergedOpts := mergeCloneOptions(cfg, []options.Opt{options.WithMaxTokens(newMax)})
48+
49+
require.NotNil(t, got.MaxTokens)
50+
assert.Equal(t, newMax, *got.MaxTokens, "MaxTokens should follow explicit override")
51+
assert.Len(t, mergedOpts, 1, "user-supplied option should appear in mergedOpts")
52+
}
53+
54+
// TestMergeCloneOptions_NoThinking covers the previously-uncovered branch in
55+
// the option-merge loop that nils out ThinkingBudget when WithNoThinking is set.
56+
func TestMergeCloneOptions_NoThinking(t *testing.T) {
57+
t.Parallel()
58+
59+
cfg := base.Config{
60+
ModelConfig: latest.ModelConfig{
61+
Provider: "openai",
62+
Model: "o3-mini",
63+
ThinkingBudget: &latest.ThinkingBudget{Effort: "medium"},
64+
},
65+
}
66+
67+
got, _ := mergeCloneOptions(cfg, []options.Opt{options.WithNoThinking()})
68+
69+
assert.Nil(t, got.ThinkingBudget, "WithNoThinking must clear ThinkingBudget")
70+
}
71+
72+
func TestMergeCloneOptions_PreservesBaseOptions(t *testing.T) {
73+
t.Parallel()
74+
75+
// ModelOptions on the base config should be reconstituted as Opts and
76+
// included in the merged slice ahead of user-supplied overrides.
77+
var baseOpts options.ModelOptions
78+
options.WithGateway("https://gw.example.com")(&baseOpts)
79+
options.WithGeneratingTitle()(&baseOpts)
80+
81+
cfg := base.Config{
82+
ModelOptions: baseOpts,
83+
ModelConfig: latest.ModelConfig{Provider: "openai", Model: "gpt-4o"},
84+
}
85+
86+
_, mergedOpts := mergeCloneOptions(cfg, []options.Opt{options.WithMaxTokens(1024)})
87+
88+
// Replay the merged opts and check all three flags survived.
89+
var probe options.ModelOptions
90+
for _, opt := range mergedOpts {
91+
opt(&probe)
92+
}
93+
assert.Equal(t, "https://gw.example.com", probe.Gateway())
94+
assert.True(t, probe.GeneratingTitle())
95+
assert.Equal(t, int64(1024), probe.MaxTokens())
96+
}
97+
98+
// TestMergeCloneOptions_LaterOverridesWin covers the documented "later opts
99+
// take precedence" contract: a user-supplied MaxTokens overrides the base one.
100+
func TestMergeCloneOptions_LaterOverridesWin(t *testing.T) {
101+
t.Parallel()
102+
103+
var baseOpts options.ModelOptions
104+
options.WithMaxTokens(int64(512))(&baseOpts)
105+
106+
cfg := base.Config{
107+
ModelOptions: baseOpts,
108+
ModelConfig: latest.ModelConfig{Provider: "openai", Model: "gpt-4o"},
109+
}
110+
111+
got, _ := mergeCloneOptions(cfg, []options.Opt{options.WithMaxTokens(int64(4096))})
112+
113+
require.NotNil(t, got.MaxTokens)
114+
assert.Equal(t, int64(4096), *got.MaxTokens, "later opt must override earlier opt")
115+
}
116+
117+
// TestCloneWithOptions_FallbackOnError verifies the previously-uncovered
118+
// fallback path: when NewWithModels fails, CloneWithOptions returns the
119+
// original base provider unchanged.
120+
func TestCloneWithOptions_FallbackOnError(t *testing.T) {
121+
// fakeProvider returns a zero-valued base.Config, so its Provider type is
122+
// empty; that always fails the factory-registry lookup in createDirectProvider.
123+
original := &fakeProvider{id: "original"}
124+
125+
got := CloneWithOptions(t.Context(), original, options.WithMaxTokens(int64(2048)))
126+
127+
assert.Same(t, original, got, "should fall back to base provider when cloning fails")
128+
}

0 commit comments

Comments
 (0)