diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | .rubocop.yml | 11 | ||||
| -rw-r--r-- | .rules/changelog/2026-03/31/01.md | 30 | ||||
| -rw-r--r-- | .rules/changelog/2026-03/31/02.md | 30 | ||||
| -rw-r--r-- | .rules/changelog/2026-03/31/03.md | 13 | ||||
| -rw-r--r-- | .rules/plan/dispatch-adapter-copilot-plan.md | 4 | ||||
| -rw-r--r-- | Gemfile | 2 | ||||
| -rw-r--r-- | Gemfile.lock | 146 | ||||
| -rw-r--r-- | README.md | 251 | ||||
| -rw-r--r-- | dispatch-adapter-copilot.gemspec | 13 | ||||
| -rw-r--r-- | lib/dispatch/adapter/base.rb | 31 | ||||
| -rw-r--r-- | lib/dispatch/adapter/copilot.rb | 651 | ||||
| -rw-r--r-- | lib/dispatch/adapter/errors.rb | 30 | ||||
| -rw-r--r-- | lib/dispatch/adapter/message.rb | 31 | ||||
| -rw-r--r-- | lib/dispatch/adapter/model_info.rb | 11 | ||||
| -rw-r--r-- | lib/dispatch/adapter/response.rb | 23 | ||||
| -rw-r--r-- | lib/dispatch/adapter/tool_definition.rb | 7 | ||||
| -rw-r--r-- | lib/dispatch/adapter/version.rb (renamed from lib/dispatch/adapter/copilot/version.rb) | 2 | ||||
| -rw-r--r-- | spec/dispatch/adapter/base_spec.rb | 41 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_spec.rb | 1122 | ||||
| -rw-r--r-- | spec/dispatch/adapter/errors_spec.rb | 69 | ||||
| -rw-r--r-- | spec/dispatch/adapter/structs_spec.rb | 185 | ||||
| -rw-r--r-- | spec/spec_helper.rb | 3 |
23 files changed, 2673 insertions, 36 deletions
@@ -9,3 +9,6 @@ # rspec failure tracking .rspec_status + +# Built gems +*.gem diff --git a/.rubocop.yml b/.rubocop.yml index ae378d0..08bc621 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,19 @@ AllCops: TargetRubyVersion: 3.2 + NewCops: enable Style/StringLiterals: EnforcedStyle: double_quotes Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + +Metrics/MethodLength: + Max: 20 + +Metrics/ClassLength: + Max: 500 diff --git a/.rules/changelog/2026-03/31/01.md b/.rules/changelog/2026-03/31/01.md new file mode 100644 index 0000000..78cf9ef --- /dev/null +++ b/.rules/changelog/2026-03/31/01.md @@ -0,0 +1,30 @@ +# Changelog — 2026-03-31 #01 + +## Full gem implementation of dispatch-adapter-copilot + +### New Files + +- `lib/dispatch/adapter/errors.rb` — Error hierarchy (Error, AuthenticationError, RateLimitError, ServerError, RequestError, ConnectionError) +- `lib/dispatch/adapter/message.rb` — Message, TextBlock, ImageBlock, ToolUseBlock, ToolResultBlock structs +- `lib/dispatch/adapter/response.rb` — Response, Usage, StreamDelta structs +- `lib/dispatch/adapter/tool_definition.rb` — ToolDefinition struct +- `lib/dispatch/adapter/model_info.rb` — ModelInfo struct +- `lib/dispatch/adapter/base.rb` — Abstract adapter base class with interface contract +- `lib/dispatch/adapter/version.rb` — CopilotVersion module for gemspec version reference +- `spec/dispatch/adapter/structs_spec.rb` — Tests for all canonical structs +- `spec/dispatch/adapter/base_spec.rb` — Tests for Base interface defaults and NotImplementedError +- `spec/dispatch/adapter/errors_spec.rb` — Tests for error hierarchy and attributes + +### Modified Files + +- `lib/dispatch/adapter/copilot.rb` — Rewrote from empty module to full Copilot adapter class inheriting from Base, with HTTP auth (device flow + Copilot token exchange), chat (non-streaming and SSE streaming), tool conversion, message merging, error mapping, list_models, and thinking/reasoning_effort support +- `spec/dispatch/adapter/copilot_spec.rb` — Comprehensive WebMock-based tests covering text responses, tool calls, mixed responses, system param, max_tokens override, tool definitions, ToolUseBlock/ToolResultBlock wire conversion, ImageBlock error, consecutive message merging, streaming text deltas, streaming tool call deltas, list_models, thinking parameter (constructor default, per-call override, validation, nil disable), and error mapping for all HTTP status codes and connection failures +- `spec/spec_helper.rb` — Cleaned up config +- `dispatch-adapter-copilot.gemspec` — Updated version reference, summary, description, homepage, removed TODOs +- `Gemfile` — Added webmock dependency +- `README.md` — Replaced with actual gem documentation and usage examples +- `.rubocop.yml` — Added NewCops, FrozenStringLiteralComment enforcement, and method/class length limits + +### Deleted Files + +- `lib/dispatch/adapter/copilot/version.rb` — Replaced by `lib/dispatch/adapter/version.rb` diff --git a/.rules/changelog/2026-03/31/02.md b/.rules/changelog/2026-03/31/02.md new file mode 100644 index 0000000..9f8a115 --- /dev/null +++ b/.rules/changelog/2026-03/31/02.md @@ -0,0 +1,30 @@ +# Changelog — 2026-03-31 #02 + +## Comprehensive test suite and bug fixes + +### Tests Added + +- **structs_spec.rb**: Added struct equality tests (same values equal, different values not equal) +- **errors_spec.rb**: Added StandardError inheritance test, rescue-as-StandardError test, RateLimitError retry_after default, rescue-as-Error test +- **copilot_spec.rb**: Added tests for: + - VERSION constant accessibility + - Multiple tool calls in a single response + - Empty tools array omits `tools` key from request body + - TextBlock array in user messages converted to string content + - ToolResultBlock with array content (Array of TextBlocks) + - ToolResultBlock with is_error: true + - finish_reason "length" maps to :max_tokens stop_reason + - Assistant message containing mixed text + tool_use blocks + - Streaming usage data capture + - Multiple parallel tool calls in streaming + - Copilot token caching (reuses token, only 1 token request for 2 chats) + - Authentication failure on Copilot token exchange + +### Bug Fixes + +- **copilot.rb**: Fixed `Array(hash)` bug in `build_wire_messages` — `Array()` on a Hash converts it to key-value pairs instead of wrapping in an array. Replaced with explicit `is_a?(Array)` check. +- **copilot.rb**: Fixed empty argument fragment yielding in streaming — empty string arguments from initial tool_call chunks were being yielded as `tool_use_delta` events. Added `next if arg_frag.empty?` guard. + +### Total Test Count + +84 examples, 0 failures across 4 spec files. diff --git a/.rules/changelog/2026-03/31/03.md b/.rules/changelog/2026-03/31/03.md new file mode 100644 index 0000000..e2e8ecc --- /dev/null +++ b/.rules/changelog/2026-03/31/03.md @@ -0,0 +1,13 @@ +# Changelog — 2026-03-31 #03 + +## Accept plain hashes as tool definitions in chat + +### Modified Files + +- **lib/dispatch/adapter/copilot.rb** — Updated `build_wire_tools` to use a new `tool_attr` helper method instead of direct method calls. Added `tool_attr(tool, key)` private method that supports both struct-style method access and Hash-style key access (symbol and string keys). This allows `chat(tools:)` to accept plain hashes alongside `ToolDefinition` structs. +- **spec/dispatch/adapter/copilot_spec.rb** — Added 3 tests: plain hashes with symbol keys, plain hashes with string keys, and mixed ToolDefinition structs + plain hashes. +- **README.md** — Updated Tool Calling section to document plain hash support and added a usage example. + +### Rationale + +The Dispatch tool registry (`Registry#to_a`) returns plain hashes to avoid cross-gem dependencies. This change allows adapters to accept tools from the registry without requiring a conversion step. diff --git a/.rules/plan/dispatch-adapter-copilot-plan.md b/.rules/plan/dispatch-adapter-copilot-plan.md index 45e6f4d..886ad5c 100644 --- a/.rules/plan/dispatch-adapter-copilot-plan.md +++ b/.rules/plan/dispatch-adapter-copilot-plan.md @@ -69,7 +69,7 @@ An abstract base class that all adapters must subclass. - `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>`. Tools available to the model. Empty = no tools. + - `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`. @@ -172,7 +172,7 @@ The Copilot adapter maps HTTP status codes to these error classes. - 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. +- 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. @@ -10,4 +10,6 @@ gem "rake", "~> 13.0" gem "rspec", "~> 3.0" +gem "webmock", "~> 3.0" + gem "rubocop", "~> 1.21" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..9762c17 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,146 @@ +PATH + remote: . + specs: + dispatch-adapter-copilot (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + bigdecimal (4.1.0) + crack (1.0.1) + bigdecimal + rexml + date (3.5.1) + diff-lcs (1.6.2) + erb (6.0.2) + hashdiff (1.2.1) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.19.3) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (1.27.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + public_suffix (7.0.5) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.86.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + stringio (3.2.0) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + dispatch-adapter-copilot! + irb + rake (~> 13.0) + rspec (~> 3.0) + rubocop (~> 1.21) + webmock (~> 3.0) + +CHECKSUMS + addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + bigdecimal (4.1.0) sha256=6dc07767aa3dc456ccd48e7ae70a07b474e9afd7c5bc576f80bd6da5c8dd6cae + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + dispatch-adapter-copilot (0.1.0) + erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae + json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.86.0) sha256=4ff1186fe16ebe9baff5e7aad66bb0ad4cabf5cdcd419f773146dbba2565d186 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 + +BUNDLED WITH + 4.0.9 @@ -1,38 +1,259 @@ # Dispatch::Adapter::Copilot -TODO: Delete this and the text below, and describe your gem +A Ruby gem that provides a provider-agnostic LLM adapter interface with a concrete GitHub Copilot implementation. Calls the Copilot API directly over HTTP using Ruby's `net/http` — no SDK, no CLI, no external dependencies. -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/dispatch/adapter/copilot`. To experiment with that code, run `bin/console` for an interactive prompt. +## What It Does + +- Defines a **canonical adapter interface** (`Dispatch::Adapter::Base`) that any LLM provider can implement +- Provides a **complete GitHub Copilot adapter** (`Dispatch::Adapter::Copilot`) supporting: + - Chat completions (text responses, tool calls, mixed responses) + - Streaming via Server-Sent Events (SSE) + - Tool/function calling with structured input/output + - Thinking/reasoning effort control for reasoning models (o1, o3, o4-mini, etc.) + - Automatic GitHub device OAuth flow for authentication + - Copilot token management with automatic refresh +- Uses **canonical structs** (`Message`, `Response`, `ToolUseBlock`, etc.) so your application code is provider-agnostic ## Installation -TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org. +Add to your Gemfile: + +```ruby +gem "dispatch-adapter-copilot" +``` -Install the gem and add to the application's Gemfile by executing: +Then run `bundle install`. -```bash -bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +## Authentication + +The adapter authenticates via a GitHub OAuth token. You have three options: + +### Option 1: Pass a token directly + +```ruby +adapter = Dispatch::Adapter::Copilot.new(github_token: "gho_your_token_here") ``` -If bundler is not being used to manage dependencies, install the gem by executing: +### Option 2: Interactive device flow -```bash -gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +Omit the token and the adapter will trigger a GitHub device authorization flow on first use: + +```ruby +adapter = Dispatch::Adapter::Copilot.new +adapter.chat(messages) # Prints a URL and code to stderr, waits for authorization +``` + +The token is persisted to `~/.config/dispatch/copilot_github_token` and reused on subsequent runs. + +### Option 3: Custom token path + +```ruby +adapter = Dispatch::Adapter::Copilot.new(token_path: "/path/to/my/token") ``` ## Usage -TODO: Write usage instructions here +### Basic Chat -## Development +```ruby +require "dispatch/adapter/copilot" + +adapter = Dispatch::Adapter::Copilot.new( + model: "gpt-4.1", # Model to use (default: "gpt-4.1") + max_tokens: 8192 # Max output tokens (default: 8192) +) + +messages = [ + Dispatch::Adapter::Message.new(role: "user", content: "What is Ruby?") +] -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +response = adapter.chat(messages, system: "You are a helpful programming assistant.") +puts response.content # => "Ruby is a dynamic, open source..." +puts response.model # => "gpt-4.1" +puts response.stop_reason # => :end_turn +puts response.usage.input_tokens # => 15 +puts response.usage.output_tokens # => 120 +``` + +### Streaming + +```ruby +adapter.chat(messages, stream: true) do |delta| + case delta.type + when :text_delta + print delta.text + when :tool_use_start + puts "\nCalling tool: #{delta.tool_name}" + when :tool_use_delta + # Partial JSON arguments being streamed + end +end +# Returns a Response after streaming completes +``` + +### Tool Calling + +Tools can be passed as `ToolDefinition` structs or plain hashes with `name`, `description`, and `parameters` keys (symbol or string). This makes it easy to integrate with tool registries that return plain hashes. + +```ruby +# Define a tool using a struct +weather_tool = Dispatch::Adapter::ToolDefinition.new( + name: "get_weather", + description: "Get the current weather for a city", + parameters: { + "type" => "object", + "properties" => { + "city" => { "type" => "string", "description" => "City name" } + }, + "required" => ["city"] + } +) + +# Send a message with tools available +messages = [Dispatch::Adapter::Message.new(role: "user", content: "What's the weather in Tokyo?")] +response = adapter.chat(messages, tools: [weather_tool]) + +if response.stop_reason == :tool_use + # The model wants to call a tool + tool_call = response.tool_calls.first + puts tool_call.name # => "get_weather" + puts tool_call.arguments # => {"city" => "Tokyo"} + puts tool_call.id # => "call_abc123" + + # Execute the tool, then send the result back + tool_result = Dispatch::Adapter::ToolResultBlock.new( + tool_use_id: tool_call.id, + content: "72F and sunny" + ) + + followup = [ + *messages, + Dispatch::Adapter::Message.new(role: "assistant", content: [tool_call]), + Dispatch::Adapter::Message.new(role: "user", content: [tool_result]) + ] + + final_response = adapter.chat(followup, tools: [weather_tool]) + puts final_response.content # => "The weather in Tokyo is 72F and sunny!" +end +``` + +You can also pass plain hashes instead of `ToolDefinition` structs: + +```ruby +# Plain hash (e.g. from a tool registry) +tools = [{ name: "get_weather", description: "Get weather", parameters: { "type" => "object", "properties" => { "city" => { "type" => "string" } } } }] +response = adapter.chat(messages, tools: tools) +``` -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). +### Thinking / Reasoning Models -## Contributing +For reasoning models like `o1`, `o3`, `o3-mini`, and `o4-mini`, you can control the thinking effort: -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/dispatch-adapter-copilot. +```ruby +# Set as default +adapter = Dispatch::Adapter::Copilot.new(model: "o3-mini", thinking: "high") + +# Or override per-call +response = adapter.chat(messages, thinking: "low") + +# Disable for a specific call (even with a constructor default) +response = adapter.chat(messages, thinking: nil) +``` + +Valid values: `"low"`, `"medium"`, `"high"`, or `nil` (disabled). + +### Per-Call Max Tokens + +```ruby +# Override the constructor default for a single call +response = adapter.chat(messages, max_tokens: 100) +``` + +### List Available Models + +```ruby +models = adapter.list_models +models.each do |m| + puts "#{m.id} (context: #{m.max_context_tokens} tokens)" +end +``` + +### Adapter Metadata + +```ruby +adapter.model_name # => "gpt-4.1" +adapter.provider_name # => "GitHub Copilot" +adapter.max_context_tokens # => 1047576 +adapter.count_tokens(msgs) # => -1 (not supported by Copilot) +``` + +## Canonical Types + +All communication uses these structs (under `Dispatch::Adapter`): + +| Struct | Purpose | +|---|---| +| `Message` | Chat message with `role` and `content` | +| `TextBlock` | Text content block | +| `ImageBlock` | Image content block (not yet supported) | +| `ToolUseBlock` | Tool call from the model | +| `ToolResultBlock` | Result you send back after executing a tool | +| `ToolDefinition` | Tool schema (name, description, JSON Schema parameters) | +| `Response` | Complete response with content, tool_calls, usage, stop_reason | +| `Usage` | Token counts (input, output, cache) | +| `StreamDelta` | Incremental streaming chunk | +| `ModelInfo` | Model metadata | + +## Error Handling + +All errors inherit from `Dispatch::Adapter::Error` (which inherits from `StandardError`): + +```ruby +begin + adapter.chat(messages) +rescue Dispatch::Adapter::AuthenticationError => e + puts "Auth failed (#{e.status_code}): #{e.message}" +rescue Dispatch::Adapter::RateLimitError => e + puts "Rate limited, retry after #{e.retry_after} seconds" +rescue Dispatch::Adapter::RequestError => e + puts "Bad request (#{e.status_code}): #{e.message}" +rescue Dispatch::Adapter::ServerError => e + puts "Server error (#{e.status_code}): #{e.message}" +rescue Dispatch::Adapter::ConnectionError => e + puts "Network error: #{e.message}" +end +``` + +## Adapter Interface + +All adapters subclass `Dispatch::Adapter::Base` and implement: + +| Method | Returns | Required? | +|---|---|---| +| `chat(messages, system:, tools:, stream:, max_tokens:, thinking:, &block)` | `Response` | Yes | +| `model_name` | `String` | Yes | +| `count_tokens(messages, system:, tools:)` | `Integer` | No (default: -1) | +| `list_models` | `Array<ModelInfo>` | No | +| `provider_name` | `String` | No (default: class name) | +| `max_context_tokens` | `Integer` or `nil` | No (default: nil) | + +## Supported Models + +Any model available through the GitHub Copilot API, including: + +- `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano` +- `gpt-4o`, `gpt-4o-mini` +- `o1`, `o1-mini`, `o3`, `o3-mini`, `o4-mini` +- `claude-3.5-sonnet`, `claude-3.7-sonnet` +- `gemini-2.0-flash-001` + +## Development + +```bash +bundle install +bundle exec rspec # Run tests (84 examples) +bundle exec rubocop # Run linter +``` ## License diff --git a/dispatch-adapter-copilot.gemspec b/dispatch-adapter-copilot.gemspec index 2255734..7f6c756 100644 --- a/dispatch-adapter-copilot.gemspec +++ b/dispatch-adapter-copilot.gemspec @@ -1,21 +1,20 @@ # frozen_string_literal: true -require_relative "lib/dispatch/adapter/copilot/version" +require_relative "lib/dispatch/adapter/version" Gem::Specification.new do |spec| spec.name = "dispatch-adapter-copilot" - spec.version = Dispatch::Adapter::Copilot::VERSION + spec.version = Dispatch::Adapter::CopilotVersion::VERSION spec.authors = ["Adam Malczewski"] spec.email = ["[email protected]"] - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "GitHub Copilot adapter for Dispatch LLM framework" + spec.description = "Provider-agnostic LLM adapter interface with a concrete GitHub Copilot implementation using the Copilot API directly over HTTP." + spec.homepage = "https://github.com/tradam/dispatch-adapter-copilot" spec.license = "MIT" spec.required_ruby_version = ">= 3.2.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." + spec.metadata["source_code_uri"] = spec.homepage # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. diff --git a/lib/dispatch/adapter/base.rb b/lib/dispatch/adapter/base.rb new file mode 100644 index 0000000..dbd136a --- /dev/null +++ b/lib/dispatch/adapter/base.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Base + def chat(_messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: nil, &_block) + raise NotImplementedError, "#{self.class}#chat must be implemented" + end + + def model_name + raise NotImplementedError, "#{self.class}#model_name must be implemented" + end + + def count_tokens(_messages, system: nil, tools: []) + -1 + end + + def list_models + raise NotImplementedError, "#{self.class}#list_models must be implemented" + end + + def provider_name + self.class.name + end + + def max_context_tokens + nil + end + end + end +end diff --git a/lib/dispatch/adapter/copilot.rb b/lib/dispatch/adapter/copilot.rb index 66e6fff..200fcb9 100644 --- a/lib/dispatch/adapter/copilot.rb +++ b/lib/dispatch/adapter/copilot.rb @@ -1,12 +1,655 @@ # frozen_string_literal: true -require_relative "copilot/version" +require "net/http" +require "uri" +require "json" +require "securerandom" +require "fileutils" + +require_relative "errors" +require_relative "message" +require_relative "response" +require_relative "tool_definition" +require_relative "model_info" +require_relative "base" + +require_relative "version" module Dispatch module Adapter - module Copilot - class Error < StandardError; end - # Your code goes here... + class Copilot < Base + VERSION = CopilotVersion::VERSION + + API_BASE = "https://api.githubcopilot.com" + GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" + GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token" + CLIENT_ID = "Iv1.b507a08c87ecfe98" + + MODEL_CONTEXT_WINDOWS = { + "gpt-4.1" => 1_047_576, + "gpt-4.1-mini" => 1_047_576, + "gpt-4.1-nano" => 1_047_576, + "gpt-4o" => 128_000, + "gpt-4o-mini" => 128_000, + "gpt-4" => 8_192, + "gpt-4-turbo" => 128_000, + "gpt-3.5-turbo" => 16_385, + "o1" => 200_000, + "o1-mini" => 128_000, + "o1-preview" => 128_000, + "o3" => 200_000, + "o3-mini" => 200_000, + "o4-mini" => 200_000, + "claude-3.5-sonnet" => 200_000, + "claude-3.7-sonnet" => 200_000, + "gemini-2.0-flash-001" => 1_048_576 + }.freeze + + STOP_REASON_MAP = { + "stop" => :end_turn, + "tool_calls" => :tool_use, + "length" => :max_tokens, + "content_filter" => :end_turn + }.freeze + + VALID_THINKING_LEVELS = %w[low medium high].freeze + + def initialize(model: "gpt-4.1", github_token: nil, token_path: nil, max_tokens: 8192, thinking: nil) + super() + @model = model + @github_token = github_token + @token_path = token_path || default_token_path + @default_max_tokens = max_tokens + @default_thinking = thinking + @copilot_token = nil + @copilot_token_expires_at = 0 + @mutex = Mutex.new + validate_thinking_level!(@default_thinking) + end + + def chat(messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: :default, &block) + ensure_authenticated! + wire_messages = build_wire_messages(messages, system) + wire_tools = build_wire_tools(tools) + effective_max_tokens = max_tokens || @default_max_tokens + effective_thinking = thinking == :default ? @default_thinking : thinking + validate_thinking_level!(effective_thinking) + + body = { + model: @model, + messages: wire_messages, + max_tokens: effective_max_tokens, + stream: stream + } + body[:tools] = wire_tools unless wire_tools.empty? + body[:reasoning_effort] = effective_thinking if effective_thinking + + if stream + chat_streaming(body, &block) + else + chat_non_streaming(body) + end + end + + def model_name + @model + end + + def provider_name + "GitHub Copilot" + end + + def max_context_tokens + MODEL_CONTEXT_WINDOWS[@model] + end + + def list_models + ensure_authenticated! + uri = URI("#{API_BASE}/v1/models") + request = Net::HTTP::Get.new(uri) + apply_headers!(request) + + response = execute_request(uri, request) + data = parse_response!(response) + models = data["data"] || [] + + models.map do |m| + ModelInfo.new( + id: m["id"], + name: m["id"], + max_context_tokens: MODEL_CONTEXT_WINDOWS.fetch(m["id"], 0), + supports_vision: false, + supports_tool_use: true, + supports_streaming: true + ) + end + end + + private + + def validate_thinking_level!(level) + return if level.nil? + + return if VALID_THINKING_LEVELS.include?(level) + + raise ArgumentError, "Invalid thinking level: #{level.inspect}. Must be one of: #{VALID_THINKING_LEVELS.join(", ")}, or nil" + end + + def default_token_path + File.join(Dir.home, ".config", "dispatch", "copilot_github_token") + end + + # --- Authentication --- + + def ensure_authenticated! + ensure_github_token! + ensure_copilot_token! + end + + def ensure_github_token! + return if @github_token + + @github_token = load_persisted_token + return if @github_token + + @github_token = perform_device_flow + persist_token(@github_token) + end + + def load_persisted_token + return nil unless File.exist?(@token_path) + + token = File.read(@token_path).strip + token.empty? ? nil : token + end + + def persist_token(token) + FileUtils.mkdir_p(File.dirname(@token_path)) + File.write(@token_path, token) + File.chmod(0o600, @token_path) + end + + def perform_device_flow + uri = URI(GITHUB_DEVICE_CODE_URL) + request = Net::HTTP::Post.new(uri) + request["Accept"] = "application/json" + request.set_form_data("client_id" => CLIENT_ID, "scope" => "copilot") + + response = execute_request(uri, request) + data = parse_json_body(response) + + device_code = data["device_code"] + user_code = data["user_code"] + verification_uri = data["verification_uri"] + interval = (data["interval"] || 5).to_i + + $stderr.puts "\n=== GitHub Device Authorization ===" + $stderr.puts "Open: #{verification_uri}" + $stderr.puts "Enter code: #{user_code}" + $stderr.puts "Waiting for authorization...\n\n" + + poll_for_access_token(device_code, interval) + end + + def poll_for_access_token(device_code, interval) + loop do + sleep(interval) + + uri = URI(GITHUB_ACCESS_TOKEN_URL) + request = Net::HTTP::Post.new(uri) + request["Accept"] = "application/json" + request.set_form_data( + "client_id" => CLIENT_ID, + "device_code" => device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + ) + + response = execute_request(uri, request) + data = parse_json_body(response) + + if data["access_token"] + return data["access_token"] + elsif data["error"] == "authorization_pending" + next + elsif data["error"] == "slow_down" + interval += 5 + else + raise AuthenticationError.new( + "Device flow failed: #{data["error_description"] || data["error"]}", + provider: "GitHub Copilot" + ) + end + end + end + + def ensure_copilot_token! + @mutex.synchronize do + return if @copilot_token && Time.now.to_i < @copilot_token_expires_at - 60 + + uri = URI(COPILOT_TOKEN_URL) + request = Net::HTTP::Get.new(uri) + request["Authorization"] = "token #{@github_token}" + request["Accept"] = "application/json" + + response = execute_request(uri, request) + + unless response.is_a?(Net::HTTPSuccess) + raise AuthenticationError.new( + "Failed to obtain Copilot token: #{response.code} #{response.body}", + status_code: response.code.to_i, + provider: "GitHub Copilot" + ) + end + + data = parse_json_body(response) + @copilot_token = data["token"] + @copilot_token_expires_at = data["expires_at"].to_i + end + end + + # --- HTTP helpers --- + + def apply_headers!(request) + request["Authorization"] = "Bearer #{@copilot_token}" + request["Content-Type"] = "application/json" + request["Accept"] = "application/json" + request["Copilot-Integration-Id"] = "vscode-chat" + request["Editor-Version"] = "dispatch/#{VERSION}" + request["Openai-Intent"] = "conversation-panel" + end + + def execute_request(uri, request) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + http.open_timeout = 30 + http.read_timeout = 120 + http.start { |h| h.request(request) } + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, + Net::OpenTimeout, Net::ReadTimeout, SocketError => e + raise ConnectionError.new( + "Connection failed: #{e.message}", + provider: "GitHub Copilot" + ) + end + + def execute_streaming_request(uri, request, &block) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + http.open_timeout = 30 + http.read_timeout = 300 + + http.start do |h| + h.request(request) do |response| + handle_error_response!(response) unless response.is_a?(Net::HTTPSuccess) + block.call(response) + end + end + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, + Net::OpenTimeout, Net::ReadTimeout, SocketError => e + raise ConnectionError.new( + "Connection failed: #{e.message}", + provider: "GitHub Copilot" + ) + end + + def parse_response!(response) + handle_error_response!(response) unless response.is_a?(Net::HTTPSuccess) + parse_json_body(response) + end + + def parse_json_body(response) + JSON.parse(response.body) + rescue JSON::ParserError => e + raise RequestError.new( + "Invalid JSON response: #{e.message}", + provider: "GitHub Copilot" + ) + end + + def handle_error_response!(response) + code = response.code.to_i + body = response.body.to_s + message = begin + JSON.parse(body).dig("error", "message") || body + rescue JSON::ParserError + body + end + + case code + when 401, 403 + raise AuthenticationError.new(message, status_code: code, provider: "GitHub Copilot") + when 429 + retry_after = response["Retry-After"]&.to_i + raise RateLimitError.new(message, status_code: code, provider: "GitHub Copilot", retry_after: retry_after) + when 400, 422 + raise RequestError.new(message, status_code: code, provider: "GitHub Copilot") + when 500, 502, 503 + raise ServerError.new(message, status_code: code, provider: "GitHub Copilot") + else + raise Error.new(message, status_code: code, provider: "GitHub Copilot") + end + end + + # --- Message conversion --- + + def build_wire_messages(messages, system) + wire = [] + wire << { role: "system", content: system } if system + + messages.each do |msg| + wire_msg = convert_message(msg) + if wire_msg.is_a?(Array) + wire.concat(wire_msg) + else + wire << wire_msg + end + end + + merge_consecutive_roles(wire) + end + + def convert_message(msg) + case msg.content + when String + { role: msg.role, content: msg.content } + when Array + convert_content_blocks(msg) + else + { role: msg.role, content: msg.content.to_s } + end + end + + def convert_content_blocks(msg) + results = [] + text_parts = [] + tool_calls = [] + + msg.content.each do |block| + case block + when TextBlock + text_parts << block.text + when ImageBlock + raise NotImplementedError, "ImageBlock is not yet supported by the Copilot adapter" + when ToolUseBlock + tool_calls << { + id: block.id, + type: "function", + function: { + name: block.name, + arguments: JSON.generate(block.arguments) + } + } + when ToolResultBlock + results << { + role: "tool", + tool_call_id: block.tool_use_id, + content: tool_result_content(block) + } + end + end + + if msg.role == "assistant" && !tool_calls.empty? + assistant_msg = { role: "assistant" } + assistant_msg[:content] = text_parts.join("\n") unless text_parts.empty? + assistant_msg[:tool_calls] = tool_calls + results.unshift(assistant_msg) + elsif !text_parts.empty? + results.unshift({ role: msg.role, content: text_parts.join("\n") }) + end + + results + end + + def tool_result_content(block) + case block.content + when String + block.content + when Array + block.content.map(&:text).join("\n") + else + block.content.to_s + end + end + + def merge_consecutive_roles(messages) + return messages if messages.empty? + + merged = [messages.first.dup] + + messages[1..].each do |msg| + prev = merged.last + + if prev[:role] == msg[:role] && prev[:role] != "tool" && !msg.key?(:tool_calls) && !prev.key?(:tool_calls) + prev[:content] = [prev[:content], msg[:content]].compact.join("\n\n") + else + merged << msg.dup + end + end + + merged + end + + # --- Tool conversion --- + + def build_wire_tools(tools) + tools.map do |td| + { + type: "function", + function: { + name: tool_attr(td, :name), + description: tool_attr(td, :description), + parameters: tool_attr(td, :parameters) + } + } + end + end + + def tool_attr(tool, key) + if tool.respond_to?(key) + tool.public_send(key) + elsif tool.is_a?(Hash) + tool[key] || tool[key.to_s] + end + end + + # --- Chat (non-streaming) --- + + def chat_non_streaming(body) + uri = URI("#{API_BASE}/chat/completions") + request = Net::HTTP::Post.new(uri) + apply_headers!(request) + request.body = JSON.generate(body) + + response = execute_request(uri, request) + data = parse_response!(response) + + build_response(data) + end + + def build_response(data) + choice = data["choices"]&.first + return empty_response(data) unless choice + + message = choice["message"] || {} + content = message["content"] + tool_calls = (message["tool_calls"] || []).map do |tc| + func = tc["function"] + ToolUseBlock.new( + id: tc["id"], + name: func["name"], + arguments: parse_tool_arguments(func["arguments"]) + ) + end + + stop_reason = STOP_REASON_MAP.fetch(choice["finish_reason"], :end_turn) + + usage_data = data["usage"] || {} + usage = Usage.new( + input_tokens: usage_data["prompt_tokens"] || 0, + output_tokens: usage_data["completion_tokens"] || 0 + ) + + Response.new( + content: content, + tool_calls: tool_calls, + model: data["model"] || @model, + stop_reason: stop_reason, + usage: usage + ) + end + + def empty_response(data) + usage_data = data["usage"] || {} + Response.new( + model: data["model"] || @model, + stop_reason: :end_turn, + usage: Usage.new( + input_tokens: usage_data["prompt_tokens"] || 0, + output_tokens: usage_data["completion_tokens"] || 0 + ) + ) + end + + def parse_tool_arguments(args_string) + return {} if args_string.nil? || args_string.empty? + + JSON.parse(args_string) + rescue JSON::ParserError + {} + end + + # --- Chat (streaming) --- + + def chat_streaming(body, &block) + uri = URI("#{API_BASE}/chat/completions") + request = Net::HTTP::Post.new(uri) + apply_headers!(request) + request.body = JSON.generate(body) + + collected = new_stream_collector + + execute_streaming_request(uri, request) do |response| + buffer = +"" + + response.read_body do |chunk| + buffer << chunk + process_sse_buffer(buffer, collected, &block) + end + end + + build_streaming_response(collected) + end + + def new_stream_collector + { + content: +"", + tool_calls: {}, + model: @model, + finish_reason: nil, + input_tokens: 0, + output_tokens: 0 + } + end + + def process_sse_buffer(buffer, collected, &block) + while (line_end = buffer.index("\n")) + line = buffer.slice!(0..line_end).strip + next if line.empty? + next unless line.start_with?("data: ") + + data_str = line.sub(/\Adata: /, "") + next if data_str == "[DONE]" + + data = JSON.parse(data_str) + process_stream_chunk(data, collected, &block) + end + rescue JSON::ParserError + # Incomplete JSON chunk, will be completed on next read + nil + end + + def process_stream_chunk(data, collected, &block) + collected[:model] = data["model"] if data["model"] + + choice = data.dig("choices", 0) + return unless choice + + collected[:finish_reason] = choice["finish_reason"] if choice["finish_reason"] + delta = choice["delta"] || {} + + process_text_delta(delta, collected, &block) + process_tool_call_deltas(delta, collected, &block) + + process_usage(data, collected) + end + + def process_text_delta(delta, collected, &block) + return unless delta["content"] + + collected[:content] << delta["content"] + block.call(StreamDelta.new(type: :text_delta, text: delta["content"])) + end + + def process_tool_call_deltas(delta, collected, &block) + return unless delta["tool_calls"] + + delta["tool_calls"].each do |tc_delta| + index = tc_delta["index"] + tc = (collected[:tool_calls][index] ||= { id: nil, name: +"", arguments: +"" }) + + if tc_delta["id"] + tc[:id] = tc_delta["id"] + tc[:name] = tc_delta.dig("function", "name") || "" + block.call(StreamDelta.new( + type: :tool_use_start, + tool_call_id: tc[:id], + tool_name: tc[:name] + )) + end + + next unless (arg_frag = tc_delta.dig("function", "arguments")) + next if arg_frag.empty? + + tc[:arguments] << arg_frag + block.call(StreamDelta.new( + type: :tool_use_delta, + tool_call_id: tc[:id], + argument_delta: arg_frag + )) + end + end + + def process_usage(data, collected) + return unless data["usage"] + + collected[:input_tokens] = data["usage"]["prompt_tokens"] || collected[:input_tokens] + collected[:output_tokens] = data["usage"]["completion_tokens"] || collected[:output_tokens] + end + + def build_streaming_response(collected) + tool_calls = collected[:tool_calls].values.map do |tc| + ToolUseBlock.new( + id: tc[:id], + name: tc[:name], + arguments: parse_tool_arguments(tc[:arguments]) + ) + end + + stop_reason = STOP_REASON_MAP.fetch(collected[:finish_reason], :end_turn) + content = collected[:content].empty? ? nil : collected[:content] + + Response.new( + content: content, + tool_calls: tool_calls, + model: collected[:model], + stop_reason: stop_reason, + usage: Usage.new( + input_tokens: collected[:input_tokens], + output_tokens: collected[:output_tokens] + ) + ) + end end end end diff --git a/lib/dispatch/adapter/errors.rb b/lib/dispatch/adapter/errors.rb new file mode 100644 index 0000000..86f9c14 --- /dev/null +++ b/lib/dispatch/adapter/errors.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + class Error < StandardError + attr_reader :status_code, :provider + + def initialize(message = nil, status_code: nil, provider: nil) + @status_code = status_code + @provider = provider + super(message) + end + end + + class AuthenticationError < Error; end + + class RateLimitError < Error + attr_reader :retry_after + + def initialize(message = nil, status_code: nil, provider: nil, retry_after: nil) + @retry_after = retry_after + super(message, status_code:, provider:) + end + end + + class ServerError < Error; end + class RequestError < Error; end + class ConnectionError < Error; end + end +end diff --git a/lib/dispatch/adapter/message.rb b/lib/dispatch/adapter/message.rb new file mode 100644 index 0000000..eb51c99 --- /dev/null +++ b/lib/dispatch/adapter/message.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + Message = Struct.new(:role, :content, keyword_init: true) + + TextBlock = Struct.new(:type, :text, keyword_init: true) do + def initialize(text:, type: "text") + super(type:, text:) + end + end + + ImageBlock = Struct.new(:type, :source, :media_type, keyword_init: true) do + def initialize(source:, media_type:, type: "image") + super(type:, source:, media_type:) + end + end + + ToolUseBlock = Struct.new(:type, :id, :name, :arguments, keyword_init: true) do + def initialize(id:, name:, arguments:, type: "tool_use") + super(type:, id:, name:, arguments:) + end + end + + ToolResultBlock = Struct.new(:type, :tool_use_id, :content, :is_error, keyword_init: true) do + def initialize(tool_use_id:, content:, is_error: false, type: "tool_result") + super(type:, tool_use_id:, content:, is_error:) + end + end + end +end diff --git a/lib/dispatch/adapter/model_info.rb b/lib/dispatch/adapter/model_info.rb new file mode 100644 index 0000000..73d8a35 --- /dev/null +++ b/lib/dispatch/adapter/model_info.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + ModelInfo = Struct.new( + :id, :name, :max_context_tokens, + :supports_vision, :supports_tool_use, :supports_streaming, + keyword_init: true + ) + end +end diff --git a/lib/dispatch/adapter/response.rb b/lib/dispatch/adapter/response.rb new file mode 100644 index 0000000..d3e4789 --- /dev/null +++ b/lib/dispatch/adapter/response.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + Response = Struct.new(:content, :tool_calls, :model, :stop_reason, :usage, keyword_init: true) do + def initialize(content: nil, tool_calls: [], model:, stop_reason:, usage:) + super(content:, tool_calls:, model:, stop_reason:, usage:) + end + end + + Usage = Struct.new(:input_tokens, :output_tokens, :cache_read_tokens, :cache_creation_tokens, keyword_init: true) do + def initialize(input_tokens:, output_tokens:, cache_read_tokens: 0, cache_creation_tokens: 0) + super(input_tokens:, output_tokens:, cache_read_tokens:, cache_creation_tokens:) + end + end + + StreamDelta = Struct.new(:type, :text, :tool_call_id, :tool_name, :argument_delta, keyword_init: true) do + def initialize(type:, text: nil, tool_call_id: nil, tool_name: nil, argument_delta: nil) + super(type:, text:, tool_call_id:, tool_name:, argument_delta:) + end + end + end +end diff --git a/lib/dispatch/adapter/tool_definition.rb b/lib/dispatch/adapter/tool_definition.rb new file mode 100644 index 0000000..7b435a3 --- /dev/null +++ b/lib/dispatch/adapter/tool_definition.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + ToolDefinition = Struct.new(:name, :description, :parameters, keyword_init: true) + end +end diff --git a/lib/dispatch/adapter/copilot/version.rb b/lib/dispatch/adapter/version.rb index 2de061c..3df9f5f 100644 --- a/lib/dispatch/adapter/copilot/version.rb +++ b/lib/dispatch/adapter/version.rb @@ -2,7 +2,7 @@ module Dispatch module Adapter - module Copilot + module CopilotVersion VERSION = "0.1.0" end end diff --git a/spec/dispatch/adapter/base_spec.rb b/spec/dispatch/adapter/base_spec.rb new file mode 100644 index 0000000..8c3a279 --- /dev/null +++ b/spec/dispatch/adapter/base_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Base do + subject(:base) { described_class.new } + + describe "#chat" do + it "raises NotImplementedError" do + expect { base.chat([]) }.to raise_error(NotImplementedError, /chat must be implemented/) + end + end + + describe "#model_name" do + it "raises NotImplementedError" do + expect { base.model_name }.to raise_error(NotImplementedError, /model_name must be implemented/) + end + end + + describe "#count_tokens" do + it "returns -1" do + expect(base.count_tokens([])).to eq(-1) + end + end + + describe "#list_models" do + it "raises NotImplementedError" do + expect { base.list_models }.to raise_error(NotImplementedError, /list_models must be implemented/) + end + end + + describe "#provider_name" do + it "returns the class name" do + expect(base.provider_name).to eq("Dispatch::Adapter::Base") + end + end + + describe "#max_context_tokens" do + it "returns nil" do + expect(base.max_context_tokens).to be_nil + end + end +end diff --git a/spec/dispatch/adapter/copilot_spec.rb b/spec/dispatch/adapter/copilot_spec.rb index eb28042..13c37be 100644 --- a/spec/dispatch/adapter/copilot_spec.rb +++ b/spec/dispatch/adapter/copilot_spec.rb @@ -1,11 +1,1125 @@ # frozen_string_literal: true +require "webmock/rspec" + RSpec.describe Dispatch::Adapter::Copilot do - it "has a version number" do - expect(Dispatch::Adapter::Copilot::VERSION).not_to be nil + let(:copilot_token) { "cop_test_token_abc" } + let(:github_token) { "gho_test_github_token" } + + let(:adapter) do + described_class.new( + model: "gpt-4.1", + github_token: github_token, + max_tokens: 4096 + ) + end + + before do + # Stub Copilot token exchange + stub_request(:get, "https://api.github.com/copilot_internal/v2/token") + .with(headers: { "Authorization" => "token #{github_token}" }) + .to_return( + status: 200, + body: JSON.generate({ + "token" => copilot_token, + "expires_at" => (Time.now.to_i + 3600) + }), + headers: { "Content-Type" => "application/json" } + ) + end + + describe "#model_name" do + it "returns the model identifier" do + expect(adapter.model_name).to eq("gpt-4.1") + end + end + + describe "VERSION" do + it "is accessible" do + expect(Dispatch::Adapter::Copilot::VERSION).to eq("0.1.0") + end + end + + describe "#provider_name" do + it "returns 'GitHub Copilot'" do + expect(adapter.provider_name).to eq("GitHub Copilot") + end end - it "does something useful" do - expect(false).to eq(true) + describe "#max_context_tokens" do + it "returns the context window for known models" do + expect(adapter.max_context_tokens).to eq(1_047_576) + end + + it "returns nil for unknown models" do + unknown = described_class.new(model: "unknown-model", github_token: github_token) + expect(unknown.max_context_tokens).to be_nil + end + end + + describe "#count_tokens" do + it "returns -1 (inherited from Base)" do + expect(adapter.count_tokens([])).to eq(-1) + end + end + + describe "#chat" do + context "with a text-only response" do + before do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 200, + body: JSON.generate({ + "id" => "chatcmpl-123", + "model" => "gpt-4.1", + "choices" => [{ + "index" => 0, + "message" => { "role" => "assistant", "content" => "Hello there!" }, + "finish_reason" => "stop" + }], + "usage" => { "prompt_tokens" => 10, "completion_tokens" => 5 } + }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns a Response with content" do + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + response = adapter.chat(messages) + + expect(response).to be_a(Dispatch::Adapter::Response) + expect(response.content).to eq("Hello there!") + expect(response.tool_calls).to be_empty + expect(response.model).to eq("gpt-4.1") + expect(response.stop_reason).to eq(:end_turn) + expect(response.usage.input_tokens).to eq(10) + expect(response.usage.output_tokens).to eq(5) + end + end + + context "with a tool call response" do + before do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 200, + body: JSON.generate({ + "id" => "chatcmpl-456", + "model" => "gpt-4.1", + "choices" => [{ + "index" => 0, + "message" => { + "role" => "assistant", + "content" => nil, + "tool_calls" => [{ + "id" => "call_abc", + "type" => "function", + "function" => { + "name" => "get_weather", + "arguments" => '{"city":"New York"}' + } + }] + }, + "finish_reason" => "tool_calls" + }], + "usage" => { "prompt_tokens" => 15, "completion_tokens" => 10 } + }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns a Response with tool_calls as ToolUseBlock array" do + messages = [Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?")] + response = adapter.chat(messages) + + expect(response.content).to be_nil + expect(response.stop_reason).to eq(:tool_use) + expect(response.tool_calls.size).to eq(1) + + tc = response.tool_calls.first + expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock) + expect(tc.id).to eq("call_abc") + expect(tc.name).to eq("get_weather") + expect(tc.arguments).to eq({ "city" => "New York" }) + end + end + + context "with multiple tool calls in response" do + before do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ + "index" => 0, + "message" => { + "role" => "assistant", + "content" => nil, + "tool_calls" => [ + { + "id" => "call_1", + "type" => "function", + "function" => { "name" => "get_weather", "arguments" => '{"city":"NYC"}' } + }, + { + "id" => "call_2", + "type" => "function", + "function" => { "name" => "get_time", "arguments" => '{"timezone":"EST"}' } + } + ] + }, + "finish_reason" => "tool_calls" + }], + "usage" => { "prompt_tokens" => 20, "completion_tokens" => 15 } + }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns multiple ToolUseBlocks" do + messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather and time?")] + response = adapter.chat(messages) + + expect(response.tool_calls.size).to eq(2) + expect(response.tool_calls[0].name).to eq("get_weather") + expect(response.tool_calls[0].id).to eq("call_1") + expect(response.tool_calls[1].name).to eq("get_time") + expect(response.tool_calls[1].id).to eq("call_2") + expect(response.tool_calls[1].arguments).to eq({ "timezone" => "EST" }) + end + end + + context "with a mixed response (text + tool calls)" do + before do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 200, + body: JSON.generate({ + "id" => "chatcmpl-789", + "model" => "gpt-4.1", + "choices" => [{ + "index" => 0, + "message" => { + "role" => "assistant", + "content" => "Let me check that for you.", + "tool_calls" => [{ + "id" => "call_def", + "type" => "function", + "function" => { + "name" => "search", + "arguments" => '{"query":"Ruby gems"}' + } + }] + }, + "finish_reason" => "tool_calls" + }], + "usage" => { "prompt_tokens" => 20, "completion_tokens" => 15 } + }), + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns both content and tool_calls" do + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Search for Ruby gems")] + response = adapter.chat(messages) + + expect(response.content).to eq("Let me check that for you.") + expect(response.tool_calls.size).to eq(1) + expect(response.stop_reason).to eq(:tool_use) + end + end + + context "with system: parameter" do + it "prepends system message in the wire format" do + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["messages"].first == { "role" => "system", "content" => "You are helpful." } + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "OK" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + adapter.chat(messages, system: "You are helpful.") + + expect(stub).to have_been_requested + end + end + + context "with max_tokens: per-call override" do + it "uses per-call max_tokens over constructor default" do + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["max_tokens"] == 100 + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "short" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + adapter.chat(messages, max_tokens: 100) + + expect(stub).to have_been_requested + end + + it "uses constructor default when max_tokens not specified" do + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["max_tokens"] == 4096 + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + adapter.chat(messages) + + expect(stub).to have_been_requested + end + end + + context "with tools parameter" do + it "sends tools in OpenAI function format" do + tool = Dispatch::Adapter::ToolDefinition.new( + name: "get_weather", + description: "Get weather for a city", + parameters: { "type" => "object", "properties" => { "city" => { "type" => "string" } } } + ) + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["tools"] == [{ + "type" => "function", + "function" => { + "name" => "get_weather", + "description" => "Get weather for a city", + "parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } } + } + }] + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")] + adapter.chat(messages, tools: [tool]) + + expect(stub).to have_been_requested + end + + it "accepts plain hashes with symbol keys as tools" do + tool_hash = { + name: "get_weather", + description: "Get weather for a city", + parameters: { "type" => "object", "properties" => { "city" => { "type" => "string" } } } + } + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["tools"] == [{ + "type" => "function", + "function" => { + "name" => "get_weather", + "description" => "Get weather for a city", + "parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } } + } + }] + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")] + adapter.chat(messages, tools: [tool_hash]) + + expect(stub).to have_been_requested + end + + it "accepts plain hashes with string keys as tools" do + tool_hash = { + "name" => "get_weather", + "description" => "Get weather for a city", + "parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } } + } + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["tools"] == [{ + "type" => "function", + "function" => { + "name" => "get_weather", + "description" => "Get weather for a city", + "parameters" => { "type" => "object", "properties" => { "city" => { "type" => "string" } } } + } + }] + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "weather?")] + adapter.chat(messages, tools: [tool_hash]) + + expect(stub).to have_been_requested + end + + it "accepts a mix of ToolDefinition structs and plain hashes" do + tool_struct = Dispatch::Adapter::ToolDefinition.new( + name: "get_weather", + description: "Get weather", + parameters: { "type" => "object", "properties" => {} } + ) + tool_hash = { + name: "get_time", + description: "Get time", + parameters: { "type" => "object", "properties" => {} } + } + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["tools"].size == 2 && + body["tools"][0]["function"]["name"] == "get_weather" && + body["tools"][1]["function"]["name"] == "get_time" + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "both?")] + adapter.chat(messages, tools: [tool_struct, tool_hash]) + + expect(stub).to have_been_requested + end + + it "does not include tools key when tools array is empty" do + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + !body.key?("tools") + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + adapter.chat(messages) + + expect(stub).to have_been_requested + end + end + + context "with ToolUseBlock and ToolResultBlock in messages" do + it "converts to OpenAI wire format" do + tool_use = Dispatch::Adapter::ToolUseBlock.new( + id: "call_1", name: "get_weather", arguments: { "city" => "NYC" } + ) + tool_result = Dispatch::Adapter::ToolResultBlock.new( + tool_use_id: "call_1", content: "72F and sunny" + ) + + messages = [ + Dispatch::Adapter::Message.new(role: "user", content: "What's the weather?"), + Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]), + Dispatch::Adapter::Message.new(role: "user", content: [tool_result]) + ] + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + msgs = body["messages"] + # user message + msgs[0]["role"] == "user" && + # assistant with tool_calls + msgs[1]["role"] == "assistant" && + msgs[1]["tool_calls"].is_a?(Array) && + msgs[1]["tool_calls"][0]["id"] == "call_1" && + # tool result + msgs[2]["role"] == "tool" && + msgs[2]["tool_call_id"] == "call_1" && + msgs[2]["content"] == "72F and sunny" + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "It's 72F and sunny in NYC!" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 20, "completion_tokens" => 10 } + }), + headers: { "Content-Type" => "application/json" } + ) + + adapter.chat(messages) + expect(stub).to have_been_requested + end + end + + context "with ImageBlock" do + it "raises NotImplementedError" do + image = Dispatch::Adapter::ImageBlock.new(source: "base64data", media_type: "image/png") + messages = [Dispatch::Adapter::Message.new(role: "user", content: [image])] + + expect { adapter.chat(messages) }.to raise_error(NotImplementedError, /ImageBlock/) + end + end + + context "with TextBlock array in user message" do + it "converts to string content" do + text_blocks = [ + Dispatch::Adapter::TextBlock.new(text: "First paragraph."), + Dispatch::Adapter::TextBlock.new(text: "Second paragraph.") + ] + messages = [Dispatch::Adapter::Message.new(role: "user", content: text_blocks)] + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + msgs = body["messages"] + msgs[0]["content"] == "First paragraph.\nSecond paragraph." + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + adapter.chat(messages) + expect(stub).to have_been_requested + end + end + + context "with ToolResultBlock containing array content" do + it "joins TextBlock array into string" do + tool_use = Dispatch::Adapter::ToolUseBlock.new( + id: "call_1", name: "search", arguments: { "q" => "test" } + ) + tool_result = Dispatch::Adapter::ToolResultBlock.new( + tool_use_id: "call_1", + content: [ + Dispatch::Adapter::TextBlock.new(text: "Result line 1"), + Dispatch::Adapter::TextBlock.new(text: "Result line 2") + ] + ) + + messages = [ + Dispatch::Adapter::Message.new(role: "user", content: "search"), + Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]), + Dispatch::Adapter::Message.new(role: "user", content: [tool_result]) + ] + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + msgs = body["messages"] + tool_msg = msgs.find { |m| m["role"] == "tool" } + tool_msg && tool_msg["content"] == "Result line 1\nResult line 2" + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + adapter.chat(messages) + expect(stub).to have_been_requested + end + end + + context "with ToolResultBlock with is_error: true" do + it "converts to tool role message" do + tool_use = Dispatch::Adapter::ToolUseBlock.new( + id: "call_err", name: "risky_op", arguments: {} + ) + tool_result = Dispatch::Adapter::ToolResultBlock.new( + tool_use_id: "call_err", content: "Something went wrong", is_error: true + ) + + messages = [ + Dispatch::Adapter::Message.new(role: "user", content: "do it"), + Dispatch::Adapter::Message.new(role: "assistant", content: [tool_use]), + Dispatch::Adapter::Message.new(role: "user", content: [tool_result]) + ] + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + msgs = body["messages"] + tool_msg = msgs.find { |m| m["role"] == "tool" } + tool_msg && tool_msg["content"] == "Something went wrong" && tool_msg["tool_call_id"] == "call_err" + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "I see the error" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 10, "completion_tokens" => 3 } + }), + headers: { "Content-Type" => "application/json" } + ) + + adapter.chat(messages) + expect(stub).to have_been_requested + end + end + + context "with finish_reason 'length'" do + it "maps to :max_tokens stop_reason" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ + "message" => { "content" => "truncated output..." }, + "finish_reason" => "length" + }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 100 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Write a long essay")] + response = adapter.chat(messages) + + expect(response.stop_reason).to eq(:max_tokens) + expect(response.content).to eq("truncated output...") + end + end + + context "with assistant message containing text + tool_use blocks" do + it "includes both content and tool_calls in wire format" do + text = Dispatch::Adapter::TextBlock.new(text: "Checking...") + tool_use = Dispatch::Adapter::ToolUseBlock.new( + id: "call_mixed", name: "lookup", arguments: { "id" => 42 } + ) + + messages = [ + Dispatch::Adapter::Message.new(role: "user", content: "lookup 42"), + Dispatch::Adapter::Message.new(role: "assistant", content: [text, tool_use]) + ] + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + msgs = body["messages"] + assistant = msgs.find { |m| m["role"] == "assistant" } + assistant && + assistant["content"] == "Checking..." && + assistant["tool_calls"].is_a?(Array) && + assistant["tool_calls"][0]["id"] == "call_mixed" + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 10, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + adapter.chat(messages) + expect(stub).to have_been_requested + end + end + + context "with consecutive same-role messages" do + it "merges them before sending" do + messages = [ + Dispatch::Adapter::Message.new(role: "user", content: "First"), + Dispatch::Adapter::Message.new(role: "user", content: "Second") + ] + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + msgs = body["messages"] + msgs.size == 1 && msgs[0]["role"] == "user" && msgs[0]["content"].include?("First") && msgs[0]["content"].include?("Second") + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + adapter.chat(messages) + expect(stub).to have_been_requested + end + end + + context "with thinking: parameter" do + it "sends reasoning_effort in the request body" do + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["reasoning_effort"] == "high" + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "thought deeply" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 3 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Think hard")] + adapter.chat(messages, thinking: "high") + + expect(stub).to have_been_requested + end + + it "uses constructor default when not specified per-call" do + thinking_adapter = described_class.new( + model: "o3-mini", + github_token: github_token, + max_tokens: 4096, + thinking: "medium" + ) + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["reasoning_effort"] == "medium" + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + thinking_adapter.chat(messages) + + expect(stub).to have_been_requested + end + + it "overrides constructor default with per-call thinking" do + thinking_adapter = described_class.new( + model: "o3-mini", + github_token: github_token, + max_tokens: 4096, + thinking: "medium" + ) + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + body["reasoning_effort"] == "low" + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + thinking_adapter.chat(messages, thinking: "low") + + expect(stub).to have_been_requested + end + + it "does not send reasoning_effort when thinking is nil" do + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + !body.key?("reasoning_effort") + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + adapter.chat(messages) + + expect(stub).to have_been_requested + end + + it "raises ArgumentError for invalid thinking level" do + expect { + described_class.new(model: "o3", github_token: github_token, thinking: "extreme") + }.to raise_error(ArgumentError, /Invalid thinking level/) + end + + it "raises ArgumentError for invalid per-call thinking level" do + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { + adapter.chat(messages, thinking: "extreme") + }.to raise_error(ArgumentError, /Invalid thinking level/) + end + + it "allows disabling constructor default with nil per-call" do + thinking_adapter = described_class.new( + model: "o3-mini", + github_token: github_token, + max_tokens: 4096, + thinking: "high" + ) + + stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| + body = JSON.parse(req.body) + !body.key?("reasoning_effort") + } + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + thinking_adapter.chat(messages, thinking: nil) + + expect(stub).to have_been_requested + end + end + end + + describe "#chat with streaming" do + it "yields StreamDelta objects and returns Response" do + sse_body = [ + "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "Hello" }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => " world" }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "stop" }], "usage" => { "prompt_tokens" => 5, "completion_tokens" => 2 } })}\n\n", + "data: [DONE]\n\n" + ].join + + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .with { |req| JSON.parse(req.body)["stream"] == true } + .to_return( + status: 200, + body: sse_body, + headers: { "Content-Type" => "text/event-stream" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + deltas = [] + response = adapter.chat(messages, stream: true) { |delta| deltas << delta } + + expect(deltas.size).to eq(2) + expect(deltas[0]).to be_a(Dispatch::Adapter::StreamDelta) + expect(deltas[0].type).to eq(:text_delta) + expect(deltas[0].text).to eq("Hello") + expect(deltas[1].text).to eq(" world") + + expect(response).to be_a(Dispatch::Adapter::Response) + expect(response.content).to eq("Hello world") + expect(response.stop_reason).to eq(:end_turn) + end + + it "yields tool_use_start and tool_use_delta for tool call streams" do + sse_body = [ + "data: #{JSON.generate({ "choices" => [{ "delta" => { "tool_calls" => [{ "index" => 0, "id" => "call_1", "type" => "function", "function" => { "name" => "search", "arguments" => "" } }] }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => { "tool_calls" => [{ "index" => 0, "function" => { "arguments" => "{\"q\":" } }] }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => { "tool_calls" => [{ "index" => 0, "function" => { "arguments" => "\"test\"}" } }] }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "tool_calls" }] })}\n\n", + "data: [DONE]\n\n" + ].join + + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 200, + body: sse_body, + headers: { "Content-Type" => "text/event-stream" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "search")] + deltas = [] + response = adapter.chat(messages, stream: true) { |delta| deltas << delta } + + starts = deltas.select { |d| d.type == :tool_use_start } + arg_deltas = deltas.select { |d| d.type == :tool_use_delta } + + expect(starts.size).to eq(1) + expect(starts.first.tool_call_id).to eq("call_1") + expect(starts.first.tool_name).to eq("search") + + expect(arg_deltas.size).to eq(2) + + expect(response.stop_reason).to eq(:tool_use) + expect(response.tool_calls.size).to eq(1) + expect(response.tool_calls.first.name).to eq("search") + expect(response.tool_calls.first.arguments).to eq({ "q" => "test" }) + end + + it "captures usage from streaming response" do + sse_body = [ + "data: #{JSON.generate({ "choices" => [{ "delta" => { "content" => "hi" }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "stop" }], "usage" => { "prompt_tokens" => 42, "completion_tokens" => 7 } })}\n\n", + "data: [DONE]\n\n" + ].join + + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 200, + body: sse_body, + headers: { "Content-Type" => "text/event-stream" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + response = adapter.chat(messages, stream: true) { |_| } + + expect(response.usage.input_tokens).to eq(42) + expect(response.usage.output_tokens).to eq(7) + end + + it "handles multiple parallel tool calls in a stream" do + sse_body = [ + "data: #{JSON.generate({ "choices" => [{ "delta" => { "tool_calls" => [{ "index" => 0, "id" => "call_a", "type" => "function", "function" => { "name" => "tool_a", "arguments" => "" } }] }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => { "tool_calls" => [{ "index" => 1, "id" => "call_b", "type" => "function", "function" => { "name" => "tool_b", "arguments" => "" } }] }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => { "tool_calls" => [{ "index" => 0, "function" => { "arguments" => "{\"x\":1}" } }] }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => { "tool_calls" => [{ "index" => 1, "function" => { "arguments" => "{\"y\":2}" } }] }, "index" => 0 }] })}\n\n", + "data: #{JSON.generate({ "choices" => [{ "delta" => {}, "index" => 0, "finish_reason" => "tool_calls" }] })}\n\n", + "data: [DONE]\n\n" + ].join + + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return(status: 200, body: sse_body, headers: { "Content-Type" => "text/event-stream" }) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "do both")] + deltas = [] + response = adapter.chat(messages, stream: true) { |d| deltas << d } + + starts = deltas.select { |d| d.type == :tool_use_start } + expect(starts.size).to eq(2) + expect(starts[0].tool_name).to eq("tool_a") + expect(starts[1].tool_name).to eq("tool_b") + + expect(response.tool_calls.size).to eq(2) + expect(response.tool_calls[0].name).to eq("tool_a") + expect(response.tool_calls[0].arguments).to eq({ "x" => 1 }) + expect(response.tool_calls[1].name).to eq("tool_b") + expect(response.tool_calls[1].arguments).to eq({ "y" => 2 }) + end + end + + describe "authentication" do + it "reuses cached Copilot token for subsequent requests" do + token_stub = stub_request(:get, "https://api.github.com/copilot_internal/v2/token") + .to_return( + status: 200, + body: JSON.generate({ "token" => copilot_token, "expires_at" => (Time.now.to_i + 3600) }), + headers: { "Content-Type" => "application/json" } + ) + + chat_stub = stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 200, + body: JSON.generate({ + "choices" => [{ "message" => { "content" => "ok" }, "finish_reason" => "stop" }], + "usage" => { "prompt_tokens" => 5, "completion_tokens" => 1 } + }), + headers: { "Content-Type" => "application/json" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + adapter.chat(messages) + adapter.chat(messages) + + expect(token_stub).to have_been_requested.once + expect(chat_stub).to have_been_requested.twice + end + + it "raises AuthenticationError when Copilot token exchange fails" do + # Override the before block stub + WebMock.reset! + stub_request(:get, "https://api.github.com/copilot_internal/v2/token") + .to_return( + status: 401, + body: JSON.generate({ "message" => "Bad credentials" }), + headers: { "Content-Type" => "application/json" } + ) + + fresh_adapter = described_class.new(model: "gpt-4.1", github_token: "bad_token", max_tokens: 4096) + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + + expect { fresh_adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) + end + end + + describe "#list_models" do + it "returns an array of ModelInfo structs" do + stub_request(:get, "https://api.githubcopilot.com/v1/models") + .to_return( + status: 200, + body: JSON.generate({ + "data" => [ + { "id" => "gpt-4.1", "object" => "model" }, + { "id" => "gpt-4o", "object" => "model" } + ] + }), + headers: { "Content-Type" => "application/json" } + ) + + models = adapter.list_models + + expect(models.size).to eq(2) + expect(models.first).to be_a(Dispatch::Adapter::ModelInfo) + expect(models.first.id).to eq("gpt-4.1") + expect(models.first.max_context_tokens).to eq(1_047_576) + expect(models.last.id).to eq("gpt-4o") + end + end + + describe "error mapping" do + it "maps 401 to AuthenticationError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return(status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } })) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) { |e| + expect(e.status_code).to eq(401) + expect(e.provider).to eq("GitHub Copilot") + } + end + + it "maps 403 to AuthenticationError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return(status: 403, body: JSON.generate({ "error" => { "message" => "Forbidden" } })) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) + end + + it "maps 429 to RateLimitError with retry_after" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return( + status: 429, + body: JSON.generate({ "error" => { "message" => "Too many requests" } }), + headers: { "Retry-After" => "30" } + ) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) { |e| + expect(e.status_code).to eq(429) + expect(e.retry_after).to eq(30) + } + end + + it "maps 400 to RequestError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return(status: 400, body: JSON.generate({ "error" => { "message" => "Bad request" } })) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) { |e| + expect(e.status_code).to eq(400) + } + end + + it "maps 422 to RequestError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return(status: 422, body: JSON.generate({ "error" => { "message" => "Unprocessable" } })) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) + end + + it "maps 500 to ServerError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return(status: 500, body: JSON.generate({ "error" => { "message" => "Internal error" } })) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) { |e| + expect(e.status_code).to eq(500) + } + end + + it "maps 502 to ServerError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return(status: 502, body: "Bad Gateway") + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) + end + + it "maps 503 to ServerError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_return(status: 503, body: "Service Unavailable") + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) + end + + it "maps connection errors to ConnectionError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_raise(Errno::ECONNREFUSED.new("Connection refused")) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError) { |e| + expect(e.provider).to eq("GitHub Copilot") + } + end + + it "maps timeout errors to ConnectionError" do + stub_request(:post, "https://api.githubcopilot.com/chat/completions") + .to_raise(Net::OpenTimeout.new("execution expired")) + + messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] + expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError) + end end end diff --git a/spec/dispatch/adapter/errors_spec.rb b/spec/dispatch/adapter/errors_spec.rb new file mode 100644 index 0000000..77c8389 --- /dev/null +++ b/spec/dispatch/adapter/errors_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Error do + it "carries message, status_code, and provider" do + error = described_class.new("test error", status_code: 500, provider: "TestProvider") + expect(error.message).to eq("test error") + expect(error.status_code).to eq(500) + expect(error.provider).to eq("TestProvider") + end + + it "defaults status_code and provider to nil" do + error = described_class.new("simple error") + expect(error.status_code).to be_nil + expect(error.provider).to be_nil + end + + it "inherits from StandardError" do + expect(described_class.ancestors).to include(StandardError) + end + + it "can be rescued as StandardError" do + expect { + raise described_class.new("test") + }.to raise_error(StandardError) + end +end + +RSpec.describe Dispatch::Adapter::AuthenticationError do + it "inherits from Error" do + expect(described_class.ancestors).to include(Dispatch::Adapter::Error) + end +end + +RSpec.describe Dispatch::Adapter::RateLimitError do + it "carries retry_after" do + error = described_class.new("rate limited", status_code: 429, provider: "Test", retry_after: 30) + expect(error.retry_after).to eq(30) + expect(error.status_code).to eq(429) + end + + it "defaults retry_after to nil" do + error = described_class.new("rate limited") + expect(error.retry_after).to be_nil + end + + it "is rescuable as Dispatch::Adapter::Error" do + expect { + raise described_class.new("rate limited") + }.to raise_error(Dispatch::Adapter::Error) + end +end + +RSpec.describe Dispatch::Adapter::ServerError do + it "inherits from Error" do + expect(described_class.ancestors).to include(Dispatch::Adapter::Error) + end +end + +RSpec.describe Dispatch::Adapter::RequestError do + it "inherits from Error" do + expect(described_class.ancestors).to include(Dispatch::Adapter::Error) + end +end + +RSpec.describe Dispatch::Adapter::ConnectionError do + it "inherits from Error" do + expect(described_class.ancestors).to include(Dispatch::Adapter::Error) + end +end diff --git a/spec/dispatch/adapter/structs_spec.rb b/spec/dispatch/adapter/structs_spec.rb new file mode 100644 index 0000000..07c1198 --- /dev/null +++ b/spec/dispatch/adapter/structs_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter do + describe "Message" do + it "creates with keyword args" do + msg = Dispatch::Adapter::Message.new(role: "user", content: "Hello") + expect(msg.role).to eq("user") + expect(msg.content).to eq("Hello") + end + + it "accepts array content" do + blocks = [Dispatch::Adapter::TextBlock.new(text: "hi")] + msg = Dispatch::Adapter::Message.new(role: "user", content: blocks) + expect(msg.content).to be_an(Array) + expect(msg.content.first.text).to eq("hi") + end + end + + describe "TextBlock" do + it "defaults type to 'text'" do + block = Dispatch::Adapter::TextBlock.new(text: "hello") + expect(block.type).to eq("text") + expect(block.text).to eq("hello") + end + end + + describe "ImageBlock" do + it "defaults type to 'image'" do + block = Dispatch::Adapter::ImageBlock.new(source: "data:image/png;base64,abc", media_type: "image/png") + expect(block.type).to eq("image") + expect(block.source).to eq("data:image/png;base64,abc") + expect(block.media_type).to eq("image/png") + end + end + + describe "ToolUseBlock" do + it "defaults type to 'tool_use'" do + block = Dispatch::Adapter::ToolUseBlock.new(id: "call_1", name: "get_weather", arguments: { "city" => "NYC" }) + expect(block.type).to eq("tool_use") + expect(block.id).to eq("call_1") + expect(block.name).to eq("get_weather") + expect(block.arguments).to eq({ "city" => "NYC" }) + end + end + + describe "ToolResultBlock" do + it "defaults type to 'tool_result' and is_error to false" do + block = Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "call_1", content: "72F") + expect(block.type).to eq("tool_result") + expect(block.tool_use_id).to eq("call_1") + expect(block.content).to eq("72F") + expect(block.is_error).to be(false) + end + + it "accepts is_error flag" do + block = Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "call_1", content: "Error", is_error: true) + expect(block.is_error).to be(true) + end + end + + describe "ToolDefinition" do + it "creates with keyword args" do + td = Dispatch::Adapter::ToolDefinition.new( + name: "search", + description: "Search the web", + parameters: { "type" => "object", "properties" => {} } + ) + expect(td.name).to eq("search") + expect(td.description).to eq("Search the web") + expect(td.parameters).to be_a(Hash) + end + end + + describe "Response" do + it "creates with defaults" do + usage = Dispatch::Adapter::Usage.new(input_tokens: 10, output_tokens: 20) + resp = Dispatch::Adapter::Response.new(model: "gpt-4", stop_reason: :end_turn, usage: usage) + expect(resp.content).to be_nil + expect(resp.tool_calls).to eq([]) + expect(resp.model).to eq("gpt-4") + expect(resp.stop_reason).to eq(:end_turn) + expect(resp.usage).to eq(usage) + end + + it "creates with all fields" do + usage = Dispatch::Adapter::Usage.new(input_tokens: 10, output_tokens: 20) + tool_call = Dispatch::Adapter::ToolUseBlock.new(id: "1", name: "test", arguments: {}) + resp = Dispatch::Adapter::Response.new( + content: "Hello", + tool_calls: [tool_call], + model: "gpt-4", + stop_reason: :tool_use, + usage: usage + ) + expect(resp.content).to eq("Hello") + expect(resp.tool_calls.size).to eq(1) + end + end + + describe "Usage" do + it "defaults cache tokens to 0" do + usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50) + expect(usage.cache_read_tokens).to eq(0) + expect(usage.cache_creation_tokens).to eq(0) + end + + it "accepts cache tokens" do + usage = Dispatch::Adapter::Usage.new( + input_tokens: 100, + output_tokens: 50, + cache_read_tokens: 10, + cache_creation_tokens: 5 + ) + expect(usage.cache_read_tokens).to eq(10) + expect(usage.cache_creation_tokens).to eq(5) + end + end + + describe "StreamDelta" do + it "creates a text_delta" do + delta = Dispatch::Adapter::StreamDelta.new(type: :text_delta, text: "Hello") + expect(delta.type).to eq(:text_delta) + expect(delta.text).to eq("Hello") + expect(delta.tool_call_id).to be_nil + end + + it "creates a tool_use_start" do + delta = Dispatch::Adapter::StreamDelta.new(type: :tool_use_start, tool_call_id: "1", tool_name: "search") + expect(delta.type).to eq(:tool_use_start) + expect(delta.tool_call_id).to eq("1") + expect(delta.tool_name).to eq("search") + end + + it "creates a tool_use_delta" do + delta = Dispatch::Adapter::StreamDelta.new(type: :tool_use_delta, tool_call_id: "1", argument_delta: '{"q":') + expect(delta.type).to eq(:tool_use_delta) + expect(delta.argument_delta).to eq('{"q":') + end + end + + describe "ModelInfo" do + it "creates with all fields" do + info = Dispatch::Adapter::ModelInfo.new( + id: "gpt-4", + name: "GPT-4", + max_context_tokens: 8192, + supports_vision: false, + supports_tool_use: true, + supports_streaming: true + ) + expect(info.id).to eq("gpt-4") + expect(info.name).to eq("GPT-4") + expect(info.max_context_tokens).to eq(8192) + expect(info.supports_vision).to be(false) + expect(info.supports_tool_use).to be(true) + expect(info.supports_streaming).to be(true) + end + end + + describe "Struct equality" do + it "considers structs with same values equal" do + a = Dispatch::Adapter::Message.new(role: "user", content: "hello") + b = Dispatch::Adapter::Message.new(role: "user", content: "hello") + expect(a).to eq(b) + end + + it "considers structs with different values not equal" do + a = Dispatch::Adapter::Message.new(role: "user", content: "hello") + b = Dispatch::Adapter::Message.new(role: "user", content: "goodbye") + expect(a).not_to eq(b) + end + + it "Usage structs are equal with same tokens" do + a = Dispatch::Adapter::Usage.new(input_tokens: 10, output_tokens: 20) + b = Dispatch::Adapter::Usage.new(input_tokens: 10, output_tokens: 20) + expect(a).to eq(b) + end + + it "ToolUseBlock structs are equal with same fields" do + a = Dispatch::Adapter::ToolUseBlock.new(id: "1", name: "test", arguments: { "k" => "v" }) + b = Dispatch::Adapter::ToolUseBlock.new(id: "1", name: "test", arguments: { "k" => "v" }) + expect(a).to eq(b) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 700d63d..d722f8e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,10 +3,7 @@ require "dispatch/adapter/copilot" RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! config.expect_with :rspec do |c| |
