Class: Kward::Client
- Inherits:
-
Object
- Object
- Kward::Client
- 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.
69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/kward/model/client.rb', line 69 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
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/kward/model/client.rb', line 177 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
83 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 |
# File 'lib/kward/model/client.rb', line 83 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
207 208 209 |
# File 'lib/kward/model/client.rb', line 207 def current_context_parts(, tools) build_context_parts(current_provider, , tools) end |
#current_context_window ⇒ Object
173 174 175 |
# File 'lib/kward/model/client.rb', line 173 def current_context_window ModelInfo.context_window(current_provider, current_model) end |
#current_model ⇒ Object
165 166 167 |
# File 'lib/kward/model/client.rb', line 165 def current_model model_for(current_provider) end |
#current_provider ⇒ Object
155 156 157 158 159 160 161 162 163 |
# File 'lib/kward/model/client.rb', line 155 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
169 170 171 |
# File 'lib/kward/model/client.rb', line 169 def current_reasoning_effort reasoning_effort(current_provider) end |
#openrouter_catalog ⇒ Object
201 202 203 204 205 |
# File 'lib/kward/model/client.rb', line 201 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
217 218 219 220 221 222 |
# File 'lib/kward/model/client.rb', line 217 def reload_config @config = load_config @copilot_models = nil @openrouter_models = nil @openrouter_catalog = nil end |
#supports_in_flight_steer? ⇒ Boolean
211 212 213 214 215 |
# File 'lib/kward/model/client.rb', line 211 def supports_in_flight_steer? current_provider == "Codex" rescue StandardError false end |