kward 0.70.0 → 0.72.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -6,6 +6,7 @@ require_relative "../auth/github_oauth"
6
6
  require_relative "../auth/openai_oauth"
7
7
  require_relative "../cancellation"
8
8
  require_relative "../config_files"
9
+ require_relative "../openrouter_model_cache"
9
10
  require_relative "context_overflow"
10
11
  require_relative "model_info"
11
12
  require_relative "payloads"
@@ -24,7 +25,6 @@ module Kward
24
25
  class Client
25
26
  include ModelPayloads
26
27
  OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
27
- OPENROUTER_MODELS_URL = URI("https://openrouter.ai/api/v1/models")
28
28
  CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
29
29
  ANTHROPIC_URL = URI("https://api.anthropic.com/v1/messages")
30
30
  AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
@@ -32,7 +32,6 @@ module Kward
32
32
  COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
33
33
  ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `ruby lib/main.rb login anthropic`."
34
34
  DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
35
- DEFAULT_OPENROUTER_MODEL = ModelInfo::DEFAULT_OPENROUTER_MODEL
36
35
  DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
37
36
  RETRY_DELAYS = [1, 2].freeze
38
37
  NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS = [
@@ -92,7 +91,6 @@ module Kward
92
91
  @telemetry_logger = telemetry_logger
93
92
  @copilot_models = nil
94
93
  @openrouter_models = nil
95
- @openrouter_catalog = nil
96
94
  end
97
95
 
98
96
  def 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)
@@ -173,7 +171,12 @@ module Kward
173
171
  # Returns the known context window for the active provider/model pair.
174
172
  def current_context_window
175
173
  state = current_model_state
176
- ModelInfo.context_window(state[:provider], state[:model])
174
+ context_window(state[:provider], state[:model])
175
+ end
176
+
177
+ # Returns the known context window for a provider/model pair.
178
+ def context_window(provider, model)
179
+ context_window_for(ModelInfo.provider_label(provider), model)
177
180
  end
178
181
 
179
182
  # Returns model choices suitable for settings UIs.
@@ -189,48 +192,40 @@ module Kward
189
192
  if provider_logged_in?("Codex")
190
193
  openai_model = model_for("Codex")
191
194
  models += ModelInfo::OPENAI_MODEL_CHOICES.map do |id|
192
- { provider: "Codex", id: id, current: provider == "Codex" && openai_model == id }
195
+ model_entry("Codex", id, current: provider == "Codex" && openai_model == id)
193
196
  end
194
- models << { provider: "Codex", id: openai_model, current: provider == "Codex" } unless ModelInfo::OPENAI_MODEL_CHOICES.include?(openai_model)
197
+ models << model_entry("Codex", openai_model, current: provider == "Codex") unless ModelInfo::OPENAI_MODEL_CHOICES.include?(openai_model)
195
198
  end
196
199
 
197
200
  if provider_logged_in?("OpenRouter")
198
201
  openrouter_model = model_for("OpenRouter")
199
- openrouter_choices = provider == "OpenRouter" ? openrouter_model_choices : ModelInfo::OPENROUTER_MODEL_CHOICES
202
+ openrouter_choices = openrouter_model_choices
200
203
  models += openrouter_choices.map do |id|
201
- { provider: "OpenRouter", id: id, current: provider == "OpenRouter" && openrouter_model == id }
204
+ model_entry("OpenRouter", id, current: provider == "OpenRouter" && openrouter_model == id)
202
205
  end
203
- models << { provider: "OpenRouter", id: openrouter_model, current: provider == "OpenRouter" } unless openrouter_choices.include?(openrouter_model)
204
206
  end
205
207
 
206
208
  if provider_logged_in?("Copilot")
207
209
  copilot_model = model_for("Copilot")
208
210
  copilot_choices = provider == "Copilot" ? copilot_model_choices : static_copilot_model_choices
209
211
  models += copilot_choices.map do |id|
