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 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."- 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, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path)) ⇒ Client
constructor
Creates an object for model provider requests.
-
#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, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path)) ⇒ Client
Creates an object for model provider requests.
90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/kward/model/client.rb', line 90 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, 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) @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.
201 202 203 204 205 206 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 |
# File 'lib/kward/model/client.rb', line 201 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| model_entry("OpenRouter", id, current: provider == "OpenRouter" && openrouter_model == id) 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 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
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 155 156 157 158 |
# File 'lib/kward/model/client.rb', line 105 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? 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.
188 189 190 191 192 193 |
# File 'lib/kward/model/client.rb', line 188 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.
251 252 253 |
# File 'lib/kward/model/client.rb', line 251 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.
182 183 184 185 |
# File 'lib/kward/model/client.rb', line 182 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.
172 173 174 |
# File 'lib/kward/model/client.rb', line 172 def current_model current_model_state[:model] end |
#current_provider ⇒ Object
Returns the active provider label after applying env/config/credential fallback rules.
161 162 163 164 165 166 167 168 169 |
# File 'lib/kward/model/client.rb', line 161 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.
177 178 179 |
# File 'lib/kward/model/client.rb', line 177 def current_reasoning_effort current_model_state[:reasoning_effort] end |
#reload_config ⇒ Object
Reloads config-backed provider settings and clears live model catalog caches.
263 264 265 266 267 268 |
# File 'lib/kward/model/client.rb', line 263 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.
256 257 258 259 260 |
# File 'lib/kward/model/client.rb', line 256 def supports_in_flight_steer? current_provider == "Codex" rescue StandardError false end |