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.
6.6 KiB
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
#[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-keyheader (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'sapplyCachinginprovider/transform.ts). - SSE mapping:
content_block_start(thinking)→ReasoningStart;thinking_delta/signature_deltadeltas;content_block_start(tool_use)→ToolInputStart;input_json_delta→ToolInputDelta(accumulate, emitToolCallatcontent_block_stop);message_delta.usage→Finish. Cache tokens fromcache_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/doneevents;response.completedfor 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)
POST https://github.com/login/device/codebody{client_id, scope: "read:user"},Accept: application/json→{verification_uri, user_code, device_code, interval}. EmitAppEvent::AuthPrompt→ TUI modal shows the code + URL.- Poll
POST https://github.com/login/oauth/access_tokenbody{client_id, device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code"}atinterval; handleauthorization_pendingandslow_down(+5 s per RFC 8628). - 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: truewhen messages contain images. Anthropic-backed Copilot models also getanthropic-beta: interleaved-thinking-2025-05-14. GET /models: capabilities, billing, andsupported_endpointsper model → route each model to the right codec:/chat/completions→ openai_chat,/responses→ openai_responses,/v1/messages→ anthropic codec against<base>/v1. Onlymodel_picker_enabledmodels surface in the picker.
Auth storage (auth.rs)
Single file ~/.local/share/ai-harness/auth.json, written mode 0600, keyed by provider id:
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:
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 bakedassets/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 exactVec<LlmEvent>viainsta. - 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.