AD-011: AI Providers
Summary
The framework integrates LLM capabilities through an LLMProvider interface
in providers/ai/ (package aiprovider), with built-in implementations for
Anthropic, OpenAI, Azure OpenAI, Ollama, and Gemini (plus an offline demo
provider) and an optional StreamingLLMProvider extension for live thinking
progress. ollama is the built-in on-device backend (driving a local Ollama
runtime over HTTP). Plugins can register further providers at runtime, so the
live provider set is whatever aiprovider.Providers() returns, not a fixed list.
Messages are multimodal — content is an ordered list of text,
image, audio, or video parts — and each provider advertises the input modalities
it accepts, so a vision- or audio-capable model reads a Block's media anchor
(AD-002) directly. AI tools call providers directly;
throughput comes from config-driven batching and bounded concurrency inside the
tool, not from a separate worker-pool subsystem. A ChatStructured method with
JSON Schema enables reliable batch translation and other structured-output tasks.
Context
Modern LLMs are capable translators, reviewers, and terminology extractors. Treating them as a separate service loses the composability of the streaming pipeline: AI tools should sit alongside memory leverage, term enforcement, and QA in the same flow.
AI APIs come with practical constraints: rate limits, cost per token, transient failures, and variable latency. The framework's answer is to keep the provider interface thin and let the calling tool decide how much work to batch into a single request and how many requests to run in parallel. Workspace-scale orchestration — async job queues, multi-tenant quotas — belongs to a platform layer, not to the framework primitives.
Providers also differ in their structured-output mechanism: OpenAI and
Azure OpenAI use response_format: json_schema, Anthropic uses tool-use
with input_schema, Ollama uses format: json, and Gemini uses
response schema hints. A single interface must paper over these details
while giving tools a predictable contract.
Decision
LLMProvider interface
type LLMProvider interface {
Name() ProviderID
InputModalities() []Modality // non-text inputs accepted; text always
Translate(ctx context.Context, req TranslateRequest) (*TranslateResponse, error)
Chat(ctx context.Context, messages []Message) (*ChatResponse, error)
ChatStructured(ctx context.Context, messages []Message,
schema JSONSchema) (*ChatResponse, error)
Close() error
}
ChatStructured extends Chat with a JSON Schema constraint that forces
the provider to return structured output. The JSONSchema type includes
Name, Description, Schema (the JSON Schema definition), and a
Strict flag for providers that support strict validation.
Provider configuration is schema-driven: fields in AI tool configs generate
CLI flags automatically via schema.FromStruct(), removing the need for
manual flag registration.
Multimodal content
A Message's content is an ordered list of typed parts, so one interface carries
text, images, audio, and video:
type Message struct {
Role string // "system" | "user" | "assistant"
Parts []ContentPart
}
type ContentPart struct {
Kind ContentKind // closed set, named type
Text string // Kind == ContentText
Media *model.Media // otherwise — a bounded slice, carried by reference
}
type ContentKind string
const (
ContentText ContentKind = "text"
ContentImage ContentKind = "image"
ContentAudio ContentKind = "audio"
ContentVideo ContentKind = "video"
)
A media part carries its payload as a model.Media — the framework's
binary-reference type (AD-002), with precedence
BlobKey > URI > Data — not a bare []byte. A small slice rides inline (Data);
a larger one (a video clip) is a BlobKey/URI and is never forced into memory.
A single helper at the provider's HTTP boundary resolves the Media to the
backend's wire form (base64 inline, or a fetchable URL where the provider supports
it), so provider implementations stay storage-agnostic — they never read a
file or the blob store. This keeps one binary idiom across Media, plugin I/O,
and provider content.
A text-only message is a single text part, so translation, QA, and terminology
tools use the interface with no media parts — the common path carries no media
ceremony. Image, audio, and video parts carry a Block's media slice
(AD-002) into the prompt, which is what the multimodal
extraction refinement tier sends (AD-030).
Backends differ in which input modalities they accept, so InputModalities()
advertises a provider's reach (Modality being the image/audio/video
subset of ContentKind; text is always accepted) and a caller selects a provider
that fits rather than discovering the limit at call time:
| Provider | Accepts |
|---|---|
| Gemini | text, image, audio, video |
| OpenAI / Azure OpenAI | text, image (audio on audio-capable models) |
| Anthropic | text, image |
| Ollama (vision models) | text, image |
Built-in providers
| Provider | File | Notes |
|---|---|---|
| Anthropic | providers/ai/anthropic.go | Extended thinking support |
| OpenAI | providers/ai/openai.go | response_format JSON schema |
| Azure OpenAI | providers/ai/azureopenai.go | Managed Identity via TokenProvider |
| Ollama | providers/ai/ollama.go | On-device local models (GPU); no key. Streaming, format: json, options + keep_alive, reasoning disabled. Managed via kapi models ollama |
| Google Gemini | providers/ai/gemini.go | SSE streaming with includeThoughts |
Default models are deliberately absent from this table — they change with every model generation, and a hardcoded list here would be wrong within weeks. They live in the model catalog (below), and the current set is on the generated AI Models reference.
Two non-network providers round out the registry: a mock provider
(providers/ai/mock.go) for deterministic tests, and a demo provider
(providers/ai/demo.go) registered as demo that returns illustrative
output so the browser playground can run AI commands with no API keys. The
provider list is generated from the registry in providers/ai/provider.go
(Providers()), not hardcoded — the live set surfaces as the provider
option in the translate reference.
The model catalog
The models kapi supports are described in one place: providers/ai/models.json,
a curated catalog embedded into the framework and read as aiprovider.Models().
It is the single source of truth, and the rest derives from it.
Why data, and why curated. One catalog rather than model knowledge spread
over a DefaultXModel constant per provider, a prefix→ceilings map, and a price
table under scripts/batcheval — none of which answers the question a user
actually asks: is this model current, superseded, or retired, and since when?
The catalog carries the defaults and the ceilings (LimitsForModel resolves
through it; a test asserts every provider default is catalogued and marked
default_for that provider) as well as the lifecycle. It is data
because a model list hardcoded in Go goes stale silently; it is curated
because the vendors' APIs return only what is live today as a flat list of ids —
they do not say when a model entered neokapi, what replaced it, or when it retires.
Those are facts about our support, and only a human (or an agent reading a model
card) can supply them.
Each entry carries the model's provider, aliases, output/context ceilings, and
its lifecycle: status (active | superseded), introduced, superseded_by,
and retirement_date. There is no retired status: a model the provider stops
serving is removed from the catalog, not kept as a tombstone — the catalog is
the list of models kapi supports, and a dead model supports nothing. An announced
future retirement is a date on a still-live entry, shown as a warning. A model can
be one provider's current default while superseded elsewhere — Azure still defaults
to gpt-4o — and the catalog records that rather than papering over it; kapi models list and the /models page both surface it.
Recommended vs known. The catalog is descriptive, not an allowlist: naming a
model it does not list is never rejected — the string goes to the provider API as-is
(the provider accepts or 404s it), and LimitsForModel simply falls back to a
conservative batch size until an entry exists. So the catalog serves two audiences
at once, and a recommended flag (absent = yes) separates them. It stays true for
the models most projects should reach for, and is set false for a model that is
fully supported but a poor default — capable-but-premium (Opus, Gemini Pro, o3),
overkill for faithful content work, or off-task (Fable, tuned for creative writing).
A non-recommended model keeps its ceilings and is callable by name; it just sits
under "Advanced" on the /models page rather than in the primary list, which is
sorted Recommended → Advanced → Legacy (superseded). An active default can never
be non-recommended — a test enforces it.
Staying honest. Curation rots, so make check-models (scripts/modelcheck)
is the alarm: it lists what each provider serves today and reports any catalogued
model that is gone (remove it) or, with -candidates, any live model the catalog
omits. The live half needs provider credentials and stays a manual or
scheduled tool — a rate-limited provider must never be mistaken for a retired one,
the same false-cliff trap the batch eval guards against. The keyless
half — every published price must be for a catalogued model — is an ordinary unit
test, so it runs in make test. The /models page is generated from the catalog
through @neokapi/reference-data and gated by make check-reference-docs, so it
cannot describe a model the catalog no longer lists. Refreshing the catalog itself
is driven by scripts/prompts/update-model-catalog.md.
Each provider takes a Config struct with API key, base URL, model name,
and generation parameters (temperature, max tokens, etc.). Azure OpenAI
additionally accepts a TokenProvider function, enabling passwordless
access via Azure Managed Identity.
StreamingLLMProvider
An optional extension interface surfaces live progress events for providers that support them:
type StreamingLLMProvider interface {
LLMProvider
ChatStream(ctx context.Context, messages []Message,
onEvent func(ChatStreamEvent)) (*ChatResponse, error)
ChatStructuredStream(ctx context.Context, messages []Message,
schema JSONSchema, onEvent func(ChatStreamEvent)) (*ChatResponse, error)
}
type ChatStreamEvent struct {
Type StreamEventType // StreamEventThinking | StreamEventContent | StreamEventDone
Content string // text chunk (thinking summary or output content)
Usage TokenUsage // cumulative usage; populated on StreamEventDone
Model string // model name; populated on StreamEventDone
}
The streaming methods deliver progress events through an onEvent
callback and return the final aggregated *ChatResponse, rather than
exposing a channel directly.
UIs and CLI tools display live thinking progress from providers that
support it (Anthropic extended thinking, Gemini includeThoughts). A
provider that does not implement StreamingLLMProvider can still be
used — callers that need streaming check for the extension with a type
assertion.
Concurrency model
AI tools call the provider directly — provider.Translate() for a single
block, provider.ChatStructured() for a batch. There is no intervening
worker pool, rate limiter, or circuit breaker in the framework. Throughput is
a property of the tool's own configuration, illustrated by translate
(core/ai/tools/translate.go):
const (
DefaultBatchSize = 100
DefaultBatchConcurrency = 1
)
AITranslateConfig exposes BatchSize and BatchConcurrency as schema
fields, so they surface as CLI flags and flow config like any other tool
option. The tool's Process method chooses a path from those values:
- Block-by-block (
batchSize <= 1andconcurrency <= 1) — the defaultBaseTool.Processdrives oneprovider.Translate()call per translatable Block. Under a session it uses the simplest sequential skip/hydrate path (sessionHandleBlock):GetOverlayto skip already-translated Blocks,PutOverlayto write the result back. The batched path also honours session overlay caching, viaprocessBatchedWithSession, which pre-filters cached Blocks and writes overlays on the way out. - Batched (
processBatched) — drains all input Parts into a slice, selects the translatable Blocks (skipping already-translated ones whenSkipMatchedis set), groups them into batches ofbatchSize, and translates each batch in a singleChatStructured()call. Batches run under achan struct{}semaphore sized toBatchConcurrency, so at most that many LLM calls are in flight at once. All Parts are then written downstream in their original order; entries missing from the structured response fall back to individual per-blocktranslate()calls (oneprovider.Translate()per missing Block).
Streaming mode is orthogonal: when the provider implements
StreamingLLMProvider and an OnProgress callback is supplied, the tool
routes calls through ChatStream / ChatStructuredStream to surface live
thinking summaries (see below). Transient-failure handling (retry, backoff)
is left to the individual provider implementations and the underlying SDK;
the framework does not impose a uniform retry policy.
This in-tool batching is distinct from the ParallelBlockTool concurrency in
AD-004: Processing Engine, which parallelizes Part
dispatch across the pipeline rather than grouping Blocks into a single API
call.
AI tools
AI capabilities reach the pipeline as ordinary Tools
(AD-006: Tool System). On the CLI surface, translation is
a single translate command across every backend, and QA a single qa
command — the LLM is selected with --provider (the per-provider commands have
collapsed into that one flag), while the underlying LLMProvider interface is
unchanged:
| Tool | Purpose |
|---|---|
translate | Translate untranslated Blocks using an LLM |
qa --provider | LLM-judged check of translations for fluency, accuracy, terminology |
term-extract | Extract terminology candidates from source Blocks |
review | Review translations with explanations |
entity-extract | Extract entities and term candidates (hybrid LLM + NER) |
Because AI tools are ordinary Tools, they compose naturally:
Terminology-aware prompts
AI tools receive terminology context from upstream stages:
- Term annotations — when
term-lookuphas run, matched terms and their preferred translations appear in the prompt. - Entity annotations — when
entity-extracthas run, identified entities (with DNT flags and locale formatting hints) appear in the prompt context. - Term constraints — a dedicated terminology section lists preferred and forbidden terms applicable to the current Block's domain, product, and market.
Terminology enforcement is not just a post-translation validation step; it actively guides AI translation quality from the start.
Structured batch output
The batched translate path relies on ChatStructured() to make a
multi-block response unambiguous. The tool sends a numbered prompt
([1] …, [2] …) and constrains the response to a JSON Schema that returns
{ translations: [{ index, text }] } with additionalProperties: false and
strict: true. Index-text pairs eliminate the text-parsing ambiguity of
free-form output and let the tool re-associate each translation with its
source Block. Blocks whose source carries inline codes are rendered as
placeholder-tagged text before the call and reconstructed from the response
via ParseRunsPlaceholderText, so inline markup survives the round trip.
Prompt templates
Prompt templates live in core/ai/prompt/ as versioned Go files
using text/template:
translate.go— translation prompts with terminology and context (single and batched)qa.go— quality assurance check prompts
Tool-specific prompts that have not been factored into the shared prompt
package (e.g. the review prompt) are built inline in their tool, such as
core/ai/tools/review.go.
Templates are context-aware: they include surrounding Blocks for document context, term constraints from term lookup, memory matches from leveraging, and format metadata (HTML tag handling instructions, CDATA boundaries, etc.).
Credential resolution
AI providers read credentials at runtime from one of three sources:
- The CLI credential store (AD-013: Kapi CLI) — provider configs as JSON, API keys in the OS keychain.
- Environment variables —
ANTHROPIC_API_KEY,OPENAI_API_KEY, etc. - Explicit
--api-keyflag on CLI invocation.
Flag overrides store overrides environment. API keys never appear in project files.
Default provider resolution
Which provider a run uses when nothing names one is a separate question from
how it authenticates, and it has exactly one resolver:
config.ResolveAIDefault. It returns the provider, the model, and where the
value came from (env | config | none), and config.SetAIDefault /
ClearAIDefault are the matching single write path.
Precedence, matching every other config key: an explicit --provider/--model
flag or an inline/recipe value first, then KAPI_AI_PROVIDER /
KAPI_AI_MODEL, then the stored ai.provider / ai.model.
One resolver rather than six read sites, because six read sites had no shared answer to "what is configured, and where did it come from" — so a scope bug in the reader was invisible in all of them at once. Reporting the source alongside the value is what lets a diagnostic name the file or the environment variable to change, instead of asserting that nothing is configured.
The app config file is pinned to config.GlobalConfigFilePath() — the same
function the writers use — never resolved through a search path. A recipe is
project configuration and app config is per-machine, so the working directory is
not a config location: a search path reaching it would load the recipe as the
app config inside any project, since a kapi project's recipe is also named
kapi.yaml, and every stored default would read as empty.
$HOME/.config/kapi/kapi.yaml and /etc/kapi/kapi.yaml are read as
lower-precedence layers beneath the pinned file, so a hand-written config works.
(On Linux the first of those is the pinned path, since os.UserConfigDir
honours XDG; on macOS it resolves to ~/Library/Application Support, which is
why the two differ at all.)
Scope boundary
The framework's responsibility ends at the provider interface and the pipeline tools that call it. Server-side asynchronous job queues, multi-tenant quota enforcement, rate-limit budgets, and workspace-scale translation orchestration are a platform layer's concern, built on top of these framework primitives.
Consequences
- AI translation is a pipeline tool, not a separate system. It composes with all other tools without special orchestration.
- Ordering is meaningful: memory leverage before AI translation avoids re-translating exact matches, reducing cost.
- Terminology context flows through the pipeline via annotations, enabling AI tools to produce terminology-consistent translations from the start.
- Throughput tuning lives on the tool, not in a hidden subsystem: a
caller raises
BatchSizeto cut API call count andBatchConcurrencyto run batches in parallel, with no separate worker pool to configure. - Structured batch output gives the tool a reliable index-text contract, so large documents translate in far fewer calls without parsing ambiguity.
- Provider abstraction enables cost optimization: local Ollama for development, Claude or OpenAI for production.
- Prompt templates are centralized and testable. The mock provider enables deterministic tests without API calls.
- Azure Managed Identity eliminates API key management for production Azure deployments while the same interface continues to support key-based auth elsewhere.
ChatStructuredgives tools a reliable JSON contract across providers with very different structured-output mechanisms.
Related
- AD-002: Content Model — annotations on Blocks; the media anchor a multimodal message carries
- AD-030: Multimodal Extraction and LLM Refinement — the refinement tier that sends image/audio/video parts
- AD-004: Processing Engine — flow execution
and
ParallelBlockTool - AD-006: Tool System — Tool pattern
- AD-009: Content memory —
recyclefeeds context to AI tools - AD-010: Terminology — term annotations feed context to AI tools
- AD-012: MT Providers — complementary external MT services
- AD-013: Kapi CLI — credential store