summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.rubocop.yml23
-rw-r--r--lib/dispatch/adapter/base.rb2
-rw-r--r--lib/dispatch/adapter/copilot.rb53
-rw-r--r--lib/dispatch/adapter/model_info.rb8
-rw-r--r--lib/dispatch/adapter/rate_limiter.rb9
-rw-r--r--spec/dispatch/adapter/copilot_rate_limiting_spec.rb13
-rw-r--r--spec/dispatch/adapter/copilot_spec.rb230
-rw-r--r--spec/dispatch/adapter/errors_spec.rb4
-rw-r--r--spec/dispatch/adapter/structs_spec.rb26
10 files changed, 341 insertions, 29 deletions
diff --git a/.gitignore b/.gitignore
index 7284ce6..acb6f1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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