kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -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 +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -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 +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -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"
@@ -35,7 +38,9 @@ module Kward
35
38
  "llama_cpp" => "http://127.0.0.1:8080/v1"
36
39
  }.freeze
37
40
  AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
38
- 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."
39
44
  COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
40
45
  ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `ruby lib/main.rb login anthropic`."
41
46
  DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
@@ -87,7 +92,7 @@ module Kward
87
92
  TRANSIENT_NETWORK_ERRORS = [IOError, EOFError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout].freeze
88
93
 
89
94
  # Creates an object for model provider requests.
90
- 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))
91
96
  @openrouter_api_key = presence(api_key)
92
97
  @openai_access_token = presence(openai_access_token)
93
98
  @oauth = oauth
@@ -95,6 +100,7 @@ module Kward
95
100
  @anthropic_oauth = anthropic_oauth
96
101
  @model = model
97
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)
98
104
  @config = load_config
99
105
  @telemetry_logger = telemetry_logger
100
106
  @copilot_models = nil
@@ -106,7 +112,7 @@ module Kward
106
112
  cancellation&.raise_if_cancelled!
107
113
  requested_provider = provider
108
114
  url, token, resolved_provider, account_id = credentials(provider: requested_provider)
109
- if token.to_s.empty? && authentication_required?(resolved_provider) && !requested_provider.to_s.empty?
115
+ if token.to_s.empty? && authentication_required?(resolved_provider) && !requested_provider.to_s.empty? && resolved_provider != "OpenAI"
110
116
  url, token, resolved_provider, account_id = credentials
111
117
  model = nil
112
118
  reasoning = nil
@@ -214,7 +220,8 @@ module Kward
214
220
  openrouter_model = model_for("OpenRouter")
215
221
  openrouter_choices = openrouter_model_choices
216
222
  models += openrouter_choices.map do |id|
217
- 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)
218
225
  end
219
226
  end
220
227
 
@@ -235,6 +242,19 @@ module Kward
235
242
  models << model_entry("Anthropic", anthropic_model, current: provider == "Anthropic") unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model)
236
243
  end
237
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
+
238
258
  if provider_logged_in?("Local")
239
259
  local_model = model_for("Local")
240
260
  local_model_choices.each do |id|
@@ -247,6 +267,17 @@ module Kward
247
267
  models.sort_by { |model| [model[:provider], model[:id]] }
248
268
  end
249
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
+
250
281
  # Projects messages/tools into the provider-specific context shape without sending it.
251
282
  def current_context_parts(messages, tools, provider: current_provider, model: nil)
252
283
  build_context_parts(ModelInfo.provider_label(provider), messages, tools, model: model)
@@ -280,6 +311,34 @@ module Kward
280
311
 
281
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:)
282
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
+ )
283
342
  when "Codex"
284
343
  chat_codex_provider(
285
344
  url: url,
@@ -329,6 +388,7 @@ module Kward
329
388
  chat_openrouter_provider(
330
389
  url: url,
331
390
  token: token,
391
+ provider: provider,
332
392
  request_body: request_body,
333
393
  current_model: current_model,
334
394
  on_assistant_delta: on_assistant_delta,
@@ -337,6 +397,95 @@ module Kward
337
397
  end
338
398
  end
339
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
+
340
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:)
341
490
  message = codex_chat(
342
491
  url,
@@ -365,10 +514,14 @@ module Kward
365
514
 
366
515
  def chat_anthropic_provider(url:, token:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:)
367
516
  request = Http.apply_user_agent(Net::HTTP::Post.new(url))
368
- request["Authorization"] = "Bearer #{token}"
517
+ if anthropic_api_key
518
+ request["x-api-key"] = token
519
+ else
520
+ request["Authorization"] = "Bearer #{token}"
521
+ end
369
522
  request["Content-Type"] = "application/json"
370
523
  request["Accept"] = "text/event-stream"
371
- anthropic_headers.each { |key, value| request[key] = value }
524
+ anthropic_headers(api_key: !anthropic_api_key.nil?).each { |key, value| request[key] = value }
372
525
  request.body = request_body
373
526
 
374
527
  message = nil
@@ -414,7 +567,7 @@ module Kward
414
567
  attach_response_metadata(message, provider: "Local", model: current_model)
415
568
  end
416
569
 
417
- def chat_openrouter_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
570
+ def chat_openrouter_provider(url:, token:, provider: "OpenRouter", request_body:, current_model:, on_assistant_delta:, cancellation:)
418
571
  request = Http.apply_user_agent(Net::HTTP::Post.new(url))
419
572
  request["Authorization"] = "Bearer #{token}"
420
573
  request["Content-Type"] = "application/json"
@@ -428,18 +581,23 @@ module Kward
428
581
  cancellation&.raise_if_cancelled!
429
582
 
430
583
  unless response.is_a?(Net::HTTPSuccess)
431
- 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)
432
586
  end
433
587
 
434
588
  body = JSON.parse(response.body)
435
589
  message = body.fetch("choices").first.fetch("message")
436
590
  cancellation&.raise_if_cancelled!
437
591
  on_assistant_delta&.call(message.fetch("content", ""))
438
- 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"]))
439
593
  end
440
594
 
441
595
  def auth_error_for(provider)
442
596
  case provider
597
+ when "Azure OpenAI"
598
+ AZURE_OPENAI_AUTH_ERROR
599
+ when "OpenAI"
600
+ OPENAI_API_AUTH_ERROR
443
601
  when "OpenRouter"
444
602
  OPENROUTER_AUTH_ERROR
445
603
  when "Copilot"
@@ -531,7 +689,13 @@ module Kward
531
689
  def request_body_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
532
690
  reasoning = false unless ModelInfo.reasoning_supported?(provider, model)
533
691
 
534
- 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"
535
699
  codex_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
536
700
  elsif provider == "Anthropic"
537
701
  anthropic_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
@@ -546,12 +710,15 @@ module Kward
546
710
  model.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
547
711
  end
548
712
 
549
- def model_entry(provider, id, current: false)
713
+ def model_entry(provider, id, current: false, metadata: nil)
714
+ metadata ||= {}
550
715
  {
551
716
  provider: provider,
552
717
  id: id,
553
718
  current: current,
554
- 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]
555
722
  }.compact
