Skip to content

AI Chat

The multi-provider AI chat client has been extracted into the standalone gitlab.com/phpboyscout/go/chat module (plus per-provider modules: chat-anthropic, chat-openai, chat-gemini, chat-bedrock, chat-openai-azure). Its full documentation: the ChatClient API, the ReAct tool-calling loop, streaming, cross-provider fallback, conversation persistence, multimodal input, token-usage accounting, and the provider capability matrix. Now lives at:

chat.go.phpboyscout.uk

go-tool-base consumes the module through a thin adapter in pkg/chat; this page documents only that adapter. See the migration note for the module map and how to consume the light client directly.

What the GTB adapter adds

The module is deliberately config-system-agnostic. pkg/chat layers GTB's framework integration on top:

  • Props construction. chat.NewFromProps(ctx, p, cfg) and chat.NewWithFallbackFromProps(...) map a Props instance and GTB's layered config (read through a pinned props.Config.View()) into the module's typed chat.Settings, then call the module constructor. chat.SettingsFromProps exposes just the mapping.
  • The GTB config-key schema. The adapter owns the config keys and their precedence; the module knows nothing about them:
Provider Literal key Env-var-reference key Keychain key Ecosystem fallback env
Claude anthropic.api.key anthropic.api.env anthropic.api.keychain ANTHROPIC_API_KEY
OpenAI openai.api.key openai.api.env openai.api.keychain OPENAI_API_KEY
Gemini gemini.api.key gemini.api.env gemini.api.keychain GEMINI_API_KEY
Azure OpenAI azure.api.key azure.api.env azure.api.keychain AZURE_OPENAI_API_KEY

openai-compatible shares OpenAI's root but never reads OPENAI_API_KEY, which belongs to api.openai.com. gemini-vertex authenticates with Google application default credentials and takes no key. The local CLIs (claude-local, codex-local, agy-local) and bedrock carry no GTB credential. The provider is chosen by ai.provider (or AI_PROVIDER when that is unset); fallback is configured under ai.fallback.*. - One credential walk, shared with doctor. The adapter hands each provider a chat.CredentialSource over the same walk doctor reports from: env-var reference → OS keychain → literal → ecosystem env var. So doctor and the client cannot disagree about where a key comes from. Nothing is read until the provider asks. A keychain entry that cannot be read, with nothing below it, fails chat.New with the reason rather than reading as a missing key, and one with a literal below it is refused as a secure-store regression (see credentials). A caller's own Config.Token or Config.Credential is left alone. The recommended path (env-var reference) keeps the literal secret out of the config file. - The whole ai: section reaches the client. ai.model, ai.base_url, ai.api_version, ai.project and ai.location fill the matching chat.Config fields when the caller left them empty, so openai-compatible, azure-openai, gemini-vertex and bedrock are configurable from a file. They apply to ai.provider only. A fallback chain is built by the module's NewWithFallbackSettings: the primary keeps its model and addressing, every other member resolves its own, and GTB supplies each member's credential source through WithProviderCredentialSources. GTB used to derive the chain itself with a denylist that cleared the primary's model and would have carried addressing to every member. - Three layers, three owners. A generated tool's chat configuration is three things that used to blur together. The author's defaults (default provider, model, endpoint) live in the manifest's chat.default block and ship as an embedded defaults bundle beside cmd/<name>/chat.go, the lowest config layer. The end user's overrides live in their own config file, written by init ai or by hand, and win over the author's. Credentials are only ever the end user's, in their file or their environment; nothing the author records carries one. See spec 0196 and the generate reference. - Hardened HTTP seam. The adapter injects the hardened transport from go/httpclient, so GTB tools get the framework's security posture; the module core carries none of it. - No provider registered here. A provider is a blank import in the binary that ships it (spec 0194): cli/cmd/gtb/providers.go links every module, and a generated tool links the ones its manifest selects. A hand-wired tool adds the imports itself; see the migration note. - Provider to module table. chat.ProviderModule(p) and chat.ProviderModules() name the module whose blank import registers each chat.Provider. The generator, doctor and error hints read this one table. When chat.New fails because a provider is not registered (gochat.ErrProviderNotRegistered), the adapter adds a hint naming the import to add. - The tool bridge. claude-local and codex-local serve their tools to the CLI over MCP through chat.ToolServerModule (go/chat-mcptools), and refuse tools unless the binary blank-imports it. chat.NeedsToolServer(p) says which providers need it; the generator emits the import for them unless the manifest sets chat.tool_bridge: false, and cli/cmd/gtb/providers.go links it. - Linked providers are a feature-set question. chat.LinkedProviders(set) is how init ai and doctor ask what this binary ships. A generated tool declares its manifest's providers as link-kind features (props.DeclareLinks(props.ChatLinkPrefix, ...) in its chat.go), so the answer is the author's choice rather than every provider a linked module registers; a declared provider the registry lacks is reported as unlinked. A tool declaring none is read from gochat.RegisteredProviders alone, the same way the forge side falls back to forge.Registered (#81).