kward 0.70.0 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +48 -2
  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 +1 -15
  12. data/doc/context-tools.md +70 -0
  13. data/doc/plugins.md +2 -2
  14. data/doc/releasing.md +14 -5
  15. data/doc/rpc.md +3 -11
  16. data/doc/session-management.md +220 -0
  17. data/doc/usage.md +7 -8
  18. data/doc/workspace-tools.md +105 -0
  19. data/lib/kward/cli/commands.rb +8 -0
  20. data/lib/kward/cli/openrouter_commands.rb +55 -0
  21. data/lib/kward/cli/prompt_interface.rb +80 -6
  22. data/lib/kward/cli/rendering.rb +11 -6
  23. data/lib/kward/cli/sessions.rb +260 -11
  24. data/lib/kward/cli/settings.rb +0 -30
  25. data/lib/kward/cli/slash_commands.rb +24 -6
  26. data/lib/kward/cli.rb +13 -0
  27. data/lib/kward/compactor.rb +4 -1
  28. data/lib/kward/config_files.rb +4 -6
  29. data/lib/kward/conversation.rb +49 -20
  30. data/lib/kward/model/client.rb +37 -50
  31. data/lib/kward/model/context_usage.rb +13 -6
  32. data/lib/kward/model/model_info.rb +92 -16
  33. data/lib/kward/model/payloads.rb +2 -0
  34. data/lib/kward/openrouter_model_cache.rb +120 -0
  35. data/lib/kward/plugin_registry.rb +47 -1
  36. data/lib/kward/prompt_interface/banner.rb +16 -51
  37. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  38. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  39. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  40. data/lib/kward/prompt_interface/layout.rb +2 -2
  41. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  42. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  43. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  44. data/lib/kward/prompt_interface/screen.rb +1 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
  46. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  47. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  48. data/lib/kward/prompt_interface.rb +22 -28
  49. data/lib/kward/prompts/commands.rb +2 -1
  50. data/lib/kward/prompts.rb +2 -2
  51. data/lib/kward/rpc/server.rb +3 -8
  52. data/lib/kward/rpc/session_manager.rb +17 -6
  53. data/lib/kward/session_store.rb +23 -4
  54. data/lib/kward/telemetry/logger.rb +5 -3
  55. data/lib/kward/tool_output_compactor.rb +127 -0
  56. data/lib/kward/tools/base.rb +8 -2
  57. data/lib/kward/tools/registry.rb +37 -6
  58. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  59. data/lib/kward/tools/search/web.rb +2 -2
  60. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  61. data/lib/kward/tools/tool_call.rb +2 -0
  62. data/lib/kward/version.rb +1 -1
  63. data/lib/kward/workspace.rb +58 -2
  64. data/templates/default/fulldoc/html/css/kward.css +256 -7
  65. data/templates/default/fulldoc/html/full_list.erb +107 -0
  66. data/templates/default/fulldoc/html/js/kward.js +161 -2
  67. data/templates/default/fulldoc/html/setup.rb +8 -0
  68. data/templates/default/kward_navigation.rb +91 -0
  69. data/templates/default/layout/html/layout.erb +39 -8
  70. data/templates/default/layout/html/setup.rb +33 -38
  71. metadata +13 -3
  72. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  73. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -32,9 +32,6 @@ module Kward
32
32
  models = run_busy_local_command_and_requeue { normalized_available_models }
33
33
  configure_model(agent.conversation, models: models)
34
34
  [true, nil]
35
- when "openrouter/catalog"
36
- run_busy_local_command_and_requeue { print_openrouter_catalog }
37
- [true, nil]
38
35
  when "reasoning"
39
36
  configure_reasoning(agent.conversation)
40
37
  [true, nil]
@@ -52,15 +49,36 @@ module Kward
52
49
  path = argument.to_s.strip
53
50
  if path.empty?
54
51
  sessions = run_busy_local_command_and_requeue { session_store.recent_tree(limit: nil) }
