kward 0.78.0 → 0.80.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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -2,6 +2,7 @@ require "json"
2
2
  require "net/http"
3
3
  require "uri"
4
4
  require_relative "../auth/anthropic_oauth"
5
+ require_relative "../auth/api_key_store"
5
6
  require_relative "../auth/github_oauth"
6
7
  require_relative "../auth/openai_oauth"
7
8
  require_relative "../cancellation"
@@ -9,8 +10,10 @@ require_relative "../config_files"
9
10
  require_relative "../http"
10
11
  require_relative "../openrouter_model_cache"
11
12
  require_relative "context_overflow"
13
+ require_relative "azure_openai_config"
12
14
  require_relative "copilot_models"
13
15
  require_relative "model_info"
16
+ require_relative "sources"
14
17
  require_relative "payloads"
15
18
  require_relative "../telemetry/logger"
16
19
  require_relative "stream_parser"
@@ -29,8 +32,15 @@ module Kward
29
32
  OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
30
33
  CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
31
34
  ANTHROPIC_URL = URI("https://api.anthropic.com/v1/messages")
35
+ LOCAL_BASE_URLS = {
36
+ "ollama" => "http://127.0.0.1:11434/v1",
37
+ "lm_studio" => "http://127.0.0.1:1234/v1",
38
+ "llama_cpp" => "http://127.0.0.1:8080/v1"
39
+ }.freeze
32
40
  AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
33
- OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config."
41
+ OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Set OPENROUTER_API_KEY or sign in with an API key."
42
+ OPENAI_API_AUTH_ERROR = "No OpenAI API key found. Set OPENAI_API_KEY or sign in with an API key."
43
+ AZURE_OPENAI_AUTH_ERROR = "No Azure OpenAI API key found. Set AZURE_OPENAI_API_KEY or sign in with an API key."
34
44
  COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
35
45
  ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `ruby lib/main.rb login anthropic`."
36
46
  DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
@@ -82,7 +92,7 @@ module Kward
82
92
  TRANSIENT_NETWORK_ERRORS = [IOError, EOFError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout].freeze
83
93
 
84
94
  # Creates an object for model provider requests.
85
- def initialize(api_key: ENV["OPENROUTER_API_KEY"], model: nil, openai_access_token: ENV["OPENAI_ACCESS_TOKEN"], oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, anthropic_oauth: AnthropicOAuth.new, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path))
95
+ def initialize(api_key: ENV["OPENROUTER_API_KEY"], model: nil, openai_access_token: ENV["OPENAI_ACCESS_TOKEN"], oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, anthropic_oauth: AnthropicOAuth.new, api_key_store: nil, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path))
86
96
  @openrouter_api_key = presence(api_key)
87
97
  @openai_access_token = presence(openai_access_token)
88
98
  @oauth = oauth
@@ -90,22 +100,24 @@ module Kward
90
100
  @anthropic_oauth = anthropic_oauth
91
101
  @model = model
92
102
  @config_path = File.expand_path(config_path)
103
+ @api_key_store = api_key_store || APIKeyStore.new(path: File.join(File.dirname(@config_path), "api_keys.json"), config_path: @config_path)
93
104
  @config = load_config
94
105
  @telemetry_logger = telemetry_logger
95
106
  @copilot_models = nil
96
107
  @openrouter_models = nil
108
+ @local_models = nil
97
109
  end
98
110
 
99
111
  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)
100
112
  cancellation&.raise_if_cancelled!
101
113
  requested_provider = provider
102
114
  url, token, resolved_provider, account_id = credentials(provider: requested_provider)
103
- if token.to_s.empty? && !requested_provider.to_s.empty?
115
+ if token.to_s.empty? && authentication_required?(resolved_provider) && !requested_provider.to_s.empty? && resolved_provider != "OpenAI"
104
116
  url, token, resolved_provider, account_id = credentials
105
117
  model = nil
106
118
  reasoning = nil
107
119
  end
108
- raise auth_error_for(resolved_provider) if token.nil? || token.empty?
120
+ raise auth_error_for(resolved_provider) if authentication_required?(resolved_provider) && (token.nil? || token.empty?)
109
121
 
110
122
  current_model = model_for(resolved_provider, override_model: model)
111
123
  current_model = resolved_copilot_chat_model(current_model) if resolved_provider == "Copilot" && model.nil?
