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
73 changes: 72 additions & 1 deletion docs/guides/go-sdk/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,78 @@ client, err := openai.NewClient(ctx, &latest.ModelConfig{
}, env, options.WithTokenSource(tokenSource))
```

The OpenAI client checks for a configured `TokenSource` before falling back to `token_key`, and uses it to set the `Authorization` header on both HTTP and WebSocket requests. Static API-key, ChatGPT, and gateway auth paths are unaffected. `FromModelOptions` round-trips the token source, so a cloned provider config keeps it. Vertex AI Model Garden's default constructor refreshes GCP access tokens through ADC. Its `NewClientWithTokenSource` constructor instead accepts a host-managed request-time token source; neither host credential files nor instance metadata are consulted on that path.
The OpenAI client checks for a configured `TokenSource` before falling back to `token_key`, and uses it to set the `Authorization` header on both HTTP and WebSocket requests. Static API-key, ChatGPT, and gateway auth paths are unaffected. `FromModelOptions` round-trips the token source, so a cloned provider config keeps it. Vertex AI Model Garden's default constructor refreshes GCP access tokens through ADC unless `options.WithTokenSource` is supplied. Its `NewClientWithTokenSource` constructor instead accepts a host-managed request-time token source; neither host credential files nor instance metadata are consulted on that path.

## Cloning Providers

Use the checked `provider.Clone` API when adjusting a provider for an auxiliary
request. Options are applied in order; later overrides win.

```go
summaryModel, err := provider.Clone(ctx, model,
options.WithStructuredOutput(nil),
options.WithMaxTokens(4096),
options.WithCompacting(),
)
if err != nil {
return fmt.Errorf("creating summary model: %w", err)
}
```

Providers constructed through a `provider.Registry` retain their reconstruction
function, environment and routing model references. Custom implementations must
supply `contracts.Config.RebuildProvider` (or `SetProviderRebuilder`), including
in test doubles. Missing reconstruction support returns `provider.ErrNoRebuilder`;
reconstruction errors are wrapped in `*provider.CloneError` and support
`errors.Is` / `errors.As`.

Clones isolate mutable model configuration, custom-provider definitions and
structured-output schemas. Transport wrappers, token-source callbacks and the
models.dev store remain shared services. Public option setters retain reference
semantics; isolation happens when cloning. Scalar types and exact numeric values are preserved. Values in `provider_opts`,
schemas and custom providers must be copyable scalars, maps, slices, arrays or
plain structs. Functions, channels, unsafe pointers, cyclic values and structs
with unexported reference-bearing fields fail closed with
`provider.ErrUncopyableConfig`, rather than panic or share mutable state. Copies
are bounded to 128 levels and 100,000 visited values. Cumulative clones preserve explicit structured-output
removal, gateway clearing and no-thinking overrides. A non-zero `WithMaxTokens`
cap supplied to `Clone` overrides direct, matched-route and fallback limits,
subject to existing provider token floors. Cumulative clones retain that cap
when it is omitted or zero; the last non-zero cap wins, including within one
clone call. Ordinary construction-time `WithMaxTokens(0)` reset semantics are
unchanged. Named routing definitions are
captured at construction and copied for each child, so later input-map mutations
do not change clones.

Configuration-declared thinking off (`none` or `0`, including provider defaults)
is retained as an option before normalization. Reconstruction applies that option
to each leaf's config too, so config-driven and option-driven providers agree.
Routed children inherit strict-off overrides and explicit clone caps. Ordinary
construction-time router limits still leave each child's token defaults unchanged.

`CloneWithOptions` retains its public signature but is deprecated. On failure it
returns an error-only provider whose stream creation returns the clone error. It
never performs inference on the original provider or exposes its optional
embedding, context-discovery or compaction capabilities.

| Caller | Clone failure behavior |
| --- | --- |
| Session title | Tries the next configured candidate; exhaustion does not interrupt chat. |
| MCP sampling, with or without tools | Returns the actionable clone error before inference. |
| LLM compaction | Reports failure and leaves the session history intact. |
| Compaction window resolution | The checked resolver returns zero plus the error; the LLM compaction path reports it. UI gauges remain best-effort. |

Hook-supplied summaries and provider-native compaction remain usable without a
clonable summary model. For Model Garden, catalogue identity and dispatch identity
are separate: `ModelConfig` supplies capability lookups, while
`DispatchConfig` / `CloneSource()` preserves the original Google configuration for
reconstruction. Both ADC and request-context token sources retain native Vertex
transport, project, location and model-in-URL semantics. Model Garden token-source
precedence is an explicit `NewClientWithTokenSource` argument, then a stored
`options.WithTokenSource`, then ADC. Register that source only for the intended
Google provider, not as a process-wide option for unrelated routed models.

This API change adds no YAML configuration surface or schema version.

## Using Different Providers

Expand Down
12 changes: 12 additions & 0 deletions docs/providers/google/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ Gemini SDK:
- **Other publishers** (e.g. `meta`, `mistral`) use Vertex AI's
OpenAI-compatible `/chat/completions` endpoint.

Title, sampling and compaction clones keep the selected Model Garden transport.
Claude requests remain on the Anthropic-native Vertex endpoints, with the model
in the URL and `anthropic_version: vertex-2023-10-16` in the body. Project,
location, regional/global host selection and Google OAuth authentication survive
cloning; clones never switch to the direct Claude API or send its API key.

### Authentication

Vertex AI uses Google Cloud Application Default Credentials (ADC). Make sure you
Expand All @@ -310,6 +316,12 @@ are authenticated:
gcloud auth application-default login
```