55
- path = select_session_path_from_sessions(sessions)
52
+ path = select_session_path_from_sessions(sessions, session_store: session_store)
53
+ end
54
+ replacement_agent = nil
55
+ selection = path
56
+ loop do
57
+ replacement_agent = if selection.respond_to?(:conversation)
58
+ selection
59
+ elsif selection.is_a?(Hash) && selection[:action] == :clone
60
+ run_busy_local_command_and_requeue(activity: "cloning") { clone_session_from_path(session_store, selection[:path]) }
61
+ elsif selection.is_a?(Hash) && selection[:action] == :fork
62
+ selection = reopen_sessions_after_fork(session_store, selection[:path], selection[:choice_label])
63
+ next
64
+ elsif selection.to_s.empty?
65
+ nil
66
+ else
67
+ run_busy_local_command_and_requeue { resume_session(session_store, selection) }
68
+ end
69
+ break
56
70
  end
57
- replacement_agent = path.to_s.empty? ? nil : run_busy_local_command_and_requeue { resume_session(session_store, path) }
58
71
  [true, replacement_agent]
59
72
  when "name"
60
- run_busy_local_command_and_requeue { rename_session(argument) }
73
+ rename_session(argument)
74
+ [true, nil]
75
+ when "rename"
76
+ rename_session(argument, require_name: true)
61
77
  [true, nil]
62
78
  when "clone"
63
79
  [true, run_busy_local_command_and_requeue { clone_session(session_store, agent) }]
80
+ when "fork"
81
+ [true, fork_session(session_store)]
64
82
  when "rewind"
65
83
  [true, run_busy_local_command_and_requeue { rewind_session(session_store) }]
66
84
  when "tree"
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")
@@ -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
@@ -111,19 +115,54 @@ module Kward
111
115
  end
112
116
 
113
117
  def append_tool(tool_call_id:, name:, content:)
114
- content = normalize_tool_content(content) if content.is_a?(String)
115
118
  append_message({
116
119
  role: "tool",
117
120
  tool_call_id: tool_call_id,
118
121
  name: name,
119
- content: content
122
+ content: self.class.normalize_tool_content(content)
120
123
  })
121
124
  end
122
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
+
123
140
  def append_tool_execution(tool_call:, content:)
124
141
  @on_tool_execution&.call(tool_call, content)
125
142
  end
126
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
+
127
166
  # @return [Array<Hash>] provider request context: current system prompt plus durable transcript
128
167
  def context_messages
129
168
  @system_message ? [@system_message] + @messages : @messages.dup
@@ -139,10 +178,10 @@ module Kward
139
178
  return nil unless @system_message_enabled
140
179
 
141
180
  @last_plugin_prompt_context = plugin_prompt_context
142
- 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)
143
182
  @system_message = replacement
144
183
  @on_system_message_change&.call(replacement)
145
- @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)
146
185
  @workspace_agents_mtime = workspace_agents_mtime
147
186
  replacement
148
187
  end
@@ -230,6 +269,10 @@ module Kward
230
269
  [system_message, transcript_messages]
231
270
  end
232
271
 
272
+ def prompt_time
273
+ Time.at(0)
274
+ end
275
+
233
276
  def workspace_agents_mtime
234
277
  path = File.join(@workspace_root, "AGENTS.md")
235
278
  File.exist?(path) ? File.mtime(path) : nil
@@ -242,19 +285,5 @@ module Kward
242
285
  message
243
286
  end
244
287
 
245
- # Tool results may arrive as ASCII-8BIT (BINARY) strings, e.g. from
246
- # Net::HTTP response bodies or shell command output. When such a string
247
- # is later concatenated with a UTF-8 string containing non-ASCII bytes
248
- # (during compaction or JSON serialization), Ruby raises
249
- # Encoding::CompatibilityError. Re-tag BINARY strings as UTF-8 when the
250
- # bytes are valid UTF-8; otherwise scrub so the content is always
251
- # serializable and concatenable.
252
- def normalize_tool_content(string)
253
- return string unless string.encoding == Encoding::ASCII_8BIT
254
-
255
- probe = string.dup.force_encoding(Encoding::UTF_8)
256
- probe.valid_encoding? ? probe : probe.scrub
257
- end
258
-
259
288
  end
260
289
  end
@@ -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?