Files
docker-agent/pkg/modelinfo/modelinfo.go
T
2026-10-28 22:03:27 +01:00

864 lines
33 KiB
Go

// Package modelinfo centralizes every model-specific behavior decision and
// capability query used by docker-agent's provider clients.
//
// Some providers must specialize their behavior depending on the underlying
// model: pick OpenAI's Responses API for o-series and gpt-5, switch Claude
// Opus 4.6/4.7/4.8/5 to adaptive thinking, use level-based thinking for Gemini 3+,
// auto-enable interleaved thinking for any Claude model regardless of host
// (Anthropic, Bedrock, Vertex AI Model Garden), decide which attachment MIME
// types can be forwarded natively, and so on.
//
// Rather than scattering name-pattern checks across the codebase, every such
// predicate lives here, with a name that describes the *capability* (not the
// version) and a doc comment that explains *why* the behavior is needed.
//
// # Two layers
//
// - "Is*" predicates take a bare model identifier and use stable name
// patterns. They are zero-allocation and safe to call on the request hot
// path.
// - Lookup helpers (LookupFamily, IsClaudeFamily, [LoadCaps], ...) use
// the models.dev database via a [modelsdev.Store] when richer information
// is needed (e.g. detecting Claude across providers, determining
// attachment MIME-type support). They are intended for config-resolution
// paths, not per-request paths.
//
// # Adding a new model
//
// New members of an existing family inherit behavior automatically as long as
// their model identifiers follow the family's naming convention; in that case
// no code change is needed. New behavior categories belong in this package,
// expressed as a capability-named predicate with a comment that explains the
// underlying API rule.
package modelinfo
import (
"context"
"log/slog"
"strings"
"sync"
"time"
"github.com/docker/docker-agent/pkg/model/provider/catalog"
"github.com/docker/docker-agent/pkg/modelsdev"
)
// SupportsResponsesAPI reports whether an OpenAI model should be served via
// the Responses API rather than the legacy Chat Completions API.
//
// The Responses API is the forward path for newer OpenAI models: gpt-4.1,
// the o-series (o1/o3/o4), gpt-5 and later generations (gpt-6+, matched via
// [gptGeneration] so a naming convention rather than a per-id list picks up
// new generations), and Codex variants. Older models stay on Chat
// Completions for compatibility.
func SupportsResponsesAPI(modelID string) bool {
m := normalizeOpenAI(modelID)
switch {
case strings.HasPrefix(m, "gpt-4.1"),
strings.HasPrefix(m, "gpt-5"),
strings.HasPrefix(m, "codex"),
strings.Contains(m, "-codex"):
return true
}
if maj, _, ok := gptGeneration(m); ok && maj >= 6 {
return true
}
return isOSeries(m)
}
// SupportsDeferredTools reports whether a first-party model supports loading
// tool definitions at a point in the transcript without invalidating its cache.
func SupportsDeferredTools(provider, modelID string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
switch provider {
case "openai", "chatgpt":
m := normalizeOpenAI(modelID)
// gpt-6 is the next generation of the gpt-5.6 trio, which already
// supports deferred tool loading; every future generation inherits
// the capability without a per-id allow-list entry.
if maj, _, ok := gptGeneration(m); ok && maj >= 6 {
return true
}
switch m {
case "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra":
return true
case "gpt-5.4-pro":
return provider == "openai"
default:
return false
}
case "anthropic":
m := normalize(modelID)
if strings.Contains(m, "haiku") {
return false
}
if strings.Contains(m, "fable") {
return true
}
major, minor, ok := claudeOpusSonnetVersion(m)
return ok && (major > 4 || major == 4 && minor >= 5)
default:
return false
}
}
// SupportsHostedToolSearch reports whether provider/model is verified to run
// the Responses API server-executed tool_search tool. Only OpenAI's own API
// qualifies: the ChatGPT backend and OpenAI-compatible endpoints are not.
func SupportsHostedToolSearch(provider, modelID string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider != "openai" {
return false
}
m := normalizeOpenAI(modelID)
// Named models may resolve to dated snapshots before provider construction.
if len(m) > len("-2006-01-02") {
cut := len(m) - len("2006-01-02")
if m[cut-1] == '-' {
if _, err := time.Parse(time.DateOnly, m[cut:]); err == nil {
m = m[:cut-1]
}
}
}
return m == "gpt-5.6" || SupportsDeferredTools(provider, m)
}
// UsesReasoningEffort reports whether an OpenAI model accepts the
// `reasoning.effort` API parameter.
//
// All reasoning-capable OpenAI models do (gpt-5, gpt-6+, the o-series),
// except the gpt-5-chat variants which are non-reasoning chat models at the
// API level. This exclusion is NOT (yet) generation-agnostic: a future
// "gpt-6-chat"-style non-reasoning id would need its own prefix check added
// here, since the maj >= 6 branch below returns true unconditionally.
func UsesReasoningEffort(modelID string) bool {
m := normalizeOpenAI(modelID)
if strings.HasPrefix(m, "gpt-5-chat") {
return false
}
if maj, _, ok := gptGeneration(m); ok && maj >= 6 {
return true
}
return isOSeries(m) || strings.HasPrefix(m, "gpt-5")
}
// AlwaysReasons reports whether an OpenAI model always reasons internally
// and therefore needs a default thinking_budget when none is configured.
//
// The o1/o3/o4 reasoning families cannot operate without thinking; they are
// seeded with reasoning_effort=medium when no thinking_budget is supplied.
// gpt-5 is excluded: it can produce visible output without reasoning, so the
// default depends on the user's intent.
func AlwaysReasons(modelID string) bool {
return isOSeries(normalizeOpenAI(modelID))
}
// adaptiveOnlyOpusPrefixes lists the bare Claude Opus model families that
// reject token-based thinking ([RejectsTokenThinking]) and instead require
// adaptive thinking. This is an API behavior quirk that models.dev does not
// describe, so it stays hard-coded here (unlike context windows, which come
// from the catalogue).
var adaptiveOnlyOpusPrefixes = []string{"claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-5"}
// isAdaptiveOnlyOpus reports whether modelID names a Claude Opus model that
// rejects token-based thinking (e.g. claude-opus-4-6, claude-opus-5, or a
// dated variant like claude-opus-4-7-20251101). Bedrock-style identifiers
// such as "global.anthropic.claude-opus-5" are recognised by stripping the
// inference-profile prefix first.
func isAdaptiveOnlyOpus(modelID string) bool {
m := normalize(modelID)
if bare, ok := bedrockClaudeModelName(m); ok {
m = bare
}
for _, prefix := range adaptiveOnlyOpusPrefixes {
if m == prefix || strings.HasPrefix(m, prefix+"-") {
return true
}
}
return false
}
// RejectsTokenThinking reports whether an Anthropic Claude model rejects
// `thinking.type=enabled` (token-based extended thinking) and instead requires
// `thinking.type=adaptive`.
//
// Applies to every model in the Claude 5.x table ([LookupClaude5]) and to
// Claude Opus 4.6, 4.7, 4.8, 5, and their dated variants (e.g.
// claude-opus-4-7-20251101, claude-opus-5-20260724). Bedrock-style identifiers
// such as "global.anthropic.claude-opus-5" are recognised too.
// For these models the agent transparently switches a token-based budget to
// adaptive thinking.
//
// See https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking
func RejectsTokenThinking(modelID string) bool {
if _, ok := LookupClaude5(modelID); ok {
return true
}
return isAdaptiveOnlyOpus(modelID)
}
// SupportsAdaptiveThinking reports whether an Anthropic Claude model accepts
// adaptive extended thinking (`thinking.type=adaptive`) together with the
// `output_config.effort` parameter.
//
// Adaptive thinking and effort levels arrived with the Claude 4.6 generation.
// Earlier models (Sonnet 4.5 and older, Haiku 4.5, Opus 4.5 and older, and all
// Claude 3.x) reject `thinking.type=adaptive`/`output_config.effort` with a 400
// and must use token-based extended thinking (`thinking.type=enabled`) instead.
//
// Supported: every model in the Claude 5.x table ([LookupClaude5]), and by
// name heuristics Opus 4.6/4.7/4.8/5 (which additionally reject token budgets, see
// [RejectsTokenThinking]), Sonnet 4.6, the Claude 5 families (e.g. Sonnet 5,
// Opus 5), and the codenamed frontier models (Fable, Mythos). Bedrock-style
// identifiers such as "global.anthropic.claude-sonnet-4-6" are recognised too.
//
// The set is a superset of [RejectsTokenThinking]: a model that rejects token
// budgets must accept adaptive thinking.
//
// See https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking
func SupportsAdaptiveThinking(modelID string) bool {
if _, ok := LookupClaude5(modelID); ok {
return true
}
m := normalize(modelID)
if bare, ok := bedrockClaudeModelName(m); ok {
m = bare
}
// Codenamed frontier models ship with adaptive thinking.
if strings.Contains(m, "fable") || strings.Contains(m, "mythos") {
return true
}
// Only Opus and Sonnet gained adaptive thinking; Haiku, Claude 3.x, and
// non-Claude models do not parse and fall through to false.
major, minor, ok := claudeOpusSonnetVersion(m)
if !ok {
return false
}
// Claude 5+ (Sonnet 5, ...) always support it; within the 4.x line only
// 4.6 and later do.
return major >= 5 || (major == 4 && minor >= 6)
}
// SupportsFullThinkingDisplay reports whether an Anthropic Claude model
// accepts `thinking.display: "display"` (full thinking blocks).
//
// The restriction arrived with the adaptive-thinking generation: models from
// Claude 4.6 onward (Opus/Sonnet 4.6+, the Claude 5 families, Fable, Mythos)
// only accept "summarized" and "omitted" and reject "display" with a 400.
// Older token-thinking models (Sonnet 4.5, Haiku 4.5, ...) still accept it.
func SupportsFullThinkingDisplay(modelID string) bool {
return !SupportsAdaptiveThinking(modelID)
}
// claudeOpusSonnetVersion extracts the major and minor version of a normalized
// bare Opus/Sonnet id such as "claude-opus-4-6", "claude-sonnet-4.5", or
// "claude-sonnet-5". It reports ok=false for other families (Haiku, Claude 3.x
// like "claude-3-opus-20240229", non-Claude), so only Opus/Sonnet parse.
//
// The major must be one or two digits; a longer run is a date stamp, not a
// version, which excludes Claude 3.x ids where the family precedes the number
// ("claude-3-opus-..."). A minor after '-' or '.' is likewise capped at two
// digits so date-stamped 4.0 ids ("claude-opus-4-20250514") yield minor 0.
func claudeOpusSonnetVersion(m string) (major, minor int, ok bool) {
for _, fam := range []string{"opus", "sonnet"} {
_, rest, found := strings.Cut(m, fam+"-")
if !found {
continue
}
maj, w := leadingInt(rest)
if w == 0 || w > 2 {
return 0, 0, false
}
rest = rest[w:]
if rest != "" && (rest[0] == '-' || rest[0] == '.') {
if n, mw := leadingInt(rest[1:]); mw > 0 && mw <= 2 {
minor = n
}
}
return maj, minor, true
}
return 0, 0, false
}
// gptGeneration extracts the major and minor version of a normalized OpenAI
// GPT model id of the form "gpt-<major>[.<minor>][-suffix]", such as
// "gpt-6-astra", "gpt-6.1-foo", or "gpt-5.6-terra". A bare major with no dot
// ("gpt-6") yields minor 0. It reports ok=false for anything that isn't a
// GPT-5-or-later id: pre-gpt-5 families ("gpt-4.1"), non-GPT ids, and
// malformed/date-shaped runs (a major or minor wider than two digits, or a
// suffix glued on without a '-' boundary, e.g. "gpt-5.20260709" or
// "gpt-6foo").
//
// This is the family-agnostic sibling of [gptFiveMinor] in
// thinking_levels.go: callers that need to compare across GPT generations
// ("is this gpt-5.6 or later, including gpt-6+") use [atLeastGPT] built on
// top of this, while gptFiveMinor stays the narrower gpt-5.x-only gate whose
// callers and tests predate the gpt-6 family and must keep rejecting a bare
// "gpt-5" (no minor) exactly as before.
func gptGeneration(modelID string) (major, minor int, ok bool) {
m := normalizeOpenAI(modelID)
rest, found := strings.CutPrefix(m, "gpt-")
if !found {
return 0, 0, false
}
maj, w := leadingInt(rest)
if w == 0 || w > 2 || maj < 5 {
return 0, 0, false
}
rest = rest[w:]
switch {
case rest == "", rest[0] == '-':
return maj, 0, true
case rest[0] != '.':
return 0, 0, false
}
mn, mw := leadingInt(rest[1:])
if mw == 0 || mw > 2 {
return 0, 0, false
}
if tail := rest[1+mw:]; tail != "" && tail[0] != '-' {
return 0, 0, false
}
return maj, mn, true
}
// atLeastGPT reports whether modelID names an OpenAI GPT model at or above
// the given generation (major, minor), e.g. atLeastGPT(id, 5, 6) is the
// gpt-5.6-or-later threshold shared by several capability checks. It uses
// [gptGeneration], so every later GPT generation (gpt-6, gpt-7, ...)
// automatically satisfies any gpt-5.x-or-later threshold without a code
// change; ok=false (non-GPT-5+ ids) reports false.
func atLeastGPT(modelID string, major, minor int) bool {
maj, mn, ok := gptGeneration(modelID)
if !ok {
return false
}
return maj > major || (maj == major && mn >= minor)
}
// UsesThinkingLevel reports whether a Google Gemini model uses level-based
// thinking configuration (`thinkingLevel`) rather than token-based budgets.
//
// Gemini 3+ models always reason and only accept ThinkingLevel; older
// Gemini 2.5 models accept the legacy ThinkingBudget tokens.
//
// Matches both "gemini-3-<family>" and "gemini-3.X-<family>" patterns.
func UsesThinkingLevel(modelID string) bool {
m := normalize(modelID)
rest, found := strings.CutPrefix(m, "gemini-3")
if !found {
return false
}
if rest == "" {
return false
}
switch rest[0] {
case '-':
return true
case '.':
return strings.Contains(rest, "-")
}
return false
}
// IsBedrockClaudeID reports whether a model identifier looks like an Anthropic
// Claude model on AWS Bedrock.
//
// Bedrock model IDs are prefixed with "anthropic." or with a regional
// inference profile such as "global.anthropic." or "us.anthropic.".
//
// Prefer [IsClaude] for cross-provider checks: this helper exists so callers
// in the Bedrock path can avoid touching the models.dev store.
func IsBedrockClaudeID(modelID string) bool {
_, ok := bedrockClaudeModelName(normalize(modelID))
return ok
}
// bedrockClaudeModelName returns the bare Claude model name for a Bedrock-style
// identifier ("anthropic.claude-...", optionally preceded by a single regional
// inference profile such as "global." or "us."). The input must already be
// normalized. Returns ("", false) for non-Bedrock IDs; ARN-style identifiers
// are not handled.
func bedrockClaudeModelName(m string) (string, bool) {
if bare, ok := strings.CutPrefix(m, "anthropic."); ok && strings.HasPrefix(bare, "claude-") {
return bare, true
}
// Strip a single regional prefix (us., eu., apac., global., ...).
if i := strings.IndexByte(m, '.'); i > 0 {
if bare, ok := strings.CutPrefix(m[i+1:], "anthropic."); ok && strings.HasPrefix(bare, "claude-") {
return bare, true
}
}
return "", false
}
// IsClaude reports whether a model belongs to the Claude family, regardless
// of provider (Anthropic, AWS Bedrock, GCP Vertex AI Model Garden, ...).
//
// Resolution order:
// 1. The models.dev database, when [store] is non-nil and the model is
// registered: the family is checked against [IsClaudeFamily].
// 2. Provider-specific name patterns (Bedrock-style IDs).
// 3. A bare "claude-" prefix on the model name.
//
// Pass a nil store to skip the models.dev lookup entirely (the name-pattern
// fallback still works, which is fine for the common case).
func IsClaude(ctx context.Context, store *modelsdev.Store, id modelsdev.ID) bool {
if family := LookupFamily(ctx, store, id); family != "" {
return IsClaudeFamily(family)
}
if IsBedrockClaudeID(id.Model) {
return true
}
return strings.HasPrefix(normalize(id.Model), "claude-")
}
// LookupFamily returns the canonical model family identifier from models.dev
// (e.g. "claude-opus", "claude-sonnet", "gemini-pro", "o", "o-mini", "gpt").
//
// Returns "" when the store is nil, the id is incomplete, or the
// model is not registered in the database. Callers that want a non-empty
// answer for unknown models should fall back to a name-pattern heuristic.
func LookupFamily(ctx context.Context, store *modelsdev.Store, id modelsdev.ID) string {
if store == nil || !id.IsValid() {
return ""
}
m, err := store.GetModel(ctx, id)
if err != nil || m == nil {
return ""
}
return m.Family
}
// IsClaudeFamily reports whether a models.dev family identifier corresponds
// to one of the Claude families (claude-opus, claude-sonnet, claude-haiku,
// claude-instant, ...). Returns false for the empty string.
func IsClaudeFamily(family string) bool {
return strings.HasPrefix(family, "claude-")
}
// openAIQualifierPrefix is the gateway convention (Vercel AI Gateway,
// OpenRouter, ...) for vendor-qualifying a model id, e.g.
// "openai/gpt-5.6-sol". [normalizeOpenAI] strips it so OpenAI-specific
// name-pattern predicates recognize the underlying OpenAI model regardless of
// which gateway fronts it. Other vendor qualifiers (DMR's "ai/qwen3",
// OpenRouter's "meta-llama/...") are left untouched: they name the model
// itself, not a wrapper around an OpenAI id.
const openAIQualifierPrefix = "openai/"
// normalize returns the lowercased, whitespace-trimmed model identifier used
// by every name-pattern predicate in this package. Provider-qualified ids as
// used by gateways and aggregators ("openai/gpt-5-nano" on OpenRouter,
// "openrouter/anthropic/claude-sonnet-4-6" in custom configs) are reduced to
// their last path segment: the predicates match on the bare model name, and
// without this a prefixed id would silently disable model-specific behavior
// (e.g. a thinking_budget on "anthropic/claude-opus-4.6" was never recognized
// as adaptive-thinking-capable).
//
// The one exception is a trailing "openai/<model>" pair: it is an explicit
// vendor qualifier (see [openAIQualifierPrefix]), so it is preserved rather
// than stripped down to <model>. Stripping it unconditionally would let a
// qualified id naming a *different* vendor's model behind an OpenAI-labeled
// gateway slug — "openai/claude-opus-4-7", "vercel/openai/gemini-3-pro" — fall
// through to the bare "claude-"/"gemini-" prefix checks and be misclassified.
// OpenAI-specific predicates still get the bare name: they call
// [normalizeOpenAI], which strips that preserved "openai/" pair afterwards.
func normalize(modelID string) string {
m := strings.ToLower(strings.TrimSpace(modelID))
prefix, last, ok := strings.CutLast(m, "/")
if !ok {
return m
}
prevSeg := prefix
if _, seg, found := strings.CutLast(prefix, "/"); found {
prevSeg = seg
}
if prevSeg == "openai" {
return openAIQualifierPrefix + last
}
return last
}
// normalizeOpenAI is [normalize] plus stripping of a leading "openai/" gateway
// qualifier (see [openAIQualifierPrefix]). OpenAI-specific name-pattern
// predicates (Responses API routing, reasoning-effort detection, gpt-5.x
// minor-version parsing) call this so a gateway-qualified id — Vercel AI
// Gateway's "openai/gpt-5.6-sol", OpenRouter's equivalent, ... — is recognized
// as the underlying OpenAI model regardless of which gateway fronts it.
// Non-OpenAI predicates (Claude, Gemini, ...) must keep calling [normalize]
// instead, or a qualified id naming a *different* vendor's model would be
// misclassified once the prefix is removed.
func normalizeOpenAI(modelID string) string {
return stripOpenAIQualifier(normalize(modelID))
}
// stripOpenAIQualifier removes a leading "openai/" gateway qualifier from an
// already-[normalize]d model id, if present.
func stripOpenAIQualifier(m string) string {
if rest, ok := strings.CutPrefix(m, openAIQualifierPrefix); ok {
return rest
}
return m
}
// HasOpenAIQualifier reports whether modelID is explicitly vendor-qualified
// with an "openai/" prefix, the convention gateways (Vercel AI Gateway,
// OpenRouter, ...) use to route a specific vendor's model through a shared
// endpoint (e.g. "openai/gpt-5.6-sol"). It is the provider-agnostic signal
// that a model is genuinely OpenAI's, independent of which alias/gateway
// provider fronts it; [github.com/docker/docker-agent/pkg/model/provider]
// uses it to gate OpenAI-only behavior (like gpt-5.6's real "none" reasoning
// effort) onto the right providers.
func HasOpenAIQualifier(modelID string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), openAIQualifierPrefix)
}
// IsOpenAIVendor reports whether a (provider, modelID) pair genuinely names an
// OpenAI vendor endpoint, as opposed to a third-party provider that merely
// speaks the OpenAI-compatible wire protocol (xai, mistral, and other
// OpenAI-compatible aliases whose models are NOT OpenAI's own).
//
// True for:
// - the direct "openai" provider, "chatgpt" (the ChatGPT/Codex backend), and
// "azure" (Azure OpenAI Service hosts OpenAI's own models)
// - any provider when modelID is explicitly vendor-qualified with "openai/"
// (the convention gateways such as Vercel AI Gateway or OpenRouter use to
// route a specific vendor's model through a shared endpoint, e.g.
// "openai/gpt-5.6-sol"), regardless of which provider/alias fronts it
//
// This is the shared, import-cycle-free core of the vendor check, used to
// gate OpenAI-only behavior (gpt-5.6's real "none" reasoning effort, its
// xhigh/max effort tiers routed through a gateway, ...) consistently across
// pkg/model/provider's defaults pipeline, the OpenAI client, and this
// package's own effort-family selection ([SupportedThinkingLevels]).
// pkg/model/provider layers one more exclusion on top for custom providers
// (providers: section) whose registered name collides with its built-in
// alias registry — that registry lives above modelinfo in the import graph,
// so it cannot be folded in here.
func IsOpenAIVendor(provider, modelID string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
if catalog.Lookup(provider).OpenAIVendor {
return true
}
return HasOpenAIQualifier(modelID)
}
// isOSeries reports whether the (already-normalized) identifier names an
// OpenAI o-series reasoning model (o1/o3/o4 and their variants).
func isOSeries(m string) bool {
return strings.HasPrefix(m, "o1") ||
strings.HasPrefix(m, "o3") ||
strings.HasPrefix(m, "o4")
}
// ---------------------------------------------------------------------------
// Attachment MIME-type capabilities
// ---------------------------------------------------------------------------
// ModelCapabilities describes what MIME types a given model can accept as
// document attachments.
type ModelCapabilities struct {
supportsImage bool
supportsPDF bool
supportsAudio bool
supportsVideo bool
}
// SupportsImage reports whether the model accepts image attachments.
func (mc ModelCapabilities) SupportsImage() bool {
return mc.supportsImage
}
// SupportsPDF reports whether the model accepts application/pdf attachments.
func (mc ModelCapabilities) SupportsPDF() bool {
return mc.supportsPDF
}
// SupportsAudio reports whether the model accepts audio attachments.
func (mc ModelCapabilities) SupportsAudio() bool {
return mc.supportsAudio
}
// SupportsVideo reports whether the model accepts video attachments.
func (mc ModelCapabilities) SupportsVideo() bool {
return mc.supportsVideo
}
// Supports reports whether the model can accept an attachment with the given
// MIME type.
//
// Only five content families are recognised:
// - image/* → requires the models.dev "image" input modality
// - application/pdf → requires the models.dev "pdf" input modality
// - audio/* → requires the models.dev "audio" input modality
// - video/* → requires the models.dev "video" input modality
// - text/* → always accepted (TXT envelope is universally safe)
//
// Everything else (Office binaries, …) returns false.
func (mc ModelCapabilities) Supports(mimeType string) bool {
mt := strings.ToLower(mimeType)
switch {
case strings.HasPrefix(mt, "image/"):
return mc.supportsImage
case mt == "application/pdf":
return mc.supportsPDF
case strings.HasPrefix(mt, "audio/"):
return mc.supportsAudio
case strings.HasPrefix(mt, "video/"):
return mc.supportsVideo
case strings.HasPrefix(mt, "text/"):
return true
default:
return false
}
}
// loadCapsTimeout is the maximum time allowed for a models.dev capability lookup.
const loadCapsTimeout = 10 * time.Second
// DefaultAnthropicContextLimit is the context window assumed for a Claude
// model only when models.dev has no entry for it AND no store is available —
// a degenerate, last-resort case. Claude 3.5 through 4.x all expose at least a
// 200k-token window, so it is a safe conservative floor for clamping retries.
//
// Model-specific windows (e.g. the 1M window of Claude Fable and Opus 4.6+)
// are NOT special-cased here: they come from the embedded models.dev snapshot,
// which is always available (even offline) and refreshed at build time. See
// [ContextLimit].
const DefaultAnthropicContextLimit = 200000
// ContextLimit returns the context-window size (in tokens) for a model.
//
// It prefers the models.dev catalogue entry for id; when the store is nil,
// the model is unknown, or the catalogue reports no context limit, it falls
// back to fallback. Callers that have no sensible fallback should pass 0 and
// treat a 0 result as "unknown".
//
// The supplied ctx is wrapped with loadCapsTimeout so the lookup stays
// cancellable with the caller and the underlying models.dev load is bounded.
// Note that the first lookup may serialize behind a shared store load: the
// timeout bounds the load itself, not time spent waiting for the store lock.
func ContextLimit(ctx context.Context, store *modelsdev.Store, id modelsdev.ID, fallback int64) int64 {
if store == nil {
return fallback
}
ctx, cancel := context.WithTimeout(ctx, loadCapsTimeout)
defer cancel()
model, err := store.GetModel(ctx, id)
if err != nil || model == nil || model.Limit.Context <= 0 {
return fallback
}
return int64(model.Limit.Context)
}
// CapsOverride is an explicit, user-declared attachment capability set that
// takes precedence over the models.dev catalogue. It is the modelinfo-level
// representation of a config capability override, deliberately free of any
// config-package dependency so modelinfo stays at the bottom of the import
// graph (it must not import pkg/config).
type CapsOverride struct {
Image bool
PDF bool
Audio bool
Video bool
}
// ResolveCaps returns the model's attachment capabilities, preferring an
// explicit override when one is supplied and otherwise consulting models.dev
// via [LoadCaps]. A nil override reproduces plain [LoadCaps] behaviour, so it
// is safe to thread a nil override through every call site.
//
// The override is the escape hatch for models the catalogue does not describe
// correctly (custom OpenAI-compatible providers, local models, dropped model
// versions); see [github.com/docker/docker-agent/pkg/config/latest.CapabilitiesConfig].
func ResolveCaps(ctx context.Context, store *modelsdev.Store, id modelsdev.ID, override *CapsOverride) ModelCapabilities {
if override != nil {
return CapsWith(override.Image, override.PDF, override.Audio, override.Video)
}
return LoadCaps(ctx, store, id)
}
// capsMissLogged dedupes the "model not in models.dev" diagnostic so a given
// model is reported at most once per process rather than on every request.
var capsMissLogged sync.Map
// warnCapsLookupMiss emits a one-shot diagnostic when a model is absent from
// models.dev, distinguishing this recoverable misconfiguration from the silent
// text-only fallback it used to cause. It points the user at the config escape
// hatch so attachments can be restored. See issue #2741.
func warnCapsLookupMiss(ctx context.Context, id modelsdev.ID, cause error) {
if _, dup := capsMissLogged.LoadOrStore(id.String(), struct{}{}); dup {
return
}
slog.WarnContext(ctx,
"modelinfo: model not found in models.dev; assuming text-only, so image and PDF "+
"attachments will be dropped. If this model does accept attachments, declare them "+
"in the agent config (models.<name>.capabilities: {image: true, pdf: true}) to "+
"override capability detection.",
"model", id.String(), "cause", cause)
}
// LoadCaps fetches (or returns from cache) the capability record for the given
// model ID using the provided store.
//
// When the store is nil or the model is not found, LoadCaps returns a
// conservative capability set that only allows text MIME types. On a direct
// catalogue miss, [AliasedCatalogCaps] may supply scoped input capabilities.
// An unresolved models.dev miss is logged once per model via [warnCapsLookupMiss].
//
// The supplied ctx is wrapped with loadCapsTimeout so the lookup stays
// cancellable with the caller and the underlying models.dev load is bounded.
// Note that the first lookup may serialize behind a shared store load: the
// timeout bounds the load itself, not time spent waiting for the store lock.
func LoadCaps(ctx context.Context, store *modelsdev.Store, id modelsdev.ID) ModelCapabilities {
if store == nil {
return ModelCapabilities{}
}
ctx, cancel := context.WithTimeout(ctx, loadCapsTimeout)
defer cancel()
model, err := store.GetModel(ctx, id)
if err != nil {
if caps, ok := AliasedCatalogCaps(ctx, store, id); ok {
return caps
}
if ctx.Err() != nil {
slog.WarnContext(ctx, "modelinfo: models.dev lookup timed out, using conservative caps",
"model", id.String(), "timeout", loadCapsTimeout)
} else {
warnCapsLookupMiss(ctx, id, err)
}
return ModelCapabilities{}
}
return capsFromModalities(model.Modalities.Input)
}
// ToolCallSupport is the models.dev catalogue's tri-state tool-call capability.
type ToolCallSupport uint8
const (
ToolCallSupportUnknown ToolCallSupport = iota
ToolCallUnsupported
ToolCallSupported
)
// ResolveToolCallSupport reports whether models.dev says a model supports tool
// calls. Missing catalogue data remains unknown rather than being treated as a
// negative capability claim.
func ResolveToolCallSupport(ctx context.Context, store *modelsdev.Store, id modelsdev.ID) ToolCallSupport {
if store == nil {
return ToolCallSupportUnknown
}
ctx, cancel := context.WithTimeout(ctx, loadCapsTimeout)
defer cancel()
model, err := store.GetModel(ctx, id)
if err != nil {
if ctx.Err() != nil {
slog.WarnContext(ctx, "modelinfo: models.dev tool-call lookup timed out, leaving support unknown",
"model", id.String(), "timeout", loadCapsTimeout)
} else {
warnCapsLookupMiss(ctx, id, err)
}
return ToolCallSupportUnknown
}
if model.ToolCall {
return ToolCallSupported
}
return ToolCallUnsupported
}
// ResolveOutputImage applies an explicit image-output override when present;
// otherwise it derives support from the models.dev output modalities. Missing
// catalogue data conservatively disables image output.
func ResolveOutputImage(ctx context.Context, store *modelsdev.Store, id modelsdev.ID, override *bool) bool {
if override != nil {
return *override
}
if store == nil {
return false
}
ctx, cancel := context.WithTimeout(ctx, loadCapsTimeout)
defer cancel()
model, err := store.GetModel(ctx, id)
if err != nil {
if ctx.Err() != nil {
slog.WarnContext(ctx, "modelinfo: models.dev output lookup timed out, disabling image output",
"model", id.String(), "timeout", loadCapsTimeout)
} else {
warnCapsLookupMiss(ctx, id, err)
}
return false
}
return hasOutputModality(model.Modalities.Output, "image")
}
func hasOutputModality(modalities []string, expected string) bool {
for _, modality := range modalities {
if strings.EqualFold(modality, expected) {
return true
}
}
return false
}
// booleans it grants. Unknown modality names are ignored.
func capsFromModalities(input []string) ModelCapabilities {
var mc ModelCapabilities
for _, modality := range input {
switch strings.ToLower(modality) {
case "image":
mc.supportsImage = true
case "pdf":
mc.supportsPDF = true
case "audio":
mc.supportsAudio = true
case "video":
mc.supportsVideo = true
}
}
return mc
}
// ResolveCapsFromModel applies the same precedence contract as [ResolveCaps]
// — an explicit override wins, otherwise capabilities derive from the
// models.dev record — for callers that fetch models through their own store
// abstraction (e.g. the runtime's ModelStore interface) instead of a concrete
// [*modelsdev.Store]. A nil model yields the same conservative text-only
// default as a store miss in [LoadCaps].
func ResolveCapsFromModel(model *modelsdev.Model, override *CapsOverride) ModelCapabilities {
if override != nil {
return CapsWith(override.Image, override.PDF, override.Audio, override.Video)
}
if model == nil {
return ModelCapabilities{}
}
return capsFromModalities(model.Modalities.Input)
}
// CapsWith constructs a ModelCapabilities value directly from booleans. This is
// intended for use in tests and provider implementations that need to create a
// capabilities value without hitting the network.
func CapsWith(supportsImage, supportsPDF bool, additional ...bool) ModelCapabilities {
supportsAudio, supportsVideo := false, false
if len(additional) > 0 {
supportsAudio = additional[0]
}
if len(additional) > 1 {
supportsVideo = additional[1]
}
return ModelCapabilities{
supportsImage: supportsImage,
supportsPDF: supportsPDF,
supportsAudio: supportsAudio,
supportsVideo: supportsVideo,
}
}