Chat Providers — Specification¶
1. Overview¶
The src/chat_providers/ package provides a thin, uniform abstraction over LLM providers used for chat interactions. It is consumed exclusively by ChatAgent (src/chat_agent.py) to drive the Discord-facing conversational interface.
The abstraction serves two purposes:
- Allow
ChatAgentto call a singlecreate_messagemethod regardless of which backend is configured, receiving a normalized response object it can inspect without knowing provider-specific details. - Keep provider-specific credential detection, format conversion, and SDK calls isolated from the rest of the codebase.
The package contains six modules:
| Module | Responsibility |
|---|---|
types.py |
Normalized response dataclasses (TextBlock, ToolUseBlock, ChatResponse) |
base.py |
Abstract base class ChatProvider |
anthropic.py |
Anthropic-backed implementation (direct API, Vertex AI, Bedrock, OAuth) |
ollama.py |
Ollama-backed implementation (OpenAI-compatible /v1 endpoint) |
tool_conversion.py |
Utility to convert Anthropic tool schemas to OpenAI function-calling format |
__init__.py |
Public surface and create_chat_provider factory |
Source Files¶
src/chat_providers/base.pysrc/chat_providers/types.pysrc/chat_providers/tool_conversion.pysrc/chat_providers/__init__.pysrc/chat_providers/anthropic.pysrc/chat_providers/ollama.py
2. Response Types¶
Defined in src/chat_providers/types.py. All types are plain dataclasses with no external dependencies.
TextBlock¶
Represents a plain-text segment within a model response.
ToolUseBlock¶
Represents a single tool invocation requested by the model.
@dataclass
class ToolUseBlock:
id: str # Unique identifier for this tool call (used to match tool results)
name: str # Name of the tool being invoked
input: dict # Parsed arguments for the tool call
ChatResponse¶
The single return type from every create_message call. Contains an ordered list of content blocks that may be any mix of TextBlock and ToolUseBlock.
Helper properties:
| Property | Type | Description |
|---|---|---|
text_parts |
list[str] |
Extracts the .text value from every TextBlock in content, in order. |
tool_uses |
list[ToolUseBlock] |
Filters content to only ToolUseBlock entries. |
has_tool_use |
bool |
True when at least one ToolUseBlock is present in content. |
All three properties are computed on access with no caching; content is the single source of truth.
3. ChatProvider Interface¶
Defined in src/chat_providers/base.py.
class ChatProvider(ABC):
@abstractmethod
async def create_message(
self,
*,
messages: list[dict],
system: str,
tools: list[dict] | None = None,
max_tokens: int = 1024,
) -> ChatResponse: ...
@property
@abstractmethod
def model_name(self) -> str: ...
All parameters to create_message are keyword-only.
Parameter conventions:
| Parameter | Description |
|---|---|
messages |
Conversation history in Anthropic message format. Each entry is a dict with at least "role" and "content" keys. Content may be a plain string or a list of typed content blocks (e.g. tool_result). |
system |
The system prompt string passed directly to the model. |
tools |
Optional list of tool definitions in Anthropic format (name, description, input_schema). Providers that do not use Anthropic's native format are responsible for converting this before passing to their SDK. |
max_tokens |
Maximum tokens to generate. Defaults to 1024. |
model_name property:
Returns the string model identifier that will appear in any logging or metadata. Set during __init__ and never mutated afterward.
4. Factory¶
Defined in src/chat_providers/__init__.py.
Accepts a ChatProviderConfig dataclass (from src/config.py) and returns an initialized provider, or None if initialization fails.
Routing logic:
- If
config.provider == "ollama", construct and return anOllamaChatProvider. This never returnsNone; it always succeeds because Ollama needs no credentials. - Default model:
"qwen2.5:32b-instruct-q3_K_M" - Default base URL:
"http://localhost:11434/v1" -
Both defaults are overridden by
config.modelandconfig.base_urlwhen set. -
For any other
config.providervalue (including the default,"anthropic"), construct anAnthropicChatProvider. Ifprovider.is_configuredisFalse(no credentials were found during__init__), returnNone.
create_chat_provider returning None signals to ChatAgent that no LLM-backed chat interface is available; ChatAgent must handle this gracefully.
Public exports from __init__.py:
5. Anthropic Provider¶
Defined in src/chat_providers/anthropic.py. Class: AnthropicChatProvider.
Credential Detection Priority¶
__init__ probes four credential sources in strict priority order, stopping at the first that succeeds. The source that is selected determines the SDK client class that is instantiated.
Priority 1 — Vertex AI
Checked when either GOOGLE_CLOUD_PROJECT or ANTHROPIC_VERTEX_PROJECT_ID is set in the environment.
- Client class:
anthropic.AsyncAnthropicVertex - Region is resolved from (in order):
GOOGLE_CLOUD_LOCATION,CLOUD_ML_REGION, fallback"us-east5". - Default model when
config.modelis empty:"claude-sonnet-4@20250514"(note the@versioning convention used by Vertex).
Priority 2 — Bedrock
Checked when either AWS_REGION or AWS_DEFAULT_REGION is set.
- Client class:
anthropic.AsyncAnthropicBedrock(no additional constructor arguments; uses standard AWS credential chain). - Default model when
config.modelis empty:"claude-sonnet-4-20250514".
Priority 3 — API Key
Checked when ANTHROPIC_API_KEY is set.
- Client class:
anthropic.AsyncAnthropic(api_key=...) - Default model when
config.modelis empty:"claude-sonnet-4-20250514".
Priority 4 — OAuth Token
Checked last by calling _load_claude_oauth_token().
- Client class:
anthropic.AsyncAnthropic(auth_token=...) - Default model when
config.modelis empty:"claude-sonnet-4-20250514".
If none of the four sources yield a client, self._client remains None.
OAuth Token Loading¶
Module-level function _load_claude_oauth_token() -> str | None.
Searches for a credentials file in ~/.claude/ under two candidate names, tried in order:
.credentials.jsoncredentials.json
For the first file that exists, it reads and JSON-parses the content, then navigates:
If an accessToken is found, it also checks "expiresAt" (a millisecond Unix timestamp). If expiresAt is present and less than the current time in milliseconds, a warning is printed to stdout but the token is still returned and used. Read errors are caught and printed as warnings; the function returns None in that case.
Returns the access token string, or None if no valid token was found.
is_configured Flag¶
False means none of the four credential sources succeeded. The factory uses this to decide whether to return None.
Message Creation¶
async def create_message(
self,
*,
messages: list[dict],
system: str,
tools: list[dict] | None = None,
max_tokens: int = 1024,
) -> ChatResponse:
Delegates directly to self._client.messages.create(...) with the following kwargs:
model:self._modelmax_tokens: as passedsystem: as passedmessages: as passed (already in Anthropic format; no conversion needed)tools: included in kwargs only when thetoolsargument is notNoneand non-empty
The raw SDK response resp.content is a list of block objects. Each block is inspected by its .type attribute:
"text"->TextBlock(text=block.text)"tool_use"->ToolUseBlock(id=block.id, name=block.name, input=block.input)- Any other block type is silently dropped.
Returns a ChatResponse with the assembled content list.
6. Ollama Provider¶
Defined in src/chat_providers/ollama.py. Class: OllamaChatProvider.
Initialization¶
def __init__(
self,
model: str = "qwen2.5:32b-instruct-q3_K_M",
base_url: str = "http://localhost:11434/v1",
):
Constructs an openai.AsyncOpenAI client pointed at the given base_url. Ollama's API does not require authentication, so the api_key is set to the literal string "ollama" to satisfy the SDK's required-parameter check.
Message Creation¶
async def create_message(
self,
*,
messages: list[dict],
system: str,
tools: list[dict] | None = None,
max_tokens: int = 1024,
) -> ChatResponse:
Steps:
- Convert the incoming
messages+systemto OpenAI format via_convert_messages. - Build kwargs:
model,max_tokens,messages. - If
toolsis provided, convert viaanthropic_tools_to_openaiand add astoolskwarg. - Call
self._client.chat.completions.create(**kwargs). - Inspect
resp.choices[0]: - If
choice.message.contentis non-empty, append aTextBlock. - If
choice.message.tool_callsis non-empty, iterate and append oneToolUseBlockper tool call. Theargumentsfield of each function call may arrive as a JSON string; if so it is parsed withjson.loads. The tool callidis used as-is; if absent or falsy a random 8-character UUID prefix is generated viauuid.uuid4(). - Return
ChatResponse(content=content).
Message Format Conversion¶
Static method _convert_messages(messages, system) -> list[dict].
Converts Anthropic-format conversation history to OpenAI chat format.
Output always starts with:
Per-message conversion rules:
| Incoming role | Incoming content type | Output |
|---|---|---|
"user" |
list |
Iterate items. Each dict with type == "tool_result" becomes a {"role": "tool", "tool_call_id": item["tool_use_id"], "content": item.get("content", "")} message. Each dict with type == "text" becomes a {"role": "user", "content": item["text"]} message. Any other item is stringified and emitted as {"role": "user", "content": str(item)}. |
"assistant" |
list |
Items are split into text parts (objects with a .text attribute) and tool calls (objects with .name and .input attributes). A single assistant message is assembled: content is the newline-joined text parts, or None if there are none. tool_calls is added only when tool call objects were found, serialized as OpenAI function call dicts with arguments JSON-encoded from .input. |
| any role | non-list | Pass through as {"role": role, "content": content} unchanged. |
Note: list-type "user" content containing tool_result blocks produces multiple separate messages in the output list, one per block, rather than a single batched message.
7. Tool Conversion Utility¶
Defined in src/chat_providers/tool_conversion.py.
Converts a list of tool definitions from Anthropic format to OpenAI function-calling format.
Anthropic input format (per tool):
{
"name": "tool_name",
"description": "What the tool does",
"input_schema": {
"type": "object",
"properties": { ... },
"required": [...]
}
}
OpenAI output format (per tool):
{
"type": "function",
"function": {
"name": "tool_name",
"description": "What the tool does",
"parameters": {
"type": "object",
"properties": { ... },
"required": [...]
}
}
}
The mapping is:
- input_schema -> parameters (the dict is used as-is, not deep-copied)
- description defaults to "" when absent
- input_schema defaults to {"type": "object", "properties": {}} when absent
This function is called by OllamaChatProvider.create_message before passing tools to the OpenAI SDK. It is not used by AnthropicChatProvider, which passes the original Anthropic-format tool definitions directly to the Anthropic SDK.