Class: Kward::Client
- Inherits:
-
Object
- Object
- Kward::Client
- Includes:
- ModelPayloads
- Defined in:
- lib/kward/model/client.rb
Overview
Provider-facing model client used by CLI, RPC, compaction, and memory flows.
Client owns runtime provider selection, credential lookup, retry telemetry,
and HTTP requests for the supported model backends. Provider-neutral payload
construction and stream parsing live in ModelPayloads and
ModelStreamParser; keep new provider mechanics there when they are reusable,
and keep product policy such as configured provider/model selection here.
Constant Summary collapse
- OPENROUTER_URL =
URI("https://openrouter.ai/api/v1/chat/completions")
- CODEX_URL =
URI("https://chatgpt.com/backend-api/codex/responses")
- ANTHROPIC_URL =
URI("https://api.anthropic.com/v1/messages")
- LOCAL_BASE_URLS =
{ "ollama" => "http://127.0.0.1:11434/v1", "lm_studio" => "http://127.0.0.1:1234/v1", "llama_cpp" => "http://127.0.0.1:8080/v1" }.freeze
- 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 sign in with an API key."- OPENAI_API_AUTH_ERROR =
"No OpenAI API key found. Set OPENAI_API_KEY or sign in with an API key."- AZURE_OPENAI_AUTH_ERROR =
"No Azure OpenAI API key found. Set AZURE_OPENAI_API_KEY or sign in with an API key."- COPILOT_AUTH_ERROR =
"No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."- ANTHROPIC_AUTH_ERROR =
"No Anthropic OAuth login found. Run `ruby lib/main.rb login anthropic`."- DEFAULT_OPENAI_MODEL =
ModelInfo::DEFAULT_OPENAI_MODEL
- DEFAULT_REASONING_EFFORT =
ModelInfo::DEFAULT_REASONING_EFFORT
- RETRY_DELAYS =
[1, 2].freeze
- DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS =
120- 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 # Creates an object for model provider requests. 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
Returns model choices suitable for settings UIs.
- #chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil) ⇒ Object
-
#context_window(provider, model) ⇒ Object
Returns the known context window for a provider/model pair.
-
#current_context_parts(messages, tools, provider: current_provider, model: nil) ⇒ Object
Projects messages/tools into the provider-specific context shape without sending it.
-
#current_context_window ⇒ Object
Returns the known context window for the active provider/model pair.
-
#current_model ⇒ Object
Returns the model id that will be used for the next request.
-
#current_provider ⇒ Object
Returns the active provider label after applying env/config/credential fallback rules.
-
#current_reasoning_effort ⇒ Object
Returns the configured reasoning effort for providers that support it.
-
#initialize(api_key: , model: nil, openai_access_token: , oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, anthropic_oauth: AnthropicOAuth.new, api_key_store: nil, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path)) ⇒ Client
constructor
Creates an object for model provider requests.
-
#refresh_available_models(provider: nil) ⇒ Object
Refreshes cached choices for an API-key provider.
-
#reload_config ⇒ Object
Reloads config-backed provider settings and clears live model catalog caches.
-
#supports_in_flight_steer? ⇒ Boolean
Returns whether the active provider can accept steering while a turn is streaming.
Constructor Details
#initialize(api_key: , model: nil, openai_access_token: , oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, anthropic_oauth: AnthropicOAuth.new, api_key_store: nil, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path)) ⇒ Client
Creates an object for model provider requests.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/kward/model/client.rb', line 95 def initialize(api_key: ENV["OPENROUTER_API_KEY"], model: nil, openai_access_token: ENV["OPENAI_ACCESS_TOKEN"], oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, anthropic_oauth: AnthropicOAuth.new, api_key_store: nil, 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 @anthropic_oauth = anthropic_oauth @model = model @config_path = File.(config_path) @api_key_store = api_key_store || APIKeyStore.new(path: File.join(File.dirname(@config_path), "api_keys.json"), config_path: @config_path) @config = load_config @telemetry_logger = telemetry_logger @copilot_models = nil @openrouter_models = nil @local_models = nil end |
Instance Method Details
#available_models ⇒ Object
Returns model choices suitable for settings UIs.
Only providers with configured credentials are listed. The active provider may use live catalog data. Inactive logged-in providers use static supported choices plus their configured model so listing models does not perform avoidable network calls for every configured credential.
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/kward/model/client.rb', line 207 def available_models provider = current_provider models = [] if provider_logged_in?("Codex") openai_model = model_for("Codex") models += ModelInfo::OPENAI_MODEL_CHOICES.map do |id| model_entry("Codex", id, current: provider == "Codex" && openai_model == id) end models << model_entry("Codex", openai_model, current: provider == "Codex") unless ModelInfo::OPENAI_MODEL_CHOICES.include?(openai_model) end if provider_logged_in?("OpenRouter") openrouter_model = model_for("OpenRouter") openrouter_choices = openrouter_model_choices models += openrouter_choices.map do |id| = openrouter_cached_model_entries.find { |entry| (entry["id"] || entry[:id]).to_s == id } model_entry("OpenRouter", id, current: provider == "OpenRouter" && openrouter_model == id, metadata: ) end end if provider_logged_in?("Copilot") copilot_model = model_for("Copilot") copilot_choices = provider == "Copilot" ? copilot_model_choices : static_copilot_model_choices models += copilot_choices.map do |id| model_entry("Copilot", id, current: provider == "Copilot" && copilot_model == id) end models << model_entry("Copilot", copilot_model, current: provider == "Copilot") unless copilot_choices.include?(copilot_model) end if provider_logged_in?("Anthropic") anthropic_model = model_for("Anthropic") models += ModelInfo::ANTHROPIC_MODEL_CHOICES.map do |id| model_entry("Anthropic", id, current: provider == "Anthropic" && anthropic_model == id) end models << model_entry("Anthropic", anthropic_model, current: provider == "Anthropic") unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model) end ProviderCatalog.api_key_providers.each do |catalog_provider| next unless ["openai_chat", "openai_responses", "gemini", "azure_openai"].include?(catalog_provider.protocol) next if catalog_provider.id == "openrouter" next unless provider_logged_in?(catalog_provider.name) selected_model = model_for(catalog_provider.name) provider_models = ModelSources.new(provider_id: catalog_provider.id, api_key: @api_key_store.fetch(catalog_provider.id)).models provider_models.each do |entry| models << model_entry(catalog_provider.name, entry.fetch("id"), current: provider == catalog_provider.name && selected_model == entry.fetch("id"), metadata: entry) end models << model_entry(catalog_provider.name, selected_model, current: provider == catalog_provider.name) unless selected_model.to_s.empty? || provider_models.any? { |entry| entry.fetch("id") == selected_model } end if provider_logged_in?("Local") local_model = model_for("Local") local_model_choices.each do |id| models << model_entry("Local", id, current: provider == "Local" && local_model == id) end models << model_entry("Local", local_model, current: provider == "Local") unless local_model.to_s.empty? || local_model_choices.include?(local_model) end # 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_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil) ⇒ Object
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 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/kward/model/client.rb', line 111 def chat(, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil) cancellation&.raise_if_cancelled! requested_provider = provider url, token, resolved_provider, account_id = credentials(provider: requested_provider) if token.to_s.empty? && authentication_required?(resolved_provider) && !requested_provider.to_s.empty? && resolved_provider != "OpenAI" url, token, resolved_provider, account_id = credentials model = nil reasoning = nil end raise auth_error_for(resolved_provider) if authentication_required?(resolved_provider) && (token.nil? || token.empty?) current_model = model_for(resolved_provider, override_model: model) current_model = resolved_copilot_chat_model(current_model) if resolved_provider == "Copilot" && model.nil? validate_image_support!(resolved_provider, current_model, ) request_body = JSON.dump(request_body_payload(resolved_provider, , tools, max_tokens: max_tokens, model: current_model, reasoning: reasoning)) with_retries(resolved_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 = chat_provider_request( provider: resolved_provider, url: url, token: token, account_id: account_id, messages: , tools: tools, request_body: request_body, current_model: current_model, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, cancellation: cancellation, max_tokens: max_tokens ) rescue StandardError => e status = "failed" error = e raise e ensure log_model_request(provider: resolved_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 |
#context_window(provider, model) ⇒ Object
Returns the known context window for a provider/model pair.
194 195 196 197 198 199 |
# File 'lib/kward/model/client.rb', line 194 def context_window(provider, model) provider = ModelInfo.provider_label(provider) return local_context_window if provider == "Local" context_window_for(provider, model) end |
#current_context_parts(messages, tools, provider: current_provider, model: nil) ⇒ Object
Projects messages/tools into the provider-specific context shape without sending it.
282 283 284 |
# File 'lib/kward/model/client.rb', line 282 def current_context_parts(, tools, provider: current_provider, model: nil) build_context_parts(ModelInfo.provider_label(provider), , tools, model: model) end |
#current_context_window ⇒ Object
Returns the known context window for the active provider/model pair.
188 189 190 191 |
# File 'lib/kward/model/client.rb', line 188 def current_context_window state = current_model_state context_window(state[:provider], state[:model]) end |
#current_model ⇒ Object
Returns the model id that will be used for the next request.
178 179 180 |
# File 'lib/kward/model/client.rb', line 178 def current_model current_model_state[:model] end |
#current_provider ⇒ Object
Returns the active provider label after applying env/config/credential fallback rules.
167 168 169 170 171 172 173 174 175 |
# File 'lib/kward/model/client.rb', line 167 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
Returns the configured reasoning effort for providers that support it.
183 184 185 |
# File 'lib/kward/model/client.rb', line 183 def current_reasoning_effort current_model_state[:reasoning_effort] end |
#refresh_available_models(provider: nil) ⇒ Object
Refreshes cached choices for an API-key provider.
271 272 273 274 275 276 277 278 279 |
# File 'lib/kward/model/client.rb', line 271 def refresh_available_models(provider: nil) provider ||= current_provider catalog_provider = ProviderCatalog.find_by_name(provider) || ProviderCatalog.find(provider.to_s.downcase) return available_models unless catalog_provider&.api_key? return available_models unless ProviderCatalog.runtime(catalog_provider.id).automatic_model_discovery? ModelSources.new(provider_id: catalog_provider.id, api_key: @api_key_store.fetch(catalog_provider.id)).refresh available_models end |
#reload_config ⇒ Object
Reloads config-backed provider settings and clears live model catalog caches.
294 295 296 297 298 299 |
# File 'lib/kward/model/client.rb', line 294 def reload_config @config = load_config @copilot_models = nil @openrouter_models = nil @local_models = nil end |
#supports_in_flight_steer? ⇒ Boolean
Returns whether the active provider can accept steering while a turn is streaming.
287 288 289 290 291 |
# File 'lib/kward/model/client.rb', line 287 def supports_in_flight_steer? current_provider == "Codex" rescue StandardError false end |