kward 0.69.1 → 0.71.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +68 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +30 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +43 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +39 -25
  11. data/doc/configuration.md +2 -16
  12. data/doc/context-tools.md +70 -0
  13. data/doc/getting-started.md +3 -1
  14. data/doc/plugins.md +2 -2
  15. data/doc/releasing.md +14 -5
  16. data/doc/rpc.md +3 -11
  17. data/doc/session-management.md +220 -0
  18. data/doc/usage.md +13 -7
  19. data/doc/workspace-tools.md +105 -0
  20. data/lib/kward/cli/commands.rb +8 -0
  21. data/lib/kward/cli/openrouter_commands.rb +55 -0
  22. data/lib/kward/cli/prompt_interface.rb +85 -7
  23. data/lib/kward/cli/rendering.rb +11 -6
  24. data/lib/kward/cli/sessions.rb +454 -15
  25. data/lib/kward/cli/settings.rb +0 -30
  26. data/lib/kward/cli/slash_commands.rb +38 -11
  27. data/lib/kward/cli.rb +14 -0
  28. data/lib/kward/compactor.rb +4 -1
  29. data/lib/kward/config_files.rb +4 -6
  30. data/lib/kward/conversation.rb +49 -5
  31. data/lib/kward/model/client.rb +37 -50
  32. data/lib/kward/model/context_usage.rb +13 -6
  33. data/lib/kward/model/model_info.rb +92 -9
  34. data/lib/kward/model/payloads.rb +2 -0
  35. data/lib/kward/openrouter_model_cache.rb +120 -0
  36. data/lib/kward/plugin_registry.rb +47 -1
  37. data/lib/kward/prompt_interface/banner.rb +16 -51
  38. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  39. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  40. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  41. data/lib/kward/prompt_interface/layout.rb +2 -2
  42. data/lib/kward/prompt_interface/overlay_renderer.rb +24 -0
  43. data/lib/kward/prompt_interface/prompt_renderer.rb +23 -2
  44. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  45. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  46. data/lib/kward/prompt_interface/screen.rb +10 -4
  47. data/lib/kward/prompt_interface/selection_prompt.rb +518 -61
  48. data/lib/kward/prompt_interface/slash_overlay.rb +4 -4
  49. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  50. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  51. data/lib/kward/prompt_interface.rb +31 -32
  52. data/lib/kward/prompts/commands.rb +6 -3
  53. data/lib/kward/prompts.rb +2 -2
  54. data/lib/kward/rpc/server.rb +3 -8
  55. data/lib/kward/rpc/session_manager.rb +19 -8
  56. data/lib/kward/session_diff.rb +106 -9
  57. data/lib/kward/session_store.rb +23 -4
  58. data/lib/kward/session_tree_renderer.rb +2 -1
  59. data/lib/kward/telemetry/logger.rb +5 -3
  60. data/lib/kward/tool_output_compactor.rb +127 -0
  61. data/lib/kward/tools/base.rb +8 -2
  62. data/lib/kward/tools/registry.rb +37 -6
  63. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  64. data/lib/kward/tools/search/web.rb +2 -2
  65. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  66. data/lib/kward/tools/tool_call.rb +2 -0
  67. data/lib/kward/version.rb +1 -1
  68. data/lib/kward/workspace.rb +58 -2
  69. data/templates/default/fulldoc/html/css/kward.css +570 -78
  70. data/templates/default/fulldoc/html/full_list.erb +107 -0
  71. data/templates/default/fulldoc/html/js/kward.js +259 -97
  72. data/templates/default/fulldoc/html/setup.rb +8 -0
  73. data/templates/default/kward_navigation.rb +91 -0
  74. data/templates/default/layout/html/layout.erb +59 -13
  75. data/templates/default/layout/html/setup.rb +34 -39
  76. metadata +13 -3
  77. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  78. data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/cli.rb CHANGED
@@ -29,6 +29,7 @@ require_relative "model/retry_message"
29
29
  require_relative "rpc/server"
30
30
  require_relative "session_diff"
31
31
  require_relative "session_store"
32
+ require_relative "session_trash"
32
33
  require_relative "session_tree_renderer"
33
34
  require_relative "starter_pack_installer"
34
35
  require_relative "steering"
@@ -41,6 +42,7 @@ require_relative "cli/auth_commands"
41
42
  require_relative "cli/doctor"
42
43
  require_relative "cli/sysprompt"
43
44
  require_relative "cli/stats"
45
+ require_relative "cli/openrouter_commands"
44
46
  require_relative "cli/runtime_helpers"
45
47
  require_relative "cli/slash_commands"
46
48
  require_relative "cli/memory_commands"
@@ -72,6 +74,7 @@ module Kward
72
74
  include CLI::Doctor
73
75
  include CLI::Sysprompt
74
76
  include CLI::Stats
