Skip to content

pkg/chat Open-Source Readiness Specification

Authors
Matt Cockayne, Claude (claude-sonnet-4-6) (AI drafting assistant)
Date
18 March 2026
Status
IMPLEMENTED

Overview

pkg/chat provides GTB's unified AI client interface over OpenAI, Claude, and Gemini. Before open-sourcing the GTB project, four issues need resolving:

  1. Gemini SDK deprecation โ€” github.com/google/generative-ai-go reached end-of-life November 30, 2025.
  2. No extension point โ€” adding a provider requires modifying client.go directly; external contributors cannot register providers from their own packages.
  3. Config pollution โ€” ClaudeCodeLocal bool is a provider-specific concern in the shared Config struct, and its implementation (askLocal) is an unimplemented stub.
  4. No OpenAI-compatible backends โ€” Ollama, Groq, Fireworks, LM Studio and most new entrants use an OpenAI-compatible API but cannot be targeted without forking the package.

Additionally, the ClaudeCodeLocal feature โ€” routing through a locally installed claude CLI binary โ€” is genuinely valuable for secure environments where direct API access to api.anthropic.com is blocked but the pre-authenticated claude binary is permitted. This spec implements it properly as a first-class provider.


Design Decisions

ProviderFactory registry over switch statement: A thread-safe global registry with a RegisterProvider() function and init()-based self-registration is the idiomatic Go pattern for this type of extensibility. It allows external packages to register providers without importing or modifying client.go.

ProviderClaudeLocal via subprocess: The claude CLI supports --output-format json for structured single-shot responses, --resume <session_id> for multi-turn continuity, --json-schema for validated structured output, and --system-prompt / --model for configuration. This gives us everything needed to implement all ChatClient methods except SetTools, which requires an MCP server integration deferred to a future spec.

BaseURL field on Config: Rather than a completely separate implementation, OpenAI-compatible providers share newOpenAI() โ€” the only difference is option.WithBaseURL() and the absence of a default model. A ProviderOpenAICompatible constant makes intent clear in user code.

Remove ClaudeCodeLocal entirely: The field exposed a leaking concern and the implementation was a stub. The feature it gestured at is now properly implemented as ProviderClaudeLocal via the registry.


Public API Changes

client.go โ€” Config struct

// Added:
BaseURL string  // overrides API endpoint; required for ProviderOpenAICompatible

// Removed:
ClaudeCodeLocal bool  // replaced by ProviderClaudeLocal

client.go โ€” New provider constants

ProviderClaudeLocal      Provider = "claude-local"
ProviderOpenAICompatible Provider = "openai-compatible"

client.go โ€” New exported symbols

// ProviderFactory creates a ChatClient for a named provider.
type ProviderFactory func(ctx context.Context, p *props.Props, cfg Config) (ChatClient, error)

// RegisterProvider registers a factory for a provider name.
// Call from an init() function in provider files or external packages.
func RegisterProvider(name Provider, factory ProviderFactory)

Internal Implementation

Task 1: Migrate Gemini SDK

Files: go.mod, pkg/chat/gemini.go, pkg/chat/gemini_schema.go

Replace github.com/google/generative-ai-go v0.20.1 with google.golang.org/genai.

Key API differences in the new SDK:

Old (google/generative-ai-go) New (google.golang.org/genai)
genai.NewClient(ctx, option.WithAPIKey(k)) genai.NewClient(ctx, &genai.ClientConfig{APIKey: k})
client.GenerativeModel(name) + model.StartChat() History managed manually; call client.Models.GenerateContent()
model.ResponseMIMEType, model.ResponseSchema Config passed per-call via genai.GenerateContentConfig
genai.FunctionDeclaration, genai.Tool Same types, different package path

All existing behaviour is preserved: agentic tool loop (max 20 steps), structured output, error parsing, history management.

Task 2: ProviderFactory Registry

Files: pkg/chat/client.go, pkg/chat/openai.go, pkg/chat/claude.go, pkg/chat/gemini.go, pkg/chat/client_test.go

// client.go
var (
    providerRegistry = map[Provider]ProviderFactory{}
    registryMu       sync.RWMutex
)

func RegisterProvider(name Provider, factory ProviderFactory) {
    registryMu.Lock()
    defer registryMu.Unlock()
    providerRegistry[name] = factory
}

func New(ctx context.Context, p *props.Props, cfg Config) (ChatClient, error) {
    if cfg.Provider == "" {
        if envProvider := os.Getenv(EnvAIProvider); envProvider != "" {
            cfg.Provider = Provider(envProvider)
        } else {
            cfg.Provider = ProviderOpenAI
        }
    }
    registryMu.RLock()
    factory, ok := providerRegistry[cfg.Provider]
    registryMu.RUnlock()
    if !ok {
        return nil, errors.Newf("unsupported provider: %s", cfg.Provider)
    }
    return factory(ctx, p, cfg)
}

Built-in providers self-register:

func init() { chat.RegisterProvider(ProviderOpenAI, newOpenAI) }       // openai.go
func init() { chat.RegisterProvider(ProviderClaude, newClaude) }       // claude.go
func init() { chat.RegisterProvider(ProviderGemini, newGemini) }       // gemini.go
func init() { chat.RegisterProvider(ProviderClaudeLocal, newClaudeLocal) } // claude_local.go

Test fix: client_test.go "default provider is Claude" test name and assertion are wrong โ€” the actual default is ProviderOpenAI. Fix to assert "OpenAI token is required".

Task 3: ProviderClaudeLocal

Files: pkg/chat/client.go (removals + constant), pkg/chat/claude.go (removals), pkg/chat/claude_local.go (new), pkg/chat/claude_local_test.go (new)

Removals from claude.go: - Guard && !cfg.ClaudeCodeLocal in newClaude() - if c.cfg.ClaudeCodeLocal branch in Ask() - askLocal() stub method

claude_local.go implementation:

type ClaudeLocal struct {
    ctx       context.Context
    props     *props.Props
    cfg       Config
    sessionID string   // captured from first response; used for --resume
    pending   []string // buffered Add() messages, prepended to next prompt
}

newClaudeLocal: No API key required. Calls exec.LookPath("claude") at construction โ€” returns an error with install instructions if not found.

Add(prompt string) error: Appends to pending. Buffered messages are prepended (with \n\n---\n\n separator) to the combined prompt on the next Chat() or Ask() call.

Chat(ctx, prompt string) (string, error): Invokes claude -p "<combined>" --output-format json [--system-prompt ...] [--model ...] [--resume <id>]. Parses the JSON result:

{"type": "result", "result": "...", "session_id": "...", "is_error": false}
Captures session_id for multi-turn continuity. Returns result field.

Ask(question string, target any) error: Same as Chat(), plus --json-schema '<schema>' using the schema generated from cfg.ResponseSchema. Unmarshals result into target.

SetTools(tools []Tool) error: Phase 1 โ€” returns an informative error. Phase 2 (future spec) will expose tools via a local MCP server and pass --mcp-config to the subprocess.

Task 4: ProviderOpenAICompatible

Files: pkg/chat/client.go, pkg/chat/openai.go, pkg/chat/client_test.go, pkg/chat/openai_test.go

newOpenAI() changes: - When cfg.BaseURL != "": pass option.WithBaseURL(cfg.BaseURL) to openai.NewClient() - When cfg.Provider == ProviderOpenAICompatible && cfg.Model == "": return an error (no sensible default for non-OpenAI model names)

Registration:

func init() {
    RegisterProvider(ProviderOpenAI, newOpenAI)
    RegisterProvider(ProviderOpenAICompatible, newOpenAI)
}

Tokenizer fallback in chunkByTokens(): Unknown model names (e.g. llama3.2, mixtral) currently cause a hard error. Fall back to cl100k_base with a debug log:

enc, err := tokenizer.ForModel(tokenizer.Model(model))
if err != nil {
    enc, err = tokenizer.Get(tokenizer.Cl100kBase)
    if err != nil {
        return nil, errors.Newf("failed to get fallback tokenizer: %w", err)
    }
}


Project Structure

pkg/chat/
โ”œโ”€โ”€ client.go           MODIFIED: ProviderFactory, RegisterProvider, registry in New(),
โ”‚                                 add ProviderClaudeLocal + ProviderOpenAICompatible constants,
โ”‚                                 add BaseURL to Config, remove ClaudeCodeLocal from Config
โ”œโ”€โ”€ openai.go           MODIFIED: init() registration, BaseURL support, tokenizer fallback,
โ”‚                                 ProviderOpenAICompatible model guard
โ”œโ”€โ”€ claude.go           MODIFIED: init() registration, remove ClaudeCodeLocal guards + askLocal()
โ”œโ”€โ”€ gemini.go           MODIFIED: init() registration, migrate to google.golang.org/genai
โ”œโ”€โ”€ gemini_schema.go    MODIFIED: update genai.Schema type references for new SDK
โ”œโ”€โ”€ claude_local.go     NEW: ProviderClaudeLocal implementation
โ”œโ”€โ”€ client_test.go      MODIFIED: fix default provider test, add registry + new provider tests
โ”œโ”€โ”€ openai_test.go      MODIFIED: add tokenizer fallback test
โ””โ”€โ”€ claude_local_test.go NEW: constructor path-check, Add() buffering, SetTools() error
go.mod                  MODIFIED: remove google/generative-ai-go, add google.golang.org/genai

Testing Strategy

File Tests
client_test.go Registry dispatch via mock factory; fixed default provider (OpenAI); ProviderClaudeLocal binary-not-found error; ProviderOpenAICompatible missing-model error
openai_test.go chunkByTokens with unknown model name falls back to cl100k_base
claude_local_test.go Constructor returns error when claude not in PATH; Add() buffers and clears; SetTools() returns informative error

Backwards Compatibility

  • The ChatClient interface is unchanged โ€” no consumer code is broken.
  • Config.ClaudeCodeLocal is removed โ€” any callers setting this field will get a compile error (intentional: the field was non-functional).
  • Config.BaseURL is additive โ€” existing callers are unaffected.
  • RegisterProvider is additive โ€” existing New() callers are unaffected.

Verification

# All unit tests must pass
go test ./pkg/chat/...

# Deprecated import gone (Task 1)
grep "generative-ai-go" go.mod go.sum pkg/chat/

# Registry replaces switch (Task 2)
grep "switch cfg.Provider" pkg/chat/client.go

# ClaudeCodeLocal fully removed (Task 3)
grep -r "ClaudeCodeLocal" pkg/

# New symbols present (Tasks 3 & 4)
grep "ProviderClaudeLocal\|ProviderOpenAICompatible\|BaseURL" pkg/chat/client.go

Future Considerations

  • ProviderClaudeLocal SetTools via MCP: When tool calling is needed for local mode, expose user-defined Tool handlers through a short-lived in-process MCP server and pass --mcp-config to the claude subprocess.
  • Streaming support: The ChatClient interface currently returns a complete response string. Streaming could be added as an optional interface (StreamingChatClient) without breaking existing consumers.
  • Provider version negotiation: The ProviderFactory registry could be extended to include capability metadata (supported features) so New() can warn consumers when a requested feature is unavailable for the chosen provider.