210
- { provider: "Copilot", id: id, current: provider == "Copilot" && copilot_model == id }
212
+ model_entry("Copilot", id, current: provider == "Copilot" && copilot_model == id)
211
213
  end
212
- models << { provider: "Copilot", id: copilot_model, current: provider == "Copilot" } unless copilot_choices.include?(copilot_model)
214
+ models << model_entry("Copilot", copilot_model, current: provider == "Copilot") unless copilot_choices.include?(copilot_model)
213
215
  end
214
216
 
215
217
  if provider_logged_in?("Anthropic")
216
218
  anthropic_model = model_for("Anthropic")
217
219
  models += ModelInfo::ANTHROPIC_MODEL_CHOICES.map do |id|
218
- { provider: "Anthropic", id: id, current: provider == "Anthropic" && anthropic_model == id }
220
+ model_entry("Anthropic", id, current: provider == "Anthropic" && anthropic_model == id)
219
221
  end
220
- models << { provider: "Anthropic", id: anthropic_model, current: provider == "Anthropic" } unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model)
222
+ models << model_entry("Anthropic", anthropic_model, current: provider == "Anthropic") unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model)
221
223
  end
222
224
 
223
225
  # Sort models by provider, then alphabetically by id
224
226
  models.sort_by { |model| [model[:provider], model[:id]] }
225
227
  end
226
228
 
227
- # Fetches the full OpenRouter public model catalog for settings UIs.
228
- def openrouter_catalog
229
- fetch_openrouter_models(full_catalog: true).map do |id|
230
- { provider: "OpenRouter", id: id, current: current_provider == "OpenRouter" && model_for("OpenRouter") == id }
231
- end.sort_by { |model| model[:id] }
232
- end
233
-
234
229
  # Projects messages/tools into the provider-specific context shape without sending it.
235
230
  def current_context_parts(messages, tools)
236
231
  build_context_parts(current_provider, messages, tools)
@@ -248,7 +243,6 @@ module Kward
248
243
  @config = load_config
249
244
  @copilot_models = nil
250
245
  @openrouter_models = nil
251
- @openrouter_catalog = nil
252
246
  end
253
247
 
254
248
  private
@@ -494,44 +488,37 @@ module Kward
494
488
  model.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
495
489
  end
496
490
 
497
- def openrouter_model_choices
498
- live_models = fetch_openrouter_models(full_catalog: false)
499
- choices = live_models.empty? ? ModelInfo::OPENROUTER_MODEL_CHOICES : live_models
500
- choices.uniq
491
+ def model_entry(provider, id, current: false)
492
+ {
493
+ provider: provider,
494
+ id: id,
495
+ current: current,
496
+ contextWindow: context_window_for(provider, id)
497
+ }.compact
501
498
  end
502
499
 
503
- def fetch_openrouter_models(full_catalog: false)
504
- cache = full_catalog ? @openrouter_catalog : @openrouter_models
505
- return cache if cache
506
-
507
- token = openrouter_api_key.to_s
508
- return [] if token.empty? && !full_catalog
500
+ def context_window_for(provider, id)
501
+ ModelInfo.context_window(provider, id, openrouter_models: openrouter_cached_model_entries)
502
+ end
509
503
 
510
- request = Net::HTTP::Get.new(OPENROUTER_MODELS_URL)
511
- request["Authorization"] = "Bearer #{token}" unless full_catalog || token.empty?
512
- request["Accept"] = "application/json"
504
+ def openrouter_model_choices
505
+ openrouter_cached_models.uniq
506
+ end
513
507
 
514
- response = Net::HTTP.start(OPENROUTER_MODELS_URL.hostname, OPENROUTER_MODELS_URL.port, use_ssl: true) { |http| http.request(request) }
515
- return [] unless response.is_a?(Net::HTTPSuccess)
508
+ def openrouter_cached_models
509
+ openrouter_cached_model_entries.filter_map do |model|
510
+ model.is_a?(Hash) ? model["id"] || model[:id] : model
511
+ end.map(&:to_s).map(&:strip).reject(&:empty?)
512
+ end
516
513
 
