Skip to content

Dependency inversion

This page explains why the chat core is SDK-free, why each vendor provider is a separate module you inject by blank import, and how the standard-library seams keep a consumer's dependency graph tiny. The practical recipe is Register a custom provider.

The problem with bundling SDKs

A multi-provider chat client talks to Anthropic, OpenAI, and Google. The obvious design ships all three vendor SDKs in one library. That choice is poisonous for a light library:

  • Each vendor SDK is large; openai-go additionally pulls a tokenizer (tiktoken), and the SDKs bring their own transitive trees.
  • A tool that only ever talks to one provider would inherit all three SDKs transitively, despite never calling two of them.
  • Security review and supply-chain surface scale with the dependency graph. A Claude-only tool should not have to audit the OpenAI and Gemini SDKs it never links.

Inverting the dependency

So the library inverts the relationship. The core defines a minimal contract — the ChatClient interface and a factory type — plus a global registry:

type ProviderFactory func(ctx context.Context, settings chat.Settings) (chat.ChatClient, error)

func RegisterProvider(name Provider, factory ProviderFactory)

The provider module implements the factory, imports the heavy SDK there, and registers itself from init(). The core depends only on stdlib-adjacent types — it never imports any vendor SDK. Control is inverted: the high-level policy (the ReAct loop, structured output, fallback, persistence) lives in the core; the low-level detail (which SDK, which wire format) lives in the provider module and is plugged in at runtime.

A light, SDK-free claude-local provider (which shells out to the claude CLI) ships as the default reference implementation, so a zero-dependency chat path works out of the box.

Why this keeps consumer graphs tiny

This is not merely tidy — it is a property of Go's module graph. A dependency that no compiled package imports is pruned: not linked into the binary, and with module-graph pruning not even required to build. Because the Anthropic, OpenAI, and Gemini SDKs are imported only by their respective provider modules, they enter a binary's graph only when that binary blank-imports that module.

The consequences:

  • A tool using only claude-local links no vendor AI SDK at all.
  • A Claude-only tool (_ "…/chat-anthropic") links the Anthropic SDK — and not OpenAI's or Gemini's.
  • A tool that wants all three imports all three, by choice, not by force.

A depfootprint_test.go guard in the core encodes this invariant directly: it runs go list -deps ./... and fails the build if any forbidden dependency appears in the core's graph — any vendor AI SDK, Viper/Cobra/pflag, Charm, OpenTelemetry, the AWS SDK, or go-tool-base itself. The core's entire declared graph is cockroachdb/errors, invopop/jsonschema, google/uuid, and spf13/afero.

Activate by side effect

Providers are turned on by blank import — the same pattern as net/http/pprof, the image/* decoders, and database/sql drivers. Importing a provider module for its init() side effect registers it; resolving it by name (chat.New → registry lookup) dispatches to it. Activation stays explicit and local to main, and the core carries no plugin or flag machinery. A provider that needs extra construction dependencies receives them through the package-owned chat.Settings — never through a framework-specific container.

The stdlib-seam corollary

The same instinct shapes the rest of the API. Where a heavier design would invent its own abstractions, chat accepts standard-library types at its seams, each optional:

  • *slog.Logger for logging — injected via Settings.Logger; nil ⇒ a discard logger.
  • *http.Client for provider transport — injected via Config.HTTPClient; nil ⇒ a plain stdlib client bounded by RequestTimeout. A host injects a hardened transport here.
  • KeychainLookup (a plain func) for credential resolution — injected via Config.Credentials.Lookup; nil ⇒ the keychain step is skipped. The core needs no keychain backend of its own; the host wires one (go-tool-base wires its pkg/credentials).
  • afero.Fs for conversation persistence — passed to chat.NewFileStore, so the same code runs against real disk or an in-memory test FS.

Each seam is a place a consumer injects a hardened or instrumented implementation without the core taking on a dependency to support it.

Config-system-agnostic by the same logic

The core owns provider mechanics, not config schema. It exposes typed shapes (Config, RuntimeConfig, FallbackConfig, CredentialConfig) and expects a host to populate them from whatever config system it uses. go-tool-base does this in a thin adapter that maps its Viper ai.*, ai.fallback.*, and provider api sections onto chat.Settings — and that adapter lives in go-tool-base, not here. Any other host maps its own config the same way, so the core never imports Viper (the depfootprint guard forbids it).

See also