77
+ include CLI::OpenRouterCommands
75
78
  include CLI::RuntimeHelpers
76
79
  include CLI::SlashCommands
77
80
  include CLI::MemoryCommands
@@ -193,6 +196,16 @@ module Kward
193
196
  return
194
197
  end
195
198
 
199
+ if @argv.first == "openrouter"
200
+ if help_option_arguments?(@argv[1..] || [])
201
+ print_command_help("openrouter")
202
+ return
203
+ end
204
+
205
+ handle_openrouter_command(@argv[1..] || [])
206
+ return
207
+ end
208
+
196
209
  if pan_mode?
197
210
  if help_option_arguments?(@argv[1..] || [])
198
211
  print_command_help("pan")
@@ -311,6 +324,7 @@ module Kward
311
324
  input = expanded_input || input
312
325
  @footer_conversation = agent.conversation
313
326
  begin
327
+ @rewind_return_leaf_id = nil
314
328
  auto_name_active_session(display_input || input)
315
329
  pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
316
330
  pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
@@ -665,7 +665,10 @@ module Kward
665
665
  ## Critical Context
666
666
  - [Preserve important context, add new context needed to continue]
667
667
 
668
- Keep each section concise. Preserve exact file paths, class names, module names, method names, constants, commands, spec names, migration names, error messages, user requirements, and unresolved problems. Do not invent work that did not happen.
668
+ ## Available Tool Artifacts
669
+ - [Preserve any toolout_* ids from compacted tool outputs, with what each id contains and why it may matter]
670
+
671
+ Keep each section concise. Preserve exact file paths, class names, module names, method names, constants, commands, spec names, migration names, error messages, toolout_* artifact ids, user requirements, and unresolved problems. Do not invent work that did not happen.
669
672
  PROMPT
670
673
 
671
674
  SPLIT_TURN_PROMPT = <<~PROMPT.strip.freeze
@@ -97,6 +97,10 @@ module Kward
97
97
  File.join(cache_dir, "code_search")
98
98
  end
99
99
 
100
+ def openrouter_models_cache_path
101
+ File.join(cache_dir, "openrouter_models.json")
102
+ end
103
+
100
104
  # @return [String] directory containing structured memory files
101
105
  def memory_dir
102
106
  File.join(config_dir, "memory")
@@ -189,12 +193,6 @@ module Kward
189
193
  composer["busy_help"] != false
190
194
  end
191
195
 
192
- # Returns whether the terminal startup banner should be displayed.
193
- def banner_enabled?(config = read_config)
194
- banner = config["banner"].is_a?(Hash) ? config["banner"] : {}
195
- banner["enabled"] != false
196
- end
197
-
198
196
  # Returns whether file tools must stay inside the active workspace root.
199
197
  def workspace_guardrails_enabled?(config = read_config)
200
198
  tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
@@ -1,3 +1,4 @@
1
+ require "digest"
1
2
  require "set"
2
3
  require_relative "image_attachments"
3
4
  require_relative "message_access"
@@ -57,6 +58,8 @@ module Kward
57
58
  attr_accessor :plugin_registry
58
59
  # @return [String, nil] plugin prompt context used in the current system prompt
59
60
  attr_reader :last_plugin_prompt_context
61
+ # @return [Hash] original large tool outputs retained outside model context
62
+ attr_reader :tool_output_artifacts
60
63
 
61
64
  def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
62
65
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
@@ -71,13 +74,13 @@ module Kward
71
74
  system_message = restored_system_message
72
75
  else
73
76
  @last_plugin_prompt_context = plugin_prompt_context
74
- system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
77
+ system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
75
78
  end
76
79
  end
77
80
  @system_message = system_message
78
81
  @system_message_enabled = !@system_message.nil?
79
82
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
80
- compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort) : nil
83
+ compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time) : nil
81
84
  end
82
85
  @compaction_system_message = compaction_system_message
83
86
  @workspace_agents_mtime = workspace_agents_mtime
@@ -85,6 +88,7 @@ module Kward
85
88
  @memory_context = memory_context
86
89
  @session_memories = Array(session_memories)
87
90
  @last_memory_retrieval = last_memory_retrieval
91
+ @tool_output_artifacts = {}
88
92
  @messages.concat(transcript_messages)
89
93
  @read_paths = Set.new(read_paths)
90
94
  @on_append = on_append
@@ -115,14 +119,50 @@ module Kward
115
119
  role: "tool",
116
120
  tool_call_id: tool_call_id,
117
121
  name: name,
118
- content: content
122
+ content: self.class.normalize_tool_content(content)
119
123
  })
120
124
  end
121
125
 
