AI Providers Setup Guide
Welcome to the TruvaG3 AI providers guide! This document explains how to configure AI providers for your agents and tools, from simple single-provider setups to production-ready multi-provider failover systems. Think of this as your complete reference for doing AI integration the right way.
Table of Contents
- Why This Guide Exists
- The Two Types of AI Clients
- Provider Aliases: The Clean Way to Configure
- Model Aliases: Portable Model Names
- Understanding Failover Behavior
- Operational Scenarios
- Kubernetes Deployment
- Troubleshooting Common Issues
- Issue 1: "No Providers Available" Error
- Issue 2: Wrong Model Being Used
- Issue 3: Failover Not Working
- Issue 4: Model Not Found After Failover
- Issue 5: Unexpected Provider Being Used
- Issue 6: Ollama Is Configured but Cloud Providers Still Win
- Issue 7: Concrete Model Name Breaks Chain Failover
- Issue 8: Streaming Text Arrives but the Request Does Not Finish Immediately
- Debugging and Observability
- Advanced Configuration
- Quick Reference
Why This Guide Exists
In a production system, AI integration is rarely as simple as "call OpenAI and hope for the best." You need to handle:
- Provider outages: What happens when OpenAI is down?
- Budget exhaustion: What happens when your API quota or spending limit is reached?
- Cost management: How do you use cheaper models in development?
- API key rotation: How do you change keys without redeploying?
- Regional routing: How do you route traffic to regional endpoints (e.g., EU data residency)?
Without a clear strategy, you end up with:
- Hardcoded API keys (security nightmare)
- No failover (single point of failure)
- Different code paths for different environments (maintenance nightmare)
This guide ensures every TruvaG3 deployment handles AI providers in a consistent, production-ready way.
The Two Types of AI Clients
TruvaG3 provides two ways to connect to AI providers. Understanding when to use each is the first decision you'll make.
Single Client: The Simple Path
A Single Client connects directly to one provider. It's the simplest approach and works great when you don't need failover.
import (
"github.com/truvaagents/truva-g3/ai"
_ "github.com/truvaagents/truva-g3/ai/providers/openai"
)
// The simplest possible setup - auto-detects from environment
client, err := ai.NewClient()
// Or explicitly choose a provider
client, err := ai.NewClient(
ai.WithProviderAlias("openai.groq"),
ai.WithModel("smart"),
)
Behind the scenes, when you call ai.NewClient() without arguments:
- The module checks registered providers in priority order
- Each provider's
DetectEnvironment()method checks for API keys - The first available provider wins
- You get a configured client without writing any configuration
When Single Client makes sense:
- Development and testing
- Simple applications where downtime is acceptable
- When you're locked into one provider (e.g., enterprise agreement)
- Background jobs where latency isn't critical
Chain Client: Production-Grade Resilience
A Chain Client tries multiple providers in order until one succeeds. It's the production-ready approach for systems that can't afford downtime.
import (
"github.com/truvaagents/truva-g3/ai"
_ "github.com/truvaagents/truva-g3/ai/providers/openai"
_ "github.com/truvaagents/truva-g3/ai/providers/anthropic"
)
// Option 1: Explicit chain — you control the order
client, err := ai.NewChainClient(
ai.WithProviderChain("openai", "anthropic", "openai.groq"),
)
// Option 2: Auto-detect — discovers providers from environment variables
// Builds chain in priority order from whatever API keys are available
client, err := ai.NewChainClient(
ai.WithChainLogger(logger),
)
// With OPENAI_API_KEY + ANTHROPIC_API_KEY + OPENROUTER_API_KEY +
// GROQ_API_KEY set:
// → chain = ["openai" (1000), "anthropic" (900),
// "openai.openrouter" (850), "openai.groq" (700)]
// Use it exactly like a single client
response, err := client.GenerateResponse(ctx, "Analyze this data...", nil)
Behind the scenes, when you make a request:
- Chain Client tries Provider 1 (OpenAI)
- If it fails with a retryable error, it tries Provider 2 (Anthropic)
- If that fails too, it tries Provider 3 (OpenRouter)
- It then tries Provider 4 (Groq)
- Returns the first successful response
- If all fail, returns one joined error containing every annotated entry failure (entry name and attempt number)
Auto-detect provider priorities (when no explicit chain is specified):
| Provider | Alias | Priority |
|---|---|---|
| OpenAI | openai | 1000 |
| Anthropic | anthropic | 900 |
| OpenRouter | openai.openrouter | 850 |
| Gemini | gemini | 800 |
| Groq | openai.groq | 700 |
| DeepSeek | openai.deepseek | 600 |
| xAI | openai.xai | 500 |
| Mistral | openai.mistral | 450 |
| Qwen | openai.qwen | 400 |
| Together | openai.together | 300 |
| Bedrock | bedrock | 200 |
| Ollama | openai.ollama | 100 |
Bedrock participates only in binaries built with -tags bedrock. Ollama
auto-detection requires an explicitly configured loopback OLLAMA_BASE_URL
whose /models endpoint responds; use an explicit alias for a remote Ollama
endpoint.
The key insight: Each provider in the chain resolves model aliases independently. When you pass Model: "smart", OpenAI resolves it to gpt-5.6-sol, Anthropic resolves it to claude-opus-5-5, and Groq resolves it to openai/gpt-oss-120b. You don't need different code for different providers.
When to Use Which
| Situation | Recommended | Why |
|---|---|---|
| Local development | Single Client | Simpler, faster iteration |
| Staging/testing | Chain Client (auto-detect) | Mirrors production env vars, tests failover |
| Production API | Chain Client | High availability is essential |
| Production (deterministic order) | Chain Client (explicit) | Locks failover order regardless of env changes |
| Production (ops-driven) | Chain Client (auto-detect) | Ops controls chain via which API keys are deployed |
| Background processing | Either | Depends on retry strategy |
| Cost-sensitive batch jobs | Chain Client (explicit) | Try cheap providers first in specific order |
| Compliance-restricted | Single Client | May not be allowed to send data to multiple providers |
The golden rule: If you'd lose money or users when AI is down, use Chain Client.
Provider Aliases: The Clean Way to Configure
What Problem Do They Solve?
Before provider aliases, configuring an OpenAI-compatible service looked like this:
// The old, messy way
client, _ := ai.NewClient(
ai.WithProvider("openai"),
ai.WithBaseURL("https://api.groq.com/openai/v1"), // Have to remember this
ai.WithAPIKey(os.Getenv("GROQ_API_KEY")), // Different env var
ai.WithModel("openai/gpt-oss-120b"), // Provider-specific model
)
Every new provider meant remembering URLs, env vars, and model names. And if Groq changed their URL? You'd have to update every project.
With provider aliases, it's one line:
// The clean way
client, _ := ai.NewClient(ai.WithProviderAlias("openai.groq"))
The framework knows that openai.groq means:
- Use
GROQ_API_KEYfor authentication - Connect to
https://api.groq.com/openai/v1 - Use Groq's model naming conventions
Complete Provider Alias Reference
| Alias | Type | Service | Credential configuration | Base URL / route configuration | Default URL |
|---|---|---|---|---|---|
openai | Native | OpenAI | OPENAI_API_KEY | OPENAI_BASE_URL | https://api.openai.com/v1 |
anthropic | Native | Anthropic Claude | ANTHROPIC_API_KEY | ANTHROPIC_BASE_URL | https://api.anthropic.com/v1 |
gemini | Native request-aware GenerateContent | Google Gemini | GOOGLE_API_KEY then GEMINI_API_KEY | GEMINI_BASE_URL or EndpointResolver | https://generativelanguage.googleapis.com/v1beta |
azureopenai.v1 | Hosted request-aware profile | Azure OpenAI v1 | CredentialSource, WithAuthHeader, or Azure WithAPIKey | Required EndpointResolver | Route-owned |
azureopenai.classic | Hosted request-aware profile | Azure OpenAI classic | CredentialSource, WithAuthHeader, or Azure WithAPIKey | Required EndpointResolver | Route-owned |
anthropic.vertex | Hosted request-aware profile | Claude on Vertex AI | Google CredentialSource or WithAuthHeader | Required EndpointResolver | Route-owned |
bedrock | Native SDK | AWS Bedrock | AWS SDK default configuration or WithAWSCredentials | AWS region/configuration plus optional SDK-destination EndpointResolver | Region-owned |
openai.groq | OpenAI-compatible | Groq | GROQ_API_KEY | GROQ_BASE_URL | https://api.groq.com/openai/v1 |
openai.openrouter | OpenAI-compatible | OpenRouter | OPENROUTER_API_KEY | OPENROUTER_BASE_URL | https://openrouter.ai/api/v1 |
openai.deepseek | OpenAI-compatible | DeepSeek | DEEPSEEK_API_KEY | DEEPSEEK_BASE_URL | https://api.deepseek.com |
openai.xai | OpenAI-compatible | xAI Grok | XAI_API_KEY | XAI_BASE_URL | https://api.x.ai/v1 |
openai.mistral | OpenAI-compatible | Mistral | MISTRAL_API_KEY | MISTRAL_BASE_URL | https://api.mistral.ai/v1 |
openai.qwen | OpenAI-compatible | Alibaba Qwen | QWEN_API_KEY | QWEN_BASE_URL | https://dashscope-intl.aliyuncs.com/compatible-mode/v1 |
openai.together | OpenAI-compatible | Together AI | TOGETHER_API_KEY | TOGETHER_BASE_URL | https://api.together.ai/v1 |
openai.ollama | OpenAI-compatible | Ollama (local) | (none) | OLLAMA_BASE_URL | http://localhost:11434/v1 |
Gemini credentials are sent in the x-goog-api-key header and never in the
request URL. Use an AI Studio auth key: Google rejects unrestricted Standard
keys and has announced that all Standard keys stop working in September 2026.
See Google's
Gemini API-key guide.
The Azure and Vertex aliases are request-aware-only, never auto-detected, and
do not accept a static base URL. Their resolver and credential recipes are in
CUSTOM_AI_PROVIDER_GUIDE.md.
Bedrock is build-tagged and SDK-native: its optional resolver selects an AWS
modelId, not an HTTP endpoint. See
AWS Bedrock SDK-Native Routing.
OpenRouter configuration
For auto-detection, a .env file needs only the key:
OPENROUTER_API_KEY=...
# Optional; omit to use https://openrouter.ai/api/v1
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OpenRouter has priority 850, immediately after Anthropic. If both
OPENROUTER_API_KEY and TOGETHER_API_KEY are present, an auto-detected
single client or chain tries OpenRouter first. Use an explicit provider chain
when environment presence should not determine order.
Adding OPENROUTER_API_KEY can change an unpinned ai.NewClient() or
ai.NewRequestClient() selection. When neither OpenAI nor Anthropic is
available, OpenRouter is selected ahead of Gemini and every remaining
provider. Use ai.WithProviderAlias("openai.openrouter") when provider
identity must be stable.
An OpenRouter model selection may be either a framework alias or an exact OpenRouter model slug. The built-in mappings and their independent environment overrides are:
| Framework selection | Built-in OpenRouter model | Environment override |
|---|---|---|
default or an unspecified model | openrouter/auto | TRUVAG3_OPENROUTER_MODEL_DEFAULT |
smart | openrouter/auto | TRUVAG3_OPENROUTER_MODEL_SMART |
fast | openai/gpt-6-luna | TRUVAG3_OPENROUTER_MODEL_FAST |
code | openrouter/pareto-code | TRUVAG3_OPENROUTER_MODEL_CODE |
Set only the aliases you want to change. For example, this .env pins GPT as
the application default, uses Kimi K3 for smart and code, and uses Gemini
for fast:
OPENROUTER_API_KEY=...
TRUVAG3_OPENROUTER_MODEL_DEFAULT=openai/gpt-5.6-sol
TRUVAG3_OPENROUTER_MODEL_SMART=moonshotai/kimi-k3
TRUVAG3_OPENROUTER_MODEL_FAST=google/gemini-2.5-flash
TRUVAG3_OPENROUTER_MODEL_CODE=moonshotai/kimi-k3
Model mappings are non-secret configuration: the same variables may live in a
local .env, a Kubernetes ConfigMap, or a Deployment env stanza. Keep only
OPENROUTER_API_KEY in a Secret.
These variables alter OpenRouter's model aliases; they do not force provider
selection when higher-priority provider keys are also present. Pin
openai.openrouter in code when the provider itself must be deterministic.
To make one exact model the constructor default for every call that omits a model:
client, err := ai.NewClient(
ai.WithProviderAlias("openai.openrouter"),
ai.WithModel("moonshotai/kimi-k3"),
)
To select an alias in code while retaining its .env override and built-in
fallback:
fastClient, err := ai.NewClient(
ai.WithProviderAlias("openai.openrouter"),
ai.WithModel("fast"), // _FAST override, otherwise openai/gpt-6-luna
)
To override the constructor default for one legacy-style call, supply an exact
model or alias in AIOptions:
response, err := client.GenerateResponse(ctx, prompt, &core.AIOptions{
Model: "google/gemini-2.5-flash",
})
The request-aware equivalent sets Generation.Model:
client, err := ai.NewRequestClient(
ai.WithProviderAlias("openai.openrouter"),
ai.WithModel("default"),
)
request := core.NewAIRequest(prompt, "plan_generation")
request.Generation.Model = "moonshotai/kimi-k3"
result, err := client.Generate(ctx, request)
Selection precedence is per-request model, then constructor WithModel, then
the constructor's implicit default alias. After an alias is selected, its
non-empty TRUVAG3_OPENROUTER_MODEL_{ALIAS} value wins over the built-in
mapping; an exact model slug passes through unchanged. Model availability is
provider-owned and can change, so confirm production slugs in OpenRouter's
model browser. The concrete Kimi and Gemini
examples above correspond to OpenRouter's current
Kimi K3 and
Gemini 2.5 Flash entries.
Every OpenRouter request protects provider.data_collection="deny" and
provider.zdr=true; portable reasoning or JSON output also protects
provider.require_parameters=true. A caller or request policy that conflicts
with or weakens those values is rejected before HTTP. Request-policy paths use
the documented canonical casing: variants such as /provider/ZDR or /Models
are rejected rather than emitted beside canonical fields. OpenRouter's top-level
request-body session_id controls upstream sticky routing; it is unrelated to
framework session_id baggage used for application correlation.
The adapter uses reviewed capability rows, not live model discovery, to validate portable options. The current exact OpenRouter rows are:
| Exact model ID | JSON object | JSON schema | Portable reasoning |
|---|---|---|---|
deepseek/deepseek-v4.1-flash | Yes | Not declared | Not declared |
z-ai/glm-5.3-flash | Yes | Not declared | Not declared |
openai/gpt-5.6-luna | Yes | Yes | OpenAI-style |
openai/gpt-6-luna | Yes | Yes | OpenAI-style |
openai/gpt-6.1-sol | Yes | Yes | OpenAI-style |
An undeclared feature is not automatically granted to other model IDs or
endpoints. Explicit unsupported portable options fail preparation; protected
require_parameters filters upstream endpoint eligibility when used. The exact
GPT-6 Luna and GPT-6.1 Sol routes each have a built-in temperature-removal rule:
default
requestpolicy.CompatibilityCompatible permits the adjustment (including
explicit values) and reports it; requestpolicy.CompatibilityStrict requires
application acknowledgement for an explicit value. This reuses the policy engine
rather than changing caller prompts.
For exact openai/gpt-6.1-sol, choose low, medium, high, xhigh or max,
or omit effort through the presence-aware API to use the model default. none
is rejected, including an inherited chain default. Its routed final validator
also rejects unsupported sampling/logprob controls and noncanonical parameter
names after policy. Shared codec validation still runs before policy, so explicit
sampling with active effort can fail before the removal rule; use explicit
temperature omission when constructing a presence-aware request. The routed
contract is separate from native OpenAI's tool restrictions. OpenRouter owns
upstream tool/API translation; this support does not add a native Responses
adapter. See the AI module's GPT-6.1 Sol setup and verification scope.
For this routed Sol contract, reasoning.enabled:false is also rejected.
enabled and exclude require booleans; exclude:true hides reasoning output
without disabling reasoning. A standalone positive-integer reasoning.max_tokens
is allowed for OpenRouter's budget-to-effort mapping, but combining it with
effort is rejected after policy. This includes inherited effort: explicitly
omit effort or remove the conflicting field through a request patch when using
the budget form. Values are validated without silently changing caller data.
For models without a declared OpenAI-style reasoning capability, effort inherited only from a client/chain default is omitted with a recorded adjustment and Debug diagnostic; explicit unsupported requests retain their error/warning contract. The capability table is an adapter contract, not a guarantee of current endpoint availability or live certification.
The built-in default, smart, and code aliases resolve to routers, so
requests using them are not AI-output-cache eligible. fast resolves to the
concrete openai/gpt-6-luna model and can be cache-eligible
when no native models fallback list is present. In a failover chain, one unstable
OpenRouter entry makes the entire ordered chain cache-ineligible.
The framework does not advertise a built-in free alias because the protected
privacy live probe failed for the tested free routes. Exact :free model IDs
and TRUVAG3_OPENROUTER_MODEL_FREE remain experimental pass-through options;
verify that the chosen route works with the protected privacy values before
depending on it, and consult OpenRouter's
current free-model and account limits rather
than assuming a fixed quota. When OpenRouter's native top-level models field
is present, it must be an array on every route. A free primary route additionally
requires every fallback to be an exact :free route; malformed or paid
fallbacks are rejected before transport.
How Auto-Configuration Works
When you use a provider alias, the framework resolves configuration in this order:
-
Explicit code configuration (highest priority)
ai.WithAPIKey("sk-explicit-key") // This wins -
Provider-specific environment variables
GROQ_API_KEY=gsk-from-env # Used if no explicit key -
Base URL overrides (for a provider-documented endpoint or an internal proxy)
GROQ_BASE_URL=https://ai-proxy.company.internal/groq/openai/v1 -
Default values (built into the framework)
- Default URL for the provider
- Default model aliases
Practical example: Your code uses openai.groq. In production, you set:
GROQ_API_KEY=gsk-prod-key
GROQ_BASE_URL=https://ai-proxy.company.internal/groq # Route through internal proxy
No code changes needed. The proxy gets all Groq traffic for logging/monitoring.
Model Aliases: Portable Model Names
The Model Name Problem
Every AI provider has different model names:
- OpenAI:
gpt-5.6-terra,gpt-5.6-luna,gpt-5.6-sol; native GPT-6 Astra/Sol/Luna and GPT-6.1 Sol are also supported explicitly - Anthropic:
claude-sonnet-5,claude-opus-5-5,claude-haiku-5-5 - Groq:
openai/gpt-oss-120b,llama-3.3-70b-versatile,llama-3.1-8b-instant
If you hardcode model names, switching providers means changing code everywhere. And when providers release new models? More code changes.
OpenAI's general default alias now selects GPT-6.1 Sol. Its fast alias selects
GPT-6 Luna, as does OpenRouter's fast alias (with the openai/ prefix).
Other alias mappings are unchanged. See the
AI module's family compatibility and override guide
for effort/sampling restrictions, explicit model selection and verification
scope. Native OpenAI and OpenRouter are separate capability routes.
Anthropic's cost-focused refresh selects Haiku 5.5 for fast and Opus 5.5 for
smart/code/vision, preserving Sonnet 5 for default and Fable 5 for
premium. These new selections use adaptive thinking; budget and native-extra
migration requirements are documented in the
Anthropic alias refresh.
Existing environment overrides still win; Vertex publisher mappings remain
application-owned. This does not change OpenRouter or Bedrock model selection.
The native OpenAI mapping is built in; the first assignment below is optional. OpenRouter still needs an explicit override to select Sol rather than its router:
# Native OpenAI (provider alias: openai; already the built-in default)
TRUVAG3_OPENAI_MODEL_DEFAULT=gpt-6.1-sol
# OpenRouter (provider alias: openai.openrouter)
TRUVAG3_OPENROUTER_MODEL_DEFAULT=openai/gpt-6.1-sol
Alternatively, supply the corresponding concrete ID with ai.WithModel or a
per-request model. Native hyphen-suffixed Sol variants/snapshots are recognized; the
OpenRouter row applies only to its exact reviewed ID. Alias selection does not
clear an inherited reasoning effort: replace an incompatible none with a
supported effort, or explicitly omit it through AIRequest.Generation.
Native reasoning budgets retain the configured multiplier; OpenRouter budgets
remain exact. No automatic model-output-limit clamp is applied.
This default is shared by any call that inherits it, including planning and
synthesis. Existing .env pins remain in force; set
TRUVAG3_OPENAI_MODEL_DEFAULT=gpt-5.6-terra to retain the previous model.
Azure/shared-catalog resolvers also receive gpt-6.1-sol for default; update
their semantic-model-to-deployment mapping or retain an explicit model pin.
Suffix matching includes names such as gpt-6.1-sol-pro; this selects family
wire rules, not verified support for that native model ID. OpenRouter's separate
openai/gpt-6.1-sol-pro listing is not covered by the exact base-model row.
Model aliases solve this by providing portable names:
// This code works across providers that define this portable alias.
client, _ := ai.NewClient(
ai.WithProviderAlias("openai"), // or "anthropic", or "openai.groq"
ai.WithModel("smart"), // Resolves to the right model for each provider
)
Standard Model Aliases
| Alias | Purpose | OpenAI | Anthropic | OpenRouter | Gemini | Groq | DeepSeek | Together |
|---|---|---|---|---|---|---|---|---|
default | General catalog choice | gpt-6.1-sol | claude-sonnet-5 | openrouter/auto | gemini-2.5-flash | openai/gpt-oss-120b | deepseek-chat | deepseek-ai/DeepSeek-V4-Flash-0731 |
fast | Latency-oriented catalog choice | gpt-6-luna | claude-haiku-5-5 | openai/gpt-6-luna | gemini-3.5-flash-lite | llama-3.1-8b-instant | deepseek-chat | google/gemma-4-31B-it |
smart | Reasoning-oriented catalog choice | gpt-5.6-sol | claude-opus-5-5 | openrouter/auto | gemini-3.1-pro-preview | openai/gpt-oss-120b | deepseek-reasoner | moonshotai/Kimi-K3 |
premium | Highest-tier catalog choice | gpt-5.6-sol | claude-fable-5 | (N/A) | gemini-3.1-pro-preview | (N/A) | (N/A) | (N/A) |
code | Code-oriented catalog choice | gpt-5.6-sol | claude-opus-5-5 | openrouter/pareto-code | gemini-3.1-pro-preview | openai/gpt-oss-120b | deepseek-chat | moonshotai/Kimi-K3 |
vision | Image-capable catalog choice | gpt-4.1 | claude-opus-5-5 | (N/A) | gemini-2.5-flash | (N/A) | (N/A) | (N/A) |
Note: The
premiumalias is available for OpenAI, Anthropic, and Gemini. Other built-in catalogs usesmartfor their reasoning-oriented choice.
The table reports the aliases compiled into this branch, not a guarantee that
every model is enabled in every account or region. Provider lifecycle and
availability can change independently; verify the current provider docs and
override an alias with TRUVAG3_<PROVIDER>_MODEL_<ALIAS> when needed.
Provider lifecycle alert (verified July 22, 2026): This branch still maps the DeepSeek aliases to
deepseek-chatanddeepseek-reasoner. DeepSeek will retire both model names after July 24, 2026 at 15:59 UTC and replaces them withdeepseek-v4-flashanddeepseek-v4-pro. Do not treat a model override as a complete migration: the framework capability catalog does not yet model V4'sthinkingobject or its reasoning-effort contract. Update and unit-test the model catalog, capability rows, and request translation before using the V4 reasoning controls in production. See DeepSeek's V4 announcement and thinking-mode contract.The Gemini
premiumcatalog entry namesgemini-3.1-pro-previewdirectly. Google shut down its predecessor,gemini-3-pro-preview, on March 9, 2026; the catalog does not rely on Google's redirect from that retired ID. See the official Gemini release notes. Live GenerateContent validation on August 17, 2026 found that Google rejectsgemini-2.5-flash-liteandgemini-2.5-profor new users and namesgemini-3.5-flash-liteandgemini-3.1-pro-previewas their replacements. The portablefast,smart, andcodealiases use those current targets; exact Gemini 2.5 IDs remain available to grandfathered projects where Google still permits them. The provider's dated GenerateContent capability snapshot also covers current in-scope models independently of the portable alias map. A model is removed only when Google's same-surface schedule gives an authoritative shutdown less than 45 calendar days away; refresh the snapshot and tests together.
Portable aliases are catalog-backed, not universal. The built-in OpenAI, Anthropic, Gemini, and registered OpenAI-compatible aliases above resolve them; Bedrock and an arbitrary custom provider do not unless that provider implements equivalent resolution. In a failover chain, use an alias only when every entry can resolve it.
Environment Variable Overrides
Here's where it gets powerful. You can override any alias at runtime without changing code:
# Pattern: TRUVAG3_{PROVIDER}_MODEL_{ALIAS}=actual-model-name
# Override OpenAI's "smart" alias
export TRUVAG3_OPENAI_MODEL_SMART=gpt-5.6-terra
# Override Anthropic's "fast" alias
export TRUVAG3_ANTHROPIC_MODEL_FAST=claude-haiku-5-5
# For OpenAI-compatible providers, strip the "openai." prefix
export TRUVAG3_GROQ_MODEL_DEFAULT=llama-3.1-8b-instant
# Use one concrete OpenRouter model as the default instead of its auto router
export TRUVAG3_OPENROUTER_MODEL_DEFAULT=openai/gpt-5.6-sol
# Do not override DeepSeek to V4 until the framework's V4 capability and
# thinking-mode translation has been updated and tested; see the alert above.
Why this matters for ops:
- Cost control: Set
TRUVAG3_OPENAI_MODEL_SMART=gpt-5.6-lunain dev to use the efficient tier - A/B testing: Route traffic to different models without code changes
- Rollback: If a new model has issues, switch back via env var
How Model Resolution Works
When you call client.GenerateResponse(ctx, prompt, &core.AIOptions{Model: "smart"}), here's the resolution order:
-
Environment variable (highest priority)
TRUVAG3_OPENAI_MODEL_SMART=gpt-4.1 # If set, use this -
Hardcoded alias mapping
modelAliases["openai"]["smart"] = "gpt-5.6-sol" // Built-in default -
Pass-through (lowest priority)
// If "smart" isn't recognized, use it literally// This lets you use explicit model names when needed
Example flow:
Request: Model="smart", Provider="openai"
↓
Check: TRUVAG3_OPENAI_MODEL_SMART env var?
→ Not set
↓
Check: modelAliases["openai"]["smart"]?
→ Returns "o3"
↓
Result: Use model "o3"
Understanding Failover Behavior
How Chain Client Decides to Failover
Not all errors should trigger failover. If your request is malformed, trying another provider won't help—you'll just get the same error three times.
Chain Client classifies completed provider failures using the
core.ProviderError interface, which carries structured metadata (HTTP status
code, provider name, model, transient flag, and provider-retryable flag) from
the AI provider layer.
Provider-owned route-resolution or invocation-viability errors may additionally
implement ai.AIRequestFailureReasoner and return the typed
ai.AIRequestFailureReasonRoute constant. This replaces fragile string
matching with type-safe classification via errors.As().
Errors that ALLOW failover (tries next provider):
| Error Type | core.ProviderError Classification | Why Failover Makes Sense |
|---|---|---|
| Authentication (401) | StatusCode() == 401 | Different providers have different keys |
| Authorization (403) | StatusCode() == 403 | Permission scopes differ per provider |
| Server errors (5xx) | StatusCode() >= 500 | Provider-specific outage |
| Rate limits (429) | StatusCode() == 429 | Limits are per-provider |
| Network errors | Non-ProviderError without cancellation/deadline identity | Might be routing/DNS issue |
| Transient proxy errors | IsTransient() == true | Proxy/CDN issue (e.g., Cloudflare HTML 400), not a request problem |
| Provider-specific terminal errors | IsRetryable() == true | Billing, hard quota, or account state may differ on another provider |
| Provider-classified HTTP timeout | IsTransient() or IsRetryable(), or an eligible 5xx status | A different provider may remain healthy; eligible OpenRouter HTTP/in-body 408 is classified as a retryable timeout |
| Provider route unavailable | AIRequestFailureReason() == ai.AIRequestFailureReasonRoute | Another independently routed entry may remain viable |
Errors that STOP failover (fails immediately):
| Error Type | core.ProviderError Classification | Why Failover Won't Help |
|---|---|---|
| Bad request (400) | StatusCode() == 400 && !IsTransient() && !IsRetryable() | Same input fails everywhere |
| Content policy | StatusCode() == 400 with policy message | Same content fails everywhere |
| Malformed input | StatusCode() == 400 with parse error | Structural issue in your code |
| Returned cancellation or deadline identity | errors.Is(err, context.Canceled) or context.DeadlineExceeded | Stops the chain, including a provider-call deadline even while the outer caller remains active |
A provider-returned timeout response with an active attempt context differs from an expired call context. The former can permit bounded recovery; returned context cancellation/deadline identity stops recovery. Buffered OpenAI-wire decoding preserves that identity through the error chain.
Transient proxy errors: When a request passes through a CDN or reverse proxy (e.g., Cloudflare) and the proxy itself returns an error (typically an HTML page with a 400 status), the framework marks this as
IsTransient() == true. These are infrastructure issues, not request problems, so failover to the next provider is appropriate. Thecore.ProviderErrorinterface distinguishes these from genuine API 400 errors.
Why Authentication Errors Allow Failover
This is a design decision that confuses some people. Traditionally, a 401 error means "your credentials are wrong, stop trying." But in a multi-provider chain, each provider has its own API key.
Consider this scenario:
Chain: ["openai", "anthropic", "openai.groq"]
Environment:
OPENAI_API_KEY=sk-expired-key # Oops, forgot to rotate
ANTHROPIC_API_KEY=sk-ant-valid # Works fine
GROQ_API_KEY=gsk-valid # Works fine
With traditional error handling:
Request → OpenAI → 401 "invalid key" → ERROR (stop)
User gets an error even though two providers would work
With Chain Client:
Request → OpenAI → 401 "invalid key" → Try next
→ Anthropic → Success!
User gets their response, ops gets alerted about OpenAI key
The tradeoff: You might make extra API calls before finding a working provider. But in production, uptime usually matters more than a few extra milliseconds.
The Options Isolation Problem (And How We Solved It)
This is a subtle bug that caused real production issues before we fixed it. Here's what happened:
The problem: When Provider 1 fails, Provider 2 receives Provider 1's resolved model name.
Step 1: Request with Model="smart"
Chain Client tries OpenAI
OpenAI resolves "smart" → "gpt-5.6-sol"
OpenAI fails with 401
Step 2: Chain Client tries Anthropic
Options still has Model="gpt-5.6-sol" (from OpenAI!)
Anthropic doesn't know "gpt-5.6-sol"
Anthropic uses default model instead of resolving "smart"
The fix: Chain Client now clones options for each provider and resets the model to the original value:
// Inside Chain Client (simplified)
originalModel := options.Model // Save "smart"
for _, provider := range providers {
providerOpts := cloneOptions(options)
providerOpts.Model = originalModel // Reset to "smart"
response, err := provider.GenerateResponse(ctx, prompt, providerOpts)
// Now each provider resolves "smart" independently
}
What this means for you: Model aliases work correctly during failover. You don't need to do anything special.
How Errors Propagate Through the Stack
Understanding the error propagation path is critical for ensuring ChainClient failover works correctly. Here's the full lifecycle of an AI provider error:
1. Provider HTTP call fails (e.g., OpenAI returns 429)
↓
2. ai/providers/base.go HandleError() wraps it as *providerError
(implements core.ProviderError: StatusCode=429, Provider="openai", Model="o3")
↓
3. ChainClient.isClientError() inspects via errors.As(err, &pe)
→ 429 is excluded from client errors → FAILOVER to next provider
↓
4. If ALL providers fail, ChainClient annotates every failure with its entry
name and attempt, joins them with errors.Join(), and wraps the joined error
with fmt.Errorf("...: %w", joined)
↓
5. Agent handler receives the error from orch.ProcessRequest()
→ errors.As(err, &pe) can find a matching ProviderError through the joined
wrapping tree (do not assume that match is the final attempt)
The key rule: Every layer in the stack uses %w verb when wrapping errors:
// CORRECT — preserves core.ProviderError for upstream callers
return fmt.Errorf("orchestration failed: %w", err)
// WRONG — destroys the ProviderError type, ChainClient can't classify it
return fmt.Errorf("orchestration failed: %s", err) // String conversion, type lost
return errors.New(err.Error()) // New error, type lost
return fmt.Errorf("orchestration failed: %v", err) // Value format, type lost
If any layer breaks the %w chain:
- ChainClient can't classify the error → defaults to failover (safe but wasteful for true client errors)
- Agent handler can't extract the status code → falls back to 500 (misleading to callers)
- LLM debug store can't extract provider/model → error records have empty metadata
What this means for agent developers: When you wrap errors from orchestrator or AI client calls, always use %w. The framework's own code already follows this rule — you just need to maintain it in your handler code.
See also: AGENT_DEVELOPMENT_GUIDE.md for the complete handler pattern showing
core.ProviderErrorextraction.
Operational Scenarios
This section covers real-world deployment scenarios. Find the one that matches your situation.
Scenario 1: Local Development with Ollama
Goal: Develop without cloud API costs, test offline
Setup on your laptop:
# Install and start Ollama
ollama serve
# Pull a model
ollama pull llama3.2
# Your .env file
OLLAMA_BASE_URL=http://localhost:11434/v1
TRUVAG3_OLLAMA_MODEL_DEFAULT=llama3.2
TRUVAG3_OLLAMA_MODEL_SMART=llama3.2
Code:
// Single client is fine for local dev
client, err := ai.NewClient(ai.WithProviderAlias("openai.ollama"))
Setup from Kind / OrbStack on your laptop:
# Pods need to reach the Ollama server running on your host
OLLAMA_BASE_URL=http://host.docker.internal:11434/v1
TRUVAG3_OLLAMA_MODEL_DEFAULT=llama3.2
If you want Ollama to win over cloud providers:
client, err := ai.NewChainClient(
ai.WithProviderChain("openai.ollama", "openai"),
)
If you rely on auto-detect and also set OPENAI_API_KEY, ANTHROPIC_API_KEY,
or other higher-priority providers, Ollama will usually be used only as a fallback.
Useful notes:
openai.ollamadoes not require an API key.- Use the exact model tag installed in your Ollama instance. If you use a different tag, set the alias override to that exact value.
openai.ollamacan now accept reasoning controls through TruvaG3, but support is still model-dependent.
Pro tip: Create a make dev target that starts Ollama:
dev:
ollama serve &
OLLAMA_BASE_URL=http://localhost:11434/v1 go run .
Scenario 2: Development with Cloud Providers
Goal: Use cloud AI for development, but minimize costs
Setup:
# .env.development
GROQ_API_KEY=gsk-dev-key
# Use smaller/cheaper models in dev
TRUVAG3_GROQ_MODEL_DEFAULT=llama-3.1-8b-instant
TRUVAG3_GROQ_MODEL_SMART=llama-3.1-8b-instant
Code:
// Use the provider selected for your development latency and cost target.
client, err := ai.NewClient(ai.WithProviderAlias("openai.groq"))
Groq exposes an OpenAI-compatible API and is often selected for low-latency development. Quotas and prices are provider- and account-specific and change independently of TruvaG3; confirm the current terms before choosing it for cost.
Scenario 3: Staging Environment
Goal: Mirror production setup, test failover, validate before deployment
Setup:
# .env.staging
# Use same providers as production
OPENAI_API_KEY=sk-staging-key
ANTHROPIC_API_KEY=sk-ant-staging-key
GROQ_API_KEY=gsk-staging-key
# But use mid-tier models to save costs
TRUVAG3_OPENAI_MODEL_SMART=gpt-5.6-terra
TRUVAG3_ANTHROPIC_MODEL_SMART=claude-haiku-5-5
Code:
// Same chain as production
client, err := ai.NewChainClient(
ai.WithProviderChain("openai", "anthropic", "openai.groq"),
)
Testing failover in staging:
# Temporarily break OpenAI to test failover
export OPENAI_API_KEY=sk-invalid-key
# Make a request - should succeed via Anthropic
curl -X POST http://staging:8080/api/analyze
# Check context-aware framework/ai logs for:
# operation=ai_chain_provider_failed entry_name=openai error_type=credential
# operation=ai_chain_failover_success successful_entry=anthropic
Scenario 4: Production with High Availability
Goal: Maximum uptime, automatic failover, production-approved models
Setup:
# .env.production
OPENAI_API_KEY=sk-prod-key
ANTHROPIC_API_KEY=sk-ant-prod-key
GROQ_API_KEY=gsk-prod-key
# Use the models approved for this production workload
TRUVAG3_OPENAI_MODEL_SMART=gpt-5.6-sol
TRUVAG3_ANTHROPIC_MODEL_SMART=claude-opus-5-5
TRUVAG3_GROQ_MODEL_SMART=openai/gpt-oss-120b
Code:
client, err := ai.NewChainClient(
ai.WithProviderChain("openai", "anthropic", "openai.groq"),
)
Monitoring production failover:
# Alert on sustained increases in the exported ai.chain.failover counter.
# Exported Prometheus names depend on your OpenTelemetry collector/exporter
# normalization. The framework metric has bounded module/status/reason labels;
# identify the affected entry from correlated logs or spans, not a metric label.
Scenario 5: Cost-Optimized Production
Goal: Minimize AI costs while maintaining quality
Setup:
# .env.production-cost-optimized
# Supply credentials for providers ordered below by your verified current cost
GROQ_API_KEY=gsk-key
OPENAI_API_KEY=sk-key
ANTHROPIC_API_KEY=sk-ant-key
# Use smaller models where acceptable
TRUVAG3_GROQ_MODEL_DEFAULT=llama-3.1-8b-instant
Code:
// Example cost-oriented chain. Validate this order against current contracts,
// regions, quotas, and measured output quality before deployment.
client, err := ai.NewChainClient(
ai.WithProviderChain("openai.groq", "openai", "anthropic"),
)
Cost monitoring:
// Log token usage per request for cost analysis
response, err := client.GenerateResponse(ctx, prompt, nil)
if err != nil {
return fmt.Errorf("generate AI response: %w", err)
}
logger.Info("Request completed", map[string]interface{}{
"model": response.Model, // Which model was used
"tokens": response.Usage.TotalTokens,
})
// Chain entry tracking is available on ai.chain.provider_attempt spans and
// on the parent ai.chain.generate span's ai.chain.successful_entry attribute.
TruvaG3 records normalized token usage, not an estimated USD amount. For authoritative spend reporting, join provider/model/token telemetry with the provider's billing export and your negotiated pricing.
Scenario 6: Privacy-First Deployment
Goal: Keep sensitive data local, use cloud only as fallback
Setup:
# .env.production-privacy
# Local model is primary
OLLAMA_BASE_URL=http://gpu-server.internal:11434/v1
# Cloud fallback for when local is overloaded
OPENAI_API_KEY=sk-key
# Use capable local model
TRUVAG3_OLLAMA_MODEL_DEFAULT=your-approved-model:tag
TRUVAG3_OLLAMA_MODEL_SMART=your-approved-model:tag
Code:
// Privacy-first: local → cloud
client, err := ai.NewChainClient(
ai.WithProviderChain("openai.ollama", "openai"),
)
Privacy monitoring (for compliance):
// Track route use via sanitized framework/ai logs and traces.
// ai.chain.generate carries ai.chain.successful_entry; each
// ai.chain.provider_attempt span carries ai.chain.entry_name and status.
// Entry/provider names are intentionally not metric labels because they are
// operator-controlled and would create unbounded time series.
Scenario 7: Multi-Region Deployment
Goal: Route to nearest provider, handle regional outages
Setup:
# .env.production-us
OPENAI_API_KEY=internal-us-token
OPENAI_BASE_URL=https://ai-gateway.us.company.internal/openai/v1
ANTHROPIC_API_KEY=sk-ant-backup
# .env.production-eu
OPENAI_API_KEY=internal-eu-token
OPENAI_BASE_URL=https://ai-gateway.eu.company.internal/openai/v1
ANTHROPIC_API_KEY=sk-ant-backup
Code (same for all regions):
client, err := ai.NewChainClient(
ai.WithProviderChain("openai", "anthropic"),
)
The key insight: Same code, different environment variables per region. Use only endpoints documented by the provider or owned by your organization; do not infer a regional vendor hostname. For Azure OpenAI, Google Cloud OpenAI compatibility, and Vertex-hosted Claude, use the validated resolver recipes in CUSTOM_AI_PROVIDER_GUIDE.md.
Kubernetes Deployment
Managing API Keys with Secrets
Never put API keys in ConfigMaps or environment variables directly. Use Kubernetes Secrets:
# secrets.yaml
apiVersion: v1
kind: Secret
metadata:
name: ai-api-keys
namespace: production
type: Opaque
stringData:
OPENAI_API_KEY: "sk-prod-..."
ANTHROPIC_API_KEY: "sk-ant-prod-..."
OPENROUTER_API_KEY: "..."
GROQ_API_KEY: "gsk-prod-..."
Apply it:
kubectl apply -f secrets.yaml
Reference in deployment:
spec:
containers:
- name: app
envFrom:
- secretRef:
name: ai-api-keys
The repository example setup scripts normally copy every configured provider
credential from .env into the example's Kubernetes Secret, enabling ordered
failover. For an isolated provider test, select one credential group for that
setup invocation without editing .env:
TRUVAG3_SETUP_AI_PROVIDER=together ./setup.sh rollout
TRUVAG3_SETUP_AI_PROVIDER is consumed only by
examples/k8-deployment/setup-env-lib.sh and is excluded from the runtime
ConfigMap. Omit it on the next rollout to restore all configured providers.
Managing Model Aliases with ConfigMaps
Model aliases aren't secrets—they can go in ConfigMaps:
# configmap-dev.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: ai-model-config
namespace: development
data:
TRUVAG3_OPENAI_MODEL_SMART: "gpt-5.6-terra"
TRUVAG3_ANTHROPIC_MODEL_SMART: "claude-haiku-5-5"
TRUVAG3_OPENROUTER_MODEL_DEFAULT: "google/gemini-2.5-flash"
TRUVAG3_OPENROUTER_MODEL_SMART: "google/gemini-2.5-flash"
TRUVAG3_GROQ_MODEL_DEFAULT: "llama-3.1-8b-instant"
---
# configmap-prod.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: ai-model-config
namespace: production
data:
TRUVAG3_OPENAI_MODEL_SMART: "gpt-5.6-sol"
TRUVAG3_ANTHROPIC_MODEL_SMART: "claude-opus-5-5"
TRUVAG3_OPENROUTER_MODEL_DEFAULT: "moonshotai/kimi-k3"
TRUVAG3_OPENROUTER_MODEL_SMART: "moonshotai/kimi-k3"
TRUVAG3_GROQ_MODEL_DEFAULT: "openai/gpt-oss-120b"
Same Image, Different Behavior
The power of this setup: one container image works in all environments.
# deployment.yaml (same for dev, staging, prod)
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-ai-service
spec:
template:
spec:
containers:
- name: app
image: my-registry/my-ai-service:v1.2.3 # Same image everywhere
envFrom:
- secretRef:
name: ai-api-keys # Different per namespace
- configMapRef:
name: ai-model-config # Different per namespace
Promotion workflow:
# Dev → Staging (same image, different config)
kubectl apply -f configmap-staging.yaml -n staging
# Staging → Prod (same image, different config)
kubectl apply -f configmap-prod.yaml -n production
Rolling Updates Without Downtime
When you need to change models or API keys:
# Update the ConfigMap
kubectl edit configmap ai-model-config -n production
# Change TRUVAG3_OPENAI_MODEL_SMART from "o3" to "gpt-4.1"
# Trigger rolling restart
kubectl rollout restart deployment/my-ai-service -n production
# Watch the rollout
kubectl rollout status deployment/my-ai-service -n production
Pods restart one at a time, picking up the new environment variables. Zero downtime.
Troubleshooting Common Issues
Issue 1: "No Providers Available" Error
Symptom: Auto-detected NewChainClient returns "no providers detected
(check API keys)", or an explicit chain cannot construct any entry.
Cause: Auto-detection found no configured provider. In an explicit chain, construction fails only when entries cannot be materialized (for example, an unregistered alias or invalid endpoint configuration). Most direct HTTP providers do not preflight a missing API key during construction; that error is returned when the entry is first called and can then fail over.
Diagnosis:
# Check which env vars are set
echo "OPENAI_API_KEY: ${OPENAI_API_KEY:-(not set)}"
echo "ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-(not set)}"
echo "GROQ_API_KEY: ${GROQ_API_KEY:-(not set)}"
Fix: For auto-detection, set at least one provider credential (or configure and start loopback Ollama):
export OPENAI_API_KEY=sk-your-key
In Kubernetes:
# Check if secret is mounted
kubectl exec -it pod/my-ai-service-xxx -- env | grep API_KEY
Issue 2: Wrong Model Being Used
Symptom: Logs show gpt-5.6-luna but you expected gpt-5.6-sol
Cause: Environment variable override is taking precedence
Diagnosis:
# Check for overrides
env | grep TRUVAG3_
# Look for:
TRUVAG3_OPENAI_MODEL_SMART=gpt-5.6-luna # This overrides the default!
Fix: Clear the override or set it to the model you want:
unset TRUVAG3_OPENAI_MODEL_SMART
# or
export TRUVAG3_OPENAI_MODEL_SMART=gpt-5.6-sol
Issue 3: Failover Not Working
Symptom: Request fails immediately instead of trying next provider
Cause: Error is classified as "client error" (non-retryable)
Diagnosis: Check logs for error classification:
{"message": "Provider failed", "error": "bad request: invalid prompt", "is_client_error": true}
Understanding: is_client_error: true means the error is in your request, not the provider. Failover won't help because the same request will fail everywhere.
Fix: Fix the underlying request issue (malformed JSON, invalid parameters, etc.)
Issue 4: Model Not Found After Failover
Symptom: First provider fails, second provider says "model not found"
Cause: You might be running an old version with the options mutation bug
Diagnosis: Check the logs:
{"provider": "anthropic", "model": "o3"} // Anthropic shouldn't see "o3"!
Fix: Update to the latest TruvaG3 version. The options cloning fix was added in December 2025.
Issue 5: Unexpected Provider Being Used
Symptom: You expected OpenAI but Groq handled the request
Cause: Auto-detection picked a different provider based on available env vars
Diagnosis:
# If OPENAI_API_KEY isn't set but GROQ_API_KEY is...
# Auto-detection will use Groq
echo "OPENAI_API_KEY: ${OPENAI_API_KEY:-(not set)}"
echo "GROQ_API_KEY: ${GROQ_API_KEY:-(not set)}"
Fix: Either set the expected API key, or use explicit provider alias:
// Force OpenAI specifically
client, err := ai.NewClient(ai.WithProviderAlias("openai"))
Issue 6: Ollama Is Configured but Cloud Providers Still Win
Symptom: OLLAMA_BASE_URL is set, but requests still use OpenAI or Anthropic.
Cause: Auto-detect uses provider priority. Ollama is lower priority than cloud providers.
Fix: Use an explicit chain if you want local models first:
client, err := ai.NewChainClient(
ai.WithProviderChain("openai.ollama", "openai", "anthropic"),
)
Issue 7: Concrete Model Name Breaks Chain Failover
Symptom: The first provider fails and the second fails with 404 model not found.
Cause: A provider-specific concrete model name was used with ChainClient.
Fix: Use portable aliases like default, fast, or smart only when every
entry in the failover chain defines that alias. Bedrock and arbitrary custom
providers require an explicit strategy because they do not inherit the
built-in OpenAI/Anthropic/Gemini catalogs.
Issue 8: Streaming Text Arrives but the Request Does Not Finish Immediately
Symptom: The user sees streamed text, but the final done metadata takes longer.
Cause: Post-synthesis hooks such as user-memory extraction still run before the streaming request is considered complete.
What to know:
- The streamed text is already on screen
- The remaining delay is usually post-processing work, not the main synthesis call
- Token usage reported at the end may include these late hook calls
Debugging and Observability
Enabling Debug Logging
For detailed provider resolution logs, set the debug environment variable:
# Enable debug logging for all TruvaG3 components
export TRUVAG3_DEBUG=true
# Or set log level directly
export TRUVAG3_LOG_LEVEL=debug
Alternatively, use a custom logger with your chain client:
import "github.com/truvaagents/truva-g3/core"
// Create a production logger with debug config
logger := core.NewProductionLogger(
core.LoggingConfig{Level: "debug", Format: "json"},
core.DevelopmentConfig{DebugLogging: true},
"my-service",
)
// Pass to chain client
client, err := ai.NewChainClient(
ai.WithProviderChain("openai", "anthropic"),
ai.WithChainLogger(logger),
)
Understanding AI Module Logs
AI module logs use the component prefix framework/ai. Here's what to look for:
Successful provider response:
{
"component": "framework/ai",
"level": "INFO",
"message": "AI response received",
"operation": "ai_response",
"provider": "openai",
"provider_alias": "openai",
"model": "o3",
"prompt_tokens": 150,
"completion_tokens": 200,
"total_tokens": 350,
"status": "success",
"duration_ms": 1250
}
Failover event:
{
"component": "framework/ai",
"level": "WARN",
"message": "Provider failed in chain, trying next",
"operation": "ai_chain_provider_failed",
"entry_name": "openai",
"attempt": 1,
"remaining": 1,
"failover_reason": "auth",
"error": "AI provider request failed: provider_client",
"error_type": "provider_client"
}
All providers exhausted:
{
"component": "framework/ai",
"level": "ERROR",
"message": "All chain providers exhausted",
"operation": "ai_chain_exhausted",
"entries_tried": 3,
"failed_entries": ["openai", "anthropic", "openai.groq"],
"failover_reason": "rate_limit",
"error": "AI provider request failed: provider_rate_limit",
"error_type": "provider_rate_limit"
}
These observation errors are deliberately sanitized; the original typed error
is still returned to the caller. request_id is included when the supplied
context carries it (framework handlers and orchestration normally seed it).
For billing, hard-quota, and account-action alerts, match the bounded
failover_reason=provider_retryable field across generate and stream failover
and exhaustion operations. A provider may report insufficient_quota as HTTP
429; the provider-retryable classification intentionally takes precedence over
the generic rate_limit failover reason in that case.
Tracing AI Requests in Jaeger
If you have telemetry enabled, AI requests create spans:
- Open Jaeger:
http://localhost:16686 - Select your service
- Find traces with
ai.generateorai.streamspans - Expand to see:
ai.provider: Which provider handled the requestai.model: Resolved model nameai.prompt_tokens,ai.completion_tokens: Token usageai.request.prepared: Sanitized request-policy preparation eventai.generate_response/ai.stream_response: Provider executionai.http_attempt: Each HTTP retry attempt
Advanced Configuration
This section covers advanced configuration options for fine-tuning AI client behavior, particularly for reasoning models and long-running requests.
Portable Fields vs Provider-Specific Escape Hatches
Most requests should use the portable core.AIOptions fields:
ModelTemperatureMaxTokensSystemPromptReasoningEffortResponseFormat
When a provider exposes a feature TruvaG3 does not model directly yet, use:
Extra map[string]interface{}Headers map[string]string
Example:
resp, err := client.GenerateResponse(ctx, prompt, &core.AIOptions{
Model: "smart",
ResponseFormat: "json",
Extra: map[string]interface{}{
"top_p": 0.9,
},
Headers: map[string]string{
"anthropic-beta": "context-1m-2025-08-07",
},
})
Precedence rules:
- Framework-managed request fields win over everything else
- Per-request
Extra/Headersoverride client defaults - Protected headers such as auth and content type are not user-overridable
- The same resolved custom headers are applied to sync and streaming requests
Use escape hatches sparingly. If a field is portable, prefer the typed option.
Request-Aware and Custom Integrations
There are two separate decisions here:
- How much request intent must TruvaG3 preserve? The ordinary API carries portable values such as model, temperature, and max tokens. The request-aware API can also say "send zero," "omit this field," or "apply this named policy."
- Who owns the provider protocol? A built-in provider can still be used with enterprise credentials and routing. You need a custom provider only when TruvaG3 does not already own the correct provider identity, wire format, SDK integration, or validation rules.
Request-aware does not automatically mean custom:
Ordinary built-in call
Prompt + AIOptions
└── built-in provider
Advanced built-in call
AIRequest + policy/routing/credentials
└── the same built-in provider
New provider integration
AIRequest + policy/routing/credentials
└── your provider factory + adapter
Most applications should stay with the first path. Move to the second or third only when the request or integration has behavior that the ordinary setup cannot preserve:
| Requirement | Use |
|---|---|
| Built-in provider, portable generation options, or homogeneous failover | NewClient or NewChainClient; continue using this guide |
| Explicit zero or omission, request policy, rotating credentials, or per-request routing | NewRequestClient |
| Independently configured or caller-owned failover entries | NewChain |
| New provider identity, custom wire contract, or SDK-native adapter | A custom request-aware provider factory |
Here are three concrete examples:
- You use OpenAI with a normal API key: stay with
NewClient. - Your company routes OpenAI through regional gateways and issues a new token
for every attempt: keep the built-in OpenAI provider and use
NewRequestClientwith routing and credential hooks. - Your company has an internal model platform with its own SDK and response format: implement a custom request-aware provider.
NewRequestClient accepts existing options such as WithProvider,
WithModel, and WithTimeout because every AIOption also satisfies
ClientOption:
client, err := ai.NewRequestClient(
ai.WithProvider("openai"),
ai.WithModel("smart"),
)
request := core.NewAIRequest("Extract the entities", "entity_extraction")
request.Generation.Temperature = core.SetAIParameter(float32(0))
request.Generation.TopP = core.OmitAIParameter[float32]()
request.Generation.ResponseFormat = core.SetAIParameter("json")
result, err := core.GenerateAI(ctx, client, request)
The zero value of AIParameter means inherit, SetAIParameter means send the
value even when it is zero, and OmitAIParameter means the provider field must
be absent. If a provider or a legacy fallback cannot preserve that intent, the
call returns core.ErrAIRequestFeatureUnsupported; it never silently drops the
field.
Request-aware built-in construction currently supports direct OpenAI,
Anthropic, and Gemini, Azure OpenAI (azureopenai.v1 and azureopenai.classic),
Google-hosted Claude (anthropic.vertex), and Bedrock when built with
-tags bedrock. The Azure and Vertex profiles are request-aware-only and
require endpoint and credential sources; they do not auto-detect. The legacy NewClient, GenerateResponse,
and NewChainClient APIs remain supported for existing and simple portable
calls.
The Custom AI Providers and Enterprise Integration Guide starts with the second example and shows when it becomes necessary to move to the third. It explains policy and middleware, enterprise credential and route hooks, heterogeneous chains, custom factories, reusable OpenAI-compatible codecs, retry-body requirements, and semantic cache fingerprints.
AWS Bedrock SDK-Native Routing
Build with -tags bedrock and import the provider package so its factory is
registered. With no model option, Bedrock uses the direct
anthropic.claude-sonnet-5 model in the default us-east-1 region. Because
AWS currently documents that bare model for direct in-region use only there,
request preparation in another region rejects an invocation that would
actually use the implicit default. Client construction remains valid when the
deployment supplies a model on every request. Supply an explicit supported
client model, per-request model/profile ID, or endpoint resolver:
import (
"context"
"fmt"
"github.com/truvaagents/truva-g3/ai"
"github.com/truvaagents/truva-g3/ai/providers/bedrock"
)
client, err := ai.NewClient(
ai.WithProvider("bedrock"),
ai.WithRegion("us-east-1"),
ai.WithModel(bedrock.ModelClaudeSonnet5),
)
For direct package construction with bedrock.NewClient, call
client.SetDefaultModel(modelID) before concurrent use. That method records
that the default was an explicit application routing choice; assigning the
embedded DefaultModel field alone does not.
TruvaG3 never silently changes that direct ID to us., global., or another
inference profile. AWS documents in-region, geographic cross-region, and global
cross-region routing as different choices. Select one explicitly when your
region, throughput, residency, IAM/SCP, or pricing requirements call for it.
For request-aware routing, ResolvedEndpoint.Deployment is the exact AWS
modelId supplied to Converse and RouteIdentity is a stable, non-secret cache
identity. Resolver maps are keyed by the post-default semantic model ID:
type bedrockRoute struct {
modelID string
identity string
}
type bedrockRoutes map[string]bedrockRoute
func (routes bedrockRoutes) ResolveEndpoint(
_ context.Context,
request ai.EndpointRequest,
) (ai.ResolvedEndpoint, error) {
route, ok := routes[request.ResolvedModel]
if !ok {
return ai.ResolvedEndpoint{}, fmt.Errorf(
"no Bedrock route for semantic model %q",
request.ResolvedModel,
)
}
return ai.ResolvedEndpoint{
Deployment: route.modelID,
RouteIdentity: route.identity,
}, nil
}
client, err := ai.NewRequestClient(
ai.WithProvider("bedrock"),
ai.WithRegion("us-east-1"),
ai.WithModel(bedrock.ModelClaudeSonnet5),
ai.WithEndpointResolver(bedrockRoutes{
bedrock.ModelClaudeSonnet5: {
modelID: "us.anthropic.claude-sonnet-5",
identity: "bedrock-us-sonnet-primary-v1",
},
}),
)
A Bedrock resolver must return no URL, query, or credential scope. AWS SDK
configuration continues to own the region, credentials, SigV4, service
endpoint, and HTTP transport. Accordingly, Bedrock rejects
WithCredentialSource, WithHTTPClient, and request headers.
The resolver's deployment must remain semantically equivalent to
request.ResolvedModel: request policy uses the semantic model and never
reclassifies an opaque deployment/profile ARN.
Current Claude compatibility is provider-owned and shared by sync and stream:
- Sonnet 5 and Opus 4.7/4.8 omit modified
temperature,top_p, andtop_k. - Fable 5 omits incompatible inherited temperature and top-k, preserves
temperature
1or omission and top-p from0.99inclusive to1exclusive or omission, and rejects other values. - Unique legacy Fable
temperature/top_pspellings remain inadditionalModelRequestFieldswhile policy runs. Application rules, middleware, or per-request patches may remove them, or set the canonicalinferenceConfigfield and remove the legacy copy. Case-insensitive duplicates and unremediated wrong-container fields fail locally. - Numeric values decoded with JSON
UseNumberare accepted and range-checked in common inference fields and remain numeric recursively in model-specific additional fields. The full additional document is validated before its fingerprint becomes stable; empty or malformed numbers, structs,uintptr, non-string map keys, cycles, and non-finite floats fail locally. Named signed and unsigned Go numeric values are also accepted. - Other model families retain ordinary sampling behavior.
- Current Mythos model cards expose the Messages surface rather than Converse, so Mythos is not included in this Converse policy table.
WithMaxRetries(n) means n retries after the first call; the Bedrock adapter
passes n+1 total attempts to each AWS SDK operation. Explicitly selected
standalone Bedrock clients and direct bedrock.NewClient values default to 60
minutes. Auto-detected clients and framework-managed chain entries default to
300 seconds. Positive WithTimeout and WithChainTimeout values override
those defaults; zero and negative values mean unset rather than unbounded.
GetEmbeddings now defaults to 1024-dimensional Titan V2. An application with
an existing 1536-dimensional Titan V1 store must pin
bedrock.ModelTitanEmbedV1 through bedrock.WithEmbeddingModel. A per-call V1
pin omits V2-only dimensions/normalization inherited from client defaults, but
explicit V2 controls on that same call are rejected. Keep the pin until the
vector store is migrated or rebuilt.
bedrock.WithoutEmbeddingNormalization() explicitly omits an inherited V2
normalization field for one call.
For the complete resolver and Titan V2 helper contracts, see the Bedrock recipe. Confirm changing catalog details against AWS's Claude Sonnet 5 model card, Claude Opus 4.7 model card, Claude Opus 4.8 model card, Converse API reference, and cross-region inference documentation.
Anthropic Sampling Compatibility
Some Anthropic model families reject explicit sampling controls. The Anthropic
adapter resolves the model alias or environment override first, then applies a
model-family policy shared by sync and streaming requests. For the restricted
families currently listed in
ai/providers/anthropic/request_policy.go,
it removes temperature, top_p, and top_k and records only fields that were
actually present as adjustments. Matching uses an exact-or-hyphen boundary, so
a similarly prefixed model name is not classified accidentally.
Known compatible model families retain sampling fields. An unknown model also preserves the legacy behavior instead of guessing that sampling is forbidden. This protects older and private models, but a newly released restricted family must be added to the built-in list. Until the framework is updated, a request-aware client can apply an explicit removal rule:
client, err := ai.NewRequestClient(
ai.WithProvider("anthropic"),
ai.WithRequestRules(core.AIProviderPatch{
Name: "new-claude-sampling-compatibility",
Version: "1",
Selector: core.AIProviderSelector{
Provider: "anthropic",
Surface: "messages",
Model: "claude-next-*",
},
Remove: []string{"/temperature", "/top_p", "/top_k"},
}),
)
Use this only after confirming the provider's model contract. Prefer upgrading to a framework release with the family in its built-in policy so every caller gets the same sync/stream behavior.
Request Timeouts
Most clients constructed through TruvaG3's built-in provider factories default
to a 300-second (5 minute) timeout. An explicitly selected standalone
SDK-native Bedrock provider declares a 60-minute default for long-running
inference; direct bedrock.NewClient does the same. Auto-detected clients and
framework-managed failover entries keep 300 seconds so one provider cannot
block selection or failover for an hour. Positive WithTimeout/WithChainTimeout
wins over TRUVAG3_AI_TIMEOUT, then the applicable default. The environment
value must be a positive Go duration; invalid present values fail construction.
Application-defined clients and injected chain entries retain their own timeout
behavior.
The environment is read by root AI constructors and managed chain entries, not
direct provider constructors/factory calls or core.Config.LoadFromEnv. This
is a total call deadline including retries/streaming, not an idle timeout.
Earlier caller/phase/auxiliary-stage or injected HTTP-client deadlines still win.
Output budgets are ceilings, not latency guarantees; set a longer bounded
timeout explicitly when measurements justify it. Deadline errors do not cause
automatic chain failover, even when the parent context is still live.
Single Client Timeout
import "time"
// Auto-detection defaults to 300s, even if it selects Bedrock.
client, err := ai.NewClient()
// Explicit standalone Bedrock defaults to 60m.
bedrockClient, err := ai.NewClient(
ai.WithProvider("bedrock"),
)
// Custom timeout for very complex tasks
client, err := ai.NewClient(
ai.WithTimeout(300 * time.Second), // 5 minutes
)
// Shorter timeout for latency-sensitive applications
client, err := ai.NewClient(
ai.WithTimeout(60 * time.Second), // 1 minute
)
Chain Client Timeout
// Chain client with custom timeout applied to all providers
chainClient, err := ai.NewChainClient(
ai.WithProviderChain("openai", "anthropic"),
ai.WithChainTimeout(240 * time.Second), // 4 minutes
)
Without WithChainTimeout, every framework-managed chain entry uses 300
seconds, including Bedrock. ProviderEntry follows the same rule. A
caller-owned ClientEntry is never mutated.
Chain clients default each entry to 0 in-provider retries because moving to
the next entry is already the retry mechanism. NewClient defaults to 3
transport retries. Use ai.WithChainMaxRetries(n) only when you intentionally
want each entry to retry before the chain advances.
If code does not supply an explicit retry option, a positive
TRUVAG3_AI_RETRY_ATTEMPTS value overrides the relevant default for both
client types. Explicit WithMaxRetries(n) or WithChainMaxRetries(n) wins over
the environment. Zero, negative, and non-integer environment values are
ignored; disable single-client retries explicitly with WithMaxRetries(0).
When to Adjust Timeout
| Scenario | Recommended Timeout |
|---|---|
| Simple queries with standard models (GPT-4, Claude) | 60s |
| Complex prompts with standard models | 120s |
| Reasoning models (GPT-5, GPT-6, o1, o3, o4) | 300s (default) |
| Orchestration/plan generation | 240-300s |
| Very complex multi-step reasoning | 300s+ |
Reasoning Model Support
Reasoning controls are supported through ReasoningEffort, but support is provider- and model-dependent.
Reasoning Effort
resp, err := client.GenerateResponse(ctx, prompt, &core.AIOptions{
Model: "gpt-5.4",
ReasoningEffort: "none", // or low, medium, high, xhigh
MaxTokens: 2000,
})
Chain-wide default:
chainClient, err := ai.NewChainClient(
ai.WithProviderChain("openai", "anthropic"),
ai.WithChainReasoningEffort("none"),
)
Notes:
- Native
openaisupportsReasoningEffortdirectly. openai.ollamanow allows reasoning controls to pass through as well.- For unsupported OpenAI-compatible aliases, advanced reasoning fields may be stripped conservatively.
- Anthropic does not expose this portable control. A request-aware
ReasoningEffortset returnscore.ErrAIRequestFeatureUnsupportedinstead of being silently dropped. - There is currently no env-var shortcut for
ReasoningEffort; use code or orchestration overrides.
OpenAI reasoning profiles (GPT-5, GPT-6, o1, o3 and o4) require model-specific translation:
- Completion budget — The OpenAI reasoning wire profile uses
max_completion_tokens. - Sampling controls — Active/default reasoning restricts sampling; support with effort
nonedepends on the model. Original GPT-6 Sol and GPT-6 Luna supportnone; GPT-6 Astra and GPT-6.1 Sol do not. Native GPT-6 validation and the exact OpenRouter Luna/Sol adaptation rules have separate contracts. - Reasoning consumption — Internal reasoning can consume the completion budget. Available provider-reported reasoning is returned separately as
ReasoningContent; it is not automatically substituted for final answer text.
The adapter selects the wire profile and validates supported controls. On a
scaling profile, the token multiplier reserves additional budget when the
effective effort is nonempty and not none, including an inherited client/chain
effort. Reviewed exact native GPT-6 Astra/Sol/Luna/GPT-6.1 Sol and routed
Luna/Sol also receive headroom with absent effort because their defaults reason;
the adapter does not inject effort. Unknown defaults, explicit omission, none
and native-extra-only reasoning do not multiply it. This is a budget
allocation, not a guarantee of visible answer length or provider acceptance.
The shared openaiwire.IsReasoningModel helper now recognizes names exactly at
the GPT-6 family boundary (gpt-6 or gpt-6-*) plus gpt-6.1-sol and its
hyphen-suffixed variants/snapshots. Matching is case-insensitive; unrelated gpt-6.1-*,
gpt-6.10-*, gpt-60 and vendor/gpt-6-sol do not match. Direct Azure adapters
use it on the resolved semantic model. Azure v1 selects its reasoning
completion/sampling profile; Azure
classic rejects reasoning-model requests because it has no verified reasoning
surface contract. Changing only the route's deployment name does not necessarily change
the semantic model classification. This recognition does not certify that a
deployment exists or supports a particular effort.
Reasoning Shares the Output Budget
Reasoning and visible text consume the same completion allowance. For example, if reasoning consumes 25000 tokens from a 30000-token cap, at most 5000 remain for the answer. Neither portion is reserved; even a larger cap can be exhausted before visible output. The default multiplier is 3× on scaling profiles; explicit 1× keeps the configured budget exact. A multiplier adds headroom only on scaling profiles; it does not guarantee a complete response.
Configuring Token Multiplier
// Default 3x multiplier on supported active-reasoning profiles
client, err := ai.NewClient()
// Explicit opt-in to additional headroom on scaling profiles
client, err := ai.NewClient(
ai.WithReasoningTokenMultiplier(3), // 3x multiplier
)
// Higher multiplier for very complex reasoning tasks
client, err := ai.NewClient(
ai.WithReasoningTokenMultiplier(8), // 8x multiplier
)
// Chain client with custom multiplier
chainClient, err := ai.NewChainClient(
ai.WithProviderChain("openai", "anthropic"),
ai.WithChainReasoningTokenMultiplier(4), // 4x multiplier
)
When to Adjust Token Multiplier
| Scenario | Recommended Multiplier |
|---|---|
| Predictable configured output cap | Explicit 1x |
| General active-reasoning headroom | 3x (default) |
| Preserve the previous implicit scaling | Explicit 5x; check the resulting model output limit |
| Truncated reasoning-heavy responses | Raise the exact output cap first; evaluate effort, latency and cost |
Note: Scaling profiles apply the multiplier for active effort (including client/chain defaults) or the reviewed native/routed defaults listed above. Unknown defaults, explicit omission,
none, native-extra-only controls and non-scaling profiles stay exact. Reviewed exact native GPT-5.6 Sol/GPT-6 Astra/Sol/Luna/GPT-6.1 Sol and routed GPT-5.6 Luna/GPT-6 Luna/GPT-6.1 Sol have a 128000 output capacity. The GPT-5.6 entries add capacity only. Headroom for a valid base is bounded in both modes (50000 × 3 → 128000); only over-cap base budgets require a built-in clamp in Compatible mode or application acknowledgement in Strict mode. Final capacity checks still cover patches. Other model/route limits remain the application's responsibility; metadata support is separate work. Configure the multiplier in code or withTRUVAG3_AI_REASONING_TOKEN_MULTIPLIER; invalid explicit settings fail construction.
Orchestration Model Overrides
The orchestration module makes several types of internal LLM calls. Each can be routed to a different model for cost/latency optimization:
| Call Type | Config Field | Factory Function | Env Var | Default |
|---|---|---|---|---|
| Plan generation | PlanAIOptions.Model | WithPlanAIOptions() | TRUVAG3_PLAN_MODEL | AIClient default |
| Synthesis (streaming + non-streaming) | SynthesisAIOptions.Model | WithSynthesisAIOptions() | TRUVAG3_SYNTHESIS_MODEL | AIClient default |
| Micro-resolution | MicroResolutionAIOptions.Model | WithMicroResolutionAIOptions() | TRUVAG3_MICRO_RESOLUTION_MODEL | AIClient default |
| Tiered selection | TieredSelectionAIOptions.Model | WithTieredSelectionAIOptions() | (code only today) | AIClient default |
| Error analysis | ErrorAnalysisAIOptions.Model | WithErrorAnalysisAIOptions() | (code only today) | AIClient default |
| Result distillation | ResultDistillAIOptions.Model | WithResultDistillAIOptions() | TRUVAG3_RESULT_DISTILL_MODEL | fast alias |
Important: Use portable aliases with ChainClient.
When your orchestrator uses a ChainClient (multi-provider failover),
model override values must be portable aliases ("fast", "default",
"smart"), not concrete model names.
Why: The ChainClient tries providers in order. If the model string is a
concrete name like "gpt-4o-mini", a non-matching provider (e.g., Anthropic)
returns 404. The ChainClient classifies 404 as a non-retryable client error
and stops immediately — it does not try the next provider. This silently
breaks failover.
Portable aliases work because each provider resolves them independently:
"fast"→ Anthropic resolves to Haiku 5.5, OpenAI resolves to GPT-6 Luna- Failover works correctly since each provider gets a model it recognizes
Example — route each orchestration phase to an appropriate model tier:
# Set orchestration model overrides to portable aliases
TRUVAG3_PLAN_MODEL=smart
TRUVAG3_SYNTHESIS_MODEL=default
TRUVAG3_MICRO_RESOLUTION_MODEL=fast
TRUVAG3_RESULT_DISTILL_MODEL=fast
# Optionally control which concrete model each alias maps to per-provider
TRUVAG3_ANTHROPIC_MODEL_SMART=claude-opus-5-5
TRUVAG3_OPENAI_MODEL_SMART=gpt-5.6-sol
TRUVAG3_ANTHROPIC_MODEL_FAST=claude-haiku-5-5
TRUVAG3_OPENAI_MODEL_FAST=gpt-5.6-luna
Or programmatically:
orch, _ := orchestration.CreateOrchestratorWithOptions(deps,
orchestration.WithPlanAIOptions(&orchestration.AIOptionsOverride{
Model: orchestration.StringPtr("smart"),
}),
orchestration.WithSynthesisAIOptions(&orchestration.AIOptionsOverride{
Model: orchestration.StringPtr("default"),
ReasoningEffort: orchestration.StringPtr("none"),
Temperature: orchestration.Float32Ptr(0.7),
}),
orchestration.WithMicroResolutionAIOptions(&orchestration.AIOptionsOverride{
Model: orchestration.StringPtr("fast"),
}),
orchestration.WithTieredSelectionAIOptions(&orchestration.AIOptionsOverride{
Model: orchestration.StringPtr("fast"),
}),
)
Concrete model names are normally appropriate for a single-provider
ai.NewClient(). In a chain they are safe only when every entry accepts that
same literal model ID:
// Safe — single provider, no failover chain
client, _ := ai.NewClient(ai.WithProviderAlias("openai"))
orch, _ := orchestration.CreateOrchestratorWithOptions(deps,
orchestration.WithPlanAIOptions(&orchestration.AIOptionsOverride{Model: orchestration.StringPtr("o3")}),
orchestration.WithSynthesisAIOptions(&orchestration.AIOptionsOverride{Model: orchestration.StringPtr("gpt-4.1")}),
orchestration.WithMicroResolutionAIOptions(&orchestration.AIOptionsOverride{Model: orchestration.StringPtr("gpt-4.1-mini")}), // OK, no chain
)
Quick Reference
Environment Variable Cheat Sheet
API Keys:
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_API_KEY=... # wins over GEMINI_API_KEY when both are set
DEEPSEEK_API_KEY=sk-...
GROQ_API_KEY=gsk-...
XAI_API_KEY=xai-...
MISTRAL_API_KEY=...
QWEN_API_KEY=...
TOGETHER_API_KEY=...
OPENROUTER_API_KEY=...
Base URL Overrides:
OPENAI_BASE_URL=https://...
ANTHROPIC_BASE_URL=https://...
GEMINI_BASE_URL=https://...
DEEPSEEK_BASE_URL=https://...
GROQ_BASE_URL=https://...
XAI_BASE_URL=https://...
MISTRAL_BASE_URL=https://...
QWEN_BASE_URL=https://...
TOGETHER_BASE_URL=https://...
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OLLAMA_BASE_URL=http://...
Model Alias Overrides:
# Pattern: TRUVAG3_{PROVIDER}_MODEL_{ALIAS}
TRUVAG3_OPENAI_MODEL_SMART=gpt-4.1
TRUVAG3_ANTHROPIC_MODEL_FAST=claude-haiku-5-5
TRUVAG3_GROQ_MODEL_DEFAULT=openai/gpt-oss-120b
TRUVAG3_OLLAMA_MODEL_DEFAULT=llama3.2
# For openai.deepseek, strip prefix → TRUVAG3_DEEPSEEK_MODEL_*
Decision Tree: Which Client Type?
Do you need 99.9%+ uptime for AI features?
├── YES → Use Chain Client
│ └── Do you need deterministic provider order?
│ ├── YES → WithProviderChain("openai", "anthropic", ...)
│ └── NO → Auto-detect (no WithProviderChain)
│ └── Chain built from available API keys, sorted by priority
│
└── NO → Use Single Client
└── Is cost a concern?
├── YES → Compare current provider pricing, quotas, and measured quality
└── NO → Use your preferred provider
Error Classification Reference
Error classification uses the core.ProviderError interface for structured, type-safe decisions. The Chain Client calls errors.As(err, &pe) to extract the provider error and inspects its fields:
core.ProviderError Field | Failover? | Rationale |
|---|---|---|
StatusCode() == 401 | Yes | Different keys per provider |
StatusCode() == 403 | Yes | Permission scopes differ per provider |
StatusCode() >= 500 | Yes | Provider outage |
StatusCode() == 429 | Yes | Per-provider rate limits |
IsTransient() == true (any status) | Yes | Proxy/CDN issue, not a request problem |
IsRetryable() == true (any status) | Yes | Provider-specific billing, quota or other classified condition may succeed elsewhere |
Non-ProviderError without context cancellation/deadline | Yes | Connection/DNS issue |
errors.Is(err, context.Canceled) | No | Returned cancellation identity takes precedence over provider flags |
errors.Is(err, context.DeadlineExceeded) | No | Returned deadline identity, including the provider-call timeout, stops the chain |
StatusCode() == 400 && !IsTransient() && !IsRetryable() | No | Same input fails everywhere |
| Other 4xx, excluding 401/403/429, with both flags false | No | Fix your request |
Implementation (ai/chain_request.go and ai/chain_client.go, simplified):
func shouldFailOver(err error) bool {
if err == nil || errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) {
return false
}
return !isClientError(err)
}
func isClientError(err error) bool {
var pe core.ProviderError
if errors.As(err, &pe) {
if pe.IsTransient() { return false } // Proxy errors → failover
if pe.IsRetryable() { return false } // Provider-specific conditions → failover
status := pe.StatusCode()
return status >= 400 && status < 500 &&
status != 401 && status != 403 && status != 429 // Auth/rate-limit → failover
}
return false // Unstructured/network error → failover
}
See Also
- ai/README.md - AI module overview and quick start
- ai/ARCHITECTURE.md - Technical architecture details
- CUSTOM_AI_PROVIDER_GUIDE.md - Request-aware clients, policy, enterprise routing and credentials, custom factories, and codecs
- AI_PROVIDER_CHANGE_PLAYBOOK.md - Day-0 responses when providers change: broken parameter contracts, new models and providers, auth/endpoint churn, cache safety
- LOGGING_IMPLEMENTATION_GUIDE.md - Logging patterns including AI module logging
- DISTRIBUTED_TRACING_GUIDE.md - Tracing AI requests in Jaeger