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 `kward login`, or set OPENAI_ACCESS_TOKEN or OPENROUTER_API_KEY."
OPENROUTER_AUTH_ERROR =
"No OpenRouter API key found. Run `kward login openrouter`, or set OPENROUTER_API_KEY."
OPENAI_API_AUTH_ERROR =
"No OpenAI API key found. Run `kward login`, or set OPENAI_API_KEY."
AZURE_OPENAI_AUTH_ERROR =
"No Azure OpenAI API key found. Run `kward login`, or set AZURE_OPENAI_API_KEY."
COPILOT_AUTH_ERROR =
"No GitHub Copilot OAuth login found. Run `kward login github`, or set COPILOT_GITHUB_TOKEN."
ANTHROPIC_AUTH_ERROR =
"No Anthropic OAuth login found. Run `kward 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
AuthenticationError =
Class.new(RuntimeError)
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.



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/kward/model/client.rb', line 97

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.



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
269
270
271
# File 'lib/kward/model/client.rb', line 210

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, provider_required: false) ⇒ Object



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
165
166
167
# File 'lib/kward/model/client.rb', line 113

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, provider_required: false)
  cancellation&.raise_if_cancelled!
  requested_provider = provider
  validate_required_provider!(requested_provider) if provider_required
  url, token, resolved_provider,  = credentials(provider: requested_provider)
  if token.to_s.empty? && authentication_required?(resolved_provider) && !provider_required && !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.



197
198
199
200
201
202
# File 'lib/kward/model/client.rb', line 197

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.



285
286
287
# File 'lib/kward/model/client.rb', line 285

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.



191
192
193
194
# File 'lib/kward/model/client.rb', line 191

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.



181
182
183
# File 'lib/kward/model/client.rb', line 181

def current_model
  current_model_state[:model]
end

#current_providerObject

Returns the active provider label after applying env/config/credential fallback rules.



170
171
172
173
174
175
176
177
178
# File 'lib/kward/model/client.rb', line 170

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.



186
187
188
# File 'lib/kward/model/client.rb', line 186

def current_reasoning_effort
  current_model_state[:reasoning_effort]
end

#refresh_available_models(provider: nil) ⇒ Object

Refreshes cached choices for an API-key provider.



274
275
276
277
278
279
280
281
282
# File 'lib/kward/model/client.rb', line 274

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.



297
298
299
300
301
302
# File 'lib/kward/model/client.rb', line 297

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)


290
291
292
293
294
# File 'lib/kward/model/client.rb', line 290

def supports_in_flight_steer?
  current_provider == "Codex"
rescue StandardError
  false
end