517
- models = parse_openrouter_models(response.body)
518
- if full_catalog
519
- @openrouter_catalog = models
520
- else
521
- @openrouter_models = models
522
- end
514
+ def openrouter_cached_model_entries
515
+ @openrouter_models ||= openrouter_model_cache.models
523
516
  rescue StandardError
524
517
  []
525
518
  end
526
519
 
527
- def parse_openrouter_models(body)
528
- model_catalog_entries(body).filter_map do |entry|
529
- if entry.is_a?(Hash)
530
- entry["id"] || entry[:id] || entry["slug"] || entry[:slug]
531
- else
532
- entry
533
- end
534
- end.map(&:to_s).map(&:strip).reject(&:empty?).uniq
520
+ def openrouter_model_cache
521
+ OpenRouterModelCache.new(api_key: openrouter_api_key, path: File.join(File.dirname(@config_path), "cache", "openrouter_models.json"))
535
522
  end
536
523
 
537
524
  def copilot_model_choices
@@ -5,14 +5,11 @@ require_relative "../message_access"
5
5
  module Kward
6
6
  # Estimates provider context usage and compaction pressure.
7
7
  class ContextUsage
8
- OPENAI_CONTEXT_PROVIDERS = ["Codex", "OpenAI"].freeze
9
-
10
8
  def initialize(token_counter: TiktokenTokenCounter.new)
11
9
  @token_counter = token_counter
12
10
  end
13
11
 
14
12
  def call(provider:, model:, context_window:, context_parts:)
15
- return nil unless OPENAI_CONTEXT_PROVIDERS.include?(provider.to_s)
16
13
  return nil unless context_window
17
14
 
18
15
  parts = redact_image_data(stringify_keys(context_parts || {}))
@@ -84,7 +81,13 @@ module Kward
84
81
  # Structured context usage result returned to frontends.
85
82
  class TiktokenTokenCounter
86
83
  def count(text, model:)
87
- encoding(model).encode(text.to_s).length
84
+ text = text.to_s
85
+ tokenizer = encoding(model)
86
+ return rough_count(text) unless tokenizer.respond_to?(:encode)
87
+
88
+ tokenizer.encode(text).length
89
+ rescue StandardError
90
+ rough_count(text)
88
91
  end
89
92
 
90
93
  private
@@ -92,9 +95,13 @@ module Kward
92
95
  def encoding(model)
93
96
  require "tiktoken_ruby"
94
97
 
95
- Tiktoken.encoding_for_model(model.to_s)
98
+ Tiktoken.encoding_for_model(model.to_s) || Tiktoken.get_encoding(encoding_name_for_model(model))
96
99
  rescue StandardError
97
- Tiktoken.get_encoding(encoding_name_for_model(model))
100
+ Tiktoken.get_encoding(encoding_name_for_model(model)) if defined?(Tiktoken)
101
+ end
102
+
103
+ def rough_count(text)
104
+ [(text.length / 4.0).ceil, 1].max
98
105
  end
99
106
 
100
107
  def encoding_name_for_model(model)
@@ -5,15 +5,10 @@ module Kward
5
5
  # Static and configured model metadata helpers.
6
6
  module ModelInfo
7
7
  DEFAULT_OPENAI_MODEL = "gpt-5.5"
8
- DEFAULT_OPENROUTER_MODEL = "openai/gpt-5.5"
9
8
  DEFAULT_COPILOT_MODEL = "gpt-5-mini"
10
9
  DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"
11
10
  DEFAULT_REASONING_EFFORT = "medium"