Go embedders can instead supply a request-context token source through
`vertexai.NewClientWithTokenSource` or `options.WithTokenSource`. That source is
preserved across clones and replaces ADC, including credential-file and metadata
lookups. HTTP transport wrappers are preserved on both authentication paths;
ADC still applies OAuth authorization around the wrapped transport.

### Configuration

```yaml
Expand Down
9 changes: 8 additions & 1 deletion pkg/acp/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import (
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/chat"
"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/model/provider/base"
"github.com/docker/docker-agent/pkg/model/provider/options"
"github.com/docker/docker-agent/pkg/modelsdev"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/session/sqlitestore"
Expand Down Expand Up @@ -52,7 +55,11 @@ func (m *mockProvider) CreateChatCompletionStream(context.Context, []chat.Messag
return m.stream, nil
}

func (m *mockProvider) BaseConfig() base.Config { return base.Config{} }
func (m *mockProvider) BaseConfig() base.Config {
cfg := base.Config{}
cfg.RebuildProvider = func(context.Context, *latest.ModelConfig, ...options.Opt) (provider.Provider, error) { return m, nil }
return cfg
}

func (m *mockProvider) MaxTokens() int { return 0 }

Expand Down
19 changes: 14 additions & 5 deletions pkg/model/provider/anthropic/vertex/vertex.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,15 @@ func newClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro
// OAuth2 (via the google transport in vertex.WithCredentials), so we
// must clear the stray X-Api-Key header that would otherwise leak a
// direct-API credential into Google's infrastructure.
sdkOptions := []option.RequestOption{
var sdkOptions []option.RequestOption
if source == nil {
// Before WithCredentials, OAuth wraps our transport instead of being replaced.
sdkOptions = append(sdkOptions, option.WithHTTPClient(wrappedHTTPClient(ctx, globalOptions)))
}
sdkOptions = append(sdkOptions,
sdkvertex.WithCredentials(ctx, location, project, creds),
option.WithAPIKey(""),
}
)
if source != nil {
sdkOptions = append(sdkOptions, tokenSourceOptions(ctx, globalOptions, source)...)
}
Expand Down Expand Up @@ -141,11 +146,9 @@ func credentials(ctx context.Context, source options.TokenSource) (*google.Crede
// as-is, so the SDK never overrides the Authorization header that the
// middleware sets from the request context.
func tokenSourceOptions(ctx context.Context, globalOptions options.ModelOptions, source options.TokenSource) []option.RequestOption {
httpClient := httpclient.NewHTTPClient(ctx)
globalOptions.WrapTransport(ctx, httpClient)
return []option.RequestOption{
option.WithMiddleware(bearerTokenMiddleware(source)),
option.WithHTTPClient(httpClient),
option.WithHTTPClient(wrappedHTTPClient(ctx, globalOptions)),
}
}

Expand All @@ -168,3 +171,9 @@ func bearerTokenMiddleware(source options.TokenSource) option.Middleware {
return next(req)
}
}

func wrappedHTTPClient(ctx context.Context, globalOptions options.ModelOptions) *http.Client {
client := httpclient.NewHTTPClient(ctx)
globalOptions.WrapTransport(ctx, client)
return client
}
150 changes: 118 additions & 32 deletions pkg/model/provider/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,134 @@ package provider

import (
"context"
"errors"
"fmt"
"log/slog"
"slices"

"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/internal/deepcopy"
"github.com/docker/docker-agent/pkg/model/provider/options"
"github.com/docker/docker-agent/pkg/modelsdev"
"github.com/docker/docker-agent/pkg/tools"
)

// CloneWithOptions returns a new Provider instance using the same provider/model
// as the base provider, applying the provided options. If cloning fails, the
// original base provider is returned.
func CloneWithOptions(ctx context.Context, baseProvider Provider, opts ...options.Opt) Provider {
cfg := baseProvider.BaseConfig()
modelConfig, mergedOpts := mergeCloneOptions(cfg, opts)
// ErrNoRebuilder means a provider has no construction function for cloning.
var ErrNoRebuilder = errors.New("provider cannot be cloned without its rebuild function")

// ErrUncopyableConfig means mutable configuration cannot be safely copied.
var ErrUncopyableConfig = errors.New("provider clone requires copyable configuration")

// CloneError identifies the provider whose reconstruction failed.
type CloneError struct {
ID modelsdev.ID
Err error
}

func (e *CloneError) Error() string { return fmt.Sprintf("cloning provider %q: %v", e.ID, e.Err) }
func (e *CloneError) Unwrap() error { return e.Err }

// Clone reconstructs a provider with ordered option overrides, or returns an error.
// Non-zero token caps also constrain routed children, subject to provider floors.
// The last non-zero cap wins; zero retains the latest explicit cap.
func Clone(ctx context.Context, p Provider, opts ...options.Opt) (Provider, error) {
clone, _, err := cloneProvider(ctx, p, opts)
return clone, err
}

// CloneWithOptions reconstructs a provider, returning an error-only provider on failure.
//
// Deprecated: use Clone to handle reconstruction errors before making a request.
func CloneWithOptions(ctx context.Context, p Provider, opts ...options.Opt) Provider {
clone, requested, err := cloneProvider(ctx, p, opts)
if err == nil {
return clone
}
slog.WarnContext(ctx, "Failed to clone provider", "error", err)
var id modelsdev.ID
if p != nil {
id = p.ID()
}
return &failedCloneProvider{config: requested, id: id, err: err}
}

func cloneProvider(ctx context.Context, p Provider, opts []options.Opt) (Provider, base.Config, error) {
if p == nil {
return nil, base.Config{}, errors.New("cannot clone a nil provider")
}
cfg := p.BaseConfig()
modelConfig, mergedOpts, err := mergeCloneOptions(cfg, opts)
if err != nil {
return nil, base.Config{}, &CloneError{ID: p.ID(), Err: err}
}
requested := base.Config{ModelConfig: modelConfig, ModelOptions: options.Apply(mergedOpts...), Env: cfg.Env, BaseURL: cfg.BaseURL}
if cfg.RebuildProvider == nil {
slog.WarnContext(ctx, "Provider cannot be cloned without its rebuild function; using base provider", "id", baseProvider.ID())
return baseProvider
return nil, requested, &CloneError{ID: p.ID(), Err: ErrNoRebuilder}
}
clone, err := cfg.RebuildProvider(ctx, &modelConfig, mergedOpts...)
if err == nil && clone == nil {
err = errors.New("rebuild returned a nil provider")
}
if err != nil {
slog.WarnContext(ctx, "Failed to clone provider; using base provider", "error", err, "id", baseProvider.ID())
return baseProvider
return nil, requested, &CloneError{ID: p.ID(), Err: err}
}
return clone, requested, nil
}

return clone
type failedCloneProvider struct {
config base.Config
id modelsdev.ID
err error
}

// mergeCloneOptions is the pure half of CloneWithOptions. Given the base
// provider's configuration and the user-supplied overrides, it returns:
//
// - a copy of the base ModelConfig with explicit overrides applied (currently
// MaxTokens and the no-thinking flag), and
// - the full ordered slice of options that should be passed to NewWithModels
// (existing options first, then user overrides; later opts win).
//
// Splitting this out from the impure NewWithModels call lets us table-test the
// option-merging logic without spinning up an HTTP server.
func mergeCloneOptions(cfg base.Config, opts []options.Opt) (latest.ModelConfig, []options.Opt) {
func (p *failedCloneProvider) ID() modelsdev.ID { return p.id }
func (p *failedCloneProvider) BaseConfig() base.Config { return p.config }
func (p *failedCloneProvider) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) {
return nil, p.err
}

// mergeCloneOptions snapshots mutable state before reconstructing a provider.
func mergeCloneOptions(cfg base.Config, opts []options.Opt) (latest.ModelConfig, []options.Opt, error) {
// Preserve existing options, then apply overrides. Later opts take precedence.
baseOpts := options.FromModelOptions(cfg.ModelOptions)
mergedOpts := append(baseOpts, opts...)

// Apply every option to a single accumulator so we can read the final
// effective values directly. "Later opt wins" semantics fall out naturally.
var merged options.ModelOptions
for _, opt := range mergedOpts {
opt(&merged)
baseOptions, err := cfg.ModelOptions.Clone()
if err != nil {
return latest.ModelConfig{}, nil, fmt.Errorf("%w: %w", ErrUncopyableConfig, err)
}
baseOpts := options.FromModelOptions(baseOptions)
mergedOpts := slices.Concat(baseOpts, opts)
var requested options.ModelOptions
var explicitCap int64
for _, opt := range opts {
if opt == nil {
continue
}
opt(&requested)
if mt := requested.MaxTokens(); mt != 0 {
explicitCap = mt
}
}
if explicitCap != 0 {
mergedOpts = append(mergedOpts, options.WithAuxiliaryMaxTokens(explicitCap))
} else if baseOptions.MaxTokensInheritable() && baseOptions.MaxTokens() != 0 {
mergedOpts = append(mergedOpts, options.WithAuxiliaryMaxTokens(baseOptions.MaxTokens()))
}

// Snapshot overrides too: option setters can carry caller-owned maps.
merged := options.Apply(mergedOpts...)
merged, err = merged.Clone()
if err != nil {
return latest.ModelConfig{}, nil, fmt.Errorf("%w: %w", ErrUncopyableConfig, err)
}
mergedOpts = options.FromModelOptions(merged)

source := cfg.CloneSource()
modelConfig, err := copyModelConfig(source)
if err != nil {
return latest.ModelConfig{}, nil, err
}

modelConfig := cfg.ModelConfig
if mt := merged.MaxTokens(); mt != 0 {
modelConfig.MaxTokens = &mt
}
Expand All @@ -61,5 +139,13 @@ func mergeCloneOptions(cfg base.Config, opts []options.Opt) (latest.ModelConfig,
// thinking_budget via its setIfNil merge.
modelConfig.ThinkingBudget = &latest.ThinkingBudget{Effort: "none"}
}
return modelConfig, mergedOpts
return modelConfig, mergedOpts, nil
}

func copyModelConfig(source latest.ModelConfig) (latest.ModelConfig, error) {
copied, err := deepcopy.Value(source)
if err != nil {
return latest.ModelConfig{}, fmt.Errorf("%w: copying model configuration: %w", ErrUncopyableConfig, err)
}
return copied, nil
}
Loading
Loading