Skip to content
stack-boundPublic

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

stackllm

A minimal, embeddable Go agent orchestration library. Single binary. Full context control.

You own the []Message — the library provides the loop and the plumbing.

Features

  • One OpenAI-compatible provider — talks to OpenAI, Azure OpenAI, Ollama, GitHub Copilot, Gemini, Groq, and OpenRouter through a single implementation
  • Unified provider manager — one entry point for login, logout, status, model discovery, and default persistence across every supported provider
  • Full context control — the caller owns the message slice and decides what goes in it
  • Block-shaped messages — each message holds an ordered slice of typed blocks (text, thinking, tool_use, tool_result, image, redacted_thinking), so interleaved assistant output replays faithfully
  • Durable session store — pure-Go SQLite (modernc.org/sqlite) backing for branching message trees, artifact offload for large tool outputs and images, and FTS5 search across block types; shares the same file with parent-app tables
  • Built-in auth — static API keys, GitHub device flow for Copilot, "Sign in with ChatGPT" for OpenAI (Codex OAuth — device or browser, no API key, no app registration), plus device/PKCE OAuth against the standard OpenAI API for embedders with their own registered app. File-backed storage with expiry handling.
  • Tool system — register Go functions as tools with auto-generated JSON Schema
  • ReAct agent loop — streaming Step/Run with per-block hooks, plus runtime provider/model swap
  • Model discovery — list and switch between every chat-capable model on every authenticated provider
  • Optional adapters — Bubbletea TUI (with slash commands) and HTTP/SSE server

Install

go get github.com/stack-bound/stackllm

Quick start

The fastest path uses the profile manager to handle auth and model selection for you. On the first call it walks the user through login; after that, LoadDefault returns a ready-to-use provider.

package main

import (
    "context"
    "fmt"

    "github.com/stack-bound/stackllm/agent"
    "github.com/stack-bound/stackllm/conversation"
    "github.com/stack-bound/stackllm/profile"
    "github.com/stack-bound/stackllm/tools"
)

type GreetArgs struct {
    Name string `json:"name" jsonschema:"description=Name to greet,required"`
}

func main() {
    ctx := context.Background()

    // 1. Load the persisted default provider. Run `go run ./examples/login`
    //    once beforehand to pick a provider and model interactively.
    mgr := profile.New()
    p, err := mgr.LoadDefault(ctx)
    if err != nil {
        panic(err)
    }

    // 2. Register tools.
    registry := tools.NewRegistry()
    registry.Register("greet", "Greet someone", func(ctx context.Context, args GreetArgs) (string, error) {
        return fmt.Sprintf("Hello, %s!", args.Name), nil
    })

    // 3. Create the agent.
    a := agent.New(p,
        agent.WithTools(registry),
        agent.WithMaxSteps(10),
        agent.WithHooks(agent.Hooks{
            OnToken: func(ctx context.Context, delta string) { fmt.Print(delta) },
        }),
    )

    // 4. Run.
    msgs := conversation.NewBuilder().
        System("You are a helpful assistant. Use the greet tool when asked.").
        User("Say hello to Alice").
        Build()

    events, _ := a.Run(ctx, msgs)
    for range events {
    }
    fmt.Println()
}

If you would rather wire a provider up directly without the manager, skip to Providers.

Provider management

profile.Manager composes auth, config, and provider into a single object that knows how to log into any supported backend, discover its models, and persist the user's choice.

mgr := profile.New(profile.WithCallbacks(profile.Callbacks{
    OnDeviceCode: func(userCode, verifyURL string) { /* show code */ },
    OnOpenURL:    func(authURL string) { /* open browser for PKCE sign-in */ },
    OnPromptKey:  func(providerName string) (string, error) { /* read API key */ },
    OnPromptURL:  func(providerName, defaultURL string) (string, error) { /* read URL */ },
}))

// Authenticate — GitHub device flow for Copilot, API key for Gemini,
// base URL prompt for Ollama.
mgr.Login(ctx, profile.ProviderCopilot)

