# 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](https://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.

```java
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.

```java
// Default read timeout (300 seconds)
SCOPBridge bridge = SCOPBridge.getInstance();

// Custom read timeout
SCOPBridge bridge = SCOPBridge.getInstance(120);  // 120 seconds
```

On 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.

```java
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.

```java
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_context` is 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.

```java
// 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](../capabilities/rag/index.md)
and [the declarative RAG tutorial](../tutorials/declarative-rag.md). The
internal Wiki page `Concepts/Chat Knowledge Binding` (TAN-5634) records
the same runtime evidence; this page does not copy that vault.

```java
@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 `@ChatKnowledge` source name;
- that source's enabled `@KnowledgeSource` on 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:

- `binding` or `context` is `null` (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 a `model`. 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.

```java
Optional<LLMClient> client = bridge.resolveLLMClient(roleObject);
```

See [External LLM Configuration](../capabilities/llm/configuration-sources.md) 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.

1. Custom endpoint from `@LLMSpec.endpoint()` (if non-empty)
2. Base URL from environment variable (e.g., `OPENAI_BASE_URL`) + provider chat path
3. Default endpoint from the provider table above

```java
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 env
```

## LLMDispatcher (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.

```java
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: Bearer` for most, `x-api-key` for 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.

```java
// 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:

1. **`@RoleSpec`** -- Role identity, description, BDI goals (`@Goal`)
2. **`@Communication`** (from `@RoleSpec.communication()` or class-level) -- Tone, formality, verbosity, persona, languages, response format, max length
3. **`@State`** fields -- Current runtime state values (read via reflection)
4. **`@ActionSpec`** methods -- Available actions with descriptions and types
6. **`@Coordination`** -- Negotiation preferences (protocol, max rounds, timeout, concession strategy)

### Generated Prompt Structure

```markdown
# 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**: LINEAR
```

### Communication Style Rules

The builder generates strict language enforcement rules:

- **Single language**: Generates a `CRITICAL LANGUAGE RULE` requiring 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 `@Communication` has all default values (NEUTRAL tone, NEUTRAL formality, NORMAL verbosity, single "en" language), the class-level `@Communication` annotation is checked as a fallback.

```java
// 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.

```java
// 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:

```java
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:

```java
@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](../capabilities/tools/catalog.md) for the full per-toolkit method list and [Tool Integration](../capabilities/tools/registration.md) 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:

1. Attempts `Class.forName("ai.scop.core.Action")` and uses `isInstance()`
2. 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:

1. Calls `action.execute()` via reflection
2. Attempts to call `action.getPostNarration()` for a result description
3. If no narration is available, falls back to `buildStateResponse()` which reads all `@State`-annotated fields to build a state summary
4. 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: " + result
```

### LLM Client Injection

For actions with `ActionType.LLM`, the bridge resolves an LLM client and injects it into the execution context before the action runs:

```java
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:

```java
// 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() -> playground
```

These 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 fallback
```

### SPI 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.

```java
// 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:

```json
{
    "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:

1. Extracts `system` role messages and puts them in a top-level `"system"` field
2. Converts OpenAI tool format to Anthropic format (`"parameters"` becomes `"input_schema"`)
3. Sets `anthropic-version: 2024-10-22` header and `x-api-key` header
4. 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:

1. Maps `"assistant"` role to `"model"`
2. Wraps text in `{ "parts": [{ "text": "..." }] }` format
3. Extracts system prompt into `"systemInstruction"` field
4. Converts tools to `"functionDeclarations"` format
5. Passes API key as a query parameter (`?key=...`)
6. 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:

```java
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

1. Builds a `JSONArray` conversation from `systemPrompt`, `history`, and `message`
2. Converts `tools` list to `JSONArray`
3. Calls `dispatcher.sendToLLM(config, conversation, toolsArray)`
4. Translates `LLMResponse.toolCalls` into `ChatResponse.ToolCall` records
5. Returns `ChatResponse.withToolCalls(...)` or `ChatResponse.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>`:

```java
@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()`:

```java
// 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:

```java
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 |