@@ -180,7 +192,10 @@ module Kward
180
192
 
181
193
  # Returns the known context window for a provider/model pair.
182
194
  def context_window(provider, model)
183
- context_window_for(ModelInfo.provider_label(provider), model)
195
+ provider = ModelInfo.provider_label(provider)
196
+ return local_context_window if provider == "Local"
197
+
198
+ context_window_for(provider, model)
184
199
  end
185
200
 
186
201
  # Returns model choices suitable for settings UIs.
@@ -205,7 +220,8 @@ module Kward
205
220
  openrouter_model = model_for("OpenRouter")
206
221
  openrouter_choices = openrouter_model_choices
207
222
  models += openrouter_choices.map do |id|
208
- model_entry("OpenRouter", id, current: provider == "OpenRouter" && openrouter_model == id)
223
+ metadata = openrouter_cached_model_entries.find { |entry| (entry["id"] || entry[:id]).to_s == id }
224
+ model_entry("OpenRouter", id, current: provider == "OpenRouter" && openrouter_model == id, metadata: metadata)
209
225
  end
210
226
  end
211
227
 
@@ -226,10 +242,42 @@ module Kward
226
242
  models << model_entry("Anthropic", anthropic_model, current: provider == "Anthropic") unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model)
227
243
  end
228
244
 
245
+ ProviderCatalog.api_key_providers.each do |catalog_provider|
246
+ next unless ["openai_chat", "openai_responses", "gemini", "azure_openai"].include?(catalog_provider.protocol)
247
+ next if catalog_provider.id == "openrouter"
248
+ next unless provider_logged_in?(catalog_provider.name)
249
+
250
+ selected_model = model_for(catalog_provider.name)
251
+ provider_models = ModelSources.new(provider_id: catalog_provider.id, api_key: @api_key_store.fetch(catalog_provider.id)).models
252
+ provider_models.each do |entry|
253
+ models << model_entry(catalog_provider.name, entry.fetch("id"), current: provider == catalog_provider.name && selected_model == entry.fetch("id"), metadata: entry)
254
+ end
255
+ models << model_entry(catalog_provider.name, selected_model, current: provider == catalog_provider.name) unless selected_model.to_s.empty? || provider_models.any? { |entry| entry.fetch("id") == selected_model }
256
+ end
257
+
258
+ if provider_logged_in?("Local")
259
+ local_model = model_for("Local")
260
+ local_model_choices.each do |id|
261
+ models << model_entry("Local", id, current: provider == "Local" && local_model == id)
262
+ end
263
+ models << model_entry("Local", local_model, current: provider == "Local") unless local_model.to_s.empty? || local_model_choices.include?(local_model)
264
+ end
265
+
229
266
  # Sort models by provider, then alphabetically by id
230
267
  models.sort_by { |model| [model[:provider], model[:id]] }
231
268
  end
232
269
 
270
+ # Refreshes cached choices for an API-key provider.
271
+ def refresh_available_models(provider: nil)
272
+ provider ||= current_provider
273
+ catalog_provider = ProviderCatalog.find_by_name(provider) || ProviderCatalog.find(provider.to_s.downcase)
274
+ return available_models unless catalog_provider&.api_key?
275
+ return available_models unless ProviderCatalog.runtime(catalog_provider.id).automatic_model_discovery?
276
+
277
+ ModelSources.new(provider_id: catalog_provider.id, api_key: @api_key_store.fetch(catalog_provider.id)).refresh
278
+ available_models
279
+ end
280
+
233
281
  # Projects messages/tools into the provider-specific context shape without sending it.
234
282
  def current_context_parts(messages, tools, provider: current_provider, model: nil)
235
283
  build_context_parts(ModelInfo.provider_label(provider), messages, tools, model: model)
@@ -247,6 +295,7 @@ module Kward
247
295
  @config = load_config
248
296
  @copilot_models = nil
249
297
  @openrouter_models = nil
298
+ @local_models = nil
250
299
  end
251
300
 
252
301
  private
@@ -262,6 +311,34 @@ module Kward
262
311
 
263
312
  def chat_provider_request(provider:, url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_reasoning_boundary:, on_assistant_delta:, cancellation:, max_tokens:)
264
313
  case provider