12
11
  OPENAI_MODEL_CHOICES = %w[gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
13
- OPENROUTER_MODEL_CHOICES = [
14
- *OPENAI_MODEL_CHOICES.map { |model| "openai/#{model}" },
15
- "z-ai/glm-5.2"
16
- ].freeze
17
12
  ANTHROPIC_MODEL_CHOICES = %w[
18
13
  claude-opus-4-8
19
14
  claude-sonnet-4-6
@@ -118,9 +113,6 @@ module Kward
118
113
  GEMINI_CONTEXT_WINDOWS = [
119
114
  [/\Agemini-(?:2\.5-pro|3(?:\.1)?-pro|3(?:\.5)?-flash)/, 1_048_576]
120
115
  ].freeze
121
- OPENROUTER_CONTEXT_WINDOWS = [
122
- [/\Az-ai\/glm-5\.2\z/, 1_048_576]
123
- ].freeze
124
116
 
125
117
  module_function
126
118
 
@@ -129,7 +121,7 @@ module Kward
129
121
 
130
122
  case provider
131
123
  when "OpenRouter"
132
- env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model") || DEFAULT_OPENROUTER_MODEL
124
+ env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model")
133
125
  when "Copilot"
134
126
  normalize_copilot_model(env["COPILOT_MODEL"] || ConfigFiles.config_value(config, "copilot_model", "model") || DEFAULT_COPILOT_MODEL)
135
127
  when "Anthropic"
@@ -234,16 +226,27 @@ module Kward
234
226
  }
235
227
  end
236
228
 
237
- def context_window(provider, id)
229
+ def context_window(provider, id, openrouter_models: nil)
238
230
  case provider
239
231
  when "Codex"
240
- pattern_context_window(CODEX_CONTEXT_WINDOWS, id)
232
+ pattern_context_window(CODEX_CONTEXT_WINDOWS, id) ||
233
+ openrouter_inferred_context_window(provider, id, openrouter_models: openrouter_models) ||
234
+ conservative_context_window(id)
241
235
  when "OpenRouter"
242
- openrouter_context_window(id)
236
+ openrouter_cached_context_window(openrouter_models, id) ||
237
+ openrouter_context_window(id) ||
238
+ conservative_openrouter_context_window(id) ||
239
+ conservative_unknown_context_window(id)
243
240
  when "Copilot"
244
- copilot_context_window(id)
241
+ copilot_context_window(id) ||
242
+ openrouter_inferred_context_window(provider, id, openrouter_models: openrouter_models) ||
243
+ conservative_context_window(id) ||
244
+ conservative_unknown_context_window(id)
245
245
  when "Anthropic"
246
- anthropic_context_window(id)
246
+ anthropic_context_window(id) ||
247
+ openrouter_inferred_context_window(provider, id, openrouter_models: openrouter_models) ||
248
+ conservative_context_window(normalize_anthropic_model(id)) ||
249
+ conservative_anthropic_context_window(id)
247
250
  end
248
251
  end
249
252
 
@@ -252,13 +255,87 @@ module Kward
252
255
  match&.last
253
256
  end
254
257
 
258
+ def openrouter_cached_context_window(models, id)
259
+ model = Array(models).find do |entry|
260
+ next false unless entry.respond_to?(:key?)
261
+
262
+ entry["id"].to_s == id.to_s || entry[:id].to_s == id.to_s
263
+ end
264
+ value = model && (model["contextWindow"] || model[:contextWindow] || model["context_window"] || model[:context_window])
265
+ positive_integer(value)
266
+ end
267
+
268
+ def openrouter_inferred_context_window(provider, id, openrouter_models: nil)
269
+ openrouter_equivalent_ids(provider, id).filter_map do |candidate|
270
+ openrouter_cached_context_window(openrouter_models, candidate)
271
+ end.min
272
+ end
273
+
274
+ def openrouter_equivalent_ids(provider, id)
275
+ text = id.to_s.strip
276
+ return [] if text.empty?
277
+
278
+ case provider
279
+ when "Codex"
280
+ ["openai/#{text.delete_prefix("openai/")}"]
281
+ when "Anthropic"
282
+ raw = text.delete_prefix("anthropic/")
283
+ normalized = normalize_anthropic_model(raw)
284
+ ["anthropic/#{raw}", "anthropic/#{normalized}"].uniq
285
+ when "Copilot"
286
+ copilot_openrouter_equivalent_ids(text)
287
+ else
288
+ []
289
+ end
290
+ end
291
+
292
+ def copilot_openrouter_equivalent_ids(id)
293
+ return ["openai/#{id.delete_prefix("openai/")}"] if id.start_with?("openai/") || id.match?(/\A(?:gpt-|o\d)/)
294
+ return ["google/#{id.delete_prefix("google/")}"] if id.start_with?("google/") || id.start_with?("gemini-")
295
+
296
+ if id.start_with?("anthropic/") || id.start_with?("claude-")
297
+ raw = id.delete_prefix("anthropic/")
298
+ normalized = normalize_anthropic_model(raw)
299
+ return ["anthropic/#{raw}", "anthropic/#{normalized}"].uniq
300
+ end
301
+
302
+ []
303
+ end
304
+
305
+ def positive_integer(value)
306
+ integer = Integer(value)
307
+ integer.positive? ? integer : nil
308
+ rescue ArgumentError, TypeError
309
+ nil
310
+ end
311
+
312
+ def conservative_context_window(id)
313
+ text = id.to_s
314
+ return 128_000 if text.match?(/\A(?:gpt-|o\d)/)
315
+ return 200_000 if text.start_with?("claude-")
316
+ return 128_000 if text.start_with?("gemini-")
317
+ end
318
+
319
+ def conservative_anthropic_context_window(id)
320
+ id.to_s.strip.empty? ? nil : 200_000
321
+ end
322
+
323
+ def conservative_unknown_context_window(id)
324
+ id.to_s.strip.empty? ? nil : 128_000
325
+ end
326
+
255
327
  def openrouter_context_window(id)
256
328
  text = id.to_s
257
329
  return pattern_context_window(OPENAI_CONTEXT_WINDOWS, text.delete_prefix("openai/")) if text.start_with?("openai/")
258
330
  return anthropic_context_window(text.delete_prefix("anthropic/")) if text.start_with?("anthropic/")
259
331
  return pattern_context_window(GEMINI_CONTEXT_WINDOWS, text.delete_prefix("google/")) if text.start_with?("google/")
332
+ end
260
333
 
261
- pattern_context_window(OPENROUTER_CONTEXT_WINDOWS, text)
334
+ def conservative_openrouter_context_window(id)
335
+ text = id.to_s
336
+ return conservative_context_window(text.delete_prefix("openai/")) if text.start_with?("openai/")
337
+ return conservative_context_window(normalize_anthropic_model(text.delete_prefix("anthropic/"))) if text.start_with?("anthropic/")
338
+ return conservative_context_window(text.delete_prefix("google/")) if text.start_with?("google/")
262
339
  end
263
340
 
264
341
  def copilot_context_window(id)
@@ -304,7 +381,6 @@ module Kward
304
381
 
305
382
  def openai_reasoning_effort_choices(id)
306
383
  text = id.to_s.delete_prefix("openai/")
307
- return REASONING_EFFORT_CHOICES if text == "z-ai/glm-5.2"
308
384
  return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)
