# ADR-0002: Multi-Provider AI Abstraction via Trait Objects

## Status

✅ Accepted

## Context

omni-dev communicates with AI models to generate commit message amendments, check reports,
and PR descriptions. The tool originally supported only the Anthropic Claude API, but users
need the option to route requests through AWS Bedrock (for enterprise environments) or
OpenAI-compatible endpoints (OpenAI, Ollama) without recompiling.

The provider is selected at runtime via environment variables (`CLAUDE_CODE_USE_BEDROCK`,
`USE_OPENAI`, `USE_OLLAMA`), and the choice is made once at startup in
`create_default_claude_client()`. All downstream code — chat, twiddle, check, and PR
generation — must work identically regardless of which provider is active.

The three providers differ in:

- **Request format** — Claude uses a top-level `system` field; Bedrock wraps it in
  `Option<String>`; OpenAI sends it as a `"system"` role message.
- **Response format** — Claude and Bedrock return a `content[]` array with typed blocks;
  OpenAI returns a `choices[].message.content` string.
- **Authentication** — API key header (Claude), Bearer token (Bedrock), optional Bearer
  token (OpenAI/Ollama).
- **URL construction** — static endpoint (Claude), model-encoded path (Bedrock), base URL
  with fixed suffix (OpenAI).

Despite these differences, the interface consumed by the rest of the codebase is minimal:
send a system prompt and user prompt, get back a string; retrieve metadata about the active
model.

Three dispatch strategies were considered:

1. **Trait objects (`Box<dyn AiClient>`)** — runtime polymorphism via vtable dispatch.
2. **Enum dispatch** — a `Provider` enum with a `match` arm per variant.
3. **Generics (`ClaudeClient<C: AiClient>`)** — monomorphised at compile time.

## Decision

We will use `Box<dyn AiClient>` trait objects to abstract over AI providers.

The `AiClient` trait defines two methods:

```rust
pub trait AiClient: Send + Sync {
    fn send_request<'a>(
        &'a self,
        system_prompt: &'a str,
        user_prompt: &'a str,
    ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>>;

    fn get_metadata(&self) -> AiClientMetadata;
}
```

Three implementations — `ClaudeAiClient`, `BedrockAiClient`, and `OpenAiAiClient` — each
own their provider-specific request/response types, URL construction, and authentication
logic.

`ClaudeClient` (the application-level wrapper in `src/claude/client.rs`) holds a single
`Box<dyn AiClient>` field. No code outside `ClaudeClient` interacts with the trait object
directly; it is fully encapsulated.

Shared logic across implementations — HTTP client construction, model registry lookups,
error response handling, and success logging — is extracted into `pub(crate)` helper
functions in the parent module (`src/claude/ai.rs`):

- `build_http_client()` — constructs an HTTP client with the standard timeout.
- `registry_max_output_tokens()` — resolves max output tokens from the model registry
  with beta override support.
- `registry_model_limits()` — resolves (context length, response length) from the
  registry with beta override support.
- `check_error_response()` — maps non-success HTTP responses to `ClaudeError`.
- `log_response_success()` — logs successful text extraction at debug level.

## Consequences

**Positive:**

- **Runtime provider selection works naturally.** The factory function
  `create_default_claude_client()` reads environment variables and returns a single
  `ClaudeClient` regardless of which provider was chosen. No generics propagate to callers.
- **Testability.** A 15-line `MockAiClient` implements the trait for unit tests without
  feature flags, conditional compilation, or polluting production code with test variants.
  This mock is used in 13+ tests covering YAML extraction, amendment parsing, and metadata.
- **Extensibility.** Adding a new provider requires a new file implementing `AiClient` and
  a new branch in the factory function. No existing `match` arms or generic bounds need
  updating.
- **Shared helpers reduce duplication.** The five `pub(crate)` helpers in the parent module
  give each cross-cutting concern a single canonical implementation. Changes to timeout
  configuration, error handling format, or registry lookup logic need to be made in exactly
  one place.
- **Minimal trait surface.** Two methods keep the vtable small and the implementation
  contract easy to satisfy.

**Negative:**

- **Mandatory future boxing.** Every `send_request` call allocates a `Box` for the returned
  future. This is inherent to making the trait object-safe with async. However, each call
  performs network I/O measured in hundreds of milliseconds to minutes, so the allocation
  cost (~nanoseconds) is negligible.
- **Manual `Pin<Box<dyn Future>>` boilerplate.** Each implementation must wrap its async
  block in `Box::pin(async move { ... })`. Rust's native async-fn-in-traits (stable since
  1.75) could simplify this in the future, though maintaining `Send` bounds for the boxed
  case would require `#[trait_variant::make(Send)]` or equivalent.

**Neutral:**

- **Performance is equivalent to alternatives.** The vtable dispatch adds ~1ns per call.
  With only 14 dispatch sites (all inside `ClaudeClient`) and each leading to a network
  round-trip, this is unmeasurable. Enum dispatch would avoid the vtable but add `match`
  boilerplate for no practical gain. Generics would avoid boxing but infect the entire call
  chain with type parameters and make the runtime factory function impossible without boxing
  anyway.