314
+ when "Azure OpenAI"
315
+ chat_azure_openai_provider(
316
+ token: token,
317
+ request_body: request_body,
318
+ current_model: current_model,
319
+ on_assistant_delta: on_assistant_delta,
320
+ cancellation: cancellation
321
+ )
322
+ when "Google Gemini"
323
+ chat_gemini_provider(
324
+ url: url,
325
+ token: token,
326
+ request_body: request_body,
327
+ current_model: current_model,
328
+ on_assistant_delta: on_assistant_delta,
329
+ cancellation: cancellation
330
+ )
331
+ when "OpenAI"
332
+ chat_openai_responses_provider(
333
+ url: url,
334
+ token: token,
335
+ request_body: request_body,
336
+ current_model: current_model,
337
+ on_reasoning_delta: on_reasoning_delta,
338
+ on_reasoning_boundary: on_reasoning_boundary,
339
+ on_assistant_delta: on_assistant_delta,
340
+ cancellation: cancellation
341
+ )
265
342
  when "Codex"
266
343
  chat_codex_provider(
267
344
  url: url,
@@ -298,10 +375,20 @@ module Kward
298
375
  on_assistant_delta: on_assistant_delta,
299
376
  cancellation: cancellation
300
377
  )
378
+ when "Local"
379
+ chat_local_provider(
380
+ url: url,
381
+ token: token,
382
+ request_body: request_body,
383
+ current_model: current_model,
384
+ on_assistant_delta: on_assistant_delta,
385
+ cancellation: cancellation
386
+ )
301
387
  else
