SCOP Bridge
The Integration module connects TnsAI with SCOP — a separate Java multi-agent development and experimentation framework — via reflection-based discovery. No compile-time dependency on SCOP is required; detection happens at runtime. The bridge also provides an HTTP fallback transport for 11 LLM providers when Core's native SPI implementations are unavailable.
Learn more about SCOP at scop-framework.netlify.app.
Quick Start
The SCOPBridge is a singleton facade that handles everything: building system prompts from annotations, executing actions, and resolving LLM clients. Here is a minimal example showing the three main operations.
SCOPBridge bridge = SCOPBridge.getInstance();
// Build a system prompt from TnsAI annotations
String systemPrompt = bridge.buildSystemPromptFromAnnotations(roleObject);
// Execute a TnsAI action
ActionExecutionResult result = bridge.executeAction(roleObject, "searchPapers", params);
if (result.isSuccess()) {
System.out.println(result.getResult());
}
// Resolve an LLM client (prefers Core SPI, falls back to HTTP)
Optional<LLMClient> client = bridge.resolveLLMClient(roleObject);SCOPBridge
The SCOPBridge is the main entry point for all SCOP integration. It acts as a facade that delegates to specialized helpers for prompt building, LLM communication, and action execution.
| Component | Responsibility |
|---|---|
SystemPromptBuilder | Annotation-to-markdown prompt conversion |
LLMDispatcher | Provider-specific HTTP transport (fallback) |
BridgeLLMClient | LLMClient adapter over HTTP fallback |
LLMConfiguration | Resolved LLM config (provider, model, endpoint, keys) |
ActionExecutionResult | Action execution result wrapper |
Factory Methods
The bridge is created as a singleton. You can optionally configure the HTTP read timeout for LLM calls.
// Default read timeout (300 seconds)
SCOPBridge bridge = SCOPBridge.getInstance();
// Custom read timeout
SCOPBridge bridge = SCOPBridge.getInstance(120); // 120 secondsOn construction, the bridge attempts to discover Core's LLMClientProvider SPI via ServiceLoader. If found, all LLM calls route through real provider implementations (with streaming, retry, etc.). Otherwise, the HTTP fallback path is used.
System Prompt Building
The bridge can automatically generate a structured system prompt by reading TnsAI annotations from your role class. This means you define the agent's identity, capabilities, and behavior through annotations, and the bridge converts them into a prompt the LLM can understand.
String prompt = bridge.buildSystemPromptFromAnnotations(myRole);Extracted annotations:
@RoleSpec-- Role identity, description, goals@Communication-- Communication style guidance@State-- Current state fields for context@ActionSpec-- Available actions with descriptions and parameters
Action Execution
The bridge routes action calls through the TnsAI ActionExecutor pipeline, which dispatches to the appropriate executor based on the action type. The result includes the return value, pre/post narration text, and error details if something went wrong.
Map<String, Object> params = Map.of("query", "sales trends", "limit", 10);
ActionExecutionResult result = bridge.executeAction(role, "analyzeData", params);
if (result.isSuccess()) {
Object data = result.getResult();
String pre = result.getPreNarration(); // Before-action narration
String post = result.getPostNarration(); // After-action narration
String display = result.getNarration(); // Smart: postNarration on success, errorMessage on failure
} else {
String error = result.getErrorMessage();
Exception cause = result.getException();
}From 0.14.1 (TAN-5876 / PR #193, tag TnsAI@c58c0432 / PR #194),
executeAction on a non-Role target runs @KnowledgeSource +
@Retrieval once in ActionExecutor.executeInternalForObject via
applyRetrieval, then writes _rag_context. SCOPBridge does not
search again. The optional RetrievalSpi matches the Role path; Role
security, @Contract, and @InvariantCheck do not. Retrieval
identity is getClass().getSimpleName() — empty on an anonymous host,
which throws.
Narration fallback (LOCAL only, not a SCOP Action return):
- non-blank method return wins, even when
_rag_contextis present - blank or empty return + populated
_rag_context→"Action {name} returned: "+ retrieved text - Conversation already surfaces
getNarration()
Config errors from RetrievalSpi.enforce surface as
ActionExecutionException(VALIDATION). Silent skip is not only a
missing tnsai-intelligence on the consumer classpath: empty
RetrievalSpi.find(), a null caller context, a blank or unpickable
query parameter, or onFailure=CONTINUE all leave the action running
without retrieved text. tnsai-intelligence stays test-scoped on
tnsai-integration; the consumer must depend on it.
This is not the six-argument sendToLLM chat path below.
ActionExecutionResult
The ActionExecutionResult wraps the outcome of an action execution, providing a consistent API for both success and failure cases.
// Factory methods
ActionExecutionResult.success(resultObject, preNarration, postNarration);
ActionExecutionResult.error(errorMessage, exception);
// Accessors
result.isSuccess();
result.getResult(); // The action return value
result.getPreNarration(); // Empty string if null
result.getPostNarration(); // Empty string if null
result.getNarration(); // Smart display text
result.getErrorMessage();
result.getException();Owner-aware chat grounding
The three-argument
SCOPBridge.sendToLLM(config, conversation, tools) path is
direct provider dispatch: it never reads @ChatKnowledge, never retrieves,
and never mutates the caller's conversation.
Maven Central 0.14.1 also ships an additive six-argument overload. Use it
when the integration already holds the exact annotated conversation owner
and should ground the latest user turn. Keep the three-argument method
when you want ungrounded dispatch.
Action-level @Retrieval on executeAction is a different door
(0.14.1, above). Do not use this overload to stand in for it.
The generic chat-binding contract lives in RAG
and the declarative RAG tutorial. The
internal Wiki page Concepts/Chat Knowledge Binding (TAN-5634) records
the same runtime evidence; this page does not copy that vault.
@KnowledgeSource(
name = "simulation-logs",
type = KnowledgeSource.KnowledgeType.MEMORY
)
@ChatKnowledge(source = "simulation-logs", topK = 5)
final class ConversationOwner {
}
ConversationOwner owner = new ConversationOwner();
ChatKnowledgeBinding binding = ChatKnowledgeBinding.live(
owner,
"conversation-42",
"simulation-logs",
5,
request -> simulationLog.retrieve(request),
simulationLog::close
);
ChatRetrievalContext context = ChatRetrievalContext.builder()
.scope("tenant-a", "session-42")
.runId("run-9")
.agentName("narrator")
.simulationState("RUNNING")
.currentEpisode("episode-4")
.sourceRevision("event-offset-120")
.build();
LLMResponse response = bridge.sendToLLM(
config,
conversation,
tools,
owner,
binding,
context
);simulationLog, bridge, config, conversation, and tools are
application-owned. The snippet is the published Maven Central 0.14.1
surface (@since 0.14.0).
When to use which sendToLLM
| Overload | Use when |
|---|---|
sendToLLM(config, conversation, tools) | Direct provider dispatch. Annotation-free owners, disabled @ChatKnowledge, or any caller that must not retrieve. |
sendToLLM(config, conversation, tools, owner, binding, context) | The caller has the exact annotated owner, an exact-owner binding, and a request-local context. |
Exact owner and isOwnedBy
Create the binding with the object-aware ChatKnowledgeBinding.create or
ChatKnowledgeBinding.live factory that takes the owner instance. The
binding holds that object in a WeakReference. isOwnedBy authorizes
with Java reference identity -- display names, equals, and ownerId()
are not authorization inputs. ownerId() is copied into retrieval
metadata only.
A legacy binding created without the owner object cannot authorize an enabled owner-aware call. Before retrieval or dispatch, the enabled path also requires:
- the exact bound owner;
- the
@ChatKnowledgesource name; - that source's enabled
@KnowledgeSourceon the same class; - the same positive
topK.
Unknown or duplicate source names, a disabled selected source, a foreign
or unbound binding, and source or topK mismatches raise
IllegalArgumentException.
Request-local fencing and fail-open
Build one ChatRetrievalContext per request so tenant, session, run,
agent, simulation state, episode, source revision, cancellation, and
deadline stay request-local. Evidence is written only onto a deep copy of
the outbound conversation, inside the existing <tnsai-memory> fence plus
the untrusted-context disclosure. The caller's JSONArray and persisted
SCOP history stay unchanged.
Annotation-free and explicitly disabled owners keep three-argument dispatch. An enabled owner also falls back to that path when:
bindingorcontextisnull(optional retrieval runtime absent);- the latest user message is missing or blank;
- retrieval returns no evidence;
- a recoverable retrieval or render failure occurs.
Cancellation and non-recoverable configuration errors still surface.
Call binding.invalidate() on episode, run, or reset transitions that
change the live corpus, and close() at conversation or run end.
Closure is idempotent.
LLM Configuration
The LLMConfiguration class holds everything needed to connect to an LLM provider: the provider name, model, temperature, max tokens, endpoint URL, and which environment variable holds the API key. The bridge resolves this configuration automatically from your annotations.
Resolution Hierarchy
Four layers contribute, lowest precedence first: playground, agent (@AgentSpec(llm = …)), role (@RoleSpec(llm = …)), and a registered LLMConfigurationSource.
They do not all combine the same way, and a single "the higher layer wins" sentence describes neither of them correctly:
- Routing —
provider,model,endpoint,apiKeyEnv— is taken as a set from the highest-precedence annotation tier that declares amodel. No other tier contributes any part of it. A tier that names a routing field but no model contributes nothing and logs a warning naming what it dropped. - Tuning —
temperature,maxTokens— merges field by field, higher precedence winning per field. - The external source is the only layer that overlays every field individually, routing included.
Optional<LLMClient> client = bridge.resolveLLMClient(roleObject);See External LLM Configuration for the source layer, the trust boundary it carries, and why routing is grouped rather than merged.
Supported Providers (11)
The bridge supports 11 LLM providers out of the box. Most use the OpenAI-compatible API format, while Anthropic and Gemini have custom formatting.
| Provider | API Format | Default Endpoint | API Key Env Var |
|---|---|---|---|
OLLAMA | OpenAI-compatible | http://localhost:11434/v1/chat/completions | OLLAMA_API_KEY |
OPENAI | Native | https://api.openai.com/v1/chat/completions | OPENAI_API_KEY |
ANTHROPIC | Native (tool format conversion) | https://api.anthropic.com/v1/messages | ANTHROPIC_API_KEY |
GEMINI | Native | https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent | GEMINI_API_KEY |
AZURE_OPENAI | OpenAI-compatible | User-configured | AZURE_OPENAI_API_KEY |
BEDROCK | AWS-specific | User-configured | AWS_ACCESS_KEY_ID |
GROQ | OpenAI-compatible | https://api.groq.com/openai/v1/chat/completions | GROQ_API_KEY |
TOGETHER | OpenAI-compatible | https://api.together.xyz/v1/chat/completions | TOGETHER_API_KEY |
MISTRAL | OpenAI-compatible | https://api.mistral.ai/v1/chat/completions | MISTRAL_API_KEY |
DEEPSEEK | OpenAI-compatible | https://api.deepseek.com/chat/completions | DEEPSEEK_API_KEY |
CUSTOM | OpenAI-compatible | User-configured | CUSTOM_LLM_API_KEY |
OpenAI-compatible providers (OLLAMA, OPENAI, GROQ, TOGETHER, MISTRAL, DEEPSEEK, AZURE_OPENAI, CUSTOM) share the same request/response format. Anthropic and Gemini have provider-specific formatting.
LLMSpec.Provider declares one constant this table does not cover: HUGGINGFACE has no default endpoint here, and a provider absent from the table falls back to the Ollama default carrying whatever apiKeyEnv came with it. An externally supplied HUGGINGFACE is therefore refused. Annotations are not checked, so @LLMSpec(provider = Provider.HUGGINGFACE) still misroutes.
Endpoint Resolution
The bridge resolves the API endpoint using a three-step fallback chain, so you can override endpoints per-role, per-environment, or rely on provider defaults.
- Custom endpoint from
@LLMSpec.endpoint()(if non-empty) - Base URL from environment variable (e.g.,
OPENAI_BASE_URL) + provider chat path - Default endpoint from the provider table above
LLMConfiguration config = new LLMConfiguration(
"OPENAI", "gpt-4o", 0.7f, 4096, "", "OPENAI_API_KEY");
String apiUrl = config.resolveApiUrl(); // Checks custom -> env var -> default
String apiKey = config.resolveApiKey(); // Checks custom env -> default envLLMDispatcher (HTTP Fallback)
The LLMDispatcher is the fallback LLM transport used when TnsAI Core's native provider SPI is not on the classpath. It communicates with all 11 LLM providers over HTTP using OkHttp, building provider-specific request bodies and parsing responses. It builds provider-specific request bodies, sends them via OkHttpClient, and parses responses.
LLMResponse response = dispatcher.sendToLLM(config, conversationArray, toolsArray);
// Response types
response.getContent(); // Text content
response.getToolCalls(); // Tool call requests (if any)
response.getErrorMessage(); // Error message (if failed)The dispatcher handles:
- OpenAI-compatible request/response format for 8 providers
- Anthropic-specific message format and tool schema conversion
- Gemini-specific content format
- Azure OpenAI endpoint path construction
- API key header injection (
Authorization: Bearerfor most,x-api-keyfor Anthropic)
BridgeLLMClient (Adapter Pattern)
The BridgeLLMClient wraps the HTTP-based LLMDispatcher behind the standard LLMClient interface. This adapter pattern means the rest of TnsAI does not need to know whether it is talking to a native SPI provider or the HTTP fallback -- the API is identical.
// Created internally by SCOPBridge when resolving LLM clients
BridgeLLMClient client = new BridgeLLMClient(config, dispatcher);
// Implements LLMClient
client.getModel(); // From LLMConfiguration
client.getTemperature(); // From LLMConfiguration
client.getMaxTokens(); // Optional<Integer> from LLMConfiguration
// Standard chat interface
ChatResponse response = client.chat(
message,
Optional.of(systemPrompt),
Optional.of(history),
Optional.of(tools)
);The adapter translates between Core's ChatResponse format and the LLMResponse format used by the dispatcher.
Design Patterns
The integration module uses several well-known design patterns to keep the code modular and testable. This table summarizes them for contributors and architects.
| Pattern | Usage |
|---|---|
| Facade | SCOPBridge delegates to SystemPromptBuilder, LLMDispatcher, BridgeLLMClient |
| Adapter | BridgeLLMClient adapts LLMDispatcher to the LLMClient interface |
| SPI Discovery | Prefers Core's LLMClientProvider when available on classpath |
| Reflection | Detects SCOP classes at runtime without compile-time dependency |
| Configuration Hierarchy | Role -> Agent -> Playground annotation resolution |
Advanced Topics
The sections below cover internal mechanics of the SCOP Bridge: the annotation-to-prompt conversion pipeline, SCOP Action detection and execution, LLM client resolution internals, and provider-specific request formatting.
SystemPromptBuilder
com.tnsai.integration.scop.SystemPromptBuilder converts TnsAI annotations into structured Markdown system prompts. The bridge calls SystemPromptBuilder.buildFromAnnotations(target) to generate a prompt the LLM can understand.
Annotation Extraction Order
The builder reads annotations from the target class in this order:
@RoleSpec-- Role identity, description, BDI goals (@Goal)@Communication(from@RoleSpec.communication()or class-level) -- Tone, formality, verbosity, persona, languages, response format, max length@Statefields -- Current runtime state values (read via reflection)@ActionSpecmethods -- Available actions with descriptions and types@Coordination-- Negotiation preferences (protocol, max rounds, timeout, concession strategy)
Generated Prompt Structure
# Role: MyAssistant
## Description
A helpful assistant that answers questions about products.
## Responsibilities
- **ProductSearch**: Find products matching user queries
Actions: searchProducts, filterResults
Invariants: always return at most 10 results
## Communication Style
- **Tone**: Friendly
- **Formality**: Casual
- **Verbosity**: Concise
- **Allowed Languages**: en
- **CRITICAL LANGUAGE RULE**: You MUST ALWAYS respond in **EN** only...
## Current State
- **mood**: happy (Current emotional state)
- **conversationCount**: 5 (Number of exchanges)
## Available Actions
- **searchProducts**: Search the product catalog [LLM]
- **placeOrder**: Place an order for a product [EXTERNAL_API]
## Negotiation Preferences
- **Protocol**: CONTRACT_NET
- **Max Rounds**: 5
- **Timeout**: 30
- **Reservation Value**: 0.5
- **Concession Strategy**: LINEARCommunication Style Rules
The builder generates strict language enforcement rules:
- Single language: Generates a
CRITICAL LANGUAGE RULErequiring the LLM to always respond in that language, even if the user writes in another language. - Multiple languages: Generates a softer rule: respond in the user's language if it is in the allowed list, otherwise default to the first language.
- Default detection: If
@Communicationhas all default values (NEUTRAL tone, NEUTRAL formality, NORMAL verbosity, single "en" language), the class-level@Communicationannotation is checked as a fallback.
// Single language -> strict enforcement
@Communication(languages = {"tr"})
// -> "CRITICAL LANGUAGE RULE: You MUST ALWAYS respond in TR only."
// Multiple languages -> flexible matching
@Communication(languages = {"en", "tr", "de"})
// -> "Respond in the same language the user writes in..."Enum Formatting
Enum names are formatted for display: VERY_FORMAL becomes Very Formal. This conversion uses Locale.ROOT for the toLowerCase call.
// Internal: SystemPromptBuilder.formatEnum("VERY_FORMAL") -> "Very Formal"Tool exposure for LLM actions
When a role action is declared @ActionSpec(type = LLM), the LLM call uses the agent's ToolMethodDispatcher — built once at AgentBuilder.build() time from the registered POJO toolkits and dynamic tools. There is no per-action tool list any more; every LLM action sees the agent's complete tool registry.
Configure tools at the agent level:
import com.tnsai.enums.BuiltInTool;
Agent agent = AgentBuilder.create()
.llm(llmClient)
.role(myRole)
.builtInTools(
BuiltInTool.CSV_TOOLS, // csv_summary, csv_columns, csv_filter, …
BuiltInTool.WEB_SEARCH_TOOLS // brave_search, duckduckgo, wikipedia, …
)
.toolPojos(new MyDomainTools())
.build();For per-action LLM overrides (system prompt, temperature) without touching the agent's global config, set them on @ActionSpec directly:
@ActionSpec(
type = ActionType.LLM,
description = "Summarise a CSV file's contents",
llmSystemPrompt = "You are a precise data analyst. Cite columns by name.",
llmTemperature = 0.2f
)
public String analyzeCsv(String path) {
return "Summarise the CSV at: " + path;
}See Tools / Catalog for the full per-toolkit method list and Tool Integration for the registration paths.
SCOP Action Detection and Execution
The bridge detects and executes SCOP framework actions through reflection, with no compile-time dependency on SCOP.
Detection
SCOPBridge.isSCOPAction(Object) checks whether a returned object is a SCOP Action by looking for ai.scop.core.Action in the class hierarchy:
- Attempts
Class.forName("ai.scop.core.Action")and usesisInstance() - If class loading fails, walks the superclass chain looking for a match by name
Execution Flow
When an @ActionSpec method returns a SCOP Action object, the bridge executes it:
- Calls
action.execute()via reflection - Attempts to call
action.getPostNarration()for a result description - If no narration is available, falls back to
buildStateResponse()which reads all@State-annotated fields to build a state summary - If neither provides text, returns a generic success message
executeAction(target, "negotiate", params)
-> ActionExecutor.execute(actionSpec, target, params, context)
-> result is SCOP Action?
YES -> executeSCOPAction(result, "negotiate", target)
-> result.execute()
-> result.getPostNarration() || buildStateResponse(target) || generic
NO -> "Action negotiate returned: " + resultLLM Client Injection
For actions with ActionType.LLM, the bridge resolves an LLM client and injects it into the execution context before the action runs:
if (actionSpec.getType() == ActionType.LLM) {
Optional<LLMClient> llmClient = resolveLLMClient(target);
if (llmClient.isPresent()) {
context.put("llm", llmClient.get());
}
}
Object result = actionExecutor.execute(actionSpec, target, params, context);State Response Building
buildStateResponse(Object target, String actionName) reads all @State-annotated fields from the target class via reflection:
// For a target with @State fields: mood="happy", energy=80
// Returns: "After negotiate: mood: happy, energy: 80"LLM Client Resolution Internals
SCOPBridge.resolveLLMClient(Object target) resolves an LLM client through a multi-step process:
Owner Chain Resolution
The target's owner agent and playground are resolved via reflection:
target.getOwner() -> agent
agent.getPlayground() -> playgroundThese are used for the annotation hierarchy lookup.
SPI vs Fallback Decision
resolveLLMClient(target)
-> resolveLLMSpec(target, agent, playground) // Get LLMConfiguration
-> clientProvider != null?
YES -> clientProvider.create(provider, model, temperature, maxTokens, null)
-> Real Core LLMClient (OllamaClient, OpenAIClient, etc.)
-> Falls back to HTTP if SPI creation fails
NO -> new BridgeLLMClient(config, dispatcher)
-> HTTP-based fallbackSPI Provider Discovery
On construction, SCOPBridge calls LLMClientProvider.discover() to find Core's SPI implementation. If tnsai-llm module is on the classpath, this returns a provider that creates real client instances with streaming and retry support.
// Logged at startup:
// "[SCOPBridge] Using Core LLMClientProvider SPI for LLM calls"
// or:
// "[SCOPBridge] Core LLMClientProvider SPI not found; using HTTP fallback"Provider-Specific Request Formatting
The LLMDispatcher handles the differences between LLM provider APIs. Understanding these details is useful when debugging integration issues.
OpenAI-Compatible Providers
Eight providers share the same request format: OLLAMA, OPENAI, GROQ, TOGETHER, MISTRAL, DEEPSEEK, AZURE_OPENAI, CUSTOM.
Request body structure:
{
"model": "gpt-4o",
"messages": [...],
"temperature": 0.7,
"max_tokens": 4096,
"tools": [...]
}Headers: Authorization: Bearer <key> (except Azure, which uses api-key: <key>).
Anthropic Format
Anthropic uses a different message format. The dispatcher:
- Extracts
systemrole messages and puts them in a top-level"system"field - Converts OpenAI tool format to Anthropic format (
"parameters"becomes"input_schema") - Sets
anthropic-version: 2024-10-22header andx-api-keyheader - Parses response content blocks (
"text"and"tool_use"types)
Tool format conversion:
OpenAI format: Anthropic format:
{ "type": "function", { "name": "search",
"function": { "description": "Search docs",
"name": "search", "input_schema": { ... }
"description": "Search docs", }
"parameters": { ... }
}
}Gemini Format
Gemini uses a distinct content structure:
- Maps
"assistant"role to"model" - Wraps text in
{ "parts": [{ "text": "..." }] }format - Extracts system prompt into
"systemInstruction"field - Converts tools to
"functionDeclarations"format - Passes API key as a query parameter (
?key=...) - Uses
"generationConfig"for temperature and"maxOutputTokens"
Response Parsing
Each provider has a dedicated parser:
| Provider | Method | Response Structure |
|---|---|---|
| OpenAI-compatible | parseOpenAIResponse() | choices[0].message.content or .tool_calls |
| Anthropic | parseAnthropicResponse() | content[] blocks of type text or tool_use |
| Gemini | parseGeminiResponse() | candidates[0].content.parts[] with text or functionCall |
LLMResponse Types
The LLMResponse wrapper normalizes responses across all providers:
public class LLMResponse {
public enum Type { TEXT, TOOL_CALLS, ERROR }
// Factory methods
static LLMResponse text(String content, JSONObject rawMessage);
static LLMResponse toolCalls(JSONArray toolCalls, JSONObject rawMessage);
static LLMResponse error(String errorMessage);
// Query methods
boolean isSuccess(); // type != ERROR
boolean hasToolCalls(); // type == TOOL_CALLS
boolean hasText(); // type == TEXT
String getDisplayContent(); // Text content, or "[ERROR] ..." for errors
}BridgeLLMClient Internals
The BridgeLLMClient adapts the HTTP-based LLMDispatcher to Core's LLMClient interface. It translates between the two APIs:
chat() Method
- Builds a
JSONArrayconversation fromsystemPrompt,history, andmessage - Converts
toolslist toJSONArray - Calls
dispatcher.sendToLLM(config, conversation, toolsArray) - Translates
LLMResponse.toolCallsintoChatResponse.ToolCallrecords - Returns
ChatResponse.withToolCalls(...)orChatResponse.textOnly(...)
streamChat() Limitation
Streaming is not supported through the HTTP fallback path. The streamChat() method delegates to chat() and wraps the result in a single-element Stream<String>:
@Override
public Stream<String> streamChat(...) {
ChatResponse response = chat(message, systemPrompt, history, tools);
return Stream.of(response.getContent());
}For real streaming support, ensure the tnsai-llm module is on the classpath so the SPI path is used instead.
Endpoint Resolution Details
LLMConfiguration.resolveApiUrl() follows a three-step fallback:
| Priority | Source | Example |
|---|---|---|
| 1 | @LLMSpec.endpoint() (annotation) | https://my-custom-llm.example.com/v1/chat |
| 2 | Environment variable + path | OPENAI_BASE_URL=https://proxy.example.com + /v1/chat/completions |
| 3 | Provider default | https://api.openai.com/v1/chat/completions |
Base URL environment variables per provider:
| Provider | Env Var |
|---|---|
OLLAMA | OLLAMA_BASE_URL |
OPENAI | OPENAI_BASE_URL |
ANTHROPIC | ANTHROPIC_BASE_URL |
GEMINI | GEMINI_BASE_URL |
AZURE_OPENAI | AZURE_OPENAI_BASE_URL |
GROQ | GROQ_BASE_URL |
TOGETHER | TOGETHER_BASE_URL |
MISTRAL | MISTRAL_BASE_URL |
DEEPSEEK | DEEPSEEK_BASE_URL |
CUSTOM | CUSTOM_LLM_BASE_URL |
API key resolution follows the same pattern: annotation apiKeyEnv first, then provider default env var. Ollama does not require an API key.
Error Handling
LLM Errors
The dispatcher catches and wraps errors at multiple levels:
- IOException: Connection errors, timeouts ->
LLMResponse.error("Connection error: ...") - JSONException: Malformed responses ->
LLMResponse.error("Invalid response format: ...") - API errors: Provider-specific error responses ->
LLMResponse.error("Provider Error: ...") - Missing API key: Detected before the request ->
LLMResponse.error("API key not configured for ...")
Action Errors
Action execution failures are wrapped in ActionExecutionResult.error():
// Not found
ActionExecutionResult.error("Action not found: searchPapers", null);
// Execution failure
ActionExecutionResult.error("Action analyzeData failed: NullPointerException", exception);
// Null target
ActionExecutionResult.error("Target object is null", null);Configuration Reference
LLMConfiguration
Full constructor:
new LLMConfiguration(
String provider, // "OPENAI", "ANTHROPIC", "GEMINI", etc.
String model, // "gpt-4o", "claude-sonnet-4-20250514", etc.
float temperature, // 0.0 - 2.0
int maxTokens, // 0 = provider default
String endpoint, // Custom endpoint URL (empty = use default)
String apiKeyEnv // Custom env var name (empty = use default)
)Query methods:
| Method | Returns |
|---|---|
hasModel() | Whether model is set (non-null, non-blank) |
hasEndpoint() | Whether a custom endpoint is configured |
isOpenAICompatible() | Whether the provider uses OpenAI-compatible format |
requiresApiKey() | true for all providers except OLLAMA |
resolveApiUrl() | Full API URL (annotation -> env var -> default) |
resolveApiKey() | API key (annotation env -> default env -> null) |
getBaseUrlEnvVar() | Environment variable name for base URL |
getApiKeyEnvVar() | Environment variable name for API key |