309
385
 
310
386
  OPENAI_REASONING_EFFORT_CHOICES
@@ -21,6 +21,8 @@ module Kward
21
21
 
22
22
  def request_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
23
23
  parts = build_context_parts(provider, messages, tools, model: model)
24
+ raise "OpenRouter model is not configured. Run `kward openrouter refresh` and select a cached model." if provider == "OpenRouter" && parts[:model].to_s.empty?
25
+
24
26
  payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
25
27
  payload[:reasoning] = { effort: reasoning || reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
26
28
  payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
@@ -0,0 +1,120 @@
1
+ require "digest"
2
+ require "json"
3
+ require "net/http"
4
+ require "time"
5
+ require "uri"
6
+ require_relative "config_files"
7
+ require_relative "private_file"
8
+
9
+ # Namespace for the Kward CLI agent runtime.
10
+ module Kward
11
+ # Fetches and stores the OpenRouter model list available to the configured API key.
12
+ class OpenRouterModelCache
13
+ MODELS_URL = URI("https://openrouter.ai/api/v1/models/user")
14
+ VERSION = 1
15
+
16
+ attr_reader :path
17
+
18
+ def initialize(api_key:, path: ConfigFiles.openrouter_models_cache_path)
19
+ @api_key = api_key.to_s
20
+ @path = File.expand_path(path)
21
+ end
22
+
23
+ def self.model_entry?(entry)
24
+ return false unless entry.is_a?(Hash)
25
+
26
+ architecture = entry["architecture"].is_a?(Hash) ? entry["architecture"] : {}
27
+ input_modalities = Array(architecture["input_modalities"]).map(&:to_s)
28
+ output_modalities = Array(architecture["output_modalities"]).map(&:to_s)
29
+ input_modalities.include?("text") && output_modalities.include?("text")
30
+ end
31
+
32
+ def refresh
33
+ raise "No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config." if @api_key.empty?
34
+
35
+ response = Net::HTTP.start(MODELS_URL.hostname, MODELS_URL.port, use_ssl: true) do |http|
36
+ http.request(refresh_request)
37
+ end
38
+ unless response.is_a?(Net::HTTPSuccess)
39
+ raise "OpenRouter model refresh failed: #{response.code} #{redact(response.body)}"
40
+ end
41
+
42
+ entries = model_entries(response.body)
43
+ models = entries.select { |entry| self.class.model_entry?(entry) }.map { |entry| normalize_model(entry) }.uniq { |model| model["id"] }.sort_by { |model| model["id"] }
44
+ data = cache_data(models)
45
+ PrivateFile.write_json(@path, data)
46
+ data
47
+ end
48
+
49
+ def read
50
+ return nil unless File.exist?(@path)
51
+
52
+ data = JSON.parse(File.read(@path))
53
+ return nil unless data.is_a?(Hash) && data["version"] == VERSION
54
+
55
+ data
56
+ rescue JSON::ParserError
57
+ nil
58
+ end
59
+
60
+ def models
61
+ Array(read&.fetch("models", []))
62
+ end
63
+
64
+ def matching_key?
65
+ data = read
66
+ return false unless data
67
+
68
+ data["api_key_sha256"] == api_key_sha256
69
+ end
70
+
71
+ private
72
+
73
+ def refresh_request
74
+ Net::HTTP::Get.new(MODELS_URL).tap do |request|
75
+ request["Authorization"] = "Bearer #{@api_key}"
76
+ request["Accept"] = "application/json"
77
+ end
78
+ end
79
+
80
+ def model_entries(body)
81
+ data = JSON.parse(body.to_s)
82
+ entries = data.is_a?(Hash) ? data["data"] || [] : data
83
+ Array(entries).select { |entry| entry.is_a?(Hash) }
84
+ rescue JSON::ParserError
85
+ raise "OpenRouter model refresh returned invalid JSON"
86
+ end
87
+
88
+ def normalize_model(entry)
89
+ {
90
+ "provider" => "OpenRouter",
91
+ "id" => entry.fetch("id").to_s,
92
+ "name" => entry["name"].to_s.empty? ? entry.fetch("id").to_s : entry["name"].to_s,
93
+ "contextWindow" => entry["context_length"],
94
+ "supportedParameters" => Array(entry["supported_parameters"]).map(&:to_s)
95
+ }.compact
96
+ end
97
+
98
+ def cache_data(models)
99
+ {
100
+ "version" => VERSION,
101
+ "refreshed_at" => Time.now.utc.iso8601,
102
+ "source" => MODELS_URL.to_s,
103
+ "filter" => {
104
+ "input_modalities" => ["text"],
105
+ "output_modalities" => ["text"]
106
+ },
107
+ "api_key_sha256" => api_key_sha256,
108
+ "models" => models
109
+ }
110
+ end
111
+
112
+ def api_key_sha256
113
+ Digest::SHA256.hexdigest(@api_key)
114
+ end
115
+
116
+ def redact(text)
117
+ text.to_s.gsub(@api_key, "[REDACTED]")
118
+ end
119
+ end
120
+ end