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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +89 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +34 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +52 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +58 -23
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +102 -13
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +83 -0
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +74 -3
- data/doc/releasing.md +45 -8
- data/doc/rpc.md +77 -15
- data/doc/session-management.md +254 -0
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +60 -15
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +144 -0
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +41 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +111 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +262 -13
- data/lib/kward/cli/settings.rb +216 -37
- data/lib/kward/cli/slash_commands.rb +439 -8
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +171 -26
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +125 -5
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +59 -22
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +108 -1
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +124 -83
- data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +416 -43
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +122 -82
- data/lib/kward/prompt_interface/runtime_state.rb +49 -1
- data/lib/kward/prompt_interface/screen.rb +17 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +307 -35
- data/lib/kward/prompts/commands.rb +7 -1
- data/lib/kward/prompts.rb +4 -2
- data/lib/kward/rpc/server.rb +45 -11
- data/lib/kward/rpc/session_manager.rb +52 -53
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +67 -4
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +92 -15
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +12 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +154 -12
- data/templates/default/fulldoc/html/css/kward.css +362 -42
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +102 -0
- data/templates/default/layout/html/layout.erb +43 -10
- data/templates/default/layout/html/setup.rb +39 -38
- metadata +65 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/model/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
195
|
+
model_entry("Codex", id, current: provider == "Codex" && openai_model == id)
|
|
193
196
|
end
|
|
194
|
-
models <<
|
|
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 =
|
|
202
|
+
openrouter_choices = openrouter_model_choices
|
|
200
203
|
models += openrouter_choices.map do |id|
|
|
201
|
-
|
|
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
|
-
|
|
212
|
+
model_entry("Copilot", id, current: provider == "Copilot" && copilot_model == id)
|
|
211
213
|
end
|
|
212
|
-
models <<
|
|
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
|
-
|
|
220
|
+
model_entry("Anthropic", id, current: provider == "Anthropic" && anthropic_model == id)
|
|
219
221
|
end
|
|
220
|
-
models <<
|
|
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
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
504
|
+
def openrouter_model_choices
|
|
505
|
+
openrouter_cached_models.uniq
|
|
506
|
+
end
|
|
513
507
|
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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
|
|
528
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -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
|