// OpenAI has three paths; pick whichever fits the UX:
mgr.LoginOpenAICodexDevice(ctx) // "Sign in with ChatGPT" — headless device code
mgr.LoginOpenAICodexWeb(ctx)    // "Sign in with ChatGPT" — browser + local callback
mgr.Login(ctx, profile.ProviderOpenAI) // paste an API key (uses OnPromptKey)

// See which providers are authenticated and which is the default.
statuses, _ := mgr.Status(ctx)

// List chat-capable models across every authenticated provider, sorted.
models, _ := mgr.ListAllModels(ctx)

// Persist the user's choice, preserving any routing metadata.
mgr.SetDefaultModel(models[0])

// Later, in your app:
p, _ := mgr.LoadDefault(ctx)

Recently selected models are tracked via RecentModels / TrackRecentModel so interactive pickers can surface them at the top of the list. Credentials are stored in ~/.config/stackllm/auth.json and preferences in ~/.config/stackllm/config.json (or the equivalent XDG_CONFIG_HOME path).

Providers

Wire any supported provider directly if you don't want the manager:

provider.OpenAIConfig("gpt-4o", auth.NewStatic(key))
provider.AzureConfig(endpoint, deployment, apiVersion, tokenSource)
provider.OllamaConfig("http://localhost:11434", "llama3")
provider.CopilotConfig("gpt-4o", auth.NewCopilotSource(cfg))
provider.GeminiConfig("gemini-2.5-pro", auth.NewStatic(key))
provider.GroqConfig("llama-3.3-70b-versatile", auth.NewStatic(key))
provider.OpenRouterConfig("openai/gpt-4o", auth.NewStatic(key))

Every provider shares the same Complete(ctx, Request) surface and returns a streaming channel of block events (BlockStart, BlockDelta, BlockEnd, ToolCall, Done, Error). Each BlockEnd carries the fully accumulated conversation.Block; the agent concatenates them in order to build the assistant message, preserving any interleaving of thinking, text, and tool_use the model produced.

Vendor-specific request fields

ExtraBody adds top-level fields the typed Request doesn't model. Set a default for every call on provider.Config.ExtraBody, per agent with agent.WithExtraBody / Agent.SetExtraBody, or per call on provider.Request.ExtraBody. Agent/request fields override config fields per key, a nil value removes a configured key, and model, messages, input and stream are reserved (Complete returns an error if you set them).

OpenRouter provider routing

OpenRouter serves most models from several upstream providers (Llama 3.3 70B, for example, is available from Groq, Together, DeepInfra, SambaNova and others). By default it picks one for you, balancing price and uptime. To choose for yourself, send OpenRouter's provider routing object through ExtraBody.

Providers are identified by lowercase slugs such as groq, together, deepinfra, sambanova, azure, openai and google-vertex. To see which providers serve a model, and their slugs, query its endpoints:

curl -s https://openrouter.ai/api/v1/models/meta-llama/llama-3.3-70b-instruct/endpoints

The fields you'll use most:

Field Type Effect
order []string Try these providers first, in this order
allow_fallbacks bool false stops OpenRouter from falling back to providers outside your list (default true)
only []string Allowlist: never use any other provider
ignore []string Blocklist: never use these providers
sort string "price", "throughput" or "latency", instead of the default balancing
require_parameters bool Only use providers that support every parameter you send (e.g. tools, reasoning_effort)
data_collection string "deny" skips providers that may store or train on your prompts
quantizations []string Restrict to precisions such as "fp8", "bf16", "fp16"

Preferred order, with fallback. Try Groq, then Together; if both are unavailable OpenRouter still routes the request elsewhere:

a := agent.New(p,
    agent.WithModel("meta-llama/llama-3.3-70b-instruct"),
    agent.WithExtraBody(map[string]any{
        "provider": map[string]any{
            "order": []string{"groq", "together"},
        },
    }),
)

Strict order, no fallback. Only Groq or Together will ever serve the request. If neither can, the call fails instead of silently going to another provider:

agent.WithExtraBody(map[string]any{
    "provider": map[string]any{
        "order":           []string{"groq", "together"},
        "allow_fallbacks": false,
    },
})