126
+ # Tool results may arrive as ASCII-8BIT (BINARY) strings, e.g. from
127
+ # Net::HTTP response bodies or shell command output. When such a string
128
+ # is later concatenated with a UTF-8 string containing non-ASCII bytes
129
+ # (during compaction or JSON serialization), Ruby raises
130
+ # Encoding::CompatibilityError. Re-tag BINARY strings as UTF-8 when the
131
+ # bytes are valid UTF-8; otherwise scrub so the content is always
132
+ # serializable and concatenable.
133
+ def self.normalize_tool_content(content)
134
+ return content unless content.is_a?(String) && content.encoding == Encoding::ASCII_8BIT
135
+
136
+ probe = content.dup.force_encoding(Encoding::UTF_8)
137
+ probe.valid_encoding? ? probe : probe.scrub
138
+ end
139
+
122
140
  def append_tool_execution(tool_call:, content:)
123
141
  @on_tool_execution&.call(tool_call, content)
124
142
  end
125
143
 
144
+ def tool_output_artifact_id_for(tool_name:, content:)
145
+ self.class.tool_output_artifact_id(tool_name: tool_name, content: self.class.normalize_tool_content(content))
146
+ end
147
+
148
+ def store_tool_output_artifact(tool_name:, content:)
149
+ text = self.class.normalize_tool_content(content)
150
+ id = tool_output_artifact_id_for(tool_name: tool_name, content: text)
151
+ @tool_output_artifacts[id] = {
152
+ id: id,
153
+ tool_name: tool_name,
154
+ content: text,
155
+ bytes: text.bytesize,
156
+ created_at: Time.now.utc
157
+ }
158
+ id
159
+ end
160
+
161
+ def self.tool_output_artifact_id(tool_name:, content:)
162
+ digest = Digest::SHA256.hexdigest("#{tool_name}\0#{content}")[0, 16]
163
+ "toolout_#{digest}"
164
+ end
165
+
126
166
  # @return [Array<Hash>] provider request context: current system prompt plus durable transcript
127
167
  def context_messages
128
168
  @system_message ? [@system_message] + @messages : @messages.dup
@@ -138,10 +178,10 @@ module Kward
138
178
  return nil unless @system_message_enabled
139
179
 
140
180
  @last_plugin_prompt_context = plugin_prompt_context
141
- replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
181
+ replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
142
182
  @system_message = replacement
143
183
  @on_system_message_change&.call(replacement)
144
- @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort)
184
+ @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
145
185
  @workspace_agents_mtime = workspace_agents_mtime
146
186
  replacement
147
187
  end
@@ -229,6 +269,10 @@ module Kward
229
269
  [system_message, transcript_messages]
230
270
  end
231
271
 
272
+ def prompt_time
273
+ Time.at(0)
274
+ end
275
+
232
276
  def workspace_agents_mtime
233
277
  path = File.join(@workspace_root, "AGENTS.md")
234
278
  File.exist?(path) ? File.mtime(path) : nil
@@ -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,12 +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 = OPENAI_MODEL_CHOICES.map { |model| "openai/#{model}" }.freeze
14
12
  ANTHROPIC_MODEL_CHOICES = %w[
15
13
  claude-opus-4-8
16
14
  claude-sonnet-4-6
@@ -123,7 +121,7 @@ module Kward
123
121
 
124
122
  case provider
125
123
  when "OpenRouter"
126
- env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model") || DEFAULT_OPENROUTER_MODEL
124
+ env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model")
127
125
  when "Copilot"
128
126
  normalize_copilot_model(env["COPILOT_MODEL"] || ConfigFiles.config_value(config, "copilot_model", "model") || DEFAULT_COPILOT_MODEL)
129
127
  when "Anthropic"
@@ -228,16 +226,27 @@ module Kward
228
226
  }
229
227
  end
230
228
 
231
- def context_window(provider, id)
229
+ def context_window(provider, id, openrouter_models: nil)
232
230
  case provider
233
231
  when "Codex"
234
- 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)
235
235
  when "OpenRouter"
236
- 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)
237
240
  when "Copilot"
238
- 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)
239
245
  when "Anthropic"
240
- 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)
241
250
  end
242
251
  end
243
252
 
@@ -246,13 +255,87 @@ module Kward
246
255
  match&.last
247
256
  end
248
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
+
249
327
  def openrouter_context_window(id)
250
328
  text = id.to_s
251
329
  return pattern_context_window(OPENAI_CONTEXT_WINDOWS, text.delete_prefix("openai/")) if text.start_with?("openai/")
252
330
  return anthropic_context_window(text.delete_prefix("anthropic/")) if text.start_with?("anthropic/")
253
331
  return pattern_context_window(GEMINI_CONTEXT_WINDOWS, text.delete_prefix("google/")) if text.start_with?("google/")
332
+ end
254
333
 
255
- nil
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/")
256
339
  end
257
340
 
258
341
  def copilot_context_window(id)
@@ -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?