302
388
  chat_openrouter_provider(
303
389
  url: url,
304
390
  token: token,
391
+ provider: provider,
305
392
  request_body: request_body,
306
393
  current_model: current_model,
307
394
  on_assistant_delta: on_assistant_delta,
@@ -310,6 +397,95 @@ module Kward
310
397
  end
311
398
  end
312
399
 
400
+ def chat_azure_openai_provider(token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
401
+ request_url = azure_openai_config(deployment: current_model).chat_completions_url
402
+ request = Http.apply_user_agent(Net::HTTP::Post.new(request_url))
403
+ request["api-key"] = token
404
+ request["Content-Type"] = "application/json"
405
+ request["Accept"] = "text/event-stream"
406
+ request.body = request_body
407
+
408
+ message = nil
409
+ Net::HTTP.start(request_url.hostname, request_url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
410
+ cancellation&.on_cancel { close_http(http) }
411
+ cancellation&.raise_if_cancelled!
412
+ http.request(request) do |response|
413
+ unless response.is_a?(Net::HTTPSuccess)
414
+ body = +""
415
+ response.read_body { |chunk| body << chunk }
416
+ raise RequestError.new(provider: "Azure OpenAI", code: response.code, body: redact(body, token))
417
+ end
418
+
419
+ message = parse_openai_chat_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation, provider_label: "Azure OpenAI")
420
+ end
421
+ end
422
+ cancellation&.raise_if_cancelled!
423
+ attach_response_metadata(message, provider: "Azure OpenAI", model: current_model)
424
+ end
425
+
426
+ def chat_gemini_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
427
+ model = current_model.to_s.delete_prefix("models/")
428
+ request_url = URI("#{url.to_s.sub(%r{/+\z}, "")}/models/#{URI.encode_www_form_component(model)}:streamGenerateContent?alt=sse")
429
+ request = Http.apply_user_agent(Net::HTTP::Post.new(request_url))
430
+ request["x-goog-api-key"] = token
431
+ request["Content-Type"] = "application/json"
432
+ request["Accept"] = "text/event-stream"
433
+ request.body = request_body
434
+
435
+ message = nil
436
+ Net::HTTP.start(request_url.hostname, request_url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
437
+ cancellation&.on_cancel { close_http(http) }
438
+ cancellation&.raise_if_cancelled!
439
+ http.request(request) do |response|
440
+ unless response.is_a?(Net::HTTPSuccess)
441
+ body = +""
442
+ response.read_body { |chunk| body << chunk }
443
+ raise RequestError.new(provider: "Google Gemini", code: response.code, body: redact(body, token))
444
+ end
445
+
446
+ message = ModelStreamParser.parse_gemini_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: method(:gemini_usage))
447
+ end
448
+ end
449
+ cancellation&.raise_if_cancelled!
450
+ attach_response_metadata(message, provider: "Google Gemini", model: current_model)
451
+ end
452
+
453
+ def gemini_usage(usage)
454
+ return nil unless usage.is_a?(Hash)
455
+
456
+ normalized_usage(
457
+ "input_tokens" => usage["promptTokenCount"],
458
+ "output_tokens" => usage["candidatesTokenCount"],
459
+ "cache_read_tokens" => usage["cachedContentTokenCount"],
460
+ "total_tokens" => usage["totalTokenCount"]
461
+ )
462
+ end
463
+
464
+ def chat_openai_responses_provider(url:, token:, request_body:, current_model:, on_reasoning_delta:, on_reasoning_boundary:, on_assistant_delta:, cancellation:)
465
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
466
+ request["Authorization"] = "Bearer #{token}"
467
+ request["Content-Type"] = "application/json"
468
+ request["Accept"] = "text/event-stream"
469
+ request.body = request_body
470
+
471
+ message = nil
472
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
473
+ cancellation&.on_cancel { close_http(http) }
474
+ cancellation&.raise_if_cancelled!
475
+ http.request(request) do |response|
476
+ unless response.is_a?(Net::HTTPSuccess)
477
+ body = +""
478
+ response.read_body { |chunk| body << chunk }
479
+ raise RequestError.new(provider: "OpenAI", code: response.code, body: redact(body, token))
480
+ end
481
+
482
+ message = parse_responses_sse_stream(response, provider: "OpenAI", on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
483
+ end
484
+ end
485
+ cancellation&.raise_if_cancelled!
486
+ attach_response_metadata(message, provider: "OpenAI", model: current_model)
487
+ end
488
+
313
489
  def chat_codex_provider(url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_reasoning_boundary:, on_assistant_delta:, cancellation:, max_tokens:)
314
490
  message = codex_chat(
315
491
  url,
@@ -338,10 +514,14 @@ module Kward
338
514
 
339
515
  def chat_anthropic_provider(url:, token:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:)
340
516
  request = Http.apply_user_agent(Net::HTTP::Post.new(url))
341
- request["Authorization"] = "Bearer #{token}"
517
+ if anthropic_api_key
518
+ request["x-api-key"] = token
519
+ else
520
+ request["Authorization"] = "Bearer #{token}"
521
+ end
342
522
  request["Content-Type"] = "application/json"
343
523
  request["Accept"] = "text/event-stream"
344
- anthropic_headers.each { |key, value| request[key] = value }
524
+ anthropic_headers(api_key: !anthropic_api_key.nil?).each { |key, value| request[key] = value }
345
525
  request.body = request_body
346
526
 
347
527
  message = nil
@@ -362,7 +542,32 @@ module Kward
362
542
  attach_response_metadata(message, provider: "Anthropic", model: current_model)
363
543
  end
364
544
 
365
- def chat_openrouter_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
545
+ def chat_local_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
546
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
547
+ request["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
548
+ request["Content-Type"] = "application/json"
549
+ request["Accept"] = "text/event-stream"
550
+ request.body = request_body
551
+
552
+ message = nil
553
+ Net::HTTP.start(url.hostname, url.port, use_ssl: url.scheme == "https", read_timeout: stream_idle_timeout_seconds) do |http|
554
+ cancellation&.on_cancel { close_http(http) }
555
+ cancellation&.raise_if_cancelled!
556
+ http.request(request) do |response|
557
+ unless response.is_a?(Net::HTTPSuccess)
558
+ body = +""
559
+ response.read_body { |chunk| body << chunk }
560
+ raise RequestError.new(provider: "Local", code: response.code, body: redact(body, token))
561
+ end
562
+
563
+ message = parse_openai_chat_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation, provider_label: "Local")
564
+ end
565
+ end
566
+ cancellation&.raise_if_cancelled!
567
+ attach_response_metadata(message, provider: "Local", model: current_model)
568
+ end
569
+
570
+ def chat_openrouter_provider(url:, token:, provider: "OpenRouter", request_body:, current_model:, on_assistant_delta:, cancellation:)
366
571
  request = Http.apply_user_agent(Net::HTTP::Post.new(url))
367
572
  request["Authorization"] = "Bearer #{token}"
368
573
  request["Content-Type"] = "application/json"
@@ -376,18 +581,23 @@ module Kward
376
581
  cancellation&.raise_if_cancelled!
377
582
 
378
583
  unless response.is_a?(Net::HTTPSuccess)
379
- raise RequestError.new(provider: "OpenRouter", code: response.code, body: response.body)
584
+ body = provider == "OpenRouter" ? response.body : redact(response.body, token)
585
+ raise RequestError.new(provider: provider, code: response.code, body: body)
380
586
  end
381
587
 
382
588
  body = JSON.parse(response.body)
383
589
  message = body.fetch("choices").first.fetch("message")
384
590
  cancellation&.raise_if_cancelled!
385
591
  on_assistant_delta&.call(message.fetch("content", ""))
386
- attach_response_metadata(message, provider: "OpenRouter", model: current_model, usage: normalized_usage(body["usage"]))
592
+ attach_response_metadata(message, provider: provider, model: current_model, usage: normalized_usage(body["usage"]))
387
593
  end
388
594
 
389
595
  def auth_error_for(provider)
390
596
  case provider
597
+ when "Azure OpenAI"
598
+ AZURE_OPENAI_AUTH_ERROR
599
+ when "OpenAI"
600
+ OPENAI_API_AUTH_ERROR
391
601
  when "OpenRouter"
392
602
  OPENROUTER_AUTH_ERROR
393
603
  when "Copilot"
@@ -479,7 +689,13 @@ module Kward
479
689
  def request_body_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
480
690
  reasoning = false unless ModelInfo.reasoning_supported?(provider, model)
481
691
 
482
- if provider == "Codex"
692
+ if provider == "Azure OpenAI"
693
+ request_payload(provider, messages, tools, max_tokens: max_tokens, model: model, reasoning: false).merge(stream: true, stream_options: { include_usage: true })
694
+ elsif provider == "Google Gemini"
695
+ gemini_payload(messages, tools, max_tokens: max_tokens, model: model)
696
+ elsif provider == "OpenAI"
697
+ openai_responses_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
698
+ elsif provider == "Codex"
483
699
  codex_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
484
700
  elsif provider == "Anthropic"
485
701
  anthropic_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
@@ -494,19 +710,43 @@ module Kward
494
710
  model.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
495
711
  end
496
712
 
497
- def model_entry(provider, id, current: false)
713
+ def model_entry(provider, id, current: false, metadata: nil)
714
+ metadata ||= {}
498
715
  {
499
716
  provider: provider,
500
717
  id: id,
501
718
  current: current,
502
- contextWindow: context_window_for(provider, id)
719
+ contextWindow: metadata["contextWindow"] || metadata[:contextWindow] || context_window_for(provider, id),
720
+ supportedParameters: metadata["supportedParameters"] || metadata[:supportedParameters],
721
+ source: metadata["source"] || metadata[:source]
503
722
  }.compact
504
723
  end
505
724
 
506
725
  def context_window_for(provider, id)
726
+ return local_context_window if provider == "Local"
727
+
507
728
  ModelInfo.context_window(provider, id, openrouter_models: openrouter_cached_model_entries)
508
729
  end
509
730
 
731
+ def local_model_choices
732
+ return @local_models if @local_models
733
+
734
+ url = local_models_url
735
+ request = Http.apply_user_agent(Net::HTTP::Get.new(url))
736
+ request["Authorization"] = "Bearer #{local_api_key}" unless local_api_key.to_s.empty?
737
+ request["Accept"] = "application/json"
738
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: url.scheme == "https", read_timeout: stream_idle_timeout_seconds) { |http| http.request(request) }
739
+ return @local_models = [] unless response.is_a?(Net::HTTPSuccess)
740
+
741
+ body = JSON.parse(response.body)
742
+ entries = body["data"] || body["models"] || []
743
+ @local_models = Array(entries).filter_map do |entry|
744
+ entry.is_a?(Hash) ? entry["id"] || entry[:id] || entry["model"] || entry[:model] : entry
745
+ end.map(&:to_s).map(&:strip).reject(&:empty?).uniq
746
+ rescue StandardError
747
+ @local_models = []
748
+ end
749
+
510
750
  def openrouter_model_choices
511
751
  openrouter_cached_models.uniq
512
752
  end
@@ -627,16 +867,22 @@ module Kward
627
867
  end
628
868
 
629
869
  def parse_openai_chat_sse(body, on_assistant_delta: nil)
630
- ModelStreamParser.parse_openai_chat_sse(body, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage))
870
+ ModelStreamParser.parse_openai_chat_sse(body, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage), provider_label: "Copilot")
631
871
  end
632
872
 
633
- def anthropic_headers
634
- {
635
- "anthropic-version" => "2023-06-01",
873
+ def parse_openai_chat_sse_stream(response, on_assistant_delta: nil, cancellation: nil, provider_label: "OpenAI-compatible provider")
874
+ ModelStreamParser.parse_openai_chat_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: method(:normalized_usage), provider_label: provider_label)
875
+ end
876
+
877
+ def anthropic_headers(api_key: false)
878
+ headers = { "anthropic-version" => "2023-06-01" }
879
+ return headers if api_key
880
+
881
+ headers.merge(
636
882
  "anthropic-beta" => "claude-code-20250219,oauth-2025-04-20",
637
883
  "anthropic-dangerous-direct-browser-access" => "true",
638
884
  "x-app" => "cli"
639
- }
885
+ )
640
886
  end
641
887
 
642
888
  def copilot_headers(messages)
@@ -706,7 +952,11 @@ module Kward
706
952
  end
707
953
 
708
954
  def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil)
709
- ModelStreamParser.parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, cancellation: cancellation, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
955
+ parse_responses_sse_stream(response, provider: "Codex", on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
956
+ end
957
+
958
+ def parse_responses_sse_stream(response, provider:, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil)
959
+ ModelStreamParser.parse_codex_sse_stream(response, provider_label: provider, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, cancellation: cancellation, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
710
960
  end
711
961
 
712
962
  def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
@@ -776,6 +1026,10 @@ module Kward
776
1026
 
777
1027
  def credentials(provider: nil)
778
1028
  provider = provider.to_s.empty? ? ModelInfo.provider_label(configured_provider) : ModelInfo.provider_label(provider)
1029
+ if provider == "Local"
1030
+ return [local_chat_url, local_api_key, provider, nil]
1031
+ end
1032
+
779
1033
  if provider == "Copilot"
780
1034
  return [copilot_chat_url, github_access_token, provider, nil]
781
1035
  end
@@ -788,6 +1042,25 @@ module Kward
788
1042
  return [OPENROUTER_URL, openrouter_api_key, provider, nil]
789
1043
  end
790
1044
 
1045
+ if provider == "OpenAI"
1046
+ runtime = ProviderCatalog.runtime("openai")
1047
+ return [URI(runtime.chat_url), @api_key_store.fetch("openai"), provider, nil]
1048
+ end
1049
+
1050
+ if provider == "Google Gemini"
1051
+ runtime = ProviderCatalog.runtime("gemini")
1052
+ return [URI(runtime.chat_url), @api_key_store.fetch("gemini"), provider, nil]
1053
+ end
1054
+
1055
+ if provider == "Azure OpenAI"
1056
+ return [azure_openai_config.chat_completions_url, @api_key_store.fetch("azure_openai"), provider, nil]
1057
+ end
1058
+
1059
+ if (catalog_provider = ProviderCatalog.find_by_name(provider)) && catalog_provider.protocol == "openai_chat"
1060
+ runtime = ProviderCatalog.runtime(catalog_provider.id)
1061
+ return [URI(runtime.chat_url), @api_key_store.fetch(catalog_provider.id), provider, nil]
1062
+ end
1063
+
791
1064
  openai_token = @openai_access_token || @oauth.access_token
792
1065
  if openai_token
793
1066
  [CODEX_URL, openai_token, "Codex", @oauth.respond_to?(:account_id) ? @oauth.account_id : nil]
@@ -806,8 +1079,14 @@ module Kward
806
1079
  ModelInfo.reasoning_effort(config: @config, provider: provider)
807
1080
  end
808
1081
 
1082
+ def authentication_required?(provider)
1083
+ provider != "Local"
1084
+ end
1085
+
809
1086
  def provider_logged_in?(provider)
810
1087
  case provider
1088
+ when "Local"
1089
+ local_configured?
811
1090
  when "Codex"
812
1091
  openai_configured?
813
1092
  when "OpenRouter"
@@ -817,7 +1096,8 @@ module Kward
817
1096
  when "Anthropic"
818
1097
  !anthropic_access_token.to_s.empty?
819
1098
  else
820
- false
1099
+ catalog_provider = ProviderCatalog.find_by_name(provider)
1100
+ catalog_provider&.api_key? && !@api_key_store.fetch(catalog_provider.id).to_s.empty?
821
1101
  end
822
1102
  rescue StandardError
823
1103
  false
@@ -829,8 +1109,55 @@ module Kward
829
1109
  false
830
1110
  end
831
1111
 
1112
+ def local_configured?
1113
+ configured_provider == "local" || !local_model.to_s.empty? || !config_value("local_base_url").to_s.empty? || !ENV["KWARD_LOCAL_BASE_URL"].to_s.empty?
1114
+ end
1115
+
1116
+ def local_model
1117
+ ENV["KWARD_LOCAL_MODEL"] || config_value("local_model")
1118
+ end
1119
+
1120
+ def local_context_window
1121
+ value = ENV["KWARD_LOCAL_CONTEXT_WINDOW"] || config_value("local_context_window")
1122
+ positive_integer(value)
1123
+ end
1124
+
1125
+ def local_api_key
1126
+ ENV["KWARD_LOCAL_API_KEY"] || config_value("local_api_key")
1127
+ end
1128
+
1129
+ def local_backend
1130
+ presence(ENV["KWARD_LOCAL_BACKEND"].to_s.strip) || presence(config_value("local_backend").to_s.strip) || "ollama"
1131
+ end
1132
+
1133
+ def local_base_url
1134
+ configured = presence(ENV["KWARD_LOCAL_BASE_URL"].to_s.strip) || presence(config_value("local_base_url").to_s.strip)
1135
+ return configured if configured
1136
+
1137
+ LOCAL_BASE_URLS.fetch(local_backend) { raise "Unknown local model backend: #{local_backend}" }
1138
+ end
1139
+
1140
+ def local_chat_url
1141
+ local_url("chat/completions")
1142
+ end
1143
+
1144
+ def local_models_url
1145
+ local_url("models")
1146
+ end
1147
+
1148
+ def local_url(path)
1149
+ base = URI.parse(local_base_url)
1150
+ unless base.is_a?(URI::HTTP) && base.host && !base.host.empty? && base.user.nil? && base.password.nil? && base.query.nil? && base.fragment.nil?
1151
+ raise "Invalid local model URL: #{local_base_url}"
1152
+ end
1153
+
1154
+ URI("#{base.to_s.sub(%r{/+\z}, "")}/#{path}")
1155
+ rescue URI::InvalidURIError
1156
+ raise "Invalid local model URL: #{local_base_url}"
1157
+ end
1158
+
832
1159
  def openrouter_api_key
833
- @openrouter_api_key || config_value("openrouter_api_key")
1160
+ @openrouter_api_key || @api_key_store.stored_value("openrouter") || config_value("openrouter_api_key")
834
1161
  end
835
1162
 
836
1163
  def github_access_token
@@ -838,13 +1165,25 @@ module Kward
838
1165
  end
839
1166
 
840
1167
  def anthropic_access_token
841
- @anthropic_oauth.access_token
1168
+ anthropic_api_key || @anthropic_oauth.access_token
1169
+ end
1170
+
1171
+ def anthropic_api_key
1172
+ @api_key_store.fetch("anthropic")
842
1173
  end
843
1174
 
844
1175
  def copilot_chat_url
845
1176
  URI("#{@github_oauth.base_url}/chat/completions")
846
1177
  end
847
1178
 
1179
+ def azure_openai_config(deployment: nil)
1180
+ AzureOpenAIConfig.new(
1181
+ endpoint: config_value("azure_openai_endpoint"),
1182
+ deployment: deployment || config_value("azure_openai_model"),
1183
+ api_version: config_value("azure_openai_api_version")
1184
+ )
1185
+ end
1186
+
848
1187
  def configured_provider
849
1188
  value = ENV["KWARD_PROVIDER"].to_s.strip
850
1189
  value = config_value("provider") if value.empty?
@@ -869,6 +1208,8 @@ module Kward
869
1208
  end
870
1209
 
871
1210
  def redact(text, token)
1211
+ return text.to_s if token.to_s.empty?
1212
+
872
1213
  text.to_s.gsub(token.to_s, "[REDACTED]")
873
1214
  end
874
1215