kward 0.67.0 → 0.68.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/CHANGELOG.md +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +37 -10
- data/lib/kward/rpc/session_manager.rb +123 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +125 -31
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- metadata +43 -1
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
require_relative "../config_files"
|
|
2
2
|
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
4
|
module Kward
|
|
5
|
+
# Static and configured model metadata helpers.
|
|
4
6
|
module ModelInfo
|
|
5
7
|
DEFAULT_OPENAI_MODEL = "gpt-5.5"
|
|
6
8
|
DEFAULT_OPENROUTER_MODEL = "openai/gpt-5.5"
|
|
7
9
|
DEFAULT_COPILOT_MODEL = "gpt-5-mini"
|
|
10
|
+
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"
|
|
8
11
|
DEFAULT_REASONING_EFFORT = "medium"
|
|
9
12
|
OPENAI_MODEL_CHOICES = %w[gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
|
|
10
13
|
OPENROUTER_MODEL_CHOICES = OPENAI_MODEL_CHOICES.map { |model| "openai/#{model}" }.freeze
|
|
14
|
+
ANTHROPIC_MODEL_CHOICES = %w[
|
|
15
|
+
claude-opus-4-8
|
|
16
|
+
claude-sonnet-4-6
|
|
17
|
+
claude-haiku-4-5
|
|
18
|
+
claude-opus-4-7
|
|
19
|
+
claude-opus-4-6
|
|
20
|
+
claude-opus-4-5
|
|
21
|
+
claude-sonnet-4-5
|
|
22
|
+
].freeze
|
|
11
23
|
COPILOT_MODEL_CHOICES = %w[
|
|
12
24
|
gpt-5-mini
|
|
13
25
|
gpt-5.3-codex
|
|
@@ -32,13 +44,38 @@ module Kward
|
|
|
32
44
|
["high", "High"],
|
|
33
45
|
["xhigh", "Extra High"]
|
|
34
46
|
].freeze
|
|
47
|
+
OPENAI_REASONING_EFFORT_CHOICES = [
|
|
48
|
+
["none", "None"],
|
|
49
|
+
*REASONING_EFFORT_CHOICES
|
|
50
|
+
].freeze
|
|
51
|
+
ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES = [
|
|
52
|
+
["low", "Low"],
|
|
53
|
+
["medium", "Medium"],
|
|
54
|
+
["high", "High"],
|
|
55
|
+
["xhigh", "Extra High"],
|
|
56
|
+
["max", "Max"]
|
|
57
|
+
].freeze
|
|
58
|
+
ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES = [
|
|
59
|
+
["low", "Low"],
|
|
60
|
+
["medium", "Medium"],
|
|
61
|
+
["high", "High"],
|
|
62
|
+
["max", "Max"]
|
|
63
|
+
].freeze
|
|
64
|
+
ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES = [
|
|
65
|
+
["low", "Low"],
|
|
66
|
+
["medium", "Medium"],
|
|
67
|
+
["high", "High"]
|
|
68
|
+
].freeze
|
|
35
69
|
|
|
36
70
|
IMAGE_UNSUPPORTED_MODELS = [
|
|
37
71
|
/(?:\A|\/)gpt-5\.3-codex-spark\z/
|
|
38
72
|
].freeze
|
|
39
73
|
|
|
40
74
|
OPENAI_CONTEXT_WINDOWS = [
|
|
41
|
-
[/\Agpt-5\.5/,
|
|
75
|
+
[/\Agpt-5\.5/, 1_050_000],
|
|
76
|
+
[/\Agpt-5\.4-mini/, 400_000],
|
|
77
|
+
[/\Agpt-5\.4/, 1_050_000],
|
|
78
|
+
[/\Agpt-5-mini/, 400_000],
|
|
42
79
|
[/\Agpt-5-codex/, 400_000],
|
|
43
80
|
[/\Agpt-5\.3-codex-spark/, 128_000],
|
|
44
81
|
[/\Agpt-5\.3-codex/, 400_000],
|
|
@@ -51,6 +88,16 @@ module Kward
|
|
|
51
88
|
[/\Agpt-4/, 128_000],
|
|
52
89
|
[/\Agpt-3\.5-turbo/, 16_385]
|
|
53
90
|
].freeze
|
|
91
|
+
ANTHROPIC_CONTEXT_WINDOWS = [
|
|
92
|
+
[/\Aclaude-(?:fable|mythos)-5(?:\z|-)/, 1_000_000],
|
|
93
|
+
[/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
|
|
94
|
+
[/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
|
|
95
|
+
[/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
|
|
96
|
+
[/\Aclaude-(?:haiku|opus|sonnet)-4(?:\z|-)/, 200_000]
|
|
97
|
+
].freeze
|
|
98
|
+
GEMINI_CONTEXT_WINDOWS = [
|
|
99
|
+
[/\Agemini-(?:2\.5-pro|3(?:\.1)?-pro|3(?:\.5)?-flash)/, 1_048_576]
|
|
100
|
+
].freeze
|
|
54
101
|
|
|
55
102
|
module_function
|
|
56
103
|
|
|
@@ -62,11 +109,28 @@ module Kward
|
|
|
62
109
|
env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model") || DEFAULT_OPENROUTER_MODEL
|
|
63
110
|
when "Copilot"
|
|
64
111
|
normalize_copilot_model(env["COPILOT_MODEL"] || ConfigFiles.config_value(config, "copilot_model", "model") || DEFAULT_COPILOT_MODEL)
|
|
112
|
+
when "Anthropic"
|
|
113
|
+
normalize_anthropic_model(env["ANTHROPIC_MODEL"] || ConfigFiles.config_value(config, "anthropic_model", "model") || DEFAULT_ANTHROPIC_MODEL)
|
|
65
114
|
else
|
|
66
115
|
env["OPENAI_MODEL"] || ConfigFiles.config_value(config, "openai_model", "model") || DEFAULT_OPENAI_MODEL
|
|
67
116
|
end
|
|
68
117
|
end
|
|
69
118
|
|
|
119
|
+
def normalize_anthropic_model(id)
|
|
120
|
+
text = id.to_s.strip
|
|
121
|
+
return DEFAULT_ANTHROPIC_MODEL if text.empty?
|
|
122
|
+
text = text.delete_prefix("anthropic/").delete_prefix("claude/")
|
|
123
|
+
{
|
|
124
|
+
"claude-sonnet-4.6" => "claude-sonnet-4-6",
|
|
125
|
+
"claude-sonnet-4.5" => "claude-sonnet-4-5",
|
|
126
|
+
"claude-opus-4.8" => "claude-opus-4-8",
|
|
127
|
+
"claude-opus-4.7" => "claude-opus-4-7",
|
|
128
|
+
"claude-opus-4.6" => "claude-opus-4-6",
|
|
129
|
+
"claude-opus-4.5" => "claude-opus-4-5",
|
|
130
|
+
"claude-haiku-4.5" => "claude-haiku-4-5"
|
|
131
|
+
}.fetch(text, text)
|
|
132
|
+
end
|
|
133
|
+
|
|
70
134
|
def normalize_copilot_model(id)
|
|
71
135
|
text = id.to_s.strip
|
|
72
136
|
return DEFAULT_COPILOT_MODEL if text.empty?
|
|
@@ -92,6 +156,8 @@ module Kward
|
|
|
92
156
|
env["OPENROUTER_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openrouter_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
|
|
93
157
|
when "Copilot"
|
|
94
158
|
env["COPILOT_REASONING_EFFORT"] || ConfigFiles.config_value(config, "copilot_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
|
|
159
|
+
when "Anthropic"
|
|
160
|
+
env["ANTHROPIC_REASONING_EFFORT"] || ConfigFiles.config_value(config, "anthropic_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
|
|
95
161
|
else
|
|
96
162
|
env["OPENAI_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openai_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
|
|
97
163
|
end
|
|
@@ -101,6 +167,7 @@ module Kward
|
|
|
101
167
|
case provider.to_s.downcase
|
|
102
168
|
when "openrouter" then "OpenRouter"
|
|
103
169
|
when "copilot" then "Copilot"
|
|
170
|
+
when "anthropic", "claude" then "Anthropic"
|
|
104
171
|
when "codex", "openai" then "Codex"
|
|
105
172
|
else provider.to_s
|
|
106
173
|
end
|
|
@@ -110,6 +177,7 @@ module Kward
|
|
|
110
177
|
case provider.to_s.downcase
|
|
111
178
|
when "openrouter" then "openrouter"
|
|
112
179
|
when "copilot" then "copilot"
|
|
180
|
+
when "anthropic", "claude" then "anthropic"
|
|
113
181
|
else "codex"
|
|
114
182
|
end
|
|
115
183
|
end
|
|
@@ -118,6 +186,7 @@ module Kward
|
|
|
118
186
|
case provider.to_s.downcase
|
|
119
187
|
when "openrouter" then "openrouter_model"
|
|
120
188
|
when "copilot" then "copilot_model"
|
|
189
|
+
when "anthropic", "claude" then "anthropic_model"
|
|
121
190
|
else "openai_model"
|
|
122
191
|
end
|
|
123
192
|
end
|
|
@@ -126,6 +195,7 @@ module Kward
|
|
|
126
195
|
case provider.to_s.downcase
|
|
127
196
|
when "openrouter" then "openrouter_reasoning_effort"
|
|
128
197
|
when "copilot" then "copilot_reasoning_effort"
|
|
198
|
+
when "anthropic", "claude" then "anthropic_reasoning_effort"
|
|
129
199
|
else "openai_reasoning_effort"
|
|
130
200
|
end
|
|
131
201
|
end
|
|
@@ -142,18 +212,87 @@ module Kward
|
|
|
142
212
|
end
|
|
143
213
|
|
|
144
214
|
def context_window(provider, id)
|
|
145
|
-
|
|
215
|
+
case provider
|
|
216
|
+
when "Codex"
|
|
217
|
+
pattern_context_window(OPENAI_CONTEXT_WINDOWS, id)
|
|
218
|
+
when "OpenRouter"
|
|
219
|
+
openrouter_context_window(id)
|
|
220
|
+
when "Copilot"
|
|
221
|
+
copilot_context_window(id)
|
|
222
|
+
when "Anthropic"
|
|
223
|
+
anthropic_context_window(id)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
146
226
|
|
|
147
|
-
|
|
227
|
+
def pattern_context_window(patterns, id)
|
|
228
|
+
match = patterns.find { |pattern, _window| id.to_s.match?(pattern) }
|
|
148
229
|
match&.last
|
|
149
230
|
end
|
|
150
231
|
|
|
232
|
+
def openrouter_context_window(id)
|
|
233
|
+
text = id.to_s
|
|
234
|
+
return pattern_context_window(OPENAI_CONTEXT_WINDOWS, text.delete_prefix("openai/")) if text.start_with?("openai/")
|
|
235
|
+
return anthropic_context_window(text.delete_prefix("anthropic/")) if text.start_with?("anthropic/")
|
|
236
|
+
return pattern_context_window(GEMINI_CONTEXT_WINDOWS, text.delete_prefix("google/")) if text.start_with?("google/")
|
|
237
|
+
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def copilot_context_window(id)
|
|
242
|
+
text = id.to_s
|
|
243
|
+
pattern_context_window(OPENAI_CONTEXT_WINDOWS, text) ||
|
|
244
|
+
anthropic_context_window(normalize_anthropic_model(text)) ||
|
|
245
|
+
pattern_context_window(GEMINI_CONTEXT_WINDOWS, text)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def anthropic_context_window(id)
|
|
249
|
+
pattern_context_window(ANTHROPIC_CONTEXT_WINDOWS, normalize_anthropic_model(id))
|
|
250
|
+
end
|
|
251
|
+
|
|
151
252
|
def supports_images?(_provider, id)
|
|
152
253
|
IMAGE_UNSUPPORTED_MODELS.none? { |pattern| id.to_s.match?(pattern) }
|
|
153
254
|
end
|
|
154
255
|
|
|
155
256
|
def reasoning_supported?(provider, id)
|
|
156
|
-
|
|
257
|
+
case provider
|
|
258
|
+
when "Codex", "OpenRouter"
|
|
259
|
+
true
|
|
260
|
+
when "Anthropic"
|
|
261
|
+
!reasoning_effort_choices(provider, id).empty?
|
|
262
|
+
when "Copilot"
|
|
263
|
+
id.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
|
|
264
|
+
else
|
|
265
|
+
false
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def reasoning_effort_choices(provider, id)
|
|
270
|
+
case provider
|
|
271
|
+
when "Codex", "OpenRouter"
|
|
272
|
+
openai_reasoning_effort_choices(id)
|
|
273
|
+
when "Anthropic"
|
|
274
|
+
anthropic_reasoning_effort_choices(id)
|
|
275
|
+
when "Copilot"
|
|
276
|
+
id.to_s.match?(/\Agpt-5(?:\.|-|\z)/) ? openai_reasoning_effort_choices(id) : []
|
|
277
|
+
else
|
|
278
|
+
[]
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def openai_reasoning_effort_choices(id)
|
|
283
|
+
text = id.to_s.delete_prefix("openai/")
|
|
284
|
+
return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)
|
|
285
|
+
|
|
286
|
+
OPENAI_REASONING_EFFORT_CHOICES
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def anthropic_reasoning_effort_choices(id)
|
|
290
|
+
text = normalize_anthropic_model(id)
|
|
291
|
+
return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
|
|
292
|
+
return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
|
|
293
|
+
return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)
|
|
294
|
+
|
|
295
|
+
[]
|
|
157
296
|
end
|
|
158
297
|
|
|
159
298
|
def normalize(model, current_provider: nil, current_model: nil, current_reasoning_effort: nil)
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
|
+
require "json"
|
|
1
2
|
require_relative "../image_attachments"
|
|
3
|
+
require_relative "../message_access"
|
|
2
4
|
require_relative "model_info"
|
|
3
5
|
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
7
|
module Kward
|
|
8
|
+
# Converts Kward conversation/tool data into provider-specific request shapes.
|
|
9
|
+
#
|
|
10
|
+
# This module is mixed into `Client` because it needs provider configuration
|
|
11
|
+
# helpers such as `model_for` and `reasoning_effort`. Keep pure transcript and
|
|
12
|
+
# schema transformations here; keep network transport, credentials, retries,
|
|
13
|
+
# and telemetry in `Client`.
|
|
14
|
+
#
|
|
15
|
+
# Kward stores one internal transcript shape, then projects it into either
|
|
16
|
+
# Chat Completions-style messages or Responses/Codex input items. Preserve both
|
|
17
|
+
# symbol and string key handling through `MessageAccess` so restored sessions,
|
|
18
|
+
# tests, and RPC-normalized messages keep working.
|
|
5
19
|
module ModelPayloads
|
|
6
20
|
private
|
|
7
21
|
|
|
8
22
|
def request_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
|
|
9
23
|
parts = build_context_parts(provider, messages, tools, model: model)
|
|
10
24
|
payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
|
|
11
|
-
payload[:reasoning] = { effort: reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
|
|
25
|
+
payload[:reasoning] = { effort: reasoning || reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
|
|
12
26
|
payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
|
|
13
27
|
payload
|
|
14
28
|
end
|
|
@@ -22,18 +36,18 @@ module Kward
|
|
|
22
36
|
|
|
23
37
|
def messages_include_images?(messages)
|
|
24
38
|
messages.any? do |message|
|
|
25
|
-
content =
|
|
39
|
+
content = MessageAccess.content(message)
|
|
26
40
|
content.is_a?(Array) && content.any? { |part| (part[:type] || part["type"]).to_s == "image" }
|
|
27
41
|
end
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
def chat_messages(messages)
|
|
31
45
|
messages.map do |message|
|
|
32
|
-
role =
|
|
33
|
-
content =
|
|
46
|
+
role = MessageAccess.role(message)
|
|
47
|
+
content = MessageAccess.content(message)
|
|
34
48
|
case role.to_s
|
|
35
49
|
when "compactionSummary"
|
|
36
|
-
{ role: "assistant", content:
|
|
50
|
+
{ role: "assistant", content: MessageAccess.summary(message) || content.to_s }
|
|
37
51
|
when "assistant"
|
|
38
52
|
api_message(message, role: "assistant", content: content.is_a?(Array) ? plain_content(content) : content, keys: ["tool_calls", :tool_calls, "name", :name])
|
|
39
53
|
when "toolResult"
|
|
@@ -51,7 +65,7 @@ module Kward
|
|
|
51
65
|
def api_message(message, role:, content:, keys: [])
|
|
52
66
|
result = { role: role, content: content }
|
|
53
67
|
keys.each_slice(2) do |string_key, symbol_key|
|
|
54
|
-
value = message
|
|
68
|
+
value = MessageAccess.value(message, string_key) || MessageAccess.value(message, symbol_key)
|
|
55
69
|
next if value.nil?
|
|
56
70
|
|
|
57
71
|
target_key = case string_key.to_s
|
|
@@ -75,6 +89,27 @@ module Kward
|
|
|
75
89
|
end
|
|
76
90
|
end
|
|
77
91
|
|
|
92
|
+
def anthropic_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
|
|
93
|
+
parts = build_context_parts("Anthropic", messages, tools, model: model)
|
|
94
|
+
system = [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }]
|
|
95
|
+
system << { type: "text", text: parts[:system] } unless parts[:system].to_s.empty?
|
|
96
|
+
payload = {
|
|
97
|
+
model: parts[:model],
|
|
98
|
+
system: system,
|
|
99
|
+
messages: parts[:messages],
|
|
100
|
+
max_tokens: max_tokens.to_i.positive? ? max_tokens.to_i : 16_384,
|
|
101
|
+
stream: true
|
|
102
|
+
}
|
|
103
|
+
payload[:tools] = parts[:tools] unless parts[:tools].empty?
|
|
104
|
+
if reasoning != false
|
|
105
|
+
payload[:thinking] = { type: "adaptive", display: "summarized" }
|
|
106
|
+
payload[:output_config] = { effort: reasoning || reasoning_effort("Anthropic") }
|
|
107
|
+
else
|
|
108
|
+
payload[:thinking] = { type: "disabled" }
|
|
109
|
+
end
|
|
110
|
+
payload
|
|
111
|
+
end
|
|
112
|
+
|
|
78
113
|
def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
|
|
79
114
|
parts = build_context_parts("Codex", messages, tools, model: model)
|
|
80
115
|
payload = {
|
|
@@ -88,12 +123,27 @@ module Kward
|
|
|
88
123
|
store: false,
|
|
89
124
|
include: []
|
|
90
125
|
}
|
|
91
|
-
payload[:reasoning] = { effort: reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
|
|
126
|
+
payload[:reasoning] = { effort: reasoning || reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
|
|
92
127
|
payload
|
|
93
128
|
end
|
|
94
129
|
|
|
130
|
+
# Builds provider-neutral context parts before final JSON serialization.
|
|
131
|
+
#
|
|
132
|
+
# Codex and Copilot Responses use `instructions` plus typed `input` items;
|
|
133
|
+
# OpenRouter and Copilot chat use OpenAI-compatible `messages`. Callers should
|
|
134
|
+
# prefer this method when showing context usage or debugging provider payloads
|
|
135
|
+
# so every frontend sees the same conversion rules.
|
|
95
136
|
def build_context_parts(provider, messages, tools, model: nil)
|
|
96
|
-
if provider == "
|
|
137
|
+
if provider == "Anthropic"
|
|
138
|
+
system, anthropic_messages = anthropic_messages(messages)
|
|
139
|
+
{
|
|
140
|
+
provider: provider,
|
|
141
|
+
model: model_for(provider, override_model: model),
|
|
142
|
+
system: system,
|
|
143
|
+
messages: anthropic_messages,
|
|
144
|
+
tools: tools.map { |tool| anthropic_tool_schema(tool) }
|
|
145
|
+
}
|
|
146
|
+
elsif provider == "CopilotResponses"
|
|
97
147
|
instructions, input = codex_messages(messages)
|
|
98
148
|
{
|
|
99
149
|
provider: provider,
|
|
@@ -121,26 +171,129 @@ module Kward
|
|
|
121
171
|
end
|
|
122
172
|
end
|
|
123
173
|
|
|
174
|
+
def anthropic_messages(messages)
|
|
175
|
+
system = []
|
|
176
|
+
output = []
|
|
177
|
+
messages.each do |message|
|
|
178
|
+
role = MessageAccess.role(message)
|
|
179
|
+
content = MessageAccess.content(message) || ""
|
|
180
|
+
case role.to_s
|
|
181
|
+
when "system"
|
|
182
|
+
system << plain_content(content).to_s
|
|
183
|
+
when "assistant"
|
|
184
|
+
blocks = []
|
|
185
|
+
text = plain_content(content).to_s
|
|
186
|
+
blocks << { type: "text", text: text } unless text.empty?
|
|
187
|
+
MessageAccess.tool_calls(message).each do |tool_call|
|
|
188
|
+
function = tool_call[:function] || tool_call["function"] || {}
|
|
189
|
+
name = function[:name] || function["name"]
|
|
190
|
+
arguments = function[:arguments] || function["arguments"] || "{}"
|
|
191
|
+
blocks << {
|
|
192
|
+
type: "tool_use",
|
|
193
|
+
id: normalize_anthropic_tool_call_id(tool_call[:id] || tool_call["id"] || "call_#{name}"),
|
|
194
|
+
name: claude_code_tool_name(name),
|
|
195
|
+
input: parse_tool_arguments(arguments)
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
output << { role: "assistant", content: blocks } unless blocks.empty?
|
|
199
|
+
when "tool", "toolResult"
|
|
200
|
+
output << {
|
|
201
|
+
role: "user",
|
|
202
|
+
content: [{
|
|
203
|
+
type: "tool_result",
|
|
204
|
+
tool_use_id: normalize_anthropic_tool_call_id(MessageAccess.tool_call_id(message) || MessageAccess.tool_name(message) || "tool-call"),
|
|
205
|
+
content: plain_content(content).to_s
|
|
206
|
+
}]
|
|
207
|
+
}
|
|
208
|
+
when "compactionSummary"
|
|
209
|
+
summary = MessageAccess.summary(message) || content
|
|
210
|
+
output << { role: "assistant", content: [{ type: "text", text: summary.to_s }] } unless summary.to_s.empty?
|
|
211
|
+
else
|
|
212
|
+
output << { role: "user", content: anthropic_user_content(content) }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
[system.join("\n\n"), output]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def anthropic_user_content(content)
|
|
219
|
+
return content.to_s unless content.is_a?(Array)
|
|
220
|
+
|
|
221
|
+
content.filter_map do |part|
|
|
222
|
+
type = part[:type] || part["type"]
|
|
223
|
+
if type == "text"
|
|
224
|
+
{ type: "text", text: part[:text] || part["text"] || "" }
|
|
225
|
+
elsif type == "image"
|
|
226
|
+
mime_type, data = ImageAttachments.data_url(part).split(",", 2)
|
|
227
|
+
media_type = mime_type.to_s[/data:([^;]+)/, 1] || "image/png"
|
|
228
|
+
{ type: "image", source: { type: "base64", media_type: media_type, data: data.to_s } }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
CLAUDE_CODE_TOOL_NAMES = %w[Read Write Edit Bash Grep Glob AskUserQuestion EnterPlanMode ExitPlanMode KillShell NotebookEdit Skill Task TaskOutput TodoWrite WebFetch WebSearch].freeze
|
|
234
|
+
|
|
235
|
+
def claude_code_tool_name(name)
|
|
236
|
+
mapped = {
|
|
237
|
+
"read_file" => "Read",
|
|
238
|
+
"write_file" => "Write",
|
|
239
|
+
"edit_file" => "Edit",
|
|
240
|
+
"run_shell_command" => "Bash",
|
|
241
|
+
"web_search" => "WebSearch",
|
|
242
|
+
"ask_user_question" => "AskUserQuestion"
|
|
243
|
+
}[name.to_s]
|
|
244
|
+
return mapped if mapped
|
|
245
|
+
|
|
246
|
+
lookup = CLAUDE_CODE_TOOL_NAMES.find { |tool_name| tool_name.downcase == name.to_s.downcase }
|
|
247
|
+
lookup || name.to_s
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def normalize_anthropic_tool_call_id(id)
|
|
251
|
+
id.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")[0, 64]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def parse_tool_arguments(arguments)
|
|
255
|
+
return arguments if arguments.is_a?(Hash)
|
|
256
|
+
|
|
257
|
+
JSON.parse(arguments.to_s.empty? ? "{}" : arguments.to_s)
|
|
258
|
+
rescue JSON::ParserError
|
|
259
|
+
{}
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def anthropic_tool_schema(tool)
|
|
263
|
+
function = tool[:function] || tool["function"] || {}
|
|
264
|
+
schema = function[:parameters] || function["parameters"] || {}
|
|
265
|
+
{
|
|
266
|
+
name: claude_code_tool_name(function[:name] || function["name"]),
|
|
267
|
+
description: function[:description] || function["description"] || "",
|
|
268
|
+
input_schema: {
|
|
269
|
+
type: "object",
|
|
270
|
+
properties: schema[:properties] || schema["properties"] || {},
|
|
271
|
+
required: schema[:required] || schema["required"] || []
|
|
272
|
+
},
|
|
273
|
+
eager_input_streaming: true
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
124
277
|
def codex_messages(messages)
|
|
125
278
|
instructions = []
|
|
126
279
|
input = []
|
|
127
280
|
|
|
128
281
|
messages.each do |message|
|
|
129
|
-
role =
|
|
130
|
-
content =
|
|
282
|
+
role = MessageAccess.role(message)
|
|
283
|
+
content = MessageAccess.content(message) || ""
|
|
131
284
|
case role.to_s
|
|
132
285
|
when "system"
|
|
133
286
|
instructions << plain_content(content).to_s
|
|
134
287
|
when "tool", "toolResult"
|
|
135
288
|
input << {
|
|
136
289
|
type: "function_call_output",
|
|
137
|
-
call_id:
|
|
290
|
+
call_id: MessageAccess.tool_call_id(message) || MessageAccess.tool_name(message) || "tool-call",
|
|
138
291
|
output: plain_content(content).to_s
|
|
139
292
|
}
|
|
140
293
|
when "assistant"
|
|
141
294
|
content = plain_content(content)
|
|
142
295
|
input << codex_message("assistant", content.to_s) unless content.to_s.empty?
|
|
143
|
-
(message
|
|
296
|
+
MessageAccess.tool_calls(message).each do |tool_call|
|
|
144
297
|
function = tool_call[:function] || tool_call["function"] || {}
|
|
145
298
|
input << {
|
|
146
299
|
type: "function_call",
|
|
@@ -150,7 +303,7 @@ module Kward
|
|
|
150
303
|
}
|
|
151
304
|
end
|
|
152
305
|
when "compactionSummary"
|
|
153
|
-
summary =
|
|
306
|
+
summary = MessageAccess.summary(message) || content
|
|
154
307
|
input << codex_message("assistant", summary.to_s) unless summary.to_s.empty?
|
|
155
308
|
else
|
|
156
309
|
input << codex_user_message(content)
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
4
|
module Kward
|
|
5
|
+
# Parses streaming provider responses into Kward assistant messages.
|
|
6
|
+
#
|
|
7
|
+
# `ModelStreamParser` is intentionally provider-shape focused and side-effect
|
|
8
|
+
# light: it accumulates assistant text, reasoning summaries, tool calls, and
|
|
9
|
+
# normalized usage from SSE events. Network IO, retries, credentials, and
|
|
10
|
+
# telemetry stay in `Client`; frontend rendering stays in CLI/RPC event
|
|
11
|
+
# handlers. Keep parser methods deterministic and easy to unit test with raw
|
|
12
|
+
# response bodies.
|
|
4
13
|
module ModelStreamParser
|
|
5
14
|
module_function
|
|
6
15
|
|
|
@@ -46,6 +55,11 @@ module Kward
|
|
|
46
55
|
raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
# Incrementally parses a Codex/Responses SSE HTTP response body.
|
|
59
|
+
#
|
|
60
|
+
# Deltas are yielded as soon as complete SSE blocks arrive so interactive
|
|
61
|
+
# frontends can render streamed assistant and reasoning text without waiting
|
|
62
|
+
# for the provider to close the response.
|
|
49
63
|
def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
|
|
50
64
|
state = codex_sse_state
|
|
51
65
|
buffer = +""
|
|
@@ -67,6 +81,121 @@ module Kward
|
|
|
67
81
|
raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
|
|
68
82
|
end
|
|
69
83
|
|
|
84
|
+
def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
|
|
85
|
+
state = anthropic_sse_state
|
|
86
|
+
buffer = +""
|
|
87
|
+
response.read_body do |chunk|
|
|
88
|
+
cancellation&.raise_if_cancelled!
|
|
89
|
+
buffer << chunk
|
|
90
|
+
while (index = buffer.index(/\r?\n\r?\n/))
|
|
91
|
+
delimiter = Regexp.last_match[0]
|
|
92
|
+
block = buffer[0...index]
|
|
93
|
+
buffer = buffer[(index + delimiter.length)..] || +""
|
|
94
|
+
process_anthropic_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
cancellation&.raise_if_cancelled!
|
|
98
|
+
process_anthropic_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
|
|
99
|
+
anthropic_sse_message(state)
|
|
100
|
+
rescue JSON::ParserError => e
|
|
101
|
+
raise "Anthropic returned invalid SSE JSON: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def anthropic_sse_state
|
|
105
|
+
{ content: +"", reasoning_summary: +"", blocks: {}, tool_calls: [], usage: nil }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def process_anthropic_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
|
|
109
|
+
event_name = nil
|
|
110
|
+
data = block.lines.filter_map do |line|
|
|
111
|
+
event_name = line.delete_prefix("event:").strip if line.start_with?("event:")
|
|
112
|
+
line.start_with?("data:") ? line.delete_prefix("data:").strip : nil
|
|
113
|
+
end.join("\n")
|
|
114
|
+
return if data.empty? || data == "[DONE]"
|
|
115
|
+
raise anthropic_sse_error(data, request_error_class: request_error_class) if event_name == "error"
|
|
116
|
+
|
|
117
|
+
event = JSON.parse(data)
|
|
118
|
+
case event["type"]
|
|
119
|
+
when "message_start"
|
|
120
|
+
state[:usage] ||= usage_normalizer&.call(event.dig("message", "usage"))
|
|
121
|
+
when "content_block_start"
|
|
122
|
+
block_data = event["content_block"] || {}
|
|
123
|
+
state[:blocks][event["index"].to_i] = block_data.merge("partial_json" => "")
|
|
124
|
+
if block_data["type"] == "text"
|
|
125
|
+
text = block_data["text"].to_s
|
|
126
|
+
state[:content] << text
|
|
127
|
+
on_assistant_delta&.call(text) unless text.empty?
|
|
128
|
+
elsif block_data["type"] == "thinking"
|
|
129
|
+
text = block_data["thinking"].to_s
|
|
130
|
+
state[:reasoning_summary] << text
|
|
131
|
+
on_reasoning_delta&.call(text) unless text.empty?
|
|
132
|
+
end
|
|
133
|
+
when "content_block_delta"
|
|
134
|
+
current = state[:blocks][event["index"].to_i] ||= { "partial_json" => "" }
|
|
135
|
+
delta = event["delta"] || {}
|
|
136
|
+
case delta["type"]
|
|
137
|
+
when "text_delta"
|
|
138
|
+
text = delta["text"].to_s
|
|
139
|
+
state[:content] << text
|
|
140
|
+
on_assistant_delta&.call(text)
|
|
141
|
+
when "thinking_delta"
|
|
142
|
+
text = delta["thinking"].to_s
|
|
143
|
+
state[:reasoning_summary] << text
|
|
144
|
+
on_reasoning_delta&.call(text)
|
|
145
|
+
when "input_json_delta"
|
|
146
|
+
current["partial_json"] = current["partial_json"].to_s + delta["partial_json"].to_s
|
|
147
|
+
end
|
|
148
|
+
when "content_block_stop"
|
|
149
|
+
block_data = state[:blocks][event["index"].to_i]
|
|
150
|
+
tool_call = anthropic_tool_call(block_data)
|
|
151
|
+
state[:tool_calls] << tool_call if tool_call
|
|
152
|
+
when "message_delta"
|
|
153
|
+
state[:usage] = usage_normalizer&.call(event["usage"]) || state[:usage]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def anthropic_sse_error(data, request_error_class: nil)
|
|
158
|
+
if request_error_class
|
|
159
|
+
request_error_class.new(provider: "Anthropic", code: 400, body: data)
|
|
160
|
+
else
|
|
161
|
+
data
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def anthropic_tool_call(block_data)
|
|
166
|
+
return nil unless block_data.is_a?(Hash) && block_data["type"] == "tool_use"
|
|
167
|
+
|
|
168
|
+
name = from_claude_code_tool_name(block_data["name"])
|
|
169
|
+
arguments = block_data["partial_json"].to_s
|
|
170
|
+
arguments = JSON.dump(block_data["input"] || {}) if arguments.empty?
|
|
171
|
+
arguments = "{}" if arguments.empty?
|
|
172
|
+
{
|
|
173
|
+
"id" => block_data["id"] || "call_#{name}",
|
|
174
|
+
"type" => "function",
|
|
175
|
+
"function" => { "name" => name, "arguments" => arguments }
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def anthropic_sse_message(state)
|
|
180
|
+
message = { "role" => "assistant", "content" => state[:content] }
|
|
181
|
+
message["reasoning_summary"] = state[:reasoning_summary] unless state[:reasoning_summary].empty?
|
|
182
|
+
message["tool_calls"] = state[:tool_calls] unless state[:tool_calls].empty?
|
|
183
|
+
message["usage"] = state[:usage] if state[:usage]
|
|
184
|
+
message
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def from_claude_code_tool_name(name)
|
|
188
|
+
case name.to_s
|
|
189
|
+
when "Read" then "read_file"
|
|
190
|
+
when "Write" then "write_file"
|
|
191
|
+
when "Edit" then "edit_file"
|
|
192
|
+
when "Bash" then "run_shell_command"
|
|
193
|
+
when "WebSearch" then "web_search"
|
|
194
|
+
when "AskUserQuestion" then "ask_user_question"
|
|
195
|
+
else name.to_s
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
70
199
|
def merge_streaming_tool_call(tool_calls, delta)
|
|
71
200
|
index = (delta["index"] || tool_calls.length).to_i
|
|
72
201
|
tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => "", "arguments" => "" } }
|
data/lib/kward/pan/server.rb
CHANGED
|
@@ -15,7 +15,9 @@ require_relative "../tools/tool_call"
|
|
|
15
15
|
require_relative "../tools/registry"
|
|
16
16
|
require_relative "../workspace"
|
|
17
17
|
|
|
18
|
+
# Namespace for the Kward CLI agent runtime.
|
|
18
19
|
module Kward
|
|
20
|
+
# Minimal local HTTP server for the experimental Pan web UI.
|
|
19
21
|
class PanServer
|
|
20
22
|
DEFAULT_HOST = "0.0.0.0"
|
|
21
23
|
DEFAULT_PORT = 8765
|
|
@@ -269,7 +271,7 @@ module Kward
|
|
|
269
271
|
end
|
|
270
272
|
|
|
271
273
|
def pan_config(config)
|
|
272
|
-
values = config["pan_mode"]
|
|
274
|
+
values = config["pan_mode"]
|
|
273
275
|
values.is_a?(Hash) ? values : {}
|
|
274
276
|
end
|
|
275
277
|
|