From 27af03cb3540539f065334c199fdb42c48776fc5 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 29 Apr 2026 21:40:58 +0900 Subject: update to support claude --- .rubocop.yml | 22 +- CHANGELOG.md | 46 +++ dispatch-adapter-interface-0.2.0.gem | Bin 0 -> 12288 bytes dispatch-adapter-interface.gemspec | 3 +- lib/dispatch/adapter/interface.rb | 3 + lib/dispatch/adapter/interface/base.rb | 61 ++- lib/dispatch/adapter/interface/message.rb | 23 +- lib/dispatch/adapter/interface/model_info.rb | 14 +- lib/dispatch/adapter/interface/pricing.rb | 34 ++ lib/dispatch/adapter/interface/rate_limiter.rb | 174 +++++++++ lib/dispatch/adapter/interface/response.rb | 33 +- lib/dispatch/adapter/interface/tool_definition.rb | 14 +- lib/dispatch/adapter/interface/usage_report.rb | 43 +++ lib/dispatch/adapter/interface/version.rb | 2 +- spec/dispatch/adapter/interface/base_spec.rb | 62 ++++ spec/dispatch/adapter/interface/pricing_spec.rb | 90 +++++ .../adapter/interface/rate_limiter_spec.rb | 407 +++++++++++++++++++++ spec/dispatch/adapter/interface/structs_spec.rb | 168 +++++++++ .../adapter/interface/usage_report_spec.rb | 171 +++++++++ spec/model_info_spec.rb | 61 +++ 20 files changed, 1417 insertions(+), 14 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 dispatch-adapter-interface-0.2.0.gem create mode 100644 lib/dispatch/adapter/interface/pricing.rb create mode 100644 lib/dispatch/adapter/interface/rate_limiter.rb create mode 100644 lib/dispatch/adapter/interface/usage_report.rb create mode 100644 spec/dispatch/adapter/interface/pricing_spec.rb create mode 100644 spec/dispatch/adapter/interface/rate_limiter_spec.rb create mode 100644 spec/dispatch/adapter/interface/usage_report_spec.rb create mode 100644 spec/model_info_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index bbf7073..345b764 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,16 +12,30 @@ Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always +Metrics/MethodLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + Metrics/BlockLength: - Exclude: - - "spec/**/*" + Enabled: false -Metrics/MethodLength: - Max: 40 +Metrics/BlockNesting: + Enabled: false Metrics/AbcSize: Enabled: false +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + Style/Documentation: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4b3983d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## 0.2.0 — 2025-01-01 + +### Added + +- **`UsageWindow`** — struct for rolling-window quota definitions (id, label, duration_ms, resets_at). +- **`UsageAmount`** — struct for utilisation values with unit, used/limit/remaining, and fraction fields. + Supported units: `:percent | :tokens | :requests | :usd | :minutes | :bytes | :unknown`. +- **`UsageLimitEntry`** — struct combining a scope, window, amount, and status for a single quota limit. + Supported statuses: `:ok | :warning | :exhausted | :unknown`. +- **`UsageReport`** — top-level struct returned by `Base#usage_report`; carries provider, fetched_at, + limits, metadata, and raw response. +- **`Base#usage_report`** — returns `nil` by default; subscription-aware adapters override this. +- **`Base#authenticate!`** — idempotent login lifecycle hook; returns `nil` by default. +- **`Base#authenticated?`** — returns `true` by default. +- **`Base#logout!`** — drops cached credentials; returns `nil` by default. +- **`ThinkingBlock`** — content block for Claude extended-thinking output; defaults `type` to + `"thinking"`, carries `thinking:` text and optional `signature:`. +- **`RedactedThinkingBlock`** — redacted variant; defaults `type` to `"redacted_thinking"`, carries `data:`. +- **`StreamDelta` thinking events** — `:thinking_start`, `:thinking_delta`, `:thinking_end` added to the + documented `:type` vocabulary; `:thinking_delta` reuses the existing `text:` field. +- **`TextBlock#cache_control`** — optional keyword (default `nil`) for prompt-cache breakpoints. + Convention: `nil | { type: :ephemeral } | { type: :ephemeral, ttl: :"5m" } | { type: :ephemeral, ttl: :"1h" }`. +- **`ToolDefinition#cache_control`** — same cache-breakpoint keyword on tool definitions. +- **`Base#chat` extended kwargs** — added `tool_choice:`, `cache_retention:`, `metadata:`, `betas:` + (all `nil` by default); `thinking:` now accepts a Hash in addition to String. All new kwargs are + optional and adapters MAY ignore them. +- **Stop-reason vocabulary** — documented comment in `response.rb` listing the seven canonical + `:stop_reason` values: `:end_turn`, `:max_tokens`, `:tool_use`, `:pause_turn`, `:refusal`, + `:sensitive`, `:error`. +- **`RateLimiter`** — migrated `Dispatch::Adapter::RateLimiter` from `dispatch-adapter-copilot` into + this gem so other adapters can depend on it independently. The Copilot gem re-exports the constant + for backwards compatibility. + +## 0.1.0 — Initial release + +- `Base` class with `chat`, `model_name`, `count_tokens`, `list_models`, `provider_name`, + `max_context_tokens`. +- `Message`, `TextBlock`, `ImageBlock`, `ToolUseBlock`, `ToolResultBlock`, `ToolDefinition`. +- `Response`, `Usage`, `UsageCost`, `StreamDelta`. +- `ModelInfo`, `ModelPricing`, `Pricing.calculate`. +- Error hierarchy: `Error`, `AuthenticationError`, `RateLimitError`, `ServerError`, `RequestError`, + `ConnectionError`. diff --git a/dispatch-adapter-interface-0.2.0.gem b/dispatch-adapter-interface-0.2.0.gem new file mode 100644 index 0000000..e3f5200 Binary files /dev/null and b/dispatch-adapter-interface-0.2.0.gem differ diff --git a/dispatch-adapter-interface.gemspec b/dispatch-adapter-interface.gemspec index 0cd449a..a240145 100644 --- a/dispatch-adapter-interface.gemspec +++ b/dispatch-adapter-interface.gemspec @@ -9,7 +9,8 @@ Gem::Specification.new do |spec| spec.email = ["github@tradam.dev"] spec.summary = "Shared interface for Dispatch LLM adapter gems" - spec.description = "Defines the base class, data structs, and error hierarchy shared by all " \ + spec.description = "Defines the base class, data structs, error hierarchy, and shared utilities " \ + "(RateLimiter, UsageReport, cache-control, thinking blocks) used by all " \ "Dispatch adapter gems (Copilot, Claude, Tester, etc.)." spec.homepage = "https://github.com/realtradam/dispatch-adapter-interface" spec.license = "MIT" diff --git a/lib/dispatch/adapter/interface.rb b/lib/dispatch/adapter/interface.rb index 54692c0..e809a46 100644 --- a/lib/dispatch/adapter/interface.rb +++ b/lib/dispatch/adapter/interface.rb @@ -7,6 +7,9 @@ require_relative "interface/message" require_relative "interface/response" require_relative "interface/tool_definition" require_relative "interface/model_info" +require_relative "interface/pricing" +require_relative "interface/usage_report" +require_relative "interface/rate_limiter" require_relative "interface/base" module Dispatch diff --git a/lib/dispatch/adapter/interface/base.rb b/lib/dispatch/adapter/interface/base.rb index 4b7a6ed..682fae0 100644 --- a/lib/dispatch/adapter/interface/base.rb +++ b/lib/dispatch/adapter/interface/base.rb @@ -3,7 +3,43 @@ module Dispatch module Adapter class Base - def chat(_messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: nil, &_block) + # Send a chat request. + # + # @param _messages [Array] the conversation messages + # @param system [String, Array, nil] system prompt; + # a String or an array of TextBlock / Hash content blocks (for + # providers that support cached system prompts). + # @param tools [Array] tool definitions + # @param stream [Boolean] whether to stream the response + # @param max_tokens [Integer, nil] maximum tokens to generate + # @param thinking [String, Hash, nil] extended thinking config; + # adapters do their own validation. + # - String: "low" | "medium" | "high" + # - Hash: e.g. { enabled: true, budget_tokens: 10_000 } + # @param tool_choice [Symbol, Hash, nil] tool-selection policy: + # :auto | :any | :none | { type: :tool, name: "fn" } + # Adapters MAY ignore this. + # @param cache_retention [Symbol, nil] caching hint: + # :none | :short | :long | nil + # Adapters MAY ignore this. + # @param metadata [Hash, nil] arbitrary passthrough metadata (e.g. { user_id: "u1" }). + # Adapters MAY ignore this. + # @param betas [Array, String, nil] extra provider-beta entries. + # Adapters MAY ignore this. + # @return [Response] + def chat( + _messages, + system: nil, + tools: [], + stream: false, + max_tokens: nil, + thinking: nil, + tool_choice: nil, # rubocop:disable Lint/UnusedMethodArgument + cache_retention: nil, # rubocop:disable Lint/UnusedMethodArgument + metadata: nil, # rubocop:disable Lint/UnusedMethodArgument + betas: nil, # rubocop:disable Lint/UnusedMethodArgument + &_block + ) raise NotImplementedError, "#{self.class}#chat must be implemented" end @@ -26,6 +62,29 @@ module Dispatch def max_context_tokens nil end + + # Subscription quota / utilisation. Return nil if the provider has no + # such concept (raw API-key tier, etc.). + # @return [Dispatch::Adapter::UsageReport, nil] + def usage_report + nil + end + + # Idempotent — perform any interactive login required (device flow, + # OAuth PKCE, etc). Safe to call before the first chat/usage_report. + def authenticate! + nil + end + + # True iff cached credentials are present and presumed valid. + def authenticated? + true + end + + # Drop cached credentials. + def logout! + nil + end end end end diff --git a/lib/dispatch/adapter/interface/message.rb b/lib/dispatch/adapter/interface/message.rb index eb51c99..dfe7301 100644 --- a/lib/dispatch/adapter/interface/message.rb +++ b/lib/dispatch/adapter/interface/message.rb @@ -4,9 +4,14 @@ 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:) + # +cache_control+ values: + # nil — no cache breakpoint (default) + # { type: :ephemeral } — provider default TTL + # { type: :ephemeral, ttl: :"5m" } — short-lived cache + # { type: :ephemeral, ttl: :"1h" } — long-lived cache + TextBlock = Struct.new(:type, :text, :cache_control, keyword_init: true) do + def initialize(text:, cache_control: nil, type: "text") + super(type:, text:, cache_control:) end end @@ -27,5 +32,17 @@ module Dispatch super(type:, tool_use_id:, content:, is_error:) end end + + ThinkingBlock = Struct.new(:type, :thinking, :signature, keyword_init: true) do + def initialize(thinking:, signature: nil, type: "thinking") + super(type:, thinking:, signature:) + end + end + + RedactedThinkingBlock = Struct.new(:type, :data, keyword_init: true) do + def initialize(data:, type: "redacted_thinking") + super(type:, data:) + end + end end end diff --git a/lib/dispatch/adapter/interface/model_info.rb b/lib/dispatch/adapter/interface/model_info.rb index 8ba2977..29228a8 100644 --- a/lib/dispatch/adapter/interface/model_info.rb +++ b/lib/dispatch/adapter/interface/model_info.rb @@ -2,14 +2,26 @@ module Dispatch module Adapter + ModelPricing = Struct.new( + :input_per_mtok, :output_per_mtok, + :cache_read_per_mtok, :cache_write_per_mtok, + keyword_init: true + ) do + def initialize(input_per_mtok:, output_per_mtok:, + cache_read_per_mtok: 0.0, cache_write_per_mtok: 0.0) + super + end + end + ModelInfo = Struct.new( :id, :name, :max_context_tokens, :supports_vision, :supports_tool_use, :supports_streaming, :premium_request_multiplier, + :pricing, keyword_init: true ) do def initialize(id:, name:, max_context_tokens:, supports_vision:, supports_tool_use:, supports_streaming:, - premium_request_multiplier: nil) + premium_request_multiplier: nil, pricing: nil) super end end diff --git a/lib/dispatch/adapter/interface/pricing.rb b/lib/dispatch/adapter/interface/pricing.rb new file mode 100644 index 0000000..a3ec9eb --- /dev/null +++ b/lib/dispatch/adapter/interface/pricing.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + module Pricing + module_function + + # Calculates UsageCost from a Usage and ModelInfo. + # + # NOTE: Reasoning tokens are NOT separately priced here. + # Anthropic bills them as output tokens; OpenAI o-series likewise. + # It is assumed that output_tokens includes reasoning_tokens if applicable. + def calculate(usage, model_info) + return nil unless model_info&.pricing + + p = model_info.pricing + mtok = ->(tokens, rate) { (rate.to_f / 1_000_000.0) * tokens.to_i } + + input = mtok.call(usage.input_tokens, p.input_per_mtok) + output = mtok.call(usage.output_tokens, p.output_per_mtok) + cread = mtok.call(usage.cache_read_tokens, p.cache_read_per_mtok) + cwrite = mtok.call(usage.cache_creation_tokens, p.cache_write_per_mtok) + + UsageCost.new( + input: input, + output: output, + cache_read: cread, + cache_write: cwrite, + total: input + output + cread + cwrite + ) + end + end + end +end diff --git a/lib/dispatch/adapter/interface/rate_limiter.rb b/lib/dispatch/adapter/interface/rate_limiter.rb new file mode 100644 index 0000000..1b05582 --- /dev/null +++ b/lib/dispatch/adapter/interface/rate_limiter.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" + +module Dispatch + module Adapter + class RateLimiter + def initialize(rate_limit_path:, min_request_interval:, rate_limit:) + validate_min_request_interval!(min_request_interval) + validate_rate_limit!(rate_limit) + + @rate_limit_path = rate_limit_path + @min_request_interval = min_request_interval + @rate_limit = rate_limit + end + + def wait! + return if disabled? + + loop do + wait_time = 0.0 + done = false + + File.open(rate_limit_file, File::RDWR | File::CREAT) do |file| + file.flock(File::LOCK_EX) + state = read_state(file) + now = Time.now.to_f + wait_time = compute_wait(state, now) + + if wait_time <= 0 + record_request(state, now) + write_state(file, state) + done = true + end + end + + return if done + + sleep(wait_time) + end + end + + private + + def disabled? + effective_min_interval.nil? && @rate_limit.nil? + end + + def effective_min_interval + return nil if @min_request_interval.nil? + return nil if @min_request_interval.zero? + + @min_request_interval + end + + def rate_limit_file + FileUtils.mkdir_p(File.dirname(@rate_limit_path)) + File.chmod(0o600, @rate_limit_path) if File.exist?(@rate_limit_path) + @rate_limit_path + end + + def read_state(file) + file.rewind + content = file.read + return default_state if content.nil? || content.strip.empty? + + parsed = JSON.parse(content) + { + "last_request_at" => parsed["last_request_at"]&.to_f, + "request_log" => Array(parsed["request_log"]).map(&:to_f) + } + rescue JSON::ParserError + default_state + end + + def default_state + { "last_request_at" => nil, "request_log" => [] } + end + + def write_state(file, state) + file.rewind + file.truncate(0) + file.write(JSON.generate(state)) + file.flush + + File.chmod(0o600, @rate_limit_path) + end + + def compute_wait(state, now) + cooldown_wait = compute_cooldown_wait(state, now) + window_wait = compute_window_wait(state, now) + [ cooldown_wait, window_wait ].max + end + + def compute_cooldown_wait(state, now) + interval = effective_min_interval + return 0.0 if interval.nil? + + last = state["last_request_at"] + return 0.0 if last.nil? + + elapsed = now - last + remaining = interval - elapsed + remaining.positive? ? remaining : 0.0 + end + + def compute_window_wait(state, now) + return 0.0 if @rate_limit.nil? + + max_requests = @rate_limit[:requests] + period = @rate_limit[:period] + window_start = now - period + + log = state["request_log"].select { |t| t > window_start } + + return 0.0 if log.size < max_requests + + oldest_in_window = log.min + wait = oldest_in_window + period - now + wait.positive? ? wait : 0.0 + end + + def record_request(state, now) + state["last_request_at"] = now + state["request_log"] << now + prune_log(state, now) + end + + def prune_log(state, now) + if @rate_limit + period = @rate_limit[:period] + cutoff = now - period + state["request_log"] = state["request_log"].select { |t| t > cutoff } + else + state["request_log"] = [] + end + end + + def validate_min_request_interval!(value) + return if value.nil? + + unless value.is_a?(Numeric) + raise ArgumentError, + "min_request_interval must be nil or a Numeric >= 0, got #{value.inspect}" + end + + return unless value.negative? + + raise ArgumentError, + "min_request_interval must be nil or a Numeric >= 0, got #{value.inspect}" + end + + def validate_rate_limit!(value) + return if value.nil? + + unless value.is_a?(Hash) + raise ArgumentError, + "rate_limit must be nil or a Hash with :requests and :period keys, got #{value.inspect}" + end + + unless value.key?(:requests) && value[:requests].is_a?(Integer) && value[:requests].positive? + raise ArgumentError, + "rate_limit[:requests] must be a positive Integer, got #{value[:requests].inspect}" + end + + return if value.key?(:period) && value[:period].is_a?(Numeric) && value[:period].positive? + + raise ArgumentError, + "rate_limit[:period] must be a positive Numeric, got #{value[:period].inspect}" + end + end + end +end diff --git a/lib/dispatch/adapter/interface/response.rb b/lib/dispatch/adapter/interface/response.rb index b4ba3eb..391b033 100644 --- a/lib/dispatch/adapter/interface/response.rb +++ b/lib/dispatch/adapter/interface/response.rb @@ -2,18 +2,47 @@ module Dispatch module Adapter + # stop_reason ∈ + # :end_turn — natural completion + # :max_tokens — output truncated by max_tokens + # :tool_use — assistant emitted tool calls + # :pause_turn — provider asked us to resubmit (Anthropic) + # :refusal — provider refused to answer + # :sensitive — output blocked by safety filters + # :error — adapter-level failure Response = Struct.new(:content, :tool_calls, :model, :stop_reason, :usage, keyword_init: true) do def initialize(model:, stop_reason:, usage:, content: nil, tool_calls: []) super 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) + UsageCost = Struct.new( + :input, :output, :cache_read, :cache_write, :total, + keyword_init: true + ) do + def initialize(input: 0.0, output: 0.0, cache_read: 0.0, + cache_write: 0.0, total: 0.0) super end end + Usage = Struct.new( + :input_tokens, :output_tokens, + :cache_read_tokens, :cache_creation_tokens, + :reasoning_tokens, :premium_requests, :cost, + keyword_init: true + ) do + def initialize(input_tokens:, output_tokens:, + cache_read_tokens: 0, cache_creation_tokens: 0, + reasoning_tokens: 0, premium_requests: nil, cost: nil) + super + end + end + + # Recognised :type values: + # :text_start, :text_delta, :text_end + # :thinking_start, :thinking_delta, :thinking_end + # :tool_use_start, :tool_use_delta, :tool_use_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 diff --git a/lib/dispatch/adapter/interface/tool_definition.rb b/lib/dispatch/adapter/interface/tool_definition.rb index 7b435a3..4a8ca5a 100644 --- a/lib/dispatch/adapter/interface/tool_definition.rb +++ b/lib/dispatch/adapter/interface/tool_definition.rb @@ -2,6 +2,18 @@ module Dispatch module Adapter - ToolDefinition = Struct.new(:name, :description, :parameters, keyword_init: true) + # +cache_control+ values: + # nil — no cache breakpoint (default) + # { type: :ephemeral } — provider default TTL + # { type: :ephemeral, ttl: :"5m" } — short-lived cache + # { type: :ephemeral, ttl: :"1h" } — long-lived cache + ToolDefinition = Struct.new( + :name, :description, :parameters, :cache_control, + keyword_init: true + ) do + def initialize(name:, description:, parameters:, cache_control: nil) + super + end + end end end diff --git a/lib/dispatch/adapter/interface/usage_report.rb b/lib/dispatch/adapter/interface/usage_report.rb new file mode 100644 index 0000000..132c484 --- /dev/null +++ b/lib/dispatch/adapter/interface/usage_report.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Dispatch + module Adapter + UsageWindow = Struct.new(:id, :label, :duration_ms, :resets_at, keyword_init: true) do + def initialize(id:, label:, duration_ms: nil, resets_at: nil) + super + end + end + + UsageAmount = Struct.new( + :used, :limit, :remaining, + :used_fraction, :remaining_fraction, + :unit, keyword_init: true + ) do + # unit ∈ :percent | :tokens | :requests | :usd | :minutes | :bytes | :unknown + def initialize(unit:, used: nil, limit: nil, remaining: nil, + used_fraction: nil, remaining_fraction: nil) + super + end + end + + UsageLimitEntry = Struct.new( + :id, :label, :scope, :window, :amount, :status, :notes, + keyword_init: true + ) do + # status ∈ :ok | :warning | :exhausted | :unknown + def initialize(id:, label:, scope:, amount:, window: nil, + status: :unknown, notes: []) + super + end + end + + UsageReport = Struct.new( + :provider, :fetched_at, :limits, :metadata, :raw, + keyword_init: true + ) do + def initialize(provider:, limits:, fetched_at: Time.now, metadata: {}, raw: nil) + super + end + end + end +end diff --git a/lib/dispatch/adapter/interface/version.rb b/lib/dispatch/adapter/interface/version.rb index 0a53b34..44b41db 100644 --- a/lib/dispatch/adapter/interface/version.rb +++ b/lib/dispatch/adapter/interface/version.rb @@ -3,7 +3,7 @@ module Dispatch module Adapter module Interface - VERSION = "0.1.0" + VERSION = "0.3.0" end end end diff --git a/spec/dispatch/adapter/interface/base_spec.rb b/spec/dispatch/adapter/interface/base_spec.rb index 8c3a279..f8b4a6e 100644 --- a/spec/dispatch/adapter/interface/base_spec.rb +++ b/spec/dispatch/adapter/interface/base_spec.rb @@ -7,6 +7,44 @@ RSpec.describe Dispatch::Adapter::Base do it "raises NotImplementedError" do expect { base.chat([]) }.to raise_error(NotImplementedError, /chat must be implemented/) end + + it "accepts system: as a String without raising" do + expect { base.chat([], system: "You are helpful.") }.to raise_error(NotImplementedError) + end + + it "accepts system: as an Array of TextBlock without raising" do + blocks = [Dispatch::Adapter::TextBlock.new(text: "prompt")] + expect { base.chat([], system: blocks) }.to raise_error(NotImplementedError) + end + + it "accepts tool_choice: without raising" do + expect { base.chat([], tool_choice: :auto) }.to raise_error(NotImplementedError) + expect { base.chat([], tool_choice: { type: :tool, name: "fn" }) }.to raise_error(NotImplementedError) + end + + it "accepts cache_retention: without raising" do + expect { base.chat([], cache_retention: :long) }.to raise_error(NotImplementedError) + end + + it "accepts metadata: without raising" do + expect { base.chat([], metadata: { user_id: "u1" }) }.to raise_error(NotImplementedError) + end + + it "accepts betas: as Array without raising" do + expect { base.chat([], betas: ["interleaved-thinking-2025-05-14"]) }.to raise_error(NotImplementedError) + end + + it "accepts betas: as String without raising" do + expect { base.chat([], betas: "interleaved-thinking-2025-05-14") }.to raise_error(NotImplementedError) + end + + it "accepts thinking: as String without raising" do + expect { base.chat([], thinking: "high") }.to raise_error(NotImplementedError) + end + + it "accepts thinking: as Hash without raising" do + expect { base.chat([], thinking: { enabled: true, budget_tokens: 10_000 }) }.to raise_error(NotImplementedError) + end end describe "#model_name" do @@ -38,4 +76,28 @@ RSpec.describe Dispatch::Adapter::Base do expect(base.max_context_tokens).to be_nil end end + + describe "#usage_report" do + it "returns nil" do + expect(base.usage_report).to be_nil + end + end + + describe "#authenticate!" do + it "returns nil" do + expect(base.authenticate!).to be_nil + end + end + + describe "#authenticated?" do + it "returns true" do + expect(base.authenticated?).to be(true) + end + end + + describe "#logout!" do + it "returns nil" do + expect(base.logout!).to be_nil + end + end end diff --git a/spec/dispatch/adapter/interface/pricing_spec.rb b/spec/dispatch/adapter/interface/pricing_spec.rb new file mode 100644 index 0000000..c37c5e7 --- /dev/null +++ b/spec/dispatch/adapter/interface/pricing_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Adapter::Pricing do + let(:pricing) do + Dispatch::Adapter::ModelPricing.new( + input_per_mtok: 3.0, + output_per_mtok: 15.0, + cache_read_per_mtok: 0.3, + cache_write_per_mtok: 3.75 + ) + end + + let(:model_info) do + Dispatch::Adapter::ModelInfo.new( + id: "claude-3-5-sonnet-20241022", + name: "Claude 3.5 Sonnet", + max_context_tokens: 200_000, + supports_vision: true, + supports_tool_use: true, + supports_streaming: true, + pricing: pricing + ) + end + + describe ".calculate" do + it "returns nil if model_info is nil" do + usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50) + expect(described_class.calculate(usage, nil)).to be_nil + end + + it "returns nil if model_info.pricing is nil" do + info = Dispatch::Adapter::ModelInfo.new( + id: "test", name: "test", max_context_tokens: 100, + supports_vision: false, supports_tool_use: false, supports_streaming: false + ) + usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50) + expect(described_class.calculate(usage, info)).to be_nil + end + + it "calculates cost correctly for fixed numbers" do + # input: 1,000,000 tokens * $3.00 / 1M = $3.00 + # output: 2,000,000 tokens * $15.00 / 1M = $30.00 + # cache_read: 1,000,000 tokens * $0.30 / 1M = $0.30 + # cache_write: 1,000,000 tokens * $3.75 / 1M = $3.75 + # total: 3.00 + 30.00 + 0.30 + 3.75 = 37.05 + usage = Dispatch::Adapter::Usage.new( + input_tokens: 1_000_000, + output_tokens: 2_000_000, + cache_read_tokens: 1_000_000, + cache_creation_tokens: 1_000_000 + ) + + cost = described_class.calculate(usage, model_info) + + expect(cost.input).to eq(3.0) + expect(cost.output).to eq(30.0) + expect(cost.cache_read).to eq(0.3) + expect(cost.cache_write).to eq(3.75) + expect(cost.total).to eq(37.05) + end + + it "handles smaller token counts" do + # input: 1,000 tokens * $3.00 / 1M = $0.003 + # output: 500 tokens * $15.00 / 1M = $0.0075 + # total: 0.0105 + usage = Dispatch::Adapter::Usage.new( + input_tokens: 1_000, + output_tokens: 500 + ) + + cost = described_class.calculate(usage, model_info) + + expect(cost.input).to eq(0.003) + expect(cost.output).to eq(0.0075) + expect(cost.total).to eq(0.0105) + end + + it "ignores reasoning_tokens (as they should be included in output_tokens by the adapter)" do + usage = Dispatch::Adapter::Usage.new( + input_tokens: 1_000, + output_tokens: 500, + reasoning_tokens: 200 + ) + + cost = described_class.calculate(usage, model_info) + # output cost should still be 500 * 15 / 1M = 0.0075 + expect(cost.output).to eq(0.0075) + end + end +end diff --git a/spec/dispatch/adapter/interface/rate_limiter_spec.rb b/spec/dispatch/adapter/interface/rate_limiter_spec.rb new file mode 100644 index 0000000..1e4e501 --- /dev/null +++ b/spec/dispatch/adapter/interface/rate_limiter_spec.rb @@ -0,0 +1,407 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" +require "tempfile" + +RSpec.describe Dispatch::Adapter::RateLimiter do + let(:tmpdir) { Dir.mktmpdir("rate_limiter_test") } + let(:rate_limit_path) { File.join(tmpdir, "copilot_rate_limit") } + + after { FileUtils.rm_rf(tmpdir) } + + describe "#initialize" do + it "accepts valid min_request_interval and nil rate_limit" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 3.0, + rate_limit: nil + ) + expect(limiter).to be_a(described_class) + end + + it "accepts nil min_request_interval" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: nil + ) + expect(limiter).to be_a(described_class) + end + + it "accepts zero min_request_interval" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 0, + rate_limit: nil + ) + expect(limiter).to be_a(described_class) + end + + it "accepts valid rate_limit hash" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 10, period: 60 } + ) + expect(limiter).to be_a(described_class) + end + + it "accepts both min_request_interval and rate_limit" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 2.0, + rate_limit: { requests: 5, period: 30 } + ) + expect(limiter).to be_a(described_class) + end + + it "raises ArgumentError for negative min_request_interval" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: -1, + rate_limit: nil + ) + end.to raise_error(ArgumentError, /min_request_interval/) + end + + it "raises ArgumentError for non-numeric min_request_interval" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: "fast", + rate_limit: nil + ) + end.to raise_error(ArgumentError, /min_request_interval/) + end + + it "raises ArgumentError when rate_limit is missing requests key" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { period: 60 } + ) + end.to raise_error(ArgumentError, /requests/) + end + + it "raises ArgumentError when rate_limit is missing period key" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 10 } + ) + end.to raise_error(ArgumentError, /period/) + end + + it "raises ArgumentError when rate_limit requests is zero" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 0, period: 60 } + ) + end.to raise_error(ArgumentError, /requests/) + end + + it "raises ArgumentError when rate_limit requests is negative" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: -1, period: 60 } + ) + end.to raise_error(ArgumentError, /requests/) + end + + it "raises ArgumentError when rate_limit period is zero" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 10, period: 0 } + ) + end.to raise_error(ArgumentError, /period/) + end + + it "raises ArgumentError when rate_limit period is negative" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 10, period: -5 } + ) + end.to raise_error(ArgumentError, /period/) + end + + it "raises ArgumentError when rate_limit is not a Hash" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: "10/60" + ) + end.to raise_error(ArgumentError) + end + end + + describe "#wait!" do + context "with both mechanisms disabled" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: nil + ) + end + + it "returns immediately without sleeping" do + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "does not create a rate limit file" do + limiter.wait! + expect(File.exist?(rate_limit_path)).to be(false) + end + end + + context "with per-request cooldown only" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: nil + ) + end + + it "does not sleep on the first request" do + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "creates the rate limit file on first request" do + limiter.wait! + expect(File.exist?(rate_limit_path)).to be(true) + end + + it "sets the rate limit file permissions to 0600" do + limiter.wait! + mode = File.stat(rate_limit_path).mode & 0o777 + expect(mode).to eq(0o600) + end + + it "records last_request_at in the state file" do + before = Time.now.to_f + limiter.wait! + after = Time.now.to_f + + state = JSON.parse(File.read(rate_limit_path)) + expect(state["last_request_at"]).to be_between(before, after) + end + + it "sleeps for the remaining cooldown on a rapid second request" do + limiter.wait! + + # Simulate that almost no time has passed + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + + it "does not sleep when enough time has elapsed between requests" do + limiter.wait! + + # Write a past timestamp to simulate time passing + state = { "last_request_at" => Time.now.to_f - 2.0, "request_log" => [] } + File.write(rate_limit_path, JSON.generate(state)) + + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + end + + context "with sliding window only" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 3, period: 10 } + ) + end + + it "allows requests up to the window limit without sleeping" do + expect(limiter).not_to receive(:sleep) + 3.times { limiter.wait! } + end + + it "sleeps when the window limit is reached" do + now = Time.now.to_f + state = { + "last_request_at" => now, + "request_log" => [ now - 2.0, now - 1.0, now ] + } + File.write(rate_limit_path, JSON.generate(state)) + + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + + it "does not sleep when oldest entries have expired from the window" do + now = Time.now.to_f + state = { + "last_request_at" => now - 5.0, + "request_log" => [ now - 15.0, now - 12.0, now - 5.0 ] + } + File.write(rate_limit_path, JSON.generate(state)) + + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "prunes expired entries from the request_log on write" do + now = Time.now.to_f + state = { + "last_request_at" => now - 5.0, + "request_log" => [ now - 20.0, now - 15.0, now - 5.0 ] + } + File.write(rate_limit_path, JSON.generate(state)) + + limiter.wait! + + updated_state = JSON.parse(File.read(rate_limit_path)) + # Old entries (20s and 15s ago) should be pruned (window is 10s) + # Only the 5s-ago entry and the new entry should remain + expect(updated_state["request_log"].size).to be <= 2 + end + end + + context "with both mechanisms enabled" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: { requests: 3, period: 10 } + ) + end + + it "uses the longer wait time when cooldown is the bottleneck" do + limiter.wait! + + # Second request immediately — cooldown should be the bottleneck + # (only 1 of 3 window slots used, but cooldown not elapsed) + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + + it "uses the longer wait time when window limit is the bottleneck" do + now = Time.now.to_f + state = { + "last_request_at" => now - 2.0, # cooldown elapsed + "request_log" => [ now - 3.0, now - 2.5, now - 2.0 ] # window full + } + File.write(rate_limit_path, JSON.generate(state)) + + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + end + + context "with a missing or corrupt state file" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: nil + ) + end + + it "treats a non-existent file as fresh state" do + expect(File.exist?(rate_limit_path)).to be(false) + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "treats an empty file as fresh state" do + FileUtils.mkdir_p(File.dirname(rate_limit_path)) + File.write(rate_limit_path, "") + + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "treats a corrupt JSON file as fresh state" do + FileUtils.mkdir_p(File.dirname(rate_limit_path)) + File.write(rate_limit_path, "not valid json{{{") + + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "overwrites corrupt state with valid state after a request" do + FileUtils.mkdir_p(File.dirname(rate_limit_path)) + File.write(rate_limit_path, "garbage") + + limiter.wait! + + state = JSON.parse(File.read(rate_limit_path)) + expect(state).to have_key("last_request_at") + expect(state["last_request_at"]).to be_a(Float) + end + end + + context "with a missing parent directory" do + let(:nested_path) { File.join(tmpdir, "sub", "dir", "copilot_rate_limit") } + + let(:limiter) do + described_class.new( + rate_limit_path: nested_path, + min_request_interval: 1.0, + rate_limit: nil + ) + end + + it "creates parent directories" do + limiter.wait! + expect(File.exist?(nested_path)).to be(true) + end + end + + context "cross-process coordination" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: nil + ) + end + + it "reads state written by another process" do + # Simulate another process having made a request just now + now = Time.now.to_f + state = { "last_request_at" => now, "request_log" => [ now ] } + FileUtils.mkdir_p(File.dirname(rate_limit_path)) + File.write(rate_limit_path, JSON.generate(state)) + + # Our limiter should see this and wait + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + + it "writes state that another process can read" do + limiter.wait! + + # Another RateLimiter instance (simulating another process) reads the file + other_limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: nil + ) + + allow(other_limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + other_limiter.wait! + end + end + end +end diff --git a/spec/dispatch/adapter/interface/structs_spec.rb b/spec/dispatch/adapter/interface/structs_spec.rb index ef8ec73..43b9db8 100644 --- a/spec/dispatch/adapter/interface/structs_spec.rb +++ b/spec/dispatch/adapter/interface/structs_spec.rb @@ -22,6 +22,28 @@ RSpec.describe Dispatch::Adapter do expect(block.type).to eq("text") expect(block.text).to eq("hello") end + + it "defaults cache_control to nil" do + block = Dispatch::Adapter::TextBlock.new(text: "hello") + expect(block.cache_control).to be_nil + end + + it "accepts cache_control with ttl" do + block = Dispatch::Adapter::TextBlock.new(text: "x", cache_control: { type: :ephemeral, ttl: :"1h" }) + expect(block.cache_control[:ttl]).to eq(:"1h") + end + + it "accepts cache_control without ttl" do + block = Dispatch::Adapter::TextBlock.new(text: "x", cache_control: { type: :ephemeral }) + expect(block.cache_control[:type]).to eq(:ephemeral) + expect(block.cache_control[:ttl]).to be_nil + end + + it "serializes cache_control via to_h" do + block = Dispatch::Adapter::TextBlock.new(text: "y", cache_control: { type: :ephemeral, ttl: :"5m" }) + h = block.to_h + expect(h[:cache_control]).to eq({ type: :ephemeral, ttl: :"5m" }) + end end describe "ImageBlock" do @@ -69,6 +91,36 @@ RSpec.describe Dispatch::Adapter do expect(td.description).to eq("Search the web") expect(td.parameters).to be_a(Hash) end + + it "defaults cache_control to nil" do + td = Dispatch::Adapter::ToolDefinition.new( + name: "search", + description: "Search", + parameters: {} + ) + expect(td.cache_control).to be_nil + end + + it "accepts cache_control" do + td = Dispatch::Adapter::ToolDefinition.new( + name: "search", + description: "Search", + parameters: {}, + cache_control: { type: :ephemeral } + ) + expect(td.cache_control).to eq({ type: :ephemeral }) + end + + it "serializes cache_control via to_h" do + td = Dispatch::Adapter::ToolDefinition.new( + name: "lookup", + description: "Look up", + parameters: {}, + cache_control: { type: :ephemeral, ttl: :"1h" } + ) + h = td.to_h + expect(h[:cache_control]).to eq({ type: :ephemeral, ttl: :"1h" }) + end end describe "Response" do @@ -114,6 +166,59 @@ RSpec.describe Dispatch::Adapter do expect(usage.cache_read_tokens).to eq(10) expect(usage.cache_creation_tokens).to eq(5) end + + it "defaults reasoning_tokens to 0" do + usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50) + expect(usage.reasoning_tokens).to eq(0) + end + + it "defaults premium_requests to nil" do + usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50) + expect(usage.premium_requests).to be_nil + end + + it "defaults cost to nil" do + usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50) + expect(usage.cost).to be_nil + end + + it "accepts a UsageCost for cost" do + cost = Dispatch::Adapter::UsageCost.new(input: 0.01, output: 0.02, total: 0.03) + usage = Dispatch::Adapter::Usage.new(input_tokens: 100, output_tokens: 50, cost: cost) + expect(usage.cost).to be_a(Dispatch::Adapter::UsageCost) + expect(usage.cost.total).to eq(0.03) + end + + it "accepts reasoning_tokens and premium_requests" do + usage = Dispatch::Adapter::Usage.new( + input_tokens: 100, + output_tokens: 50, + reasoning_tokens: 30, + premium_requests: 2.5 + ) + expect(usage.reasoning_tokens).to eq(30) + expect(usage.premium_requests).to eq(2.5) + end + end + + describe "UsageCost" do + it "defaults all fields to 0.0" do + cost = Dispatch::Adapter::UsageCost.new + expect(cost.input).to eq(0.0) + expect(cost.output).to eq(0.0) + expect(cost.cache_read).to eq(0.0) + expect(cost.cache_write).to eq(0.0) + expect(cost.total).to eq(0.0) + end + + it "accepts keyword args" do + cost = Dispatch::Adapter::UsageCost.new(input: 0.005, output: 0.015, total: 0.02) + expect(cost.input).to eq(0.005) + expect(cost.output).to eq(0.015) + expect(cost.cache_read).to eq(0.0) + expect(cost.cache_write).to eq(0.0) + expect(cost.total).to eq(0.02) + end end describe "StreamDelta" do @@ -136,6 +241,24 @@ RSpec.describe Dispatch::Adapter do expect(delta.type).to eq(:tool_use_delta) expect(delta.argument_delta).to eq('{"q":') end + + it "creates a thinking_start" do + delta = Dispatch::Adapter::StreamDelta.new(type: :thinking_start) + expect(delta.type).to eq(:thinking_start) + expect(delta.text).to be_nil + end + + it "creates a thinking_delta with text payload" do + delta = Dispatch::Adapter::StreamDelta.new(type: :thinking_delta, text: "I am reasoning about this") + expect(delta.type).to eq(:thinking_delta) + expect(delta.text).to eq("I am reasoning about this") + end + + it "creates a thinking_end" do + delta = Dispatch::Adapter::StreamDelta.new(type: :thinking_end) + expect(delta.type).to eq(:thinking_end) + expect(delta.text).to be_nil + end end describe "ModelInfo" do @@ -183,6 +306,51 @@ RSpec.describe Dispatch::Adapter do end end + describe "ThinkingBlock" do + it "defaults type to 'thinking'" do + block = Dispatch::Adapter::ThinkingBlock.new(thinking: "Let me consider this...") + expect(block.type).to eq("thinking") + expect(block.thinking).to eq("Let me consider this...") + expect(block.signature).to be_nil + end + + it "accepts an optional signature" do + block = Dispatch::Adapter::ThinkingBlock.new(thinking: "deep thought", signature: "abc123") + expect(block.signature).to eq("abc123") + end + + it "serializes correctly via to_h" do + block = Dispatch::Adapter::ThinkingBlock.new(thinking: "analysis", signature: "sig42") + h = block.to_h + expect(h[:type]).to eq("thinking") + expect(h[:thinking]).to eq("analysis") + expect(h[:signature]).to eq("sig42") + end + + it "serializes with nil signature via to_h" do + block = Dispatch::Adapter::ThinkingBlock.new(thinking: "just thinking") + h = block.to_h + expect(h[:type]).to eq("thinking") + expect(h[:thinking]).to eq("just thinking") + expect(h[:signature]).to be_nil + end + end + + describe "RedactedThinkingBlock" do + it "defaults type to 'redacted_thinking'" do + block = Dispatch::Adapter::RedactedThinkingBlock.new(data: "base64encodeddata==") + expect(block.type).to eq("redacted_thinking") + expect(block.data).to eq("base64encodeddata==") + end + + it "serializes correctly via to_h" do + block = Dispatch::Adapter::RedactedThinkingBlock.new(data: "encodedblob") + h = block.to_h + expect(h[:type]).to eq("redacted_thinking") + expect(h[:data]).to eq("encodedblob") + end + end + describe "Struct equality" do it "considers structs with same values equal" do a = Dispatch::Adapter::Message.new(role: "user", content: "hello") diff --git a/spec/dispatch/adapter/interface/usage_report_spec.rb b/spec/dispatch/adapter/interface/usage_report_spec.rb new file mode 100644 index 0000000..6cb3079 --- /dev/null +++ b/spec/dispatch/adapter/interface/usage_report_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +RSpec.describe "UsageReport and friends" do + describe Dispatch::Adapter::UsageWindow do + it "constructs with required keywords only" do + w = described_class.new(id: "daily", label: "Daily") + expect(w.id).to eq("daily") + expect(w.label).to eq("Daily") + expect(w.duration_ms).to be_nil + expect(w.resets_at).to be_nil + end + + it "accepts optional duration_ms and resets_at" do + t = Time.now + w = described_class.new(id: "hourly", label: "Hourly", duration_ms: 3_600_000, resets_at: t) + expect(w.duration_ms).to eq(3_600_000) + expect(w.resets_at).to eq(t) + end + + it "round-trips through to_h" do + w = described_class.new(id: "daily", label: "Daily", duration_ms: 86_400_000) + h = w.to_h + expect(h[:id]).to eq("daily") + expect(h[:label]).to eq("Daily") + expect(h[:duration_ms]).to eq(86_400_000) + expect(h[:resets_at]).to be_nil + end + end + + describe Dispatch::Adapter::UsageAmount do + it "constructs with required unit keyword only" do + a = described_class.new(unit: :tokens) + expect(a.unit).to eq(:tokens) + expect(a.used).to be_nil + expect(a.limit).to be_nil + expect(a.remaining).to be_nil + expect(a.used_fraction).to be_nil + expect(a.remaining_fraction).to be_nil + end + + it "accepts all optional fields" do + a = described_class.new( + unit: :requests, + used: 250, + limit: 1000, + remaining: 750, + used_fraction: 0.25, + remaining_fraction: 0.75 + ) + expect(a.used).to eq(250) + expect(a.limit).to eq(1000) + expect(a.remaining).to eq(750) + expect(a.used_fraction).to eq(0.25) + expect(a.remaining_fraction).to eq(0.75) + end + + it "round-trips through to_h" do + a = described_class.new(unit: :usd, used: 1.5, limit: 10.0) + h = a.to_h + expect(h[:unit]).to eq(:usd) + expect(h[:used]).to eq(1.5) + expect(h[:limit]).to eq(10.0) + expect(h[:remaining]).to be_nil + end + + it "supports all documented unit symbols" do + %i[percent tokens requests usd minutes bytes unknown].each do |u| + expect { described_class.new(unit: u) }.not_to raise_error + end + end + end + + describe Dispatch::Adapter::UsageLimitEntry do + let(:amount) { Dispatch::Adapter::UsageAmount.new(unit: :tokens, used: 100, limit: 1000) } + + it "constructs with required keywords only" do + entry = described_class.new(id: "token_limit", label: "Token Limit", scope: "org", amount: amount) + expect(entry.id).to eq("token_limit") + expect(entry.label).to eq("Token Limit") + expect(entry.scope).to eq("org") + expect(entry.amount).to eq(amount) + expect(entry.window).to be_nil + expect(entry.status).to eq(:unknown) + expect(entry.notes).to eq([]) + end + + it "accepts optional window, status, and notes" do + window = Dispatch::Adapter::UsageWindow.new(id: "daily", label: "Daily") + entry = described_class.new( + id: "req_limit", + label: "Request Limit", + scope: "user", + amount: amount, + window: window, + status: :ok, + notes: ["All good"] + ) + expect(entry.window).to eq(window) + expect(entry.status).to eq(:ok) + expect(entry.notes).to eq(["All good"]) + end + + it "supports all documented status values" do + %i[ok warning exhausted unknown].each do |s| + entry = described_class.new(id: "x", label: "X", scope: "org", amount: amount, status: s) + expect(entry.status).to eq(s) + end + end + + it "round-trips through to_h" do + entry = described_class.new(id: "e1", label: "E1", scope: "workspace", amount: amount) + h = entry.to_h + expect(h[:id]).to eq("e1") + expect(h[:label]).to eq("E1") + expect(h[:scope]).to eq("workspace") + expect(h[:status]).to eq(:unknown) + expect(h[:notes]).to eq([]) + end + end + + describe Dispatch::Adapter::UsageReport do + let(:amount) { Dispatch::Adapter::UsageAmount.new(unit: :requests, used: 5, limit: 100) } + let(:entry) do + Dispatch::Adapter::UsageLimitEntry.new( + id: "req", label: "Requests", scope: "account", amount: amount + ) + end + + it "constructs with required keywords only" do + report = described_class.new(provider: "acme", limits: [entry]) + expect(report.provider).to eq("acme") + expect(report.limits).to eq([entry]) + expect(report.fetched_at).to be_a(Time) + expect(report.metadata).to eq({}) + expect(report.raw).to be_nil + end + + it "accepts all optional fields" do + t = Time.now + raw = { "foo" => "bar" } + report = described_class.new( + provider: "acme", + limits: [entry], + fetched_at: t, + metadata: { source: "api" }, + raw: raw + ) + expect(report.fetched_at).to eq(t) + expect(report.metadata).to eq({ source: "api" }) + expect(report.raw).to eq(raw) + end + + it "round-trips through to_h" do + t = Time.now + report = described_class.new(provider: "test_provider", limits: [], fetched_at: t) + h = report.to_h + expect(h[:provider]).to eq("test_provider") + expect(h[:limits]).to eq([]) + expect(h[:fetched_at]).to eq(t) + expect(h[:metadata]).to eq({}) + expect(h[:raw]).to be_nil + end + + it "struct equality holds when values are the same" do + t = Time.now + a = described_class.new(provider: "p", limits: [], fetched_at: t) + b = described_class.new(provider: "p", limits: [], fetched_at: t) + expect(a).to eq(b) + end + end +end diff --git a/spec/model_info_spec.rb b/spec/model_info_spec.rb new file mode 100644 index 0000000..25930f4 --- /dev/null +++ b/spec/model_info_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Dispatch::Adapter::ModelInfo do + let(:base_kwargs) do + { + id: "gpt-4", + name: "GPT-4", + max_context_tokens: 8192, + supports_vision: false, + supports_tool_use: true, + supports_streaming: true + } + end + + it "still works without pricing (existing shape unchanged)" do + info = described_class.new(**base_kwargs) + 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) + expect(info.premium_request_multiplier).to be_nil + expect(info.pricing).to be_nil + end + + it "accepts a ModelPricing and exposes it on #pricing" do + pricing = Dispatch::Adapter::ModelPricing.new(input_per_mtok: 3.0, output_per_mtok: 15.0) + info = described_class.new(**base_kwargs, pricing: pricing) + expect(info.pricing).to be_a(Dispatch::Adapter::ModelPricing) + expect(info.pricing.input_per_mtok).to eq(3.0) + expect(info.pricing.output_per_mtok).to eq(15.0) + end +end + +RSpec.describe Dispatch::Adapter::ModelPricing do + it "requires input_per_mtok and output_per_mtok" do + pricing = described_class.new(input_per_mtok: 3.0, output_per_mtok: 15.0) + expect(pricing.input_per_mtok).to eq(3.0) + expect(pricing.output_per_mtok).to eq(15.0) + end + + it "defaults cache_read_per_mtok and cache_write_per_mtok to 0.0" do + pricing = described_class.new(input_per_mtok: 1.0, output_per_mtok: 2.0) + expect(pricing.cache_read_per_mtok).to eq(0.0) + expect(pricing.cache_write_per_mtok).to eq(0.0) + end + + it "accepts explicit cache pricing" do + pricing = described_class.new( + input_per_mtok: 3.0, + output_per_mtok: 15.0, + cache_read_per_mtok: 0.3, + cache_write_per_mtok: 3.75 + ) + expect(pricing.cache_read_per_mtok).to eq(0.3) + expect(pricing.cache_write_per_mtok).to eq(3.75) + end +end -- cgit v1.2.3