kward 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -0,0 +1,875 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require_relative "../auth/github_oauth"
5
+ require_relative "../auth/openai_oauth"
6
+ require_relative "../cancellation"
7
+ require_relative "../config_files"
8
+ require_relative "context_overflow"
9
+ require_relative "../image_attachments"
10
+ require_relative "model_info"
11
+ require_relative "../telemetry/logger"
12
+ require_relative "stream_parser"
13
+
14
+ module Kward
15
+ class Client
16
+ OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
17
+ OPENROUTER_MODELS_URL = URI("https://openrouter.ai/api/v1/models")
18
+ CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
19
+ AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
20
+ OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config."
21
+ COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
22
+ DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
23
+ DEFAULT_OPENROUTER_MODEL = ModelInfo::DEFAULT_OPENROUTER_MODEL
24
+ DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
25
+ RETRY_DELAYS = [1, 2].freeze
26
+ NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS = [
27
+ /GoUsageLimitError/i,
28
+ /FreeUsageLimitError/i,
29
+ /Monthly usage limit reached/i,
30
+ /available balance/i,
31
+ /insufficient[_ ]quota/i,
32
+ /out of (?:budget|credits?)/i,
33
+ /quota exceeded/i,
34
+ /billing/i,
35
+ /payment required/i,
36
+ /(?:usage|spend|credit|quota).*(?:exceeded|reached|exhausted|depleted)/i,
37
+ /(?:exceeded|reached).*(?:usage|quota|credit|budget|balance)/i
38
+ ].freeze
39
+
40
+ RequestError = Class.new(StandardError) do
41
+ attr_reader :provider, :code, :body
42
+
43
+ def initialize(provider:, code:, body:)
44
+ @provider = provider
45
+ @code = code.to_i
46
+ @body = body.to_s
47
+ super("#{provider} request failed: #{code} #{@body}")
48
+ end
49
+
50
+ def context_overflow?
51
+ ContextOverflow.error?(self)
52
+ end
53
+
54
+ def transient?
55
+ !context_overflow? && !provider_limit? && (code == 429 || code.between?(500, 599))
56
+ end
57
+
58
+ def provider_limit?
59
+ text = [message, body].compact.join("\n")
60
+ Kward::Client::NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS.any? { |pattern| text.match?(pattern) }
61
+ end
62
+
63
+ def message_after_attempts(attempts)
64
+ "#{provider} request failed after #{attempts} attempts: #{code} #{body}"
65
+ end
66
+ end
67
+ TRANSIENT_NETWORK_ERRORS = [IOError, EOFError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout].freeze
68
+
69
+ def initialize(api_key: ENV["OPENROUTER_API_KEY"], model: nil, openai_access_token: ENV["OPENAI_ACCESS_TOKEN"], oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path))
70
+ @openrouter_api_key = presence(api_key)
71
+ @openai_access_token = presence(openai_access_token)
72
+ @oauth = oauth
73
+ @github_oauth = github_oauth
74
+ @model = model
75
+ @config_path = File.expand_path(config_path)
76
+ @config = load_config
77
+ @telemetry_logger = telemetry_logger
78
+ @copilot_models = nil
79
+ @openrouter_models = nil
80
+ @openrouter_catalog = nil
81
+ end
82
+
83
+ def chat(messages, tools: [], on_reasoning_delta: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, model: nil, reasoning: nil)
84
+ cancellation&.raise_if_cancelled!
85
+ url, token, provider, account_id = credentials
86
+ raise auth_error_for(provider) if token.nil? || token.empty?
87
+
88
+ current_model = model_for(provider, override_model: model)
89
+ current_model = resolved_copilot_chat_model(current_model) if provider == "Copilot" && model.nil?
90
+
91
+ validate_image_support!(provider, current_model, messages)
92
+ request_body = JSON.dump(request_body_payload(provider, messages, tools, max_tokens: max_tokens, model: current_model, reasoning: reasoning))
93
+ with_retries(provider, current_model, request_bytes: request_body.bytesize, on_retry: on_retry, cancellation: cancellation) do
94
+ request_started_at = @telemetry_logger.monotonic_now
95
+ message = nil
96
+ status = "completed"
97
+ error = nil
98
+ begin
99
+ if provider == "Codex"
100
+ message = codex_chat(url, token, account_id, messages, tools, request_body: request_body, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, max_tokens: max_tokens)
101
+ message = attach_response_metadata(message, provider: provider, model: current_model)
102
+ next message
103
+ end
104
+
105
+ if provider == "Copilot"
106
+ message = if copilot_responses_model?(current_model)
107
+ copilot_responses_chat(token, request_body: request_body, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
108
+ else
109
+ copilot_chat(url, token, messages, tools, request_body: request_body, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
110
+ end
111
+ message = attach_response_metadata(message, provider: provider, model: current_model)
112
+ next message
113
+ end
114
+
115
+ request = Net::HTTP::Post.new(url)
116
+ request["Authorization"] = "Bearer #{token}"
117
+ request["Content-Type"] = "application/json"
118
+ request.body = request_body
119
+
120
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
121
+ cancellation&.on_cancel { close_http(http) }
122
+ cancellation&.raise_if_cancelled!
123
+ http.request(request)
124
+ end
125
+ cancellation&.raise_if_cancelled!
126
+
127
+ unless response.is_a?(Net::HTTPSuccess)
128
+ raise RequestError.new(provider: provider, code: response.code, body: response.body)
129
+ end
130
+
131
+ body = JSON.parse(response.body)
132
+ message = body.fetch("choices").first.fetch("message")
133
+ cancellation&.raise_if_cancelled!
134
+ on_assistant_delta&.call(message.fetch("content", ""))
135
+ message = attach_response_metadata(message, provider: provider, model: current_model, usage: normalized_usage(body["usage"]))
136
+ message
137
+ rescue StandardError => e
138
+ status = "failed"
139
+ error = e
140
+ raise e
141
+ ensure
142
+ log_model_request(provider: 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]))
143
+ end
144
+ end
145
+ rescue *TRANSIENT_NETWORK_ERRORS => e
146
+ raise Kward::Cancellation::CancelledError, "cancelled" if cancellation&.cancelled?
147
+
148
+ log_error("model_request_error", e)
149
+ raise e
150
+ rescue StandardError => e
151
+ log_error("model_request_error", e)
152
+ raise e
153
+ end
154
+
155
+ def current_provider
156
+ _url, _token, provider = credentials
157
+ provider
158
+ rescue StandardError
159
+ label = ModelInfo.provider_label(configured_provider)
160
+ return label unless label.empty?
161
+
162
+ openai_configured? ? "Codex" : "OpenRouter"
163
+ end
164
+
165
+ def current_model
166
+ model_for(current_provider)
167
+ end
168
+
169
+ def current_reasoning_effort
170
+ reasoning_effort(current_provider)
171
+ end
172
+
173
+ def current_context_window
174
+ ModelInfo.context_window(current_provider, current_model)
175
+ end
176
+
177
+ def available_models
178
+ provider = current_provider
179
+ openai_model = model_for("Codex")
180
+ openrouter_model = model_for("OpenRouter")
181
+ copilot_model = model_for("Copilot")
182
+ openrouter_choices = openrouter_model_choices
183
+ copilot_choices = copilot_model_choices
184
+ models = ModelInfo::OPENAI_MODEL_CHOICES.map do |id|
185
+ { provider: "Codex", id: id, current: provider == "Codex" && openai_model == id }
186
+ end
187
+ models += openrouter_choices.map do |id|
188
+ { provider: "OpenRouter", id: id, current: provider == "OpenRouter" && openrouter_model == id }
189
+ end
190
+ models += copilot_choices.map do |id|
191
+ { provider: "Copilot", id: id, current: provider == "Copilot" && copilot_model == id }
192
+ end
193
+ models << { provider: "Codex", id: openai_model, current: provider == "Codex" } unless ModelInfo::OPENAI_MODEL_CHOICES.include?(openai_model)
194
+ models << { provider: "OpenRouter", id: openrouter_model, current: provider == "OpenRouter" } unless openrouter_choices.include?(openrouter_model)
195
+ models << { provider: "Copilot", id: copilot_model, current: provider == "Copilot" } unless copilot_choices.include?(copilot_model)
196
+
197
+ # Sort models by provider, then alphabetically by id
198
+ models.sort_by { |model| [model[:provider], model[:id]] }
199
+ end
200
+
201
+ def openrouter_catalog
202
+ fetch_openrouter_models(full_catalog: true).map do |id|
203
+ { provider: "OpenRouter", id: id, current: current_provider == "OpenRouter" && model_for("OpenRouter") == id }
204
+ end.sort_by { |model| model[:id] }
205
+ end
206
+
207
+ def current_context_parts(messages, tools)
208
+ build_context_parts(current_provider, messages, tools)
209
+ end
210
+
211
+ def supports_in_flight_steer?
212
+ current_provider == "Codex"
213
+ rescue StandardError
214
+ false
215
+ end
216
+
217
+ def reload_config
218
+ @config = load_config
219
+ @copilot_models = nil
220
+ @openrouter_models = nil
221
+ @openrouter_catalog = nil
222
+ end
223
+
224
+ private
225
+
226
+ def auth_error_for(provider)
227
+ case provider
228
+ when "OpenRouter"
229
+ OPENROUTER_AUTH_ERROR
230
+ when "Copilot"
231
+ COPILOT_AUTH_ERROR
232
+ else
233
+ AUTH_ERROR
234
+ end
235
+ end
236
+
237
+ def with_retries(provider, model, request_bytes: nil, on_retry: nil, cancellation: nil)
238
+ attempts = RETRY_DELAYS.length + 1
239
+ attempt = 1
240
+
241
+ begin
242
+ cancellation&.raise_if_cancelled!
243
+ yield
244
+ rescue RequestError => e
245
+ raise unless e.transient?
246
+ raise e.message_after_attempts(attempt) if attempt >= attempts
247
+
248
+ delay = RETRY_DELAYS[attempt - 1]
249
+ retry_info = { provider: provider, model: model, attempt: attempt + 1, max_attempts: attempts, delay_seconds: delay, error: e.message, request_bytes: request_bytes }
250
+ log_retry(retry_info)
251
+ on_retry&.call(retry_info)
252
+ sleep_with_cancellation(delay, cancellation)
253
+ attempt += 1
254
+ retry
255
+ rescue *TRANSIENT_NETWORK_ERRORS => e
256
+ raise Kward::Cancellation::CancelledError, "cancelled" if cancellation&.cancelled?
257
+ raise "#{provider} request failed after #{attempt} attempts: #{e.message}" if attempt >= attempts
258
+
259
+ delay = RETRY_DELAYS[attempt - 1]
260
+ retry_info = { provider: provider, model: model, attempt: attempt + 1, max_attempts: attempts, delay_seconds: delay, error: e.message, request_bytes: request_bytes }
261
+ log_retry(retry_info)
262
+ on_retry&.call(retry_info)
263
+ sleep_with_cancellation(delay, cancellation)
264
+ attempt += 1
265
+ retry
266
+ end
267
+ end
268
+
269
+ def log_model_request(provider:, model:, request_bytes:, duration_ms:, status:, error:, usage:)
270
+ payload = {
271
+ "provider" => provider,
272
+ "model" => model,
273
+ "request_bytes" => request_bytes,
274
+ "duration_ms" => duration_ms,
275
+ "status" => status
276
+ }
277
+ if usage.respond_to?(:key?)
278
+ usage_payload = usage.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
279
+ @telemetry_logger.log("tokens", "model_usage", payload.merge("usage" => usage_payload))
280
+ end
281
+ @telemetry_logger.log("performance", "model_request", payload)
282
+ end
283
+
284
+ def log_retry(retry_info)
285
+ payload = {
286
+ "provider" => retry_info[:provider],
287
+ "model" => retry_info[:model],
288
+ "attempt" => retry_info[:attempt],
289
+ "max_attempts" => retry_info[:max_attempts],
290
+ "delay_seconds" => retry_info[:delay_seconds],
291
+ "request_bytes" => retry_info[:request_bytes]
292
+ }.merge(TelemetryLogger.error_payload(StandardError.new(retry_info[:error].to_s)))
293
+ @telemetry_logger.log("performance", "model_retry", payload)
294
+ @telemetry_logger.log("errors", "model_retry", payload)
295
+ end
296
+
297
+ def log_error(event, error, payload = {})
298
+ return unless error
299
+
300
+ @telemetry_logger.log("errors", event, payload.merge(TelemetryLogger.error_payload(error)))
301
+ end
302
+
303
+ def sleep_with_cancellation(seconds, cancellation)
304
+ deadline = Time.now + seconds.to_f
305
+ loop do
306
+ cancellation&.raise_if_cancelled!
307
+ remaining = deadline - Time.now
308
+ break if remaining <= 0
309
+
310
+ sleep([remaining, 0.1].min)
311
+ end
312
+ end
313
+
314
+ def request_body_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
315
+ if provider == "Codex"
316
+ codex_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
317
+ elsif provider == "Copilot" && copilot_responses_model?(model)
318
+ copilot_responses_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
319
+ else
320
+ request_payload(provider, messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
321
+ end
322
+ end
323
+
324
+ def copilot_responses_model?(model)
325
+ model.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
326
+ end
327
+
328
+ def openrouter_model_choices
329
+ live_models = fetch_openrouter_models(full_catalog: false)
330
+ choices = live_models.empty? ? ModelInfo::OPENROUTER_MODEL_CHOICES : live_models
331
+ choices.uniq
332
+ end
333
+
334
+ def fetch_openrouter_models(full_catalog: false)
335
+ cache = full_catalog ? @openrouter_catalog : @openrouter_models
336
+ return cache if cache
337
+
338
+ token = openrouter_api_key.to_s
339
+ return [] if token.empty? && !full_catalog
340
+
341
+ request = Net::HTTP::Get.new(OPENROUTER_MODELS_URL)
342
+ request["Authorization"] = "Bearer #{token}" unless full_catalog || token.empty?
343
+ request["Accept"] = "application/json"
344
+
345
+ response = Net::HTTP.start(OPENROUTER_MODELS_URL.hostname, OPENROUTER_MODELS_URL.port, use_ssl: true) { |http| http.request(request) }
346
+ return [] unless response.is_a?(Net::HTTPSuccess)
347
+
348
+ models = parse_openrouter_models(response.body)
349
+ if full_catalog
350
+ @openrouter_catalog = models
351
+ else
352
+ @openrouter_models = models
353
+ end
354
+ rescue StandardError
355
+ []
356
+ end
357
+
358
+ def parse_openrouter_models(body)
359
+ data = JSON.parse(body.to_s)
360
+ entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
361
+ Array(entries).filter_map do |entry|
362
+ if entry.is_a?(Hash)
363
+ entry["id"] || entry[:id] || entry["slug"] || entry[:slug]
364
+ else
365
+ entry
366
+ end
367
+ end.map(&:to_s).map(&:strip).reject(&:empty?).uniq
368
+ end
369
+
370
+ def copilot_model_choices
371
+ live_models = fetch_copilot_models
372
+ choices = live_models.empty? ? ModelInfo::COPILOT_MODEL_CHOICES : live_models
373
+ choices.select { |model| copilot_supported_model?(model) }.uniq
374
+ end
375
+
376
+ def resolved_copilot_chat_model(configured_model)
377
+ choices = fetch_copilot_models
378
+ return configured_model if choices.empty? || choices.include?(configured_model)
379
+
380
+ supported = choices.find { |model| copilot_supported_model?(model) }
381
+ raise "No Copilot models supported by Kward are available for this account. Kward currently supports Copilot GPT-5 Responses and Gemini/GPT-4.1 chat models." unless supported
382
+
383
+ supported
384
+ end
385
+
386
+ def copilot_supported_model?(model)
387
+ text = model.to_s
388
+ copilot_responses_model?(text) || text.match?(/\A(?:gemini-|gpt-4\.1|oswe-)/)
389
+ end
390
+
391
+ def fetch_copilot_models
392
+ return @copilot_models if @copilot_models
393
+
394
+ token = github_access_token.to_s
395
+ return [] if token.empty?
396
+
397
+ url = URI("#{@github_oauth.base_url}/models")
398
+ request = Net::HTTP::Get.new(url)
399
+ request["Authorization"] = "Bearer #{token}"
400
+ request["Accept"] = "application/json"
401
+ copilot_headers([]).each { |key, value| request[key] = value }
402
+
403
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
404
+ return [] unless response.is_a?(Net::HTTPSuccess)
405
+
406
+ @copilot_models = parse_copilot_models(response.body)
407
+ rescue StandardError
408
+ []
409
+ end
410
+
411
+ def parse_copilot_models(body)
412
+ data = JSON.parse(body.to_s)
413
+ entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
414
+ Array(entries).filter_map do |entry|
415
+ copilot_model_id(entry)
416
+ end.uniq
417
+ rescue JSON::ParserError
418
+ []
419
+ end
420
+
421
+ def copilot_model_id(entry)
422
+ return entry.to_s.strip unless entry.is_a?(Hash)
423
+ return nil if entry.key?("model_picker_enabled") && entry["model_picker_enabled"] == false
424
+
425
+ id = entry["id"] || entry["model"] || entry["name"]
426
+ id.to_s.strip unless id.to_s.strip.empty?
427
+ end
428
+
429
+ def copilot_responses_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
430
+ parts = build_context_parts("CopilotResponses", messages, tools, model: model)
431
+ payload = {
432
+ model: parts[:model],
433
+ instructions: parts[:instructions],
434
+ input: parts[:input],
435
+ tools: parts[:tools],
436
+ stream: true,
437
+ store: false
438
+ }
439
+ payload[:reasoning] = { effort: reasoning_effort("Copilot"), summary: "auto" } unless reasoning == false
440
+ payload[:max_output_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
441
+ payload
442
+ end
443
+
444
+ def copilot_responses_chat(token, request_body:, on_assistant_delta: nil, cancellation: nil)
445
+ url = URI("#{@github_oauth.base_url}/responses")
446
+ request = Net::HTTP::Post.new(url)
447
+ request["Authorization"] = "Bearer #{token}"
448
+ request["Content-Type"] = "application/json"
449
+ request["Accept"] = "text/event-stream"
450
+ copilot_headers([]).each { |key, value| request[key] = value }
451
+ request.body = request_body
452
+
453
+ message = nil
454
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
455
+ cancellation&.on_cancel { close_http(http) }
456
+ cancellation&.raise_if_cancelled!
457
+ http.request(request) do |response|
458
+ unless response.is_a?(Net::HTTPSuccess)
459
+ body = +""
460
+ response.read_body { |chunk| body << chunk }
461
+ raise RequestError.new(provider: "Copilot", code: response.code, body: redact(body, token))
462
+ end
463
+
464
+ message = parse_codex_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
465
+ end
466
+ end
467
+ cancellation&.raise_if_cancelled!
468
+ message
469
+ end
470
+
471
+ def copilot_chat(url, token, messages, tools, request_body: nil, on_assistant_delta: nil, cancellation: nil)
472
+ request = Net::HTTP::Post.new(url)
473
+ request["Authorization"] = "Bearer #{token}"
474
+ request["Content-Type"] = "application/json"
475
+ request["Accept"] = "text/event-stream"
476
+ copilot_headers(messages).each { |key, value| request[key] = value }
477
+ request.body = request_body || JSON.dump(request_payload("Copilot", messages, tools))
478
+
479
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
480
+ cancellation&.on_cancel { close_http(http) }
481
+ cancellation&.raise_if_cancelled!
482
+ http.request(request)
483
+ end
484
+ cancellation&.raise_if_cancelled!
485
+
486
+ unless response.is_a?(Net::HTTPSuccess)
487
+ raise RequestError.new(provider: "Copilot", code: response.code, body: redact(response.body, token))
488
+ end
489
+
490
+ parse_openai_chat_sse(response.body.to_s, on_assistant_delta: on_assistant_delta)
491
+ end
492
+
493
+ def parse_openai_chat_sse(body, on_assistant_delta: nil)
494
+ ModelStreamParser.parse_openai_chat_sse(body, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage))
495
+ end
496
+
497
+ def copilot_headers(messages)
498
+ headers = GithubOAuth::COPILOT_HEADERS.dup
499
+ headers["X-Initiator"] = copilot_initiator(messages)
500
+ headers["Openai-Intent"] = "conversation-edits"
501
+ headers["Copilot-Vision-Request"] = "true" if messages_include_images?(messages)
502
+ headers
503
+ end
504
+
505
+ def copilot_initiator(messages)
506
+ last = messages.last || {}
507
+ role = last[:role] || last["role"]
508
+ role.to_s == "user" ? "user" : "agent"
509
+ end
510
+
511
+ def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
512
+ request = Net::HTTP::Post.new(url)
513
+ request["Authorization"] = "Bearer #{token}"
514
+ request["ChatGPT-Account-Id"] = account_id if account_id
515
+ request["Content-Type"] = "application/json"
516
+ request["Accept"] = "text/event-stream"
517
+ request["originator"] = "codex_cli_rs"
518
+ request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
519
+
520
+ message = nil
521
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
522
+ cancellation&.on_cancel { close_http(http) }
523
+ cancellation&.raise_if_cancelled!
524
+ http.request(request) do |response|
525
+ unless response.is_a?(Net::HTTPSuccess)
526
+ body = +""
527
+ response.read_body { |chunk| body << chunk }
528
+ raise RequestError.new(provider: "Codex", code: response.code, body: redact(body, token))
529
+ end
530
+
531
+ message = parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
532
+ end
533
+ end
534
+
535
+ cancellation&.raise_if_cancelled!
536
+ message
537
+ rescue *TRANSIENT_NETWORK_ERRORS => e
538
+ raise Kward::Cancellation::CancelledError, "cancelled" if cancellation&.cancelled?
539
+
540
+ raise e
541
+ end
542
+
543
+ def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil)
544
+ ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
545
+ end
546
+
547
+ def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
548
+ ModelStreamParser.parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
549
+ end
550
+
551
+ def close_http(http)
552
+ http.finish if http&.started?
553
+ rescue IOError
554
+ nil
555
+ end
556
+
557
+ def attach_response_metadata(message, provider:, model:, usage: nil)
558
+ return message unless message.is_a?(Hash)
559
+
560
+ message["provider"] ||= provider
561
+ message["model"] ||= model
562
+ message["usage"] ||= usage if usage
563
+ message
564
+ end
565
+
566
+ def normalized_usage(usage)
567
+ return nil unless usage.is_a?(Hash)
568
+
569
+ input_tokens = integer_value(usage, "input_tokens", "prompt_tokens")
570
+ output_tokens = integer_value(usage, "output_tokens", "completion_tokens")
571
+ cache_read_tokens = positive_integer(
572
+ nested_value(usage, "input_tokens_details", "cached_tokens") ||
573
+ nested_value(usage, "prompt_tokens_details", "cached_tokens") ||
574
+ usage["cache_read_tokens"] || usage[:cache_read_tokens] || usage["cacheReadTokens"] || usage[:cacheReadTokens]
575
+ )
576
+ cache_write_tokens = integer_value(usage, "cache_write_tokens", "cacheWriteTokens")
577
+ total_tokens = integer_value(usage, "total_tokens", "totalTokens")
578
+ total_tokens ||= [input_tokens, output_tokens, cache_read_tokens, cache_write_tokens].compact.sum
579
+ return nil unless total_tokens&.positive? || input_tokens&.positive? || output_tokens&.positive?
580
+
581
+ {
582
+ "input_tokens" => input_tokens || 0,
583
+ "output_tokens" => output_tokens || 0,
584
+ "cache_read_tokens" => cache_read_tokens || 0,
585
+ "cache_write_tokens" => cache_write_tokens || 0,
586
+ "total_tokens" => total_tokens || 0,
587
+ "estimated" => false
588
+ }
589
+ end
590
+
591
+ def integer_value(source, *keys)
592
+ return nil unless source.respond_to?(:key?)
593
+
594
+ key = keys.find { |candidate| source.key?(candidate) || source.key?(candidate.to_sym) }
595
+ return nil unless key
596
+
597
+ positive_integer(source[key] || source[key.to_sym])
598
+ end
599
+
600
+ def nested_value(source, outer_key, inner_key)
601
+ outer = source[outer_key] || source[outer_key.to_sym]
602
+ return nil unless outer.respond_to?(:key?)
603
+
604
+ outer[inner_key] || outer[inner_key.to_sym]
605
+ end
606
+
607
+ def positive_integer(value)
608
+ integer = value.to_i
609
+ integer.positive? ? integer : nil
610
+ end
611
+
612
+ def credentials
613
+ provider = ModelInfo.provider_label(configured_provider)
614
+ if provider == "Copilot"
615
+ return [copilot_chat_url, github_access_token, provider, nil]
616
+ end
617
+
618
+ if provider == "OpenRouter"
619
+ return [OPENROUTER_URL, openrouter_api_key, provider, nil]
620
+ end
621
+
622
+ openai_token = @openai_access_token || @oauth.access_token
623
+ if openai_token
624
+ [CODEX_URL, openai_token, "Codex", @oauth.respond_to?(:account_id) ? @oauth.account_id : nil]
625
+ elsif openrouter_api_key
626
+ [OPENROUTER_URL, openrouter_api_key, "OpenRouter", nil]
627
+ else
628
+ [CODEX_URL, nil, "Codex", nil]
629
+ end
630
+ end
631
+
632
+ def request_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
633
+ parts = build_context_parts(provider, messages, tools, model: model)
634
+ payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
635
+ payload[:reasoning] = { effort: reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
636
+ payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
637
+ payload
638
+ end
639
+
640
+ def validate_image_support!(provider, model, messages)
641
+ return if ModelInfo.supports_images?(provider, model)
642
+ return unless messages_include_images?(messages)
643
+
644
+ raise "Model '#{model}' does not support image inputs. Switch to a vision-capable model or remove the image attachment."
645
+ end
646
+
647
+ def messages_include_images?(messages)
648
+ messages.any? do |message|
649
+ content = message[:content] || message["content"]
650
+ content.is_a?(Array) && content.any? { |part| (part[:type] || part["type"]).to_s == "image" }
651
+ end
652
+ end
653
+
654
+ def chat_messages(messages)
655
+ messages.map do |message|
656
+ role = message[:role] || message["role"]
657
+ content = message[:content] || message["content"]
658
+ case role.to_s
659
+ when "compactionSummary"
660
+ { role: "assistant", content: message[:summary] || message["summary"] || content.to_s }
661
+ when "assistant"
662
+ api_message(message, role: "assistant", content: content.is_a?(Array) ? plain_content(content) : content, keys: ["tool_calls", :tool_calls, "name", :name])
663
+ when "toolResult"
664
+ api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "toolCallId", :toolCallId, "name", :name, "toolName", :toolName])
665
+ when "tool"
666
+ api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "name", :name])
667
+ when "user"
668
+ api_message(message, role: "user", content: content.is_a?(Array) ? chat_user_content(content) : content, keys: ["name", :name])
669
+ else
670
+ api_message(message, role: role, content: content, keys: ["name", :name])
671
+ end
672
+ end
673
+ end
674
+
675
+ def api_message(message, role:, content:, keys: [])
676
+ result = { role: role, content: content }
677
+ keys.each_slice(2) do |string_key, symbol_key|
678
+ value = message[string_key] || message[symbol_key]
679
+ next if value.nil?
680
+
681
+ target_key = case string_key.to_s
682
+ when "toolCallId" then :tool_call_id
683
+ when "toolName" then :name
684
+ else string_key.to_sym
685
+ end
686
+ result[target_key] = value
687
+ end
688
+ result
689
+ end
690
+
691
+ def chat_user_content(content)
692
+ content.filter_map do |part|
693
+ type = part[:type] || part["type"]
694
+ if type == "text"
695
+ { type: "text", text: part[:text] || part["text"] || "" }
696
+ elsif type == "image"
697
+ { type: "image_url", image_url: { url: ImageAttachments.data_url(part) } }
698
+ end
699
+ end
700
+ end
701
+
702
+ def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
703
+ parts = build_context_parts("Codex", messages, tools, model: model)
704
+ payload = {
705
+ model: parts[:model],
706
+ instructions: parts[:instructions],
707
+ input: parts[:input],
708
+ tools: parts[:tools],
709
+ tool_choice: "auto",
710
+ parallel_tool_calls: false,
711
+ stream: true,
712
+ store: false,
713
+ include: []
714
+ }
715
+ payload[:reasoning] = { effort: reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
716
+ payload
717
+ end
718
+
719
+ def build_context_parts(provider, messages, tools, model: nil)
720
+ if provider == "CopilotResponses"
721
+ instructions, input = codex_messages(messages)
722
+ {
723
+ provider: provider,
724
+ model: model_for("Copilot", override_model: model),
725
+ instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
726
+ input: input,
727
+ tools: tools.map { |tool| codex_tool_schema(tool) }
728
+ }
729
+ elsif provider == "Codex"
730
+ instructions, input = codex_messages(messages)
731
+ {
732
+ provider: provider,
733
+ model: model_for(provider, override_model: model),
734
+ instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
735
+ input: input,
736
+ tools: tools.map { |tool| codex_tool_schema(tool) }
737
+ }
738
+ else
739
+ {
740
+ provider: provider,
741
+ model: model_for(provider, override_model: model),
742
+ messages: chat_messages(messages),
743
+ tools: tools
744
+ }
745
+ end
746
+ end
747
+
748
+ def codex_messages(messages)
749
+ instructions = []
750
+ input = []
751
+
752
+ messages.each do |message|
753
+ role = message[:role] || message["role"]
754
+ content = message[:content] || message["content"] || ""
755
+ case role.to_s
756
+ when "system"
757
+ instructions << plain_content(content).to_s
758
+ when "tool", "toolResult"
759
+ input << {
760
+ type: "function_call_output",
761
+ call_id: message[:tool_call_id] || message["tool_call_id"] || message[:toolCallId] || message["toolCallId"] || message[:name] || message["name"] || message[:toolName] || message["toolName"] || "tool-call",
762
+ output: plain_content(content).to_s
763
+ }
764
+ when "assistant"
765
+ content = plain_content(content)
766
+ input << codex_message("assistant", content.to_s) unless content.to_s.empty?
767
+ (message[:tool_calls] || message["tool_calls"] || []).each do |tool_call|
768
+ function = tool_call[:function] || tool_call["function"] || {}
769
+ input << {
770
+ type: "function_call",
771
+ call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
772
+ name: function[:name] || function["name"],
773
+ arguments: function[:arguments] || function["arguments"] || "{}"
774
+ }
775
+ end
776
+ when "compactionSummary"
777
+ summary = message[:summary] || message["summary"] || content
778
+ input << codex_message("assistant", summary.to_s) unless summary.to_s.empty?
779
+ else
780
+ input << codex_user_message(content)
781
+ end
782
+ end
783
+
784
+ [instructions.join("\n\n"), input]
785
+ end
786
+
787
+ def codex_user_message(content)
788
+ return codex_message("user", content.to_s) unless content.is_a?(Array)
789
+
790
+ parts = content.filter_map do |part|
791
+ type = part[:type] || part["type"]
792
+ if type == "text"
793
+ { type: "input_text", text: part[:text] || part["text"] || "" }
794
+ elsif type == "image"
795
+ { type: "input_image", image_url: ImageAttachments.data_url(part) }
796
+ end
797
+ end
798
+ { type: "message", role: "user", content: parts }
799
+ end
800
+
801
+ def codex_message(role, text)
802
+ type = role == "assistant" ? "output_text" : "input_text"
803
+ { type: "message", role: role, content: [{ type: type, text: text }] }
804
+ end
805
+
806
+ def plain_content(content)
807
+ return content unless content.is_a?(Array)
808
+
809
+ content.filter_map do |part|
810
+ type = part[:type] || part["type"]
811
+ part[:text] || part["text"] if type == "text"
812
+ end.join
813
+ end
814
+
815
+ def codex_tool_schema(tool)
816
+ function = tool[:function] || tool["function"] || {}
817
+ {
818
+ type: "function",
819
+ name: function[:name] || function["name"],
820
+ description: function[:description] || function["description"] || "",
821
+ parameters: function[:parameters] || function["parameters"] || {},
822
+ strict: false
823
+ }
824
+ end
825
+
826
+ def model_for(provider, override_model: nil)
827
+ ModelInfo.model_for(provider, config: @config, override_model: override_model || @model)
828
+ end
829
+
830
+ def reasoning_effort(provider = nil)
831
+ ModelInfo.reasoning_effort(config: @config, provider: provider)
832
+ end
833
+
834
+ def openai_configured?
835
+ !@openai_access_token.to_s.empty? || @oauth.access_token.to_s != ""
836
+ rescue StandardError
837
+ false
838
+ end
839
+
840
+ def openrouter_api_key
841
+ @openrouter_api_key || config_value("openrouter_api_key")
842
+ end
843
+
844
+ def github_access_token
845
+ @github_oauth.access_token
846
+ end
847
+
848
+ def copilot_chat_url
849
+ URI("#{@github_oauth.base_url}/chat/completions")
850
+ end
851
+
852
+ def configured_provider
853
+ value = ENV["KWARD_PROVIDER"].to_s.strip
854
+ value = config_value("provider") if value.empty?
855
+ value.to_s.downcase
856
+ end
857
+
858
+ def config_value(*keys)
859
+ ConfigFiles.config_value(@config, *keys)
860
+ end
861
+
862
+ def load_config
863
+ ConfigFiles.read_config(@config_path)
864
+ end
865
+
866
+ def redact(text, token)
867
+ text.to_s.gsub(token.to_s, "[REDACTED]")
868
+ end
869
+
870
+ def presence(value)
871
+ text = value.to_s
872
+ text.empty? ? nil : text
873
+ end
874
+ end
875
+ end