diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | .rubocop.yml | 23 | ||||
| -rw-r--r-- | lib/dispatch/adapter/base.rb | 2 | ||||
| -rw-r--r-- | lib/dispatch/adapter/copilot.rb | 53 | ||||
| -rw-r--r-- | lib/dispatch/adapter/model_info.rb | 8 | ||||
| -rw-r--r-- | lib/dispatch/adapter/rate_limiter.rb | 9 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_rate_limiting_spec.rb | 13 | ||||
| -rw-r--r-- | spec/dispatch/adapter/copilot_spec.rb | 230 | ||||
| -rw-r--r-- | spec/dispatch/adapter/errors_spec.rb | 4 | ||||
| -rw-r--r-- | spec/dispatch/adapter/structs_spec.rb | 26 |
10 files changed, 341 insertions, 29 deletions
@@ -12,3 +12,5 @@ # Built gems *.gem + +reference/ diff --git a/.rubocop.yml b/.rubocop.yml index 4762417..37b7a19 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,13 +13,32 @@ Style/FrozenStringLiteralComment: EnforcedStyle: always Metrics/MethodLength: - Max: 20 + Max: 40 Metrics/ClassLength: - Max: 500 + Enabled: false + +Metrics/BlockLength: + Exclude: + - "spec/**/*" + +Metrics/ParameterLists: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false Layout/LineLength: Enabled: false Style/Documentation: Enabled: false + +Style/RedundantStructKeywordInit: + Enabled: false diff --git a/lib/dispatch/adapter/base.rb b/lib/dispatch/adapter/base.rb index dbd136a..4b7a6ed 100644 --- a/lib/dispatch/adapter/base.rb +++ b/lib/dispatch/adapter/base.rb @@ -11,7 +11,7 @@ module Dispatch raise NotImplementedError, "#{self.class}#model_name must be implemented" end - def count_tokens(_messages, system: nil, tools: []) + def count_tokens(_messages, system: nil, tools: []) # rubocop:disable Lint/UnusedMethodArgument -1 end diff --git a/lib/dispatch/adapter/copilot.rb b/lib/dispatch/adapter/copilot.rb index 728ce99..d8a54a0 100644 --- a/lib/dispatch/adapter/copilot.rb +++ b/lib/dispatch/adapter/copilot.rb @@ -116,23 +116,20 @@ module Dispatch def list_models ensure_authenticated! @rate_limiter.wait! - uri = URI("#{API_BASE}/v1/models") + uri = URI("#{API_BASE}/models") request = Net::HTTP::Get.new(uri) apply_headers!(request) + request["X-Github-Api-Version"] = "2025-10-01" 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 - ) + models.filter_map do |m| + next unless m["model_picker_enabled"] + next unless chat_model?(m) + + build_model_info(m) end end @@ -147,6 +144,40 @@ module Dispatch "Invalid thinking level: #{level.inspect}. Must be one of: #{VALID_THINKING_LEVELS.join(", ")}, or nil" end + def chat_model?(model_data) + capabilities = model_data["capabilities"] + return true unless capabilities + + model_type = capabilities["type"] + return true if model_type.nil? + + if model_type.is_a?(Array) + model_type.include?("chat") + else + model_type == "chat" + end + end + + def build_model_info(model_data) + capabilities = model_data["capabilities"] || {} + supports = capabilities["supports"] || {} + limits = capabilities["limits"] || {} + billing = model_data["billing"] || {} + + context_tokens = limits["max_context_window_tokens"] || + MODEL_CONTEXT_WINDOWS.fetch(model_data["id"], 0) + + ModelInfo.new( + id: model_data["id"], + name: model_data["name"] || model_data["id"], + max_context_tokens: context_tokens.to_i, + supports_vision: !!supports["vision"], + supports_tool_use: !!supports["tool_calls"], + supports_streaming: !!supports["streaming"], + premium_request_multiplier: billing["multiplier"]&.to_f + ) + end + def default_token_path File.join(Dir.home, ".config", "dispatch", "copilot_github_token") end @@ -571,7 +602,7 @@ module Dispatch next if line.empty? next unless line.start_with?("data: ") - data_str = line.sub(/\Adata: /, "") + data_str = line.delete_prefix("data: ") next if data_str == "[DONE]" data = JSON.parse(data_str) diff --git a/lib/dispatch/adapter/model_info.rb b/lib/dispatch/adapter/model_info.rb index 73d8a35..8ba2977 100644 --- a/lib/dispatch/adapter/model_info.rb +++ b/lib/dispatch/adapter/model_info.rb @@ -5,7 +5,13 @@ module Dispatch ModelInfo = Struct.new( :id, :name, :max_context_tokens, :supports_vision, :supports_tool_use, :supports_streaming, + :premium_request_multiplier, keyword_init: true - ) + ) do + def initialize(id:, name:, max_context_tokens:, supports_vision:, supports_tool_use:, supports_streaming:, + premium_request_multiplier: nil) + super + end + end end end diff --git a/lib/dispatch/adapter/rate_limiter.rb b/lib/dispatch/adapter/rate_limiter.rb index 6f10905..8d0789e 100644 --- a/lib/dispatch/adapter/rate_limiter.rb +++ b/lib/dispatch/adapter/rate_limiter.rb @@ -20,6 +20,7 @@ module Dispatch loop do wait_time = 0.0 + done = false File.open(rate_limit_file, File::RDWR | File::CREAT) do |file| file.flock(File::LOCK_EX) @@ -30,10 +31,12 @@ module Dispatch if wait_time <= 0 record_request(state, now) write_state(file, state) - return + done = true end end + return if done + sleep(wait_time) end end @@ -99,7 +102,7 @@ module Dispatch elapsed = now - last remaining = interval - elapsed - remaining > 0 ? remaining : 0.0 + remaining.positive? ? remaining : 0.0 end def compute_window_wait(state, now) @@ -115,7 +118,7 @@ module Dispatch oldest_in_window = log.min wait = oldest_in_window + period - now - wait > 0 ? wait : 0.0 + wait.positive? ? wait : 0.0 end def record_request(state, now) diff --git a/spec/dispatch/adapter/copilot_rate_limiting_spec.rb b/spec/dispatch/adapter/copilot_rate_limiting_spec.rb index abd21ee..a51ed2b 100644 --- a/spec/dispatch/adapter/copilot_rate_limiting_spec.rb +++ b/spec/dispatch/adapter/copilot_rate_limiting_spec.rb @@ -199,7 +199,7 @@ RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do github_token: github_token, token_path: token_path ) - adapter.chat(messages, stream: true) { |_| } + adapter.chat(messages, stream: true) { |_| nil } expect(rate_limiter).to have_received(:wait!).once end @@ -207,10 +207,17 @@ RSpec.describe Dispatch::Adapter::Copilot, "rate limiting" do describe "#list_models with rate limiting" do it "calls wait! before list_models request" do - stub_request(:get, "https://api.githubcopilot.com/v1/models") + stub_request(:get, "https://api.githubcopilot.com/models") .to_return( status: 200, - body: JSON.generate({ "data" => [{ "id" => "gpt-4.1", "object" => "model" }] }), + body: JSON.generate({ + "data" => [{ + "id" => "gpt-4.1", + "name" => "GPT 4.1", + "model_picker_enabled" => true, + "capabilities" => { "type" => "chat", "supports" => {} } + }] + }), headers: { "Content-Type" => "application/json" } ) diff --git a/spec/dispatch/adapter/copilot_spec.rb b/spec/dispatch/adapter/copilot_spec.rb index 61766bf..6e14728 100644 --- a/spec/dispatch/adapter/copilot_spec.rb +++ b/spec/dispatch/adapter/copilot_spec.rb @@ -10,7 +10,8 @@ RSpec.describe Dispatch::Adapter::Copilot do described_class.new( model: "gpt-4.1", github_token: github_token, - max_tokens: 4096 + max_tokens: 4096, + min_request_interval: 0 ) end @@ -936,7 +937,7 @@ RSpec.describe Dispatch::Adapter::Copilot do ) messages = [Dispatch::Adapter::Message.new(role: "user", content: "Hi")] - response = adapter.chat(messages, stream: true) { |_| } + response = adapter.chat(messages, stream: true) { |_delta| nil } expect(response.usage.input_tokens).to eq(42) expect(response.usage.output_tokens).to eq(7) @@ -1031,14 +1032,36 @@ RSpec.describe Dispatch::Adapter::Copilot do end describe "#list_models" do - it "returns an array of ModelInfo structs" do - stub_request(:get, "https://api.githubcopilot.com/v1/models") + it "returns an array of ModelInfo structs with billing data" do + stub_request(:get, "https://api.githubcopilot.com/models") .to_return( status: 200, body: JSON.generate({ "data" => [ - { "id" => "gpt-4.1", "object" => "model" }, - { "id" => "gpt-4o", "object" => "model" } + { + "id" => "gpt-4.1", + "name" => "GPT 4.1", + "vendor" => "copilot", + "model_picker_enabled" => true, + "capabilities" => { + "type" => "chat", + "supports" => { "streaming" => true, "tool_calls" => true, "vision" => false }, + "limits" => { "max_context_window_tokens" => 1_047_576 } + }, + "billing" => { "is_premium" => true, "multiplier" => 1.0 } + }, + { + "id" => "gpt-4o", + "name" => "GPT 4o", + "vendor" => "copilot", + "model_picker_enabled" => true, + "capabilities" => { + "type" => "chat", + "supports" => { "streaming" => true, "tool_calls" => true, "vision" => true }, + "limits" => { "max_context_window_tokens" => 128_000 } + }, + "billing" => { "is_premium" => false, "multiplier" => 0.33 } + } ] }), headers: { "Content-Type" => "application/json" } @@ -1049,8 +1072,203 @@ RSpec.describe Dispatch::Adapter::Copilot do 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.name).to eq("GPT 4.1") expect(models.first.max_context_tokens).to eq(1_047_576) + expect(models.first.supports_tool_use).to be(true) + expect(models.first.supports_streaming).to be(true) + expect(models.first.supports_vision).to be(false) + expect(models.first.premium_request_multiplier).to eq(1.0) + expect(models.last.id).to eq("gpt-4o") + expect(models.last.premium_request_multiplier).to eq(0.33) + expect(models.last.supports_vision).to be(true) + end + + it "sends X-Github-Api-Version header" do + stub = stub_request(:get, "https://api.githubcopilot.com/models") + .with(headers: { "X-Github-Api-Version" => "2025-10-01" }) + .to_return( + status: 200, + body: JSON.generate({ "data" => [] }), + headers: { "Content-Type" => "application/json" } + ) + + adapter.list_models + + expect(stub).to have_been_requested + end + + it "filters out models with model_picker_enabled false" do + stub_request(:get, "https://api.githubcopilot.com/models") + .to_return( + status: 200, + body: JSON.generate({ + "data" => [ + { + "id" => "visible-model", + "name" => "Visible Model", + "model_picker_enabled" => true, + "capabilities" => { "type" => "chat", "supports" => {} }, + "billing" => { "multiplier" => 1.0 } + }, + { + "id" => "hidden-model", + "name" => "Hidden Model", + "model_picker_enabled" => false, + "capabilities" => { "type" => "chat", "supports" => {} }, + "billing" => { "multiplier" => 1.0 } + } + ] + }), + headers: { "Content-Type" => "application/json" } + ) + + models = adapter.list_models + + expect(models.size).to eq(1) + expect(models.first.id).to eq("visible-model") + end + + it "filters out non-chat models" do + stub_request(:get, "https://api.githubcopilot.com/models") + .to_return( + status: 200, + body: JSON.generate({ + "data" => [ + { + "id" => "chat-model", + "name" => "Chat Model", + "model_picker_enabled" => true, + "capabilities" => { "type" => "chat", "supports" => {} }, + "billing" => { "multiplier" => 1.0 } + }, + { + "id" => "completion-model", + "name" => "Completion Model", + "model_picker_enabled" => true, + "capabilities" => { "type" => "completion", "supports" => {} }, + "billing" => { "multiplier" => 0.5 } + } + ] + }), + headers: { "Content-Type" => "application/json" } + ) + + models = adapter.list_models + + expect(models.size).to eq(1) + expect(models.first.id).to eq("chat-model") + end + + it "includes models with array type containing chat" do + stub_request(:get, "https://api.githubcopilot.com/models") + .to_return( + status: 200, + body: JSON.generate({ + "data" => [ + { + "id" => "multi-type-model", + "name" => "Multi Type", + "model_picker_enabled" => true, + "capabilities" => { "type" => %w[chat completion], "supports" => {} }, + "billing" => { "multiplier" => 3.0 } + } + ] + }), + headers: { "Content-Type" => "application/json" } + ) + + models = adapter.list_models + + expect(models.size).to eq(1) + expect(models.first.id).to eq("multi-type-model") + expect(models.first.premium_request_multiplier).to eq(3.0) + end + + it "falls back to MODEL_CONTEXT_WINDOWS when limits not in response" do + stub_request(:get, "https://api.githubcopilot.com/models") + .to_return( + status: 200, + body: JSON.generate({ + "data" => [ + { + "id" => "gpt-4o", + "name" => "GPT 4o", + "model_picker_enabled" => true, + "capabilities" => { "type" => "chat", "supports" => {} }, + "billing" => { "multiplier" => 0.33 } + } + ] + }), + headers: { "Content-Type" => "application/json" } + ) + + models = adapter.list_models + + expect(models.first.max_context_tokens).to eq(128_000) + end + + it "returns nil premium_request_multiplier when billing is absent" do + stub_request(:get, "https://api.githubcopilot.com/models") + .to_return( + status: 200, + body: JSON.generate({ + "data" => [ + { + "id" => "no-billing-model", + "name" => "No Billing", + "model_picker_enabled" => true, + "capabilities" => { "type" => "chat", "supports" => {} } + } + ] + }), + headers: { "Content-Type" => "application/json" } + ) + + models = adapter.list_models + + expect(models.first.premium_request_multiplier).to be_nil + end + + it "returns premium models with high multipliers" do + stub_request(:get, "https://api.githubcopilot.com/models") + .to_return( + status: 200, + body: JSON.generate({ + "data" => [ + { + "id" => "o3", + "name" => "o3", + "model_picker_enabled" => true, + "capabilities" => { + "type" => "chat", + "supports" => { "streaming" => true, "tool_calls" => true } + }, + "billing" => { "is_premium" => true, "multiplier" => 30.0 } + }, + { + "id" => "gpt-4.1-nano", + "name" => "GPT 4.1 Nano", + "model_picker_enabled" => true, + "capabilities" => { + "type" => "chat", + "supports" => { "streaming" => true, "tool_calls" => true } + }, + "billing" => { "is_premium" => false, "multiplier" => 0.33 } + } + ] + }), + headers: { "Content-Type" => "application/json" } + ) + + models = adapter.list_models + + expect(models.size).to eq(2) + o3 = models.find { |m| m.id == "o3" } + nano = models.find { |m| m.id == "gpt-4.1-nano" } + + expect(o3.premium_request_multiplier).to eq(30.0) + expect(nano.premium_request_multiplier).to eq(0.33) end end diff --git a/spec/dispatch/adapter/errors_spec.rb b/spec/dispatch/adapter/errors_spec.rb index 6893ac8..906a4c2 100644 --- a/spec/dispatch/adapter/errors_spec.rb +++ b/spec/dispatch/adapter/errors_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Dispatch::Adapter::Error do it "can be rescued as StandardError" do expect do - raise described_class.new("test") + raise described_class, "test" end.to raise_error(StandardError) end end @@ -45,7 +45,7 @@ RSpec.describe Dispatch::Adapter::RateLimitError do it "is rescuable as Dispatch::Adapter::Error" do expect do - raise described_class.new("rate limited") + raise described_class, "rate limited" end.to raise_error(Dispatch::Adapter::Error) end end diff --git a/spec/dispatch/adapter/structs_spec.rb b/spec/dispatch/adapter/structs_spec.rb index 07c1198..ef8ec73 100644 --- a/spec/dispatch/adapter/structs_spec.rb +++ b/spec/dispatch/adapter/structs_spec.rb @@ -154,6 +154,32 @@ RSpec.describe Dispatch::Adapter do expect(info.supports_vision).to be(false) expect(info.supports_tool_use).to be(true) expect(info.supports_streaming).to be(true) + expect(info.premium_request_multiplier).to be_nil + end + + it "accepts premium_request_multiplier" do + info = Dispatch::Adapter::ModelInfo.new( + id: "o3", + name: "o3", + max_context_tokens: 200_000, + supports_vision: false, + supports_tool_use: true, + supports_streaming: true, + premium_request_multiplier: 30.0 + ) + expect(info.premium_request_multiplier).to eq(30.0) + end + + it "defaults premium_request_multiplier to nil" do + info = Dispatch::Adapter::ModelInfo.new( + id: "gpt-4.1-nano", + name: "GPT 4.1 Nano", + max_context_tokens: 1_047_576, + supports_vision: false, + supports_tool_use: true, + supports_streaming: true + ) + expect(info.premium_request_multiplier).to be_nil end end |
