Class: Kward::Client
- Inherits:
-
Object
- Object
- Kward::Client
- Includes:
- ModelPayloads
- Defined in:
- lib/kward/model/client.rb
Constant Summary collapse
- OPENROUTER_URL =
URI("https://openrouter.ai/api/v1/chat/completions")
- OPENROUTER_MODELS_URL =
URI("https://openrouter.ai/api/v1/models")
- CODEX_URL =
URI("https://chatgpt.com/backend-api/codex/responses")
- AUTH_ERROR =
"No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."- OPENROUTER_AUTH_ERROR =
"No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config."- COPILOT_AUTH_ERROR =
"No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."- DEFAULT_OPENAI_MODEL =
ModelInfo::DEFAULT_OPENAI_MODEL
- DEFAULT_OPENROUTER_MODEL =
ModelInfo::DEFAULT_OPENROUTER_MODEL
- DEFAULT_REASONING_EFFORT =
ModelInfo::DEFAULT_REASONING_EFFORT
- RETRY_DELAYS =
[1, 2].freeze
- NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS =
[ /GoUsageLimitError/i, /FreeUsageLimitError/i, /Monthly usage limit reached/i, /available balance/i, /insufficient[_ ]quota/i, /out of (?:budget|credits?)/i, /quota exceeded/i, /billing/i, /payment required/i, /(?:usage|spend|credit|quota).*(?:exceeded|reached|exhausted|depleted)/i, /(?:exceeded|reached).*(?:usage|quota|credit|budget|balance)/i ].freeze
- RequestError =
Class.new(StandardError) do attr_reader :provider, :code, :body def initialize(provider:, code:, body:) @provider = provider @code = code.to_i @body = body.to_s super("#{provider} request failed: #{code} #{@body}") end def context_overflow? ContextOverflow.error?(self) end def transient? !context_overflow? && !provider_limit? && (code == 429 || code.between?(500, 599)) end def provider_limit? text = [, body].compact.join("\n") Kward::Client::NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS.any? { |pattern| text.match?(pattern) } end def (attempts) "#{provider} request failed after #{attempts} attempts: #{code} #{body}" end end
- TRANSIENT_NETWORK_ERRORS =
[IOError, EOFError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout].freeze
Instance Method Summary collapse
- #available_models ⇒ Object
- #chat(messages, tools: [], on_reasoning_delta: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, model: nil, reasoning: nil) ⇒ Object
- #current_context_parts(messages, tools) ⇒ Object
- #current_context_window ⇒ Object
- #current_model ⇒ Object
- #current_provider ⇒ Object
- #current_reasoning_effort ⇒ Object
-
#initialize(api_key: , model: nil, openai_access_token: , oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path)) ⇒ Client
constructor
A new instance of Client.
- #openrouter_catalog ⇒ Object
- #reload_config ⇒ Object
- #supports_in_flight_steer? ⇒ Boolean
Constructor Details
#initialize(api_key: , model: nil, openai_access_token: , oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path)) ⇒ Client
Returns a new instance of Client.
70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/kward/model/client.rb', line 70 def initialize(api_key: ENV["OPENROUTER_API_KEY"], model: nil, openai_access_token: ENV["OPENAI_ACCESS_TOKEN"], oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path)) @openrouter_api_key = presence(api_key) @openai_access_token = presence(openai_access_token) @oauth = oauth @github_oauth = github_oauth @model = model @config_path = File.(config_path) @config = load_config @telemetry_logger = telemetry_logger @copilot_models = nil @openrouter_models = nil @openrouter_catalog = nil end |
Instance Method Details
#available_models ⇒ Object
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/kward/model/client.rb', line 178 def available_models provider = current_provider openai_model = model_for("Codex") openrouter_model = model_for("OpenRouter") copilot_model = model_for("Copilot") openrouter_choices = openrouter_model_choices copilot_choices = copilot_model_choices models = ModelInfo::OPENAI_MODEL_CHOICES.map do |id| { provider: "Codex", id: id, current: provider == "Codex" && openai_model == id } end models += openrouter_choices.map do |id| { provider: "OpenRouter", id: id, current: provider == "OpenRouter" && openrouter_model == id } end models += copilot_choices.map do |id| { provider: "Copilot", id: id, current: provider == "Copilot" && copilot_model == id } end models << { provider: "Codex", id: openai_model, current: provider == "Codex" } unless ModelInfo::OPENAI_MODEL_CHOICES.include?(openai_model) models << { provider: "OpenRouter", id: openrouter_model, current: provider == "OpenRouter" } unless openrouter_choices.include?(openrouter_model) models << { provider: "Copilot", id: copilot_model, current: provider == "Copilot" } unless copilot_choices.include?(copilot_model) # Sort models by provider, then alphabetically by id models.sort_by { |model| [model[:provider], model[:id]] } end |
#chat(messages, tools: [], on_reasoning_delta: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, model: nil, reasoning: nil) ⇒ Object
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/kward/model/client.rb', line 84 def chat(, tools: [], on_reasoning_delta: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, model: nil, reasoning: nil) cancellation&.raise_if_cancelled! url, token, provider, account_id = credentials raise auth_error_for(provider) if token.nil? || token.empty? current_model = model_for(provider, override_model: model) current_model = resolved_copilot_chat_model(current_model) if provider == "Copilot" && model.nil? validate_image_support!(provider, current_model, ) request_body = JSON.dump(request_body_payload(provider, , tools, max_tokens: max_tokens, model: current_model, reasoning: reasoning)) with_retries(provider, current_model, request_bytes: request_body.bytesize, on_retry: on_retry, cancellation: cancellation) do request_started_at = @telemetry_logger.monotonic_now = nil status = "completed" error = nil begin if provider == "Codex" = codex_chat(url, token, account_id, , tools, request_body: request_body, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, max_tokens: max_tokens) = (, provider: provider, model: current_model) next end if provider == "Copilot" = if copilot_responses_model?(current_model) copilot_responses_chat(token, request_body: request_body, on_assistant_delta: on_assistant_delta, cancellation: cancellation) else copilot_chat(url, token, , tools, request_body: request_body, on_assistant_delta: on_assistant_delta, cancellation: cancellation) end = (, provider: provider, model: current_model) next end request = Net::HTTP::Post.new(url) request["Authorization"] = "Bearer #{token}" request["Content-Type"] = "application/json" request.body = request_body response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http| cancellation&.on_cancel { close_http(http) } cancellation&.raise_if_cancelled! http.request(request) end cancellation&.raise_if_cancelled! unless response.is_a?(Net::HTTPSuccess) raise RequestError.new(provider: provider, code: response.code, body: response.body) end body = JSON.parse(response.body) = body.fetch("choices").first.fetch("message") cancellation&.raise_if_cancelled! on_assistant_delta&.call(.fetch("content", "")) = (, provider: provider, model: current_model, usage: normalized_usage(body["usage"])) rescue StandardError => e status = "failed" error = e raise e ensure log_model_request(provider: provider, model: current_model, request_bytes: request_body.bytesize, duration_ms: @telemetry_logger.duration_ms(request_started_at), status: status, error: error, usage: && (["usage"] || [:usage])) end end rescue *TRANSIENT_NETWORK_ERRORS => e raise Kward::Cancellation::CancelledError, "cancelled" if cancellation&.cancelled? log_error("model_request_error", e) raise e rescue StandardError => e log_error("model_request_error", e) raise e end |
#current_context_parts(messages, tools) ⇒ Object
208 209 210 |
# File 'lib/kward/model/client.rb', line 208 def current_context_parts(, tools) build_context_parts(current_provider, , tools) end |
#current_context_window ⇒ Object
174 175 176 |
# File 'lib/kward/model/client.rb', line 174 def current_context_window ModelInfo.context_window(current_provider, current_model) end |
#current_model ⇒ Object
166 167 168 |
# File 'lib/kward/model/client.rb', line 166 def current_model model_for(current_provider) end |
#current_provider ⇒ Object
156 157 158 159 160 161 162 163 164 |
# File 'lib/kward/model/client.rb', line 156 def current_provider _url, _token, provider = credentials provider rescue StandardError label = ModelInfo.provider_label(configured_provider) return label unless label.empty? openai_configured? ? "Codex" : "OpenRouter" end |
#current_reasoning_effort ⇒ Object
170 171 172 |
# File 'lib/kward/model/client.rb', line 170 def current_reasoning_effort reasoning_effort(current_provider) end |
#openrouter_catalog ⇒ Object
202 203 204 205 206 |
# File 'lib/kward/model/client.rb', line 202 def openrouter_catalog fetch_openrouter_models(full_catalog: true).map do |id| { provider: "OpenRouter", id: id, current: current_provider == "OpenRouter" && model_for("OpenRouter") == id } end.sort_by { |model| model[:id] } end |
#reload_config ⇒ Object
218 219 220 221 222 223 |
# File 'lib/kward/model/client.rb', line 218 def reload_config @config = load_config @copilot_models = nil @openrouter_models = nil @openrouter_catalog = nil end |
#supports_in_flight_steer? ⇒ Boolean
212 213 214 215 216 |
# File 'lib/kward/model/client.rb', line 212 def supports_in_flight_steer? current_provider == "Codex" rescue StandardError false end |