kward 0.77.0 → 0.79.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/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -2
- data/doc/agent-tools.md +1 -1
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +106 -1
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +180 -0
- data/doc/plugins.md +58 -0
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +73 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +15 -5
- data/doc/skills.md +10 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +15 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +31 -10
- data/lib/kward/config_files.rb +90 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +173 -15
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +7 -1
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +54 -2
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +20 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
- data/lib/kward/rpc/server.rb +76 -3
- data/lib/kward/rpc/session_manager.rb +43 -8
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +56 -8
- data/lib/kward/tools/search/code.rb +46 -12
- data/lib/kward/tools/search/web.rb +60 -17
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +5 -2
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +22 -2
|
@@ -18,7 +18,7 @@ module Kward
|
|
|
18
18
|
@terminate_on_output_limit = terminate_on_output_limit
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
|
|
21
|
+
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, unsetenv_others: false, &block)
|
|
22
22
|
cancellation&.raise_if_cancelled!
|
|
23
23
|
stdout_buffer = +""
|
|
24
24
|
stderr_buffer = +""
|
|
@@ -27,7 +27,7 @@ module Kward
|
|
|
27
27
|
timed_out = false
|
|
28
28
|
queue = Queue.new
|
|
29
29
|
|
|
30
|
-
Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thread|
|
|
30
|
+
Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true, unsetenv_others: unsetenv_others) do |stdin, stdout, stderr, wait_thread|
|
|
31
31
|
stdin.close
|
|
32
32
|
readers = [
|
|
33
33
|
read_stream(stdout, :stdout, queue),
|
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 "../http"
|
|
9
10
|
require_relative "../openrouter_model_cache"
|
|
10
11
|
require_relative "context_overflow"
|
|
11
12
|
require_relative "copilot_models"
|
|
@@ -28,6 +29,11 @@ module Kward
|
|
|
28
29
|
OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
|
|
29
30
|
CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
|
|
30
31
|
ANTHROPIC_URL = URI("https://api.anthropic.com/v1/messages")
|
|
32
|
+
LOCAL_BASE_URLS = {
|
|
33
|
+
"ollama" => "http://127.0.0.1:11434/v1",
|
|
34
|
+
"lm_studio" => "http://127.0.0.1:1234/v1",
|
|
35
|
+
"llama_cpp" => "http://127.0.0.1:8080/v1"
|
|
36
|
+
}.freeze
|
|
31
37
|
AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
|
|
32
38
|
OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config."
|
|
33
39
|
COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
|
|
@@ -35,6 +41,7 @@ module Kward
|
|
|
35
41
|
DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
|
|
36
42
|
DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
|
|
37
43
|
RETRY_DELAYS = [1, 2].freeze
|
|
44
|
+
DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 120
|
|
38
45
|
NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS = [
|
|
39
46
|
/GoUsageLimitError/i,
|
|
40
47
|
/FreeUsageLimitError/i,
|
|
@@ -92,18 +99,19 @@ module Kward
|
|
|
92
99
|
@telemetry_logger = telemetry_logger
|
|
93
100
|
@copilot_models = nil
|
|
94
101
|
@openrouter_models = nil
|
|
102
|
+
@local_models = nil
|
|
95
103
|
end
|
|
96
104
|
|
|
97
105
|
def chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
|
|
98
106
|
cancellation&.raise_if_cancelled!
|
|
99
107
|
requested_provider = provider
|
|
100
108
|
url, token, resolved_provider, account_id = credentials(provider: requested_provider)
|
|
101
|
-
if token.to_s.empty? && !requested_provider.to_s.empty?
|
|
109
|
+
if token.to_s.empty? && authentication_required?(resolved_provider) && !requested_provider.to_s.empty?
|
|
102
110
|
url, token, resolved_provider, account_id = credentials
|
|
103
111
|
model = nil
|
|
104
112
|
reasoning = nil
|
|
105
113
|
end
|
|
106
|
-
raise auth_error_for(resolved_provider) if token.nil? || token.empty?
|
|
114
|
+
raise auth_error_for(resolved_provider) if authentication_required?(resolved_provider) && (token.nil? || token.empty?)
|
|
107
115
|
|
|
108
116
|
current_model = model_for(resolved_provider, override_model: model)
|
|
109
117
|
current_model = resolved_copilot_chat_model(current_model) if resolved_provider == "Copilot" && model.nil?
|
|
@@ -178,7 +186,10 @@ module Kward
|
|
|
178
186
|
|
|
179
187
|
# Returns the known context window for a provider/model pair.
|
|
180
188
|
def context_window(provider, model)
|
|
181
|
-
|
|
189
|
+
provider = ModelInfo.provider_label(provider)
|
|
190
|
+
return local_context_window if provider == "Local"
|
|
191
|
+
|
|
192
|
+
context_window_for(provider, model)
|
|
182
193
|
end
|
|
183
194
|
|
|
184
195
|
# Returns model choices suitable for settings UIs.
|
|
@@ -224,6 +235,14 @@ module Kward
|
|
|
224
235
|
models << model_entry("Anthropic", anthropic_model, current: provider == "Anthropic") unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model)
|
|
225
236
|
end
|
|
226
237
|
|
|
238
|
+
if provider_logged_in?("Local")
|
|
239
|
+
local_model = model_for("Local")
|
|
240
|
+
local_model_choices.each do |id|
|
|
241
|
+
models << model_entry("Local", id, current: provider == "Local" && local_model == id)
|
|
242
|
+
end
|
|
243
|
+
models << model_entry("Local", local_model, current: provider == "Local") unless local_model.to_s.empty? || local_model_choices.include?(local_model)
|
|
244
|
+
end
|
|
245
|
+
|
|
227
246
|
# Sort models by provider, then alphabetically by id
|
|
228
247
|
models.sort_by { |model| [model[:provider], model[:id]] }
|
|
229
248
|
end
|
|
@@ -245,6 +264,7 @@ module Kward
|
|
|
245
264
|
@config = load_config
|
|
246
265
|
@copilot_models = nil
|
|
247
266
|
@openrouter_models = nil
|
|
267
|
+
@local_models = nil
|
|
248
268
|
end
|
|
249
269
|
|
|
250
270
|
private
|
|
@@ -296,6 +316,15 @@ module Kward
|
|
|
296
316
|
on_assistant_delta: on_assistant_delta,
|
|
297
317
|
cancellation: cancellation
|
|
298
318
|
)
|
|
319
|
+
when "Local"
|
|
320
|
+
chat_local_provider(
|
|
321
|
+
url: url,
|
|
322
|
+
token: token,
|
|
323
|
+
request_body: request_body,
|
|
324
|
+
current_model: current_model,
|
|
325
|
+
on_assistant_delta: on_assistant_delta,
|
|
326
|
+
cancellation: cancellation
|
|
327
|
+
)
|
|
299
328
|
else
|
|
300
329
|
chat_openrouter_provider(
|
|
301
330
|
url: url,
|
|
@@ -335,7 +364,7 @@ module Kward
|
|
|
335
364
|
end
|
|
336
365
|
|
|
337
366
|
def chat_anthropic_provider(url:, token:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:)
|
|
338
|
-
request = Net::HTTP::Post.new(url)
|
|
367
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
339
368
|
request["Authorization"] = "Bearer #{token}"
|
|
340
369
|
request["Content-Type"] = "application/json"
|
|
341
370
|
request["Accept"] = "text/event-stream"
|
|
@@ -343,7 +372,7 @@ module Kward
|
|
|
343
372
|
request.body = request_body
|
|
344
373
|
|
|
345
374
|
message = nil
|
|
346
|
-
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout:
|
|
375
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
|
|
347
376
|
cancellation&.on_cancel { close_http(http) }
|
|
348
377
|
cancellation&.raise_if_cancelled!
|
|
349
378
|
http.request(request) do |response|
|
|
@@ -360,8 +389,33 @@ module Kward
|
|
|
360
389
|
attach_response_metadata(message, provider: "Anthropic", model: current_model)
|
|
361
390
|
end
|
|
362
391
|
|
|
392
|
+
def chat_local_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
|
|
393
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
394
|
+
request["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
|
|
395
|
+
request["Content-Type"] = "application/json"
|
|
396
|
+
request["Accept"] = "text/event-stream"
|
|
397
|
+
request.body = request_body
|
|
398
|
+
|
|
399
|
+
message = nil
|
|
400
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: url.scheme == "https", read_timeout: stream_idle_timeout_seconds) do |http|
|
|
401
|
+
cancellation&.on_cancel { close_http(http) }
|
|
402
|
+
cancellation&.raise_if_cancelled!
|
|
403
|
+
http.request(request) do |response|
|
|
404
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
405
|
+
body = +""
|
|
406
|
+
response.read_body { |chunk| body << chunk }
|
|
407
|
+
raise RequestError.new(provider: "Local", code: response.code, body: redact(body, token))
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
message = parse_openai_chat_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation, provider_label: "Local")
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
cancellation&.raise_if_cancelled!
|
|
414
|
+
attach_response_metadata(message, provider: "Local", model: current_model)
|
|
415
|
+
end
|
|
416
|
+
|
|
363
417
|
def chat_openrouter_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
|
|
364
|
-
request = Net::HTTP::Post.new(url)
|
|
418
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
365
419
|
request["Authorization"] = "Bearer #{token}"
|
|
366
420
|
request["Content-Type"] = "application/json"
|
|
367
421
|
request.body = request_body
|
|
@@ -502,9 +556,30 @@ module Kward
|
|
|
502
556
|
end
|
|
503
557
|
|
|
504
558
|
def context_window_for(provider, id)
|
|
559
|
+
return local_context_window if provider == "Local"
|
|
560
|
+
|
|
505
561
|
ModelInfo.context_window(provider, id, openrouter_models: openrouter_cached_model_entries)
|
|
506
562
|
end
|
|
507
563
|
|
|
564
|
+
def local_model_choices
|
|
565
|
+
return @local_models if @local_models
|
|
566
|
+
|
|
567
|
+
url = local_models_url
|
|
568
|
+
request = Http.apply_user_agent(Net::HTTP::Get.new(url))
|
|
569
|
+
request["Authorization"] = "Bearer #{local_api_key}" unless local_api_key.to_s.empty?
|
|
570
|
+
request["Accept"] = "application/json"
|
|
571
|
+
response = Net::HTTP.start(url.hostname, url.port, use_ssl: url.scheme == "https", read_timeout: stream_idle_timeout_seconds) { |http| http.request(request) }
|
|
572
|
+
return @local_models = [] unless response.is_a?(Net::HTTPSuccess)
|
|
573
|
+
|
|
574
|
+
body = JSON.parse(response.body)
|
|
575
|
+
entries = body["data"] || body["models"] || []
|
|
576
|
+
@local_models = Array(entries).filter_map do |entry|
|
|
577
|
+
entry.is_a?(Hash) ? entry["id"] || entry[:id] || entry["model"] || entry[:model] : entry
|
|
578
|
+
end.map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
579
|
+
rescue StandardError
|
|
580
|
+
@local_models = []
|
|
581
|
+
end
|
|
582
|
+
|
|
508
583
|
def openrouter_model_choices
|
|
509
584
|
openrouter_cached_models.uniq
|
|
510
585
|
end
|
|
@@ -547,7 +622,7 @@ module Kward
|
|
|
547
622
|
return [] if token.empty?
|
|
548
623
|
|
|
549
624
|
url = URI("#{@github_oauth.base_url}/models")
|
|
550
|
-
request = Net::HTTP::Get.new(url)
|
|
625
|
+
request = Http.apply_user_agent(Net::HTTP::Get.new(url))
|
|
551
626
|
request["Authorization"] = "Bearer #{token}"
|
|
552
627
|
request["Accept"] = "application/json"
|
|
553
628
|
copilot_headers([]).each { |key, value| request[key] = value }
|
|
@@ -577,7 +652,7 @@ module Kward
|
|
|
577
652
|
|
|
578
653
|
def copilot_responses_chat(token, request_body:, on_assistant_delta: nil, cancellation: nil)
|
|
579
654
|
url = URI("#{@github_oauth.base_url}/responses")
|
|
580
|
-
request = Net::HTTP::Post.new(url)
|
|
655
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
581
656
|
request["Authorization"] = "Bearer #{token}"
|
|
582
657
|
request["Content-Type"] = "application/json"
|
|
583
658
|
request["Accept"] = "text/event-stream"
|
|
@@ -585,7 +660,7 @@ module Kward
|
|
|
585
660
|
request.body = request_body
|
|
586
661
|
|
|
587
662
|
message = nil
|
|
588
|
-
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout:
|
|
663
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
|
|
589
664
|
cancellation&.on_cancel { close_http(http) }
|
|
590
665
|
cancellation&.raise_if_cancelled!
|
|
591
666
|
http.request(request) do |response|
|
|
@@ -603,7 +678,7 @@ module Kward
|
|
|
603
678
|
end
|
|
604
679
|
|
|
605
680
|
def copilot_chat(url, token, messages, tools, request_body: nil, on_assistant_delta: nil, cancellation: nil)
|
|
606
|
-
request = Net::HTTP::Post.new(url)
|
|
681
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
607
682
|
request["Authorization"] = "Bearer #{token}"
|
|
608
683
|
request["Content-Type"] = "application/json"
|
|
609
684
|
request["Accept"] = "text/event-stream"
|
|
@@ -625,7 +700,11 @@ module Kward
|
|
|
625
700
|
end
|
|
626
701
|
|
|
627
702
|
def parse_openai_chat_sse(body, on_assistant_delta: nil)
|
|
628
|
-
ModelStreamParser.parse_openai_chat_sse(body, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage))
|
|
703
|
+
ModelStreamParser.parse_openai_chat_sse(body, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage), provider_label: "Copilot")
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def parse_openai_chat_sse_stream(response, on_assistant_delta: nil, cancellation: nil, provider_label: "OpenAI-compatible provider")
|
|
707
|
+
ModelStreamParser.parse_openai_chat_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: method(:normalized_usage), provider_label: provider_label)
|
|
629
708
|
end
|
|
630
709
|
|
|
631
710
|
def anthropic_headers
|
|
@@ -633,7 +712,6 @@ module Kward
|
|
|
633
712
|
"anthropic-version" => "2023-06-01",
|
|
634
713
|
"anthropic-beta" => "claude-code-20250219,oauth-2025-04-20",
|
|
635
714
|
"anthropic-dangerous-direct-browser-access" => "true",
|
|
636
|
-
"user-agent" => "claude-cli/2.1.75",
|
|
637
715
|
"x-app" => "cli"
|
|
638
716
|
}
|
|
639
717
|
end
|
|
@@ -653,16 +731,16 @@ module Kward
|
|
|
653
731
|
end
|
|
654
732
|
|
|
655
733
|
def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
|
|
656
|
-
request = Net::HTTP::Post.new(url)
|
|
734
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
657
735
|
request["Authorization"] = "Bearer #{token}"
|
|
658
736
|
request["ChatGPT-Account-Id"] = account_id if account_id
|
|
659
737
|
request["Content-Type"] = "application/json"
|
|
660
738
|
request["Accept"] = "text/event-stream"
|
|
661
|
-
request["originator"] = "codex_cli_rs"
|
|
662
739
|
request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
|
|
740
|
+
apply_codex_identity(request, luna: luna_request?(request.body))
|
|
663
741
|
|
|
664
742
|
message = nil
|
|
665
|
-
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout:
|
|
743
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
|
|
666
744
|
cancellation&.on_cancel { close_http(http) }
|
|
667
745
|
cancellation&.raise_if_cancelled!
|
|
668
746
|
http.request(request) do |response|
|
|
@@ -684,6 +762,22 @@ module Kward
|
|
|
684
762
|
raise e
|
|
685
763
|
end
|
|
686
764
|
|
|
765
|
+
def apply_codex_identity(request, luna:)
|
|
766
|
+
request["originator"] = "kward"
|
|
767
|
+
return unless luna
|
|
768
|
+
|
|
769
|
+
# TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
|
|
770
|
+
request["originator"] = "codex_cli_rs"
|
|
771
|
+
request["User-Agent"] = "codex_cli_rs/0.144.1"
|
|
772
|
+
request["x-openai-internal-codex-responses-lite"] = "true"
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def luna_request?(request_body)
|
|
776
|
+
JSON.parse(request_body)["model"] == "gpt-5.6-luna"
|
|
777
|
+
rescue JSON::ParserError
|
|
778
|
+
false
|
|
779
|
+
end
|
|
780
|
+
|
|
687
781
|
def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
|
|
688
782
|
ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
|
|
689
783
|
end
|
|
@@ -759,6 +853,10 @@ module Kward
|
|
|
759
853
|
|
|
760
854
|
def credentials(provider: nil)
|
|
761
855
|
provider = provider.to_s.empty? ? ModelInfo.provider_label(configured_provider) : ModelInfo.provider_label(provider)
|
|
856
|
+
if provider == "Local"
|
|
857
|
+
return [local_chat_url, local_api_key, provider, nil]
|
|
858
|
+
end
|
|
859
|
+
|
|
762
860
|
if provider == "Copilot"
|
|
763
861
|
return [copilot_chat_url, github_access_token, provider, nil]
|
|
764
862
|
end
|
|
@@ -789,8 +887,14 @@ module Kward
|
|
|
789
887
|
ModelInfo.reasoning_effort(config: @config, provider: provider)
|
|
790
888
|
end
|
|
791
889
|
|
|
890
|
+
def authentication_required?(provider)
|
|
891
|
+
provider != "Local"
|
|
892
|
+
end
|
|
893
|
+
|
|
792
894
|
def provider_logged_in?(provider)
|
|
793
895
|
case provider
|
|
896
|
+
when "Local"
|
|
897
|
+
local_configured?
|
|
794
898
|
when "Codex"
|
|
795
899
|
openai_configured?
|
|
796
900
|
when "OpenRouter"
|
|
@@ -812,6 +916,53 @@ module Kward
|
|
|
812
916
|
false
|
|
813
917
|
end
|
|
814
918
|
|
|
919
|
+
def local_configured?
|
|
920
|
+
configured_provider == "local" || !local_model.to_s.empty? || !config_value("local_base_url").to_s.empty? || !ENV["KWARD_LOCAL_BASE_URL"].to_s.empty?
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def local_model
|
|
924
|
+
ENV["KWARD_LOCAL_MODEL"] || config_value("local_model")
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def local_context_window
|
|
928
|
+
value = ENV["KWARD_LOCAL_CONTEXT_WINDOW"] || config_value("local_context_window")
|
|
929
|
+
positive_integer(value)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def local_api_key
|
|
933
|
+
ENV["KWARD_LOCAL_API_KEY"] || config_value("local_api_key")
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def local_backend
|
|
937
|
+
presence(ENV["KWARD_LOCAL_BACKEND"].to_s.strip) || presence(config_value("local_backend").to_s.strip) || "ollama"
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
def local_base_url
|
|
941
|
+
configured = presence(ENV["KWARD_LOCAL_BASE_URL"].to_s.strip) || presence(config_value("local_base_url").to_s.strip)
|
|
942
|
+
return configured if configured
|
|
943
|
+
|
|
944
|
+
LOCAL_BASE_URLS.fetch(local_backend) { raise "Unknown local model backend: #{local_backend}" }
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def local_chat_url
|
|
948
|
+
local_url("chat/completions")
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def local_models_url
|
|
952
|
+
local_url("models")
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def local_url(path)
|
|
956
|
+
base = URI.parse(local_base_url)
|
|
957
|
+
unless base.is_a?(URI::HTTP) && base.host && !base.host.empty? && base.user.nil? && base.password.nil? && base.query.nil? && base.fragment.nil?
|
|
958
|
+
raise "Invalid local model URL: #{local_base_url}"
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
URI("#{base.to_s.sub(%r{/+\z}, "")}/#{path}")
|
|
962
|
+
rescue URI::InvalidURIError
|
|
963
|
+
raise "Invalid local model URL: #{local_base_url}"
|
|
964
|
+
end
|
|
965
|
+
|
|
815
966
|
def openrouter_api_key
|
|
816
967
|
@openrouter_api_key || config_value("openrouter_api_key")
|
|
817
968
|
end
|
|
@@ -842,11 +993,18 @@ module Kward
|
|
|
842
993
|
@config["codex_show_raw_reasoning"] == true
|
|
843
994
|
end
|
|
844
995
|
|
|
996
|
+
def stream_idle_timeout_seconds
|
|
997
|
+
value = @config["stream_idle_timeout_seconds"].to_i
|
|
998
|
+
value.positive? ? value : DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS
|
|
999
|
+
end
|
|
1000
|
+
|
|
845
1001
|
def load_config
|
|
846
1002
|
ConfigFiles.read_config(@config_path)
|
|
847
1003
|
end
|
|
848
1004
|
|
|
849
1005
|
def redact(text, token)
|
|
1006
|
+
return text.to_s if token.to_s.empty?
|
|
1007
|
+
|
|
850
1008
|
text.to_s.gsub(token.to_s, "[REDACTED]")
|
|
851
1009
|
end
|
|
852
1010
|
|
|
@@ -128,6 +128,8 @@ module Kward
|
|
|
128
128
|
return override_model if override_model
|
|
129
129
|
|
|
130
130
|
case provider
|
|
131
|
+
when "Local"
|
|
132
|
+
env["KWARD_LOCAL_MODEL"] || ConfigFiles.config_value(config, "local_model")
|
|
131
133
|
when "OpenRouter"
|
|
132
134
|
env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model")
|
|
133
135
|
when "Copilot"
|
|
@@ -175,6 +177,8 @@ module Kward
|
|
|
175
177
|
|
|
176
178
|
def reasoning_effort(config:, env: ENV, provider: nil)
|
|
177
179
|
case provider.to_s
|
|
180
|
+
when "Local"
|
|
181
|
+
nil
|
|
178
182
|
when "OpenRouter"
|
|
179
183
|
env["OPENROUTER_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openrouter_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
|
|
180
184
|
when "Copilot"
|
|
@@ -191,6 +195,7 @@ module Kward
|
|
|
191
195
|
when "openrouter" then "OpenRouter"
|
|
192
196
|
when "copilot" then "Copilot"
|
|
193
197
|
when "anthropic", "claude" then "Anthropic"
|
|
198
|
+
when "local" then "Local"
|
|
194
199
|
when "codex", "openai" then "Codex"
|
|
195
200
|
else provider.to_s
|
|
196
201
|
end
|
|
@@ -201,6 +206,7 @@ module Kward
|
|
|
201
206
|
when "openrouter" then "openrouter"
|
|
202
207
|
when "copilot" then "copilot"
|
|
203
208
|
when "anthropic", "claude" then "anthropic"
|
|
209
|
+
when "local" then "local"
|
|
204
210
|
else "codex"
|
|
205
211
|
end
|
|
206
212
|
end
|
|
@@ -210,6 +216,7 @@ module Kward
|
|
|
210
216
|
when "openrouter" then "openrouter_model"
|
|
211
217
|
when "copilot" then "copilot_model"
|
|
212
218
|
when "anthropic", "claude" then "anthropic_model"
|
|
219
|
+
when "local" then "local_model"
|
|
213
220
|
else "openai_model"
|
|
214
221
|
end
|
|
215
222
|
end
|
|
@@ -219,6 +226,7 @@ module Kward
|
|
|
219
226
|
when "openrouter" then "openrouter_reasoning_effort"
|
|
220
227
|
when "copilot" then "copilot_reasoning_effort"
|
|
221
228
|
when "anthropic", "claude" then "anthropic_reasoning_effort"
|
|
229
|
+
when "local" then "local_reasoning_effort"
|
|
222
230
|
else "openai_reasoning_effort"
|
|
223
231
|
end
|
|
224
232
|
end
|
|
@@ -357,7 +365,9 @@ module Kward
|
|
|
357
365
|
pattern_context_window(ANTHROPIC_CONTEXT_WINDOWS, normalize_anthropic_model(id))
|
|
358
366
|
end
|
|
359
367
|
|
|
360
|
-
def supports_images?(
|
|
368
|
+
def supports_images?(provider, id)
|
|
369
|
+
return false if provider == "Local"
|
|
370
|
+
|
|
361
371
|
IMAGE_UNSUPPORTED_MODELS.none? { |pattern| id.to_s.match?(pattern) }
|
|
362
372
|
end
|
|
363
373
|
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Kward
|
|
|
26
26
|
|
|
27
27
|
payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
|
|
28
28
|
payload[:reasoning] = { effort: reasoning || reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
|
|
29
|
+
payload[:stream] = true if provider == "Local"
|
|
29
30
|
payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
|
|
30
31
|
payload
|
|
31
32
|
end
|
|
@@ -126,7 +127,12 @@ module Kward
|
|
|
126
127
|
store: false,
|
|
127
128
|
include: []
|
|
128
129
|
}
|
|
129
|
-
|
|
130
|
+
unless reasoning == false
|
|
131
|
+
effort = reasoning || reasoning_effort("Codex")
|
|
132
|
+
payload[:reasoning] = { effort: effort, summary: "auto" }
|
|
133
|
+
# TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
|
|
134
|
+
payload[:reasoning][:context] = "all_turns" if parts[:model] == "gpt-5.6-luna"
|
|
135
|
+
end
|
|
130
136
|
payload
|
|
131
137
|
end
|
|
132
138
|
|
|
@@ -13,36 +13,68 @@ module Kward
|
|
|
13
13
|
module ModelStreamParser
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
|
-
def parse_openai_chat_sse(body, on_assistant_delta: nil, usage_normalizer: nil)
|
|
17
|
-
|
|
18
|
-
tool_calls = []
|
|
19
|
-
usage = nil
|
|
16
|
+
def parse_openai_chat_sse(body, on_assistant_delta: nil, usage_normalizer: nil, provider_label: "OpenAI-compatible provider")
|
|
17
|
+
state = openai_chat_sse_state
|
|
20
18
|
body.split(/\r?\n\r?\n/).each do |block|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
process_openai_chat_sse_block(block, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer)
|
|
20
|
+
end
|
|
21
|
+
openai_chat_sse_message(state)
|
|
22
|
+
rescue JSON::ParserError => e
|
|
23
|
+
raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Incrementally parses a Chat Completions SSE HTTP response body. Local
|
|
27
|
+
# OpenAI-compatible servers stream this shape, so emit assistant deltas as
|
|
28
|
+
# complete SSE blocks arrive rather than waiting for connection close.
|
|
29
|
+
def parse_openai_chat_sse_stream(response, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, provider_label: "OpenAI-compatible provider")
|
|
30
|
+
state = openai_chat_sse_state
|
|
31
|
+
buffer = +""
|
|
32
|
+
|
|
33
|
+
response.read_body do |chunk|
|
|
34
|
+
cancellation&.raise_if_cancelled!
|
|
35
|
+
buffer << chunk
|
|
36
|
+
while (index = buffer.index(/\r?\n\r?\n/))
|
|
37
|
+
delimiter = Regexp.last_match[0]
|
|
38
|
+
block = buffer[0...index]
|
|
39
|
+
buffer = buffer[(index + delimiter.length)..] || +""
|
|
40
|
+
process_openai_chat_sse_block(block, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer)
|
|
35
41
|
end
|
|
36
|
-
message = choice["message"] || {}
|
|
37
|
-
content << message["content"].to_s if content.empty? && message["content"]
|
|
38
|
-
Array(message["tool_calls"]).each { |tool_call| merge_streaming_tool_call(tool_calls, tool_call) }
|
|
39
42
|
end
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
result
|
|
43
|
+
cancellation&.raise_if_cancelled!
|
|
44
|
+
process_openai_chat_sse_block(buffer, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer) unless buffer.empty?
|
|
45
|
+
openai_chat_sse_message(state)
|
|
44
46
|
rescue JSON::ParserError => e
|
|
45
|
-
raise "
|
|
47
|
+
raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def openai_chat_sse_state
|
|
51
|
+
{ content: +"", tool_calls: [], usage: nil }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def process_openai_chat_sse_block(block, state, on_assistant_delta: nil, usage_normalizer: nil)
|
|
55
|
+
data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
|
|
56
|
+
return if data.empty? || data == "[DONE]"
|
|
57
|
+
|
|
58
|
+
event = JSON.parse(data)
|
|
59
|
+
state[:usage] ||= usage_normalizer&.call(event["usage"])
|
|
60
|
+
choice = Array(event["choices"]).first || {}
|
|
61
|
+
delta = choice["delta"] || {}
|
|
62
|
+
if delta["content"]
|
|
63
|
+
text = delta["content"].to_s
|
|
64
|
+
state[:content] << text
|
|
65
|
+
on_assistant_delta&.call(text)
|
|
66
|
+
end
|
|
67
|
+
Array(delta["tool_calls"]).each { |tool_call| merge_streaming_tool_call(state[:tool_calls], tool_call) }
|
|
68
|
+
message = choice["message"] || {}
|
|
69
|
+
state[:content] << message["content"].to_s if state[:content].empty? && message["content"]
|
|
70
|
+
Array(message["tool_calls"]).each { |tool_call| merge_streaming_tool_call(state[:tool_calls], tool_call) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def openai_chat_sse_message(state)
|
|
74
|
+
result = { "role" => "assistant", "content" => state[:content] }
|
|
75
|
+
result["tool_calls"] = finalized_streaming_tool_calls(state[:tool_calls]) unless state[:tool_calls].empty?
|
|
76
|
+
result["usage"] = state[:usage] if state[:usage]
|
|
77
|
+
result
|
|
46
78
|
end
|
|
47
79
|
|
|
48
80
|
def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "time"
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "config_files"
|
|
7
|
+
require_relative "http"
|
|
7
8
|
require_relative "private_file"
|
|
8
9
|
|
|
9
10
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -71,7 +72,7 @@ module Kward
|
|
|
71
72
|
private
|
|
72
73
|
|
|
73
74
|
def refresh_request
|
|
74
|
-
Net::HTTP::Get.new(MODELS_URL).tap do |request|
|
|
75
|
+
Http.apply_user_agent(Net::HTTP::Get.new(MODELS_URL)).tap do |request|
|
|
75
76
|
request["Authorization"] = "Bearer #{@api_key}"
|
|
76
77
|
request["Accept"] = "application/json"
|
|
77
78
|
end
|
|
@@ -164,6 +164,7 @@
|
|
|
164
164
|
<div class="title-row"><span class="live-dot"></span><h1 id="conversation-title">New session</h1></div>
|
|
165
165
|
<div class="top-meta" id="session-meta">Secure LAN workspace</div>
|
|
166
166
|
</div>
|
|
167
|
+
<button class="text-button" id="capture-skill" type="button">Capture skill</button>
|
|
167
168
|
<button class="text-button" id="rename-session" type="button">Rename</button>
|
|
168
169
|
<button class="text-button danger" id="delete-session" type="button">Delete</button>
|
|
169
170
|
</header>
|
|
@@ -456,7 +457,56 @@
|
|
|
456
457
|
document.getElementById('open-sessions').addEventListener('click', () => document.body.classList.add('sessions-open'));
|
|
457
458
|
document.getElementById('close-sessions').addEventListener('click', closeSessions);
|
|
458
459
|
document.getElementById('backdrop').addEventListener('click', closeSessions);
|
|
460
|
+
async function captureSkill() {
|
|
461
|
+
try {
|
|
462
|
+
const sourceResponse = await fetch('/skill-capture/sessions', { cache: 'no-store' });
|
|
463
|
+
const sourceData = await sourceResponse.json();
|
|
464
|
+
const candidates = sourceData.sessions || [];
|
|
465
|
+
if (!candidates.length) throw new Error('No saved sessions found.');
|
|
466
|
+
const choices = candidates.map((session, index) => `${index + 1}. ${session.name || session.firstMessage || 'Untitled session'}`).join('\n');
|
|
467
|
+
const answer = window.prompt(`Capture a personal skill from which saved session?\n\n${choices}`, '1');
|
|
468
|
+
if (answer === null) return;
|
|
469
|
+
const source = candidates[Number(answer) - 1];
|
|
470
|
+
if (!source) throw new Error('Choose a session number from the list.');
|
|
471
|
+
setStatus({ active: true, pending: 0 });
|
|
472
|
+
const draftResponse = await fetch('/skill-capture/draft', {
|
|
473
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionPath: source.path })
|
|
474
|
+
});
|
|
475
|
+
const draft = await draftResponse.json();
|
|
476
|
+
if (!draftResponse.ok || !draft.ok) throw new Error(draft.error || 'Could not create a skill draft.');
|
|
477
|
+
reviewSkillDraft(draft.content);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
showError(error.message);
|
|
480
|
+
} finally {
|
|
481
|
+
setStatus({ active: false, pending: 0 });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function reviewSkillDraft(content) {
|
|
486
|
+
const overlay = document.createElement('div');
|
|
487
|
+
overlay.style.cssText = 'position:fixed;inset:0;z-index:50;display:grid;place-items:center;padding:1rem;background:#000b';
|
|
488
|
+
const panel = document.createElement('section');
|
|
489
|
+
panel.style.cssText = 'display:grid;gap:.8rem;width:min(58rem,100%);max-height:90dvh;padding:1rem;border:1px solid var(--line-bright);border-radius:12px;background:var(--panel)';
|
|
490
|
+
const heading = document.createElement('strong'); heading.textContent = 'Review captured skill';
|
|
491
|
+
const note = document.createElement('p'); note.style.margin = '0'; note.style.color = 'var(--muted)'; note.textContent = 'Edit the draft. Saving writes a personal skill but does not activate it.';
|
|
492
|
+
const editor = document.createElement('textarea'); editor.value = content; editor.style.cssText = 'min-height:55dvh;resize:vertical;padding:.75rem;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);font-family:ui-monospace,monospace;font-size:.8rem';
|
|
493
|
+
const actions = document.createElement('div'); actions.style.cssText = 'display:flex;justify-content:flex-end;gap:.6rem';
|
|
494
|
+
const cancel = document.createElement('button'); cancel.className = 'text-button'; cancel.textContent = 'Cancel';
|
|
495
|
+
const save = document.createElement('button'); save.className = 'new-session'; save.style.margin = '0'; save.textContent = 'Save personal skill';
|
|
496
|
+
cancel.onclick = () => overlay.remove();
|
|
497
|
+
save.onclick = async () => {
|
|
498
|
+
const request = async overwrite => fetch('/skill-capture/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: editor.value, overwrite }) });
|
|
499
|
+
let response = await request(false); let result = await response.json();
|
|
500
|
+
if (response.status === 409 && window.confirm(`${result.error}\n\nOverwrite it?`)) { response = await request(true); result = await response.json(); }
|
|
501
|
+
if (!response.ok || !result.ok) { showError(result.error || 'Could not save the skill.'); return; }
|
|
502
|
+
overlay.remove();
|
|
503
|
+
window.alert(`Saved personal skill: ${result.path}`);
|
|
504
|
+
};
|
|
505
|
+
actions.append(cancel, save); panel.append(heading, note, editor, actions); overlay.append(panel); document.body.append(overlay); editor.focus();
|
|
506
|
+
}
|
|
507
|
+
|
|
459
508
|
document.getElementById('new-session').addEventListener('click', () => changeSession('new'));
|
|
509
|
+
document.getElementById('capture-skill').addEventListener('click', captureSkill);
|
|
460
510
|
document.getElementById('rename-session').addEventListener('click', () => {
|
|
461
511
|
if (!activeSession) return;
|
|
462
512
|
const name = window.prompt('Session name', activeSession.name || '');
|