summaryrefslogtreecommitdiffhomepage
path: root/.rules/plan/dispatch-adapter-copilot-plan.md
blob: 886ad5cfe3a60a097305425cf49baa116570aaaa (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# Dispatch Adapter Copilot — Gem Implementation Plan

This plan covers the full implementation of the `dispatch-adapter-copilot` gem.

> **Canonical interface:** See `prototype_interface.md` for the full adapter interface definition, including all struct types, the `Base` class, the error hierarchy, and concrete adapter sketches. This plan must conform to that interface.

---

## Overview

This gem provides:
1. A **provider-agnostic adapter interface** (`Dispatch::Adapter::Base`) that defines the contract all LLM adapters must implement.
2. A **concrete Copilot implementation** (`Dispatch::Adapter::Copilot`) that calls the GitHub Copilot API directly over HTTP (no SDK).

---

## Gem Structure

```
dispatch-adapter-copilot/
├── lib/
│   └── dispatch/
│       └── adapter/
│           ├── base.rb
│           ├── copilot.rb
│           ├── message.rb        # Message, TextBlock, ImageBlock, ToolUseBlock, ToolResultBlock structs
│           ├── response.rb       # Response, Usage, StreamDelta structs
│           ├── tool_definition.rb # ToolDefinition struct
│           ├── model_info.rb     # ModelInfo struct
│           └── errors.rb         # Error hierarchy
├── spec/
│   └── dispatch/
│       └── adapter/
│           ├── base_spec.rb
│           └── copilot_spec.rb
├── dispatch-adapter-copilot.gemspec
├── Gemfile
├── Rakefile
└── README.md
```

---

## 1. Canonical Structs (under `Dispatch::Adapter`)

Implement all structs defined in the prototype interface:

- **`Message`** — `role` (String: "user" | "assistant"), `content` (String | Array<ContentBlock>).
- **`TextBlock`** — `type` ("text"), `text` (String).
- **`ImageBlock`** — `type` ("image"), `source` (String), `media_type` (String).
- **`ToolUseBlock`** — `type` ("tool_use"), `id` (String), `name` (String), `arguments` (Hash).
- **`ToolResultBlock`** — `type` ("tool_result"), `tool_use_id` (String), `content` (String | Array<TextBlock>), `is_error` (Boolean, optional).
- **`ToolDefinition`** — `name` (String), `description` (String), `parameters` (Hash — JSON Schema).
- **`Response`** — `content` (String?), `tool_calls` (Array<ToolUseBlock>), `model` (String), `stop_reason` (Symbol: `:end_turn` | `:tool_use` | `:max_tokens` | `:stop_sequence`), `usage` (Usage).
- **`Usage`** — `input_tokens` (Integer), `output_tokens` (Integer), `cache_read_tokens` (Integer, default 0), `cache_creation_tokens` (Integer, default 0).
- **`StreamDelta`** — `type` (Symbol: `:text_delta` | `:tool_use_delta` | `:tool_use_start`), `text` (String?), `tool_call_id` (String?), `tool_name` (String?), `argument_delta` (String?).
- **`ModelInfo`** — `id` (String), `name` (String), `max_context_tokens` (Integer), `supports_vision` (Boolean), `supports_tool_use` (Boolean), `supports_streaming` (Boolean).

All structs use `keyword_init: true`.

---

## 2. Adapter Interface (`Dispatch::Adapter::Base`)

An abstract base class that all adapters must subclass.

### Required Methods (raise `NotImplementedError` in base)

- `chat(messages, system: nil, tools: [], stream: false, max_tokens: nil, &block)` — Send a chat completion request.
  - `messages` — `Array<Message>` (canonical structs, not raw hashes).
  - `system:` — `String` or `nil`. System prompt. Adapters handle placement differences (Claude: top-level param; Copilot/OpenAI: system role message).
  - `tools:` — `Array<ToolDefinition>` or `Array<Hash>`. Tools available to the model. Empty = no tools. Accepts both `ToolDefinition` structs and plain hashes with `name`, `description`, `parameters` keys (duck-typed — `Registry#to_a` returns plain hashes to avoid cross-gem dependency).
  - `stream:` — `Boolean`. If `true`, yields `StreamDelta` objects to the block.
  - `max_tokens:` — `Integer` or `nil`. Per-call override of the constructor default.
  - **Return:** `Dispatch::Adapter::Response`.
- `model_name` — Returns `String`, the resolved model identifier.

### Optional Methods (base provides defaults)

- `count_tokens(messages, system: nil, tools: [])` — Returns `Integer` token count, or `-1` if unsupported. Base returns `-1`.
- `list_models` — Returns `Array<ModelInfo>`. Base raises `NotImplementedError`.
- `provider_name` — Returns `String`. Base returns `self.class.name`.
- `max_context_tokens` — Returns `Integer` or `nil`. Base returns `nil`.

---

## 3. Copilot Implementation (`Dispatch::Adapter::Copilot`)

Subclass of `Dispatch::Adapter::Base`. Calls `api.githubcopilot.com` directly over HTTP — no CLI, no SDK, pure Ruby. Authentication uses a 3-step flow: GitHub device OAuth → GitHub token → Copilot token (auto-refreshed).

### Constructor

```ruby
Copilot.new(model: "gpt-4.1", github_token: nil, token_path: default_token_path, max_tokens: 8192)
```

- `model:` — Model identifier (default: `"gpt-4.1"`).
- `github_token:` — Pre-existing `gho_xxx` token. If nil, triggers interactive device flow on first use.
- `token_path:` — Path to persist the github token.
- `max_tokens:` — Default max output tokens (default: 8192). Per-call `max_tokens:` on `chat` overrides this.

### `chat(messages, system: nil, tools: [], stream: false, max_tokens: nil, &block)`

- Accepts `Array<Message>` (canonical structs).
- Converts canonical structs to OpenAI wire format internally.
- Handles `system:` by prepending as a `role: "system"` message.
- Translates `ToolDefinition` structs → OpenAI function tools format.
- Translates `ToolUseBlock`/`ToolResultBlock` in messages to OpenAI `tool_calls`/`tool` role format.
- Merges consecutive same-role messages before sending.
- Uses `max_tokens` keyword or constructor default (`@default_max_tokens`).
- If `stream: true`, parses SSE chunks and yields `StreamDelta` objects.
- Returns `Dispatch::Adapter::Response` with `tool_calls` as `ToolUseBlock[]`.
- Raises `Dispatch::Adapter::*Error` on HTTP failures.
- `ImageBlock` in messages raises `NotImplementedError` (not yet supported).

### `model_name` → `String`
### `provider_name` → `"GitHub Copilot"`
### `max_context_tokens` → `Integer` (from MODEL_CONTEXT_WINDOWS lookup)

### `list_models` → `Array<ModelInfo>`

- `GET /v1/models` with Copilot headers.
- Translates to `ModelInfo` structs.

### `count_tokens` — Inherits base (`-1`). No native counting API.

---

## 4. Error Hierarchy (under `Dispatch::Adapter`)

All errors carry `message`, `status_code` (Integer or nil), and `provider` (String) attributes.

- `Dispatch::Adapter::Error` — base error for all adapter errors.
- `Dispatch::Adapter::AuthenticationError` — 401/403, invalid or expired credentials.
- `Dispatch::Adapter::RateLimitError` — 429, rate limit exceeded. Has `retry_after` attribute (seconds).
- `Dispatch::Adapter::ServerError` — 500/502/503, provider-side failure.
- `Dispatch::Adapter::RequestError` — 400/422, malformed request, invalid model, bad parameters.
- `Dispatch::Adapter::ConnectionError` — network timeouts, DNS failures, connection refused.

The Copilot adapter maps HTTP status codes to these error classes.

---

## 5. Testing

- **Unit tests for `Base`:** Verify that calling any interface method on `Base` directly raises `NotImplementedError`. Verify optional methods return defaults (`count_tokens` → `-1`, `max_context_tokens` → `nil`, `provider_name` → class name).
- **Unit tests for canonical structs:** Verify struct creation with keyword args, field access.
- **Unit tests for `Copilot`:**
  - Mock HTTP responses (not a real SDK — mock `Net::HTTP` or use WebMock).
  - Test `chat` with text-only responses → returns `Response` with `content` set, `tool_calls` empty.
  - Test `chat` with tool-call responses → returns `Response` with `tool_calls` as `ToolUseBlock[]`.
  - Test `chat` with mixed responses (text + tool calls).
  - Test `chat` with `system:` param → system message prepended correctly.
  - Test `chat` with `max_tokens:` per-call override vs constructor default.
  - Test streaming: verify `StreamDelta` objects are yielded correctly and `Response` is returned.
  - Test `model_name`, `provider_name`, `max_context_tokens`.
  - Test `list_models` returns `ModelInfo[]`.
  - Test error mapping: 401 → `AuthenticationError`, 429 → `RateLimitError`, 500 → `ServerError`, 400 → `RequestError`, network error → `ConnectionError`.
  - Test consecutive same-role message merging.
- **Integration tests (optional, requires real Copilot access):** Mark with a tag so they can be skipped.

---

## 6. Gemspec Dependencies

- No external SDK dependency. Uses Ruby's `net/http` (or a lightweight HTTP client like `httpx`).
- No dependency on other dispatch gems. This gem is standalone.

---

## Key Constraints

- The adapter interface will be extracted into its own gem post-MVP (Phase 7). For now it lives here.
- Streaming support is required for ActionCable relay in the Rails app.
- All adapters accept and return canonical structs (`Message`, `Response`, `ToolUseBlock`, etc.) — not raw hashes. **Exception:** the `tools:` parameter on `chat` accepts both `ToolDefinition` structs and plain hashes (duck-typed on `name`/`description`/`parameters`), since `Registry#to_a` returns plain hashes to avoid a cross-gem dependency.
- The response format is consistent regardless of which adapter is used — the Rails agent loop depends on it.
- Thread-safety: adapters may be called from multiple GoodJob workers concurrently. Ensure no shared mutable state.
- `system:` is a separate parameter on `chat`, not a message role. Adapters handle placement internally.
- `max_tokens:` is accepted both in the constructor (default) and per-call on `chat` (override).
- `count_tokens` returns `-1` when not natively supported (Copilot case). Callers must check for `-1`.