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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +68 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +30 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +43 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +39 -25
- data/doc/configuration.md +2 -16
- data/doc/context-tools.md +70 -0
- data/doc/getting-started.md +3 -1
- data/doc/plugins.md +2 -2
- data/doc/releasing.md +14 -5
- data/doc/rpc.md +3 -11
- data/doc/session-management.md +220 -0
- data/doc/usage.md +13 -7
- data/doc/workspace-tools.md +105 -0
- data/lib/kward/cli/commands.rb +8 -0
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/prompt_interface.rb +85 -7
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/sessions.rb +454 -15
- data/lib/kward/cli/settings.rb +0 -30
- data/lib/kward/cli/slash_commands.rb +38 -11
- data/lib/kward/cli.rb +14 -0
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +4 -6
- data/lib/kward/conversation.rb +49 -5
- 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 -9
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +47 -1
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +60 -87
- data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
- data/lib/kward/prompt_interface/key_handler.rb +31 -10
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +24 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +23 -2
- data/lib/kward/prompt_interface/question_prompt.rb +34 -42
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +10 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +518 -61
- data/lib/kward/prompt_interface/slash_overlay.rb +4 -4
- data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +31 -32
- data/lib/kward/prompts/commands.rb +6 -3
- data/lib/kward/prompts.rb +2 -2
- data/lib/kward/rpc/server.rb +3 -8
- data/lib/kward/rpc/session_manager.rb +19 -8
- data/lib/kward/session_diff.rb +106 -9
- data/lib/kward/session_store.rb +23 -4
- data/lib/kward/session_tree_renderer.rb +2 -1
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/registry.rb +37 -6
- 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 +2 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +58 -2
- data/templates/default/fulldoc/html/css/kward.css +570 -78
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +259 -97
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +91 -0
- data/templates/default/layout/html/layout.erb +59 -13
- data/templates/default/layout/html/setup.rb +34 -39
- metadata +13 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- 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) }
|
data/lib/kward/compactor.rb
CHANGED
|
@@ -665,7 +665,10 @@ module Kward
|
|
|
665
665
|
## Critical Context
|
|
666
666
|
- [Preserve important context, add new context needed to continue]
|
|
667
667
|
|
|
668
|
-
|
|
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
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -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"] : {}
|
data/lib/kward/conversation.rb
CHANGED
|
@@ -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
|
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,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")
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
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?
|