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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +48 -2
- 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 +1 -15
- data/doc/context-tools.md +70 -0
- 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 +7 -8
- 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 +80 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/sessions.rb +260 -11
- data/lib/kward/cli/settings.rb +0 -30
- data/lib/kward/cli/slash_commands.rb +24 -6
- data/lib/kward/cli.rb +13 -0
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +4 -6
- data/lib/kward/conversation.rb +49 -20
- 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 +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/prompt_renderer.rb +32 -13
- 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 +1 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
- 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 +22 -28
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/prompts.rb +2 -2
- data/lib/kward/rpc/server.rb +3 -8
- data/lib/kward/rpc/session_manager.rb +17 -6
- data/lib/kward/session_store.rb +23 -4
- 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 +256 -7
- 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 +91 -0
- data/templates/default/layout/html/layout.erb +39 -8
- data/templates/default/layout/html/setup.rb +33 -38
- metadata +13 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- 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
|
-
|
|
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")
|
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
|
|
@@ -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
|
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?
|