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-goadditionally 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-locallinks 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.Loggerfor logging — injected viaSettings.Logger; nil ⇒ a discard logger.*http.Clientfor provider transport — injected viaConfig.HTTPClient; nil ⇒ a plain stdlib client bounded byRequestTimeout. A host injects a hardened transport here.KeychainLookup(a plain func) for credential resolution — injected viaConfig.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 itspkg/credentials).afero.Fsfor conversation persistence — passed tochat.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¶
- Register a custom provider — do it.
- Providers & the per-provider module pattern — the module map.
- Architecture & the seams — the seams in context.