556
723
  end
557
724
 
@@ -707,13 +874,15 @@ module Kward
707
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)
708
875
  end
709
876
 
710
- def anthropic_headers
711
- {
712
- "anthropic-version" => "2023-06-01",
877
+ def anthropic_headers(api_key: false)
878
+ headers = { "anthropic-version" => "2023-06-01" }
879
+ return headers if api_key
880
+
881
+ headers.merge(
713
882
  "anthropic-beta" => "claude-code-20250219,oauth-2025-04-20",
714
883
  "anthropic-dangerous-direct-browser-access" => "true",
715
884
  "x-app" => "cli"
716
- }
885
+ )
717
886
  end
718
887
 
719
888
  def copilot_headers(messages)
@@ -783,7 +952,11 @@ module Kward
783
952
  end
784
953
 
785
954
  def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil)
786
- 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)
787
960
  end
788
961
 
789
962
  def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
@@ -869,6 +1042,25 @@ module Kward
869
1042
  return [OPENROUTER_URL, openrouter_api_key, provider, nil]
870
1043
  end
871
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
+
872
1064
  openai_token = @openai_access_token || @oauth.access_token
873
1065
  if openai_token
874
1066
  [CODEX_URL, openai_token, "Codex", @oauth.respond_to?(:account_id) ? @oauth.account_id : nil]
@@ -904,7 +1096,8 @@ module Kward
904
1096
  when "Anthropic"
905
1097
  !anthropic_access_token.to_s.empty?
906
1098
  else
907
- false
1099
+ catalog_provider = ProviderCatalog.find_by_name(provider)
1100
+ catalog_provider&.api_key? && !@api_key_store.fetch(catalog_provider.id).to_s.empty?
908
1101
  end
909
1102
  rescue StandardError
910
1103
  false
@@ -964,7 +1157,7 @@ module Kward
964
1157
  end
965
1158
 
966
1159
  def openrouter_api_key
967
- @openrouter_api_key || config_value("openrouter_api_key")
1160
+ @openrouter_api_key || @api_key_store.stored_value("openrouter") || config_value("openrouter_api_key")
968
1161
  end
969
1162
 
970
1163
  def github_access_token
@@ -972,13 +1165,25 @@ module Kward
972
1165
  end
973
1166
 
974
1167
  def anthropic_access_token
975
- @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")
976
1173
  end
977
1174
 
978
1175
  def copilot_chat_url
979
1176
  URI("#{@github_oauth.base_url}/chat/completions")
980
1177
  end
981
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
+
982
1187
  def configured_provider
983
1188
  value = ENV["KWARD_PROVIDER"].to_s.strip
984
1189
  value = config_value("provider") if value.empty?
@@ -1,4 +1,5 @@
1
1
  require_relative "../config_files"
2
+ require_relative "provider_catalog"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -127,6 +128,17 @@ module Kward
127
128
  def model_for(provider, config:, override_model: nil, env: ENV)
128
129
  return override_model if override_model
129
130
 
