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")
- 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
- 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_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) ⇒ 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.
82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/kward/model/client.rb', line 82 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 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.
188 189 190 191 192 193 194 195 196 197 198 199 200 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 |
# File 'lib/kward/model/client.rb', line 188 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 # 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, provider: nil, model: nil, reasoning: nil) ⇒ Object
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 |
# File 'lib/kward/model/client.rb', line 96 def chat(, tools: [], on_reasoning_delta: 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? && !requested_provider.to_s.empty? url, token, resolved_provider, account_id = credentials model = nil reasoning = nil end raise auth_error_for(resolved_provider) if 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_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.
178 179 180 |
# File 'lib/kward/model/client.rb', line 178 def context_window(provider, model) context_window_for(ModelInfo.provider_label(provider), model) end |
#current_context_parts(messages, tools) ⇒ Object
Projects messages/tools into the provider-specific context shape without sending it.
230 231 232 |
# File 'lib/kward/model/client.rb', line 230 def current_context_parts(, tools) build_context_parts(current_provider, , tools) end |
#current_context_window ⇒ Object
Returns the known context window for the active provider/model pair.
172 173 174 175 |
# File 'lib/kward/model/client.rb', line 172 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.
162 163 164 |
# File 'lib/kward/model/client.rb', line 162 def current_model current_model_state[:model] end |
#current_provider ⇒ Object
Returns the active provider label after applying env/config/credential fallback rules.
151 152 153 154 155 156 157 158 159 |
# File 'lib/kward/model/client.rb', line 151 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.
167 168 169 |
# File 'lib/kward/model/client.rb', line 167 def current_reasoning_effort current_model_state[:reasoning_effort] end |
#reload_config ⇒ Object
Reloads config-backed provider settings and clears live model catalog caches.
242 243 244 245 246 |
# File 'lib/kward/model/client.rb', line 242 def reload_config @config = load_config @copilot_models = nil @openrouter_models = nil end |
#supports_in_flight_steer? ⇒ Boolean
Returns whether the active provider can accept steering while a turn is streaming.
235 236 237 238 239 |
# File 'lib/kward/model/client.rb', line 235 def supports_in_flight_steer? current_provider == "Codex" rescue StandardError false end |