Extract pkg/chat into a standalone module¶
- Authors
- Matt Cockayne, Claude Opus 4.8 (AI drafting assistant)
- Date
- 5 July 2026
- Status
- IMPLEMENTED (2026-07-18) — shipped to main; originally APPROVED
- Builds on
2026-07-12-go-module-extraction-playbook.md(naming, repo framework, migration procedure), and thesigning/signing-aws-kmsextraction (work item #1, relocated togitlab.com/phpboyscout/go/*v0.2.0)- Feasibility
chat-extraction-feasibility.md(companion analysis)
1. Context & motivation¶
pkg/chat is a 4,362-line multi-provider AI client (Anthropic Claude, Claude-local CLI,
OpenAI, OpenAI-compatible, Google Gemini) with a ReAct tool-calling loop, streaming,
fallback policy, usage accounting, and encrypted persistence. Recent work made it more
multimodal. It is a genuinely reusable asset that non-GTB projects would want.
Two forces make extraction worthwhile — the same two that drove the signing extraction:
- Reuse. A consumer should be able to
import gitlab.com/phpboyscout/go/chatwithout dragging in go-tool-base (viper, otel, controls, vcs, version, …). - Selective SDK weight. Importing
pkg/chattoday unconditionally links all three vendor SDKs —anthropics/anthropic-sdk-go,openai/openai-go/v3(+tiktoken-go),google.golang.org/genai— because all four providers live in one package. A tool that only talks to Claude still compiles the OpenAI and Gemini SDKs.
The perceived blocker — deep coupling to GTB — does not hold up. Non-test chat source uses
only a narrow slice of each GTB package (see §2). The provider registry needed for
selective inclusion already exists (RegisterProvider, ProviderFactory,
providerRegistry in client.go); it just doesn't pay off yet because there is no package
boundary per provider.
2. The actual coupling surface¶
Chat imports five GTB packages. What it uses is far narrower than what those packages weigh:
| Import | Used surface | Transitive weight | Inversion |
|---|---|---|---|
pkg/props |
p.Logger, p.Config, p.GetConfig only |
heavy (vcs/release, version, errorhandling) |
narrow Deps interface |
pkg/logger |
logging interface (Debugf…), 12 sites |
light | local Logger interface |
pkg/config |
Containable.GetString only |
heavy (viper, afero, fsnotify) | local ConfigLookup interface |
pkg/http |
NewTransport/NewClient/WithTransport/WithTimeout |
very heavy (otel SDK, controls, authn, tls, redact) | injected *http.Client |
pkg/credentials |
Retrieve(ctx, service, account) |
light; keychain already inverted | CredentialResolver seam |
chat.New(ctx, *props.Props, cfg) is called 87× and ProviderFactory is typed on
*props.Props, which is what makes chat look welded to GTB. Underneath, every provider
body only reads p.Logger and p.Config. Replacing that one parameter with a two-method
interface removes the welding.
3. Goals / non-goals¶
Goals¶
- A standalone
gitlab.com/phpboyscout/go/chatcore module with zero go-tool-base and zero vendor-SDK dependencies beyond the SDK-freeclaude-localprovider. - Per-provider modules —
chat-anthropic,chat-openai,chat-gemini— each adding exactly one SDK and self-registering viainit()(blank-import to activate). - go-tool-base consumes the module(s) via a thin adapter; no behaviour change for existing GTB consumers, all 14 of them.
- Coverage parity (chat is currently well above the 90%
pkg/bar; the module must keep it).
Non-goals¶
- No change to provider behaviour, the ReAct loop, streaming semantics, or the wire protocol.
- No new provider in this spec.
- The GTB config-key schema (
claude.api.env, …) and the 5-step credential cascade stay on the GTB side — the module is config-system-agnostic (see §4.4). - Not touching
pkg/chat's consumers' logic, only the entry-point they call.
4. Design¶
4.1 The seams (what the module defines)¶
// Logger — the minimal logging surface chat needs.
type Logger interface {
Debugf(format string, args ...any)
// …exact set derived from current call sites
}
// ConfigLookup — read-only key lookup; GTB's Containable satisfies it.
type ConfigLookup interface {
GetString(key string) string
}
// CredentialResolver — resolves a provider's API key however the consumer wishes.
// GTB supplies the 5-step cascade; a bare consumer can return a constant.
type CredentialResolver interface {
Resolve(ctx context.Context, provider Provider) (string, error)
}
// Deps — everything a factory needs, replacing *props.Props.
type Deps struct {
Logger Logger
Config ConfigLookup
HTTPClient *http.Client // injected; module default is a plain bounded client
Credentials CredentialResolver // optional; nil ⇒ direct-token / env-fallback only
FS afero.Fs // optional; nil ⇒ afero.NewOsFs() for filestore/persistence
}
type ProviderFactory func(ctx context.Context, d Deps, cfg Config) (ChatClient, error)
4.2 Module layout¶
Core — gitlab.com/phpboyscout/go/chat: client.go (registry, New, Config,
ChatClient), schema, streaming, usage, fallback, fallback_policy, persistence,
filestore, baseurl, tools, constants, the seams above, and claude_local.go
(SDK-free default provider).
Per-provider modules, each depends on chat + one SDK, init()-registers:
gitlab.com/phpboyscout/go/chat-anthropic→anthropics/anthropic-sdk-gogitlab.com/phpboyscout/go/chat-openai→openai/openai-go/v3+tiktoken-go/tokenizergitlab.com/phpboyscout/go/chat-gemini→google.golang.org/genai
Activation is a blank import, e.g. import _ "gitlab.com/phpboyscout/go/chat-anthropic".
4.3 HTTP injection¶
The module default is a plain *http.Client with the existing DefaultChatRequestTimeout
(5m) and a response-header timeout — no otel, no circuit breaker. GTB's adapter injects
pkg/http's hardened transport instead, preserving today's behaviour for GTB tools.
4.4 Credential / config-key division of labour¶
The module owns provider mechanics. The GTB adapter owns:
- the
ConfigKey*/Env*constant naming (GTB's config schema), - the 5-step resolution cascade (
credentials.go,resolveAPIKey), wired as aCredentialResolver, - hardened-HTTP injection.
This keeps the module free of GTB config conventions — a non-GTB consumer supplies a key via
Config.Token, an env fallback, or its own CredentialResolver.
4.5 go-tool-base cut-over¶
pkg/chat becomes a thin adapter (or is removed and callers repoint) that:
- maps
*props.Props → Deps(Logger, Config, hardened HTTP, 5-step resolver), - re-exports the config-key constants and
Provider/Config/Tool/schema/streaming types the 14 consumers use, so their call sites change minimally, - blank-imports all three provider modules (GTB wants every provider).
Mirrors refactor(signing): consume external signing + signing-aws-kms modules.
4.6 Footprint¶
A Claude-only consumer links chat + chat-anthropic + anthropic-sdk-go — not
openai-go, tiktoken, or genai. GTB links all three (no change from today). The SDK-free
claude-local path links no vendor SDK at all.
5. Sequencing — invert in place, then move¶
To keep the extraction low-risk, split into two stages:
- In-place inversion (a
refactorinside GTB, no new deps, identical behaviour): introduceDeps/Logger/ConfigLookup/CredentialResolver, retypeProviderFactoryandNew, inject the HTTP client and resolver. Ships as one reviewable, fully-covered refactor with no functional change. Land onmain. - Extraction (mechanical): move files to the new module(s), split
go.mod, wire the GTB adapter, blank-import providers. Because stage 1 already removed the coupling, this is a file move + module plumbing, not a rewrite. Branch off stage 1 once it lands (stacked-MR ordering: dependency inmainfirst).
6. Development model (TDD / BDD)¶
- The module carries its own unit tests (the existing
pkg/chat/*_test.go, de-GTB'd) and must hold coverage ≥ the current level. - GTB's existing chat E2E/integration scenarios (
INT_TEST_E2E_*, fallback/parallel integration tests) run against the adapter unchanged — they are the cut-over's safety net. - New seams get table-driven tests (nil-resolver fall-through, injected-client honoured, registry blank-import activation).
7. Documentation (Diátaxis)¶
- Module
README+doc.go(reference) in each new repo. - GTB
docs/components/chat.mdrepointed to the module, retaining the GTB-side security notes (endpoint validation, redaction, credential storage) as adapter concerns. - A
docs/migration/note for the GTB version that cuts over (analogous to the v0.27→v0.28 signing-modules-extracted guide).
8. Repository & project framework¶
Each new module is its own GitLab repo in the phpboyscout/go subgroup
(gitlab.com/phpboyscout/go/chat, …/go/chat-anthropic, etc.), bootstrapped to
the standard framework defined in the
extraction playbook §3: cicd
v0.21.0 components (go-lint / go-test+Godog / go-security / releaser-pleaser /
zensical-pages / renovate-self), .golangci.yaml v2 with the module's local
prefix, .mockery.yml, go 1.26.5, cockroachdb/errors, *slog.Logger seams,
and a depfootprint_test.go guard. The core module's guard forbids
go-tool-base, viper, pflag, charmbracelet, and every vendor AI SDK
(anthropic-sdk-go, openai-go, genai, tiktoken) — proving the SDK-free
core. Each provider module's guard forbids go-tool-base and the other
providers' SDKs. Docs publish to chat.go.phpboyscout.uk; GTB's component page
becomes a stub linking there (playbook §4.1). Only the core carries a full docs
site; the provider modules ship README + Example + a page on the core site.
9. Resolved decisions¶
Playbook-resolved and 2026-07-12 review.
- Core default provider — include
claude-localin the core. It is SDK-free, so it adds no vendor weight and gives a working default with zero provider modules imported. - Tiktoken lives in
chat-openai. Token counting is OpenAI-specific; it must not sit in the core (the dep guard enforces this). - Config-key constants — re-export from the GTB adapter. The module stays
config-schema-agnostic; GTB owns the
claude.api.env-style keys and the 5-step cascade. A sharedchat-gtbschemahelper is deferred until a second GTB-convention consumer actually needs it (YAGNI). - Module naming —
gitlab.com/phpboyscout/go/chat(playbook convention). Thegosubgroup disambiguates the genericchattoken, so noaichat/llmchatrename is needed; per-provider backends followgo/chat-<provider>. - N repos, not a monorepo — matches the signing precedent and the playbook;
keeps each provider's SDK weight quarantined by its own
go.mod. - Persistence/encryption seam — inject
afero.Fs.filestore/persistencetake an injectedafero.Fs(GTB passes its own; a bare consumer passesafero.NewOsFs()), so the module never assumes GTB's filesystem wiring. This is part of the Stage-1Depsinversion.
10. Work items (once approved)¶
- Stage-1 in-place inversion (
Deps+ seams + HTTP/credential injection) — GTB refactor. - Create
gitlab.com/phpboyscout/go/chatcore module; move de-GTB'd core +claude-local. - Create
chat-anthropic,chat-openai,chat-geminiprovider modules. - GTB adapter + blank-imports; repoint the 14 consumers.
- Docs (module READMEs,
chat.mdrepoint, migration note) + Renovate/CI wiring.