Allowlist. Let OpenRouter choose, but only among these providers:

agent.WithExtraBody(map[string]any{
    "provider": map[string]any{
        "only": []string{"azure", "openai"},
    },
})

Blocklist. Use any provider except these:

agent.WithExtraBody(map[string]any{
    "provider": map[string]any{
        "ignore": []string{"deepinfra", "novita"},
    },
})

Combining fields. Pick the cheapest of a shortlist, skip providers that retain data, and skip any that can't handle tool calls:

agent.WithExtraBody(map[string]any{
    "provider": map[string]any{
        "only":               []string{"groq", "together", "deepinfra", "sambanova"},
        "sort":               "price",
        "data_collection":    "deny",
        "require_parameters": true,
    },
})

Different providers for each model

Routing is per request, so keep a routing table keyed by model and switch it whenever you switch models. SetModel and SetExtraBody must not be called while a Run or Step is in progress:

routing := map[string]map[string]any{
    "meta-llama/llama-3.3-70b-instruct": {"order": []string{"groq", "sambanova"}, "allow_fallbacks": false},
    "openai/gpt-4o":                     {"only": []string{"azure"}},
    "deepseek/deepseek-chat-v3.1":       {"ignore": []string{"novita"}, "sort": "throughput"},
}

useModel := func(a *agent.Agent, model string) {
    a.SetModel(model)
    if r, ok := routing[model]; ok {
        a.SetExtraBody(map[string]any{"provider": r})
    } else {
        a.SetExtraBody(nil) // no routing: OpenRouter's default choice
    }
}

useModel(a, "openai/gpt-4o")
events, err := a.Run(ctx, msgs)

This works the same whether the provider came from profile.Manager (mgr.LoadDefault(ctx) / mgr.LoadProvider(ctx, "openrouter", model)) or was built directly.

A default for every call

When you build the provider yourself, you can set routing once on the config. Agent routing still overrides it, and a nil value drops it for particular requests:

cfg := provider.OpenRouterConfig("meta-llama/llama-3.3-70b-instruct", auth.NewStatic(os.Getenv("OPENROUTER_API_KEY")))
cfg.ExtraBody = map[string]any{
    "provider": map[string]any{"ignore": []string{"deepinfra"}},
}
p := provider.New(cfg)

// One call routed differently, without touching the config:
events, err := p.Complete(ctx, provider.Request{
    Messages:  msgs,
    Stream:    true,
    ExtraBody: map[string]any{"provider": map[string]any{"only": []string{"groq"}}},
})

// One call with the configured routing removed:
events, err = p.Complete(ctx, provider.Request{
    Messages:  msgs,
    Stream:    true,
    ExtraBody: map[string]any{"provider": nil},
})

The whole provider object is replaced, not merged field by field: a request that sets {"only": ["groq"]} does not keep the configured ignore list.

Shortcuts and account settings

  • Model suffixes. For speed or price alone, no ExtraBody is needed: append :nitro (highest throughput) or :floor (lowest price) to the model ID, e.g. openrouter/meta-llama/llama-3.3-70b-instruct:nitro as the default model, or a.SetModel("meta-llama/llama-3.3-70b-instruct:floor").
  • Account-wide lists. OpenRouter's account settings also have Allowed providers and Ignored providers. They apply to every request made with your key and cannot target individual models. A request's only can narrow the allowed list but not widen it, and its ignore is added to the account's ignored list.

Sessions

session.SessionStore is the persistence interface. InMemoryStore ships in the session package; the durable SQLite-backed sqlitestore.Store lives in the session/sqlitestore subpackage so embedders that bring their own store (Redis, Postgres, SQL Server, etc.) never link modernc.org/sqlite into their binary — the session package itself is guaranteed driver-free.

import "github.com/stack-bound/stackllm/session/sqlitestore"

store, _ := sqlitestore.Open(sqlitestore.Config{AppName: "myapp"})
defer store.Close()

sess := session.New()
sess.Name = "Refactor auth middleware" // optional, surfaced as a column
store.Save(ctx, sess)

