Skip to content
Kward Search API index

Class: Kward::Client

Inherits:
Object
  • Object
show all
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 = [message, body].compact.join("\n")
    Kward::Client::NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS.any? { |pattern| text.match?(pattern) }
  end

  def message_after_attempts(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

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.expand_path(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_modelsObject

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(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)
  cancellation&.raise_if_cancelled!
  requested_provider = provider
  url, token, resolved_provider,  = 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,  = 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, messages)
  request_body = JSON.dump(request_body_payload(resolved_provider, messages, 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
    message = nil
    status = "completed"
    error = nil
    begin
      message = chat_provider_request(
        provider: resolved_provider,
        url: url,
        token: token,
        account_id: ,
        messages: 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: message && (message["usage"] || message[: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(messages, tools, provider: current_provider, model: nil)
  build_context_parts(ModelInfo.provider_label(provider), messages, tools, model: model)
end

#current_context_windowObject

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_modelObject

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_providerObject

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_effortObject

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_configObject

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.

Returns:

  • (Boolean)


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