Files
ai-harness/docs/03-providers.md
T
darman 420f00494f M0: scaffold Cargo workspace, core types, event bus, permission engine, storage actor
Sets up the Cargo workspace (harness-core, harness-tools, harness-providers, harness-mcp, harness-lsp, harness-app, harness-tui) and the ten architecture docs under docs/. harness-core gets its foundational types (session/message/part/model ids), an in-process event bus, a table-driven permission evaluate function, and a SQLite-backed storage actor with schema and roundtrip coverage. Every crate compiles empty and a bin stub prints its version.
2026-07-10 16:19:27 +02:00

113 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 03 — Provider Layer
`harness-providers`: three providers sharing wire codecs, because Copilot multiplexes all three wire formats (chat completions, responses, anthropic messages).
```
src/
registry.rs # ProviderRegistry: id → Arc<dyn Provider>; model resolution "provider/model"
auth.rs # AuthStorage (auth.json, 0600)
modelsdev.rs # models.dev metadata cache
codec/
openai_chat.rs # request builder + SSE decoder for /chat/completions
openai_responses.rs # request builder + SSE decoder for /responses
anthropic.rs # request builder + SSE decoder for /v1/messages
openai.rs
anthropic.rs
copilot/
device_flow.rs
token.rs
provider.rs
```
## Provider trait & request model
```rust
#[async_trait]
pub trait Provider: Send + Sync {
fn id(&self) -> &str;
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError>;
async fn stream(&self, req: LlmRequest, cancel: CancellationToken)
-> Result<LlmEventStream, ProviderError>;
}
pub struct LlmRequest {
pub model: String,
pub system: Vec<String>, // ordered blocks (cache breakpoints need structure)
pub messages: Vec<WireMessage>, // role + Vec<WireContent{Text, ToolCall, ToolResult, Image}>
pub tools: Vec<ToolSchema>, // { name, description, parameters: Value }
pub temperature: Option<f32>, pub max_tokens: Option<u32>,
pub reasoning: Option<ReasoningOpts>, // effort low/medium/high; anthropic thinking budget
pub initiator: Initiator, // User | Agent → copilot x-initiator header
}
```
Decoders are pure functions `fn decode(sse: impl Stream<Item = Event>) -> LlmEventStream` written with `async_stream::try_stream!` over `eventsource_stream::Eventsource` — unit-testable against fixture files.
## Anthropic
- Auth: `x-api-key` header (API key from config/auth.json/`ANTHROPIC_API_KEY`).
- Headers: `anthropic-beta: interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14` (from opencode).
- **Prompt caching:** `cache_control: {type: "ephemeral"}` breakpoints on the first 2 system blocks + last 2 non-system messages (opencode's `applyCaching` in `provider/transform.ts`).
- SSE mapping: `content_block_start(thinking)``ReasoningStart`; `thinking_delta`/`signature_delta` deltas; `content_block_start(tool_use)``ToolInputStart`; `input_json_delta``ToolInputDelta` (accumulate, emit `ToolCall` at `content_block_stop`); `message_delta.usage``Finish`. Cache tokens from `cache_creation_input_tokens` / `cache_read_input_tokens`.
## OpenAI
- Auth: `Authorization: Bearer <api key>`.
- **Responses API** for gpt-* / o-* models; chat completions as fallback for compatible endpoints.
- Responses SSE: `response.output_item.added/delta/done` events; `response.completed` for usage. Chat SSE: `choices[].delta.tool_calls[]` accumulated by index; `[DONE]` sentinel.
- Reasoning models: `reasoning: { effort }`, `reasoning_summary: auto`; reasoning tokens counted separately in usage.
## GitHub Copilot
### Auth — GitHub OAuth device flow (`copilot/device_flow.rs`)
1. `POST https://github.com/login/device/code` body `{client_id, scope: "read:user"}`, `Accept: application/json``{verification_uri, user_code, device_code, interval}`. Emit `AppEvent::AuthPrompt` → TUI modal shows the code + URL.
2. Poll `POST https://github.com/login/oauth/access_token` body `{client_id, device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code"}` at `interval`; handle `authorization_pending` and `slow_down` (+5 s per RFC 8628).
3. Store the OAuth token in auth.json.
Client ID: `Ov23li8tweQw6odWQebz` (opencode's; confirm or register our own).
### Token strategy (`copilot/token.rs`) — flagged risk
opencode currently sends the **GitHub OAuth token directly** as the Bearer for `api.githubcopilot.com` (no exchange, `expires: 0`). The classic Copilot API instead requires exchanging it at `GET https://api.github.com/copilot_internal/v2/token` for a short-lived `{token, expires_at}`. Implement **both behind one function**: exchange as primary (cached until `expires_at 120s`), direct-Bearer fallback if the exchange endpoint rejects. Verify against the live API early in M3.
### Requests (`copilot/provider.rs`)
- Base: `https://api.githubcopilot.com` (enterprise: `https://copilot-api.<domain>`).
- Headers: `Authorization: Bearer`, `User-Agent: ai-harness/<version>`, `X-GitHub-Api-Version: 2026-06-01`, `Openai-Intent: conversation-edits`, `x-initiator: agent|user` (subagent/utility sessions → `agent`), `Copilot-Vision-Request: true` when messages contain images. Anthropic-backed Copilot models also get `anthropic-beta: interleaved-thinking-2025-05-14`.
- `GET /models`: capabilities, billing, and `supported_endpoints` per model → route each model to the right codec: `/chat/completions` → openai_chat, `/responses` → openai_responses, `/v1/messages` → anthropic codec against `<base>/v1`. Only `model_picker_enabled` models surface in the picker.
## Auth storage (`auth.rs`)
Single file `~/.local/share/ai-harness/auth.json`, written mode `0600`, keyed by provider id:
```rust
enum AuthRecord {
OAuth { access: String, refresh: String, expires: i64 }, // expires 0 = never
Api { key: String },
}
```
Token refresh lives inside each provider's HTTP path: on 401 or known expiry, refresh under a `tokio::Mutex` (single-flight), persist, retry once.
Login is exposed as `EngineHandle::login(provider_id) -> LoginFlow`:
```rust
enum LoginFlow {
DeviceCode { user_code: String, verification_uri: String, done: oneshot::Receiver<Result<()>> },
ApiKey { submit: /* fn(String) */ },
}
```
## Model metadata & cost (`modelsdev.rs`)
- Fetch `https://models.dev/api.json` → cache `~/.cache/ai-harness/models.json` (24 h TTL); fallback to baked `assets/models-snapshot.json`.
- Yields `ModelInfo { context_limit, output_limit, cost { input, output, cache_read, cache_write } (per-million), reasoning, tool_call, attachment }` keyed by `(provider, model)`.
- At `Finish`: `cost = Σ usage_component × rate`; accumulate on message and session. Copilot models cost 0 unless metadata says otherwise.
## Testing
- Recorded SSE fixtures per codec (`tests/fixtures/{anthropic,openai_chat,openai_responses,copilot}/*.sse`): text-only, tool call, parallel tool calls, thinking, mid-stream error, `data:` split across chunks → assert exact `Vec<LlmEvent>` via `insta`.
- Request builders: snapshot outgoing JSON (cache_control placement, tool schemas, header sets) via `wiremock`.
- Device flow: wiremock sequence `authorization_pending → slow_down → success`; refresh single-flight test.