loaded, _ := store.Load(ctx, sess.ID)
all, _   := store.List(ctx) // every session, ordered by Updated desc

Listing in pages

List(ctx) returns every row in one slice — fine for small UIs, but unworkable once a long-running embedder accumulates thousands of conversations. Stores that can paginate opt into the optional SessionPaginator capability:

type SessionPaginator interface {
    ListPage(ctx context.Context, opts ListOptions) (ListResult, error)
}

Both InMemoryStore and sqlitestore.Store implement it. Feature-detect via type assertion so custom stores stay free to omit it:

if p, ok := store.(session.SessionPaginator); ok {
    page, _ := p.ListPage(ctx, session.ListOptions{Limit: 25, Offset: 50})
    fmt.Printf("page 3 of %d (%d rows)\n",
        (page.Total+24)/25, page.Total)
    for _, s := range page.Sessions {
        fmt.Println(s.ID, s.Name)
    }
}
  • ListOptions{Limit, Offset} — Limit == 0 falls back to session.DefaultListLimit (50); a negative Limit returns every matching row (handy for "export everything"). Negative Offset is treated as 0.
  • ListResult{Sessions, Total} — Total is the row count ignoring Limit/Offset, so you can render "page X of Y" without a second query.
  • Sort order matches List: most-recently-updated first.
  • Like List, ListPage returns sessions with metadata populated and Messages empty — call Load for the rows you actually want to read in full.

Filters beyond pagination (name search, date range, project filter, etc.) are deliberately left to the embedder for now: the stackllm_sessions schema is documented and stable, so apps that need them can drop down to the shared *sql.DB returned by store.DB() and write the join their domain needs.

Packages

Package Purpose
conversation/ Block-shaped Message types, builder, context compaction
auth/ Token sources, storage, OAuth flows
config/ User preferences (default provider/model, provider settings)
profile/ Provider manager: login, status, model discovery, defaults
tools/ Tool interface, JSON Schema generation, registry
provider/ OpenAI-compatible LLM provider
agent/ ReAct agent loop with hooks
session/ Session state and persistence
tui/ Bubbletea terminal UI adapter
web/ HTTP/SSE server adapter

Examples

Runnable examples live in examples/.

Example Description
examples/login Interactive CLI for provider management — login, logout, status, browse models, and set the default. For OpenAI the menu prompts between device-code ChatGPT sign-in (default), browser ChatGPT sign-in, or an API key; subcommand shortcuts (login openai device, login openai web, login openai key, login copilot, status, models, default copilot/gpt-4o) are available for scripting.
examples/simple Minimal agent with greet and add tools. Walks the user through provider login and model selection on first run, then uses the persisted default on subsequent runs.
examples/copilot Direct Copilot wiring without the manager — shows the two-phase GitHub device flow and caching token source.
examples/tui Full Bubbletea TUI agent. Streams tokens as they arrive, renders tool calls and results inline, supports Ctrl+V image paste (inserts a [Image #N] placeholder and attaches a BlockImage on send), and supports slash commands: /models to switch provider/model at runtime (with recently-used models surfaced first), /new to start a fresh session. Uses the persisted default or falls back to OPENAI_API_KEY.
examples/sqlite Shared-DB demo for sqlitestore.Store: opens a single SQLite file, runs a parent-app migration (memories table), hands the same *sql.DB to sqlitestore.New, saves a conversation, and queries both namespaces to prove coexistence. No network calls — usable as a CI smoke test.
examples/web Browser-only embedding. Serves web.ManagedHandler under /api/* and a minimal single-page UI at / that drives provider login (API keys, Ollama URL, Copilot device flow, "Sign in with ChatGPT" for OpenAI), model selection, default setting, and streaming chat — all over HTTP with no TUI. The ChatGPT browser/PKCE flow is deliberately CLI-only because its OAuth callback lands on the user's localhost, not the server's — remote-hosted web UIs should use the device-code flow (which works regardless of where the server lives).

Run any of them with go run ./examples/<name>.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages