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")
- OPENROUTER_MODELS_URL =
URI("https://openrouter.ai/api/v1/models")
- 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_OPENROUTER_MODEL =
ModelInfo::DEFAULT_OPENROUTER_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
-
#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.
-
#openrouter_catalog ⇒ Object
Fetches the full OpenRouter public model catalog for settings UIs.
-
#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.
83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/kward/model/client.rb', line 83 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 @openrouter_catalog = 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.
185 186 187 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 |
# File 'lib/kward/model/client.rb', line 185 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| { provider: "Codex", id: id, current: provider == "Codex" && openai_model == id } end models << { provider: "Codex", id: 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 = provider == "OpenRouter" ? openrouter_model_choices : ModelInfo::OPENROUTER_MODEL_CHOICES models += openrouter_choices.map do |id| { provider: "OpenRouter", id: id, current: provider == "OpenRouter" && openrouter_model == id } end models << { provider: "OpenRouter", id: openrouter_model, current: provider == "OpenRouter" } unless openrouter_choices.include?(openrouter_model) 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| { provider: "Copilot", id: id, current: provider == "Copilot" && copilot_model == id } end models << { provider: "Copilot", id: 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| { provider: "Anthropic", id: id, current: provider == "Anthropic" && anthropic_model == id } end models << { provider: "Anthropic", id: 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
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 149 150 |
# File 'lib/kward/model/client.rb', line 98 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 |
#current_context_parts(messages, tools) ⇒ Object
Projects messages/tools into the provider-specific context shape without sending it.
235 236 237 |
# File 'lib/kward/model/client.rb', line 235 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.
174 175 176 177 |
# File 'lib/kward/model/client.rb', line 174 def current_context_window state = current_model_state ModelInfo.context_window(state[:provider], state[:model]) end |
#current_model ⇒ Object
Returns the model id that will be used for the next request.
164 165 166 |
# File 'lib/kward/model/client.rb', line 164 def current_model current_model_state[:model] end |
#current_provider ⇒ Object
Returns the active provider label after applying env/config/credential fallback rules.
153 154 155 156 157 158 159 160 161 |
# File 'lib/kward/model/client.rb', line 153 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.
169 170 171 |
# File 'lib/kward/model/client.rb', line 169 def current_reasoning_effort current_model_state[:reasoning_effort] end |
#openrouter_catalog ⇒ Object
Fetches the full OpenRouter public model catalog for settings UIs.
228 229 230 231 232 |
# File 'lib/kward/model/client.rb', line 228 def openrouter_catalog fetch_openrouter_models(full_catalog: true).map do |id| { provider: "OpenRouter", id: id, current: current_provider == "OpenRouter" && model_for("OpenRouter") == id } end.sort_by { |model| model[:id] } end |
#reload_config ⇒ Object
Reloads config-backed provider settings and clears live model catalog caches.
247 248 249 250 251 252 |
# File 'lib/kward/model/client.rb', line 247 def reload_config @config = load_config @copilot_models = nil @openrouter_models = nil @openrouter_catalog = nil end |
#supports_in_flight_steer? ⇒ Boolean
Returns whether the active provider can accept steering while a turn is streaming.
240 241 242 243 244 |
# File 'lib/kward/model/client.rb', line 240 def supports_in_flight_steer? current_provider == "Codex" rescue StandardError false end |