131
+ if provider_label(provider) == "OpenAI"
132
+ runtime = ProviderCatalog.runtime("openai")
133
+ return env["OPENAI_API_MODEL"] || ConfigFiles.config_value(config, runtime.model_config_key) || DEFAULT_OPENAI_MODEL
134
+ end
135
+
136
+ catalog_provider = ProviderCatalog.find_by_name(provider) || ProviderCatalog.find(provider.to_s.downcase)
137
+ if catalog_provider && !["anthropic", "openrouter", "copilot"].include?(catalog_provider.id)
138
+ runtime = ProviderCatalog.runtime(catalog_provider.id)
139
+ return env["#{catalog_provider.id.upcase}_MODEL"] || ConfigFiles.config_value(config, runtime.model_config_key, "model")
140
+ end
141
+
130
142
  case provider
131
143
  when "Local"
132
144
  env["KWARD_LOCAL_MODEL"] || ConfigFiles.config_value(config, "local_model")
@@ -176,6 +188,11 @@ module Kward
176
188
  end
177
189
 
178
190
  def reasoning_effort(config:, env: ENV, provider: nil)
191
+ if provider_label(provider) == "OpenAI"
192
+ runtime = ProviderCatalog.runtime("openai")
193
+ return env["OPENAI_API_REASONING_EFFORT"] || ConfigFiles.config_value(config, runtime.reasoning_config_key) || DEFAULT_REASONING_EFFORT
194
+ end
195
+
179
196
  case provider.to_s
180
197
  when "Local"
181
198
  nil
@@ -191,6 +208,11 @@ module Kward
191
208
  end
192
209
 
193
210
  def provider_label(provider)
211
+ return "OpenAI" if provider.to_s == "OpenAI" || provider.to_s.downcase == "openai_api"
212
+
213
+ catalog_provider = ProviderCatalog.find(provider.to_s.downcase) || ProviderCatalog.find_by_name(provider)
214
+ return catalog_provider.name if catalog_provider && !["openai", "copilot"].include?(catalog_provider.id)
215
+
194
216
  case provider.to_s.downcase
195
217
  when "openrouter" then "OpenRouter"
196
218
  when "copilot" then "Copilot"
@@ -202,6 +224,11 @@ module Kward
202
224
  end
203
225
 
204
226
  def provider_config_value(provider)
227
+ return ProviderCatalog.runtime("openai").id if provider_label(provider) == "OpenAI"
228
+
229
+ catalog_provider = ProviderCatalog.find(provider.to_s.downcase) || ProviderCatalog.find_by_name(provider)
230
+ return ProviderCatalog.runtime(catalog_provider.id).id if catalog_provider && catalog_provider.id != "openai"
231
+
205
232
  case provider.to_s.downcase
206
233
  when "openrouter" then "openrouter"
207
234
  when "copilot" then "copilot"
@@ -212,6 +239,11 @@ module Kward
212
239
  end
213
240
 
214
241
  def config_key_for_provider(provider)
242
+ return ProviderCatalog.runtime("openai").model_config_key if provider_label(provider) == "OpenAI"
243
+
244
+ catalog_provider = ProviderCatalog.find(provider.to_s.downcase) || ProviderCatalog.find_by_name(provider)
245
+ return ProviderCatalog.runtime(catalog_provider.id).model_config_key if catalog_provider && catalog_provider.id != "openai"
246
+
215
247
  case provider.to_s.downcase
216
248
  when "openrouter" then "openrouter_model"
217
249
  when "copilot" then "copilot_model"
@@ -222,6 +254,11 @@ module Kward
222
254
  end
223
255
 
224
256
  def reasoning_config_key_for_provider(provider)
257
+ return ProviderCatalog.runtime("openai").reasoning_config_key if provider_label(provider) == "OpenAI"
258
+
259
+ catalog_provider = ProviderCatalog.find(provider.to_s.downcase) || ProviderCatalog.find_by_name(provider)
260
+ return ProviderCatalog.runtime(catalog_provider.id).reasoning_config_key if catalog_provider && catalog_provider.id != "openai"
261
+
225
262
  case provider.to_s.downcase
226
263
  when "openrouter" then "openrouter_reasoning_effort"
227
264
  when "copilot" then "copilot_reasoning_effort"
@@ -244,6 +281,16 @@ module Kward
244
281
 
245
282
  def context_window(provider, id, openrouter_models: nil)
246
283
  case provider
284
+ when "Azure OpenAI"
285
+ pattern_context_window(OPENAI_CONTEXT_WINDOWS, id) ||
286
+ conservative_unknown_context_window(id)
287
+ when "Google Gemini"
288
+ pattern_context_window(GEMINI_CONTEXT_WINDOWS, id.to_s.delete_prefix("models/")) ||
289
+ conservative_unknown_context_window(id)
290
+ when "OpenAI"
291
+ pattern_context_window(OPENAI_CONTEXT_WINDOWS, id) ||
292
+ conservative_context_window(id) ||
293
+ conservative_unknown_context_window(id)
247
294
  when "Codex"
248
295
  pattern_context_window(CODEX_CONTEXT_WINDOWS, id) ||
249
296
  openrouter_inferred_context_window(provider, id, openrouter_models: openrouter_models) ||
@@ -427,6 +474,8 @@ module Kward
427
474
  reasoning: reasoning,
428
475
  reasoningEffort: reasoning_effort,
429
476
  contextWindow: model["contextWindow"] || model["context_window"] || context_window(provider, id),
477
+ supportedParameters: model["supportedParameters"] || model["supported_parameters"],
478
+ source: model["source"],
430
479
  current: boolean_value(model["current"], default: provider == current_provider && id == current_model)
431
480
  }.compact
432
481
  end
@@ -114,8 +114,67 @@ module Kward
114
114
  payload
115
115
  end
116
116
 
117
+ def gemini_payload(messages, tools, max_tokens: nil, model: nil)
118
+ system_parts = messages.filter_map do |message|
119
+ plain_content(MessageAccess.content(message)).to_s if MessageAccess.role(message).to_s == "system"
120
+ end.reject(&:empty?).map { |text| { text: text } }
121
+ payload = {
122
+ contents: messages.filter_map { |message| gemini_message(message) },
123
+ tools: tools.empty? ? nil : [{ functionDeclarations: tools.map { |tool| gemini_tool_schema(tool) } }]
124
+ }.compact
125
+ payload[:systemInstruction] = { parts: system_parts } unless system_parts.empty?
126
+ payload[:generationConfig] = { maxOutputTokens: max_tokens.to_i } if max_tokens.to_i.positive?
127
+ payload
128
+ end
129
+
130
+ def gemini_message(message)
131
+ role = MessageAccess.role(message).to_s
132
+ content = MessageAccess.content(message)
133
+ case role
134
+ when "system"
135
+ nil
136
+ when "assistant", "compactionSummary"
137
+ parts = []
138
+ text = role == "compactionSummary" ? MessageAccess.summary(message) || content.to_s : plain_content(content).to_s
139
+ parts << { text: text } unless text.empty?
140
+ MessageAccess.tool_calls(message).each do |tool_call|
141
+ function = ToolCall.function(tool_call)
142
+ parts << { functionCall: { name: ToolCall.value(function, :name), args: parse_tool_arguments(ToolCall.value(function, :arguments)) } }
143
+ end
144
+ { role: "model", parts: parts }
145
+ when "tool", "toolResult"
146
+ name = MessageAccess.tool_name(message) || MessageAccess.tool_call_id(message) || "tool"
147
+ response = JSON.parse(plain_content(content).to_s)
148
+ response = { "result" => response } unless response.is_a?(Hash)
149
+ { role: "user", parts: [{ functionResponse: { name: name, response: response } }] }
150
+ else
151
+ { role: "user", parts: [{ text: plain_content(content).to_s }] }
152
+ end
153
+ rescue JSON::ParserError
154
+ { role: "user", parts: [{ functionResponse: { name: name, response: { result: plain_content(content).to_s } } }] }
155
+ end
156
+
157
+ def gemini_tool_schema(tool)
158
+ function = ToolCall.function(tool)
159
+ {
160
+ name: ToolCall.value(function, :name),
161
+ description: ToolCall.value(function, :description) || "",
162
+ parameters: ToolCall.value(function, :parameters) || {}
163
+ }
164
+ end
165
+
117
166
  def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
118
- parts = build_context_parts("Codex", messages, tools, model: model)
167
+ responses_payload("Codex", messages, tools, model: model, reasoning: reasoning)
168
+ end
169
+
170
+ def openai_responses_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
171
+ payload = responses_payload("OpenAI", messages, tools, model: model, reasoning: reasoning)
172
+ payload[:max_output_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
173
+ payload
174
+ end
175
+
176
+ def responses_payload(provider, messages, tools, model:, reasoning:)
177
+ parts = build_context_parts(provider, messages, tools, model: model)
119
178
  payload = {
120
179
  model: parts[:model],
121
180
  instructions: parts[:instructions],
@@ -128,10 +187,10 @@ module Kward
128
187
  include: []
129
188
  }
130
189
  unless reasoning == false
131
- effort = reasoning || reasoning_effort("Codex")
190
+ effort = reasoning || reasoning_effort(provider)
132
191
  payload[:reasoning] = { effort: effort, summary: "auto" }
133
192
  # 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"
193
+ payload[:reasoning][:context] = "all_turns" if provider == "Codex" && parts[:model] == "gpt-5.6-luna"
135
194
  end
136
195
  payload
137
196
  end
@@ -161,7 +220,7 @@ module Kward
161
220
  input: input,
162
221
  tools: tools.map { |tool| codex_tool_schema(tool) }
163
222
  }
164
- elsif provider == "Codex"
223
+ elsif ["Codex", "OpenAI"].include?(provider)
165
224
  instructions, input = codex_messages(messages)
166
225
  {
167
226
  provider: provider,