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
@@ -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,7 +128,20 @@ 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
143
+ when "Local"
144
+ env["KWARD_LOCAL_MODEL"] || ConfigFiles.config_value(config, "local_model")
131
145
  when "OpenRouter"
132
146
  env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model")
133
147
  when "Copilot"
@@ -174,7 +188,14 @@ module Kward
174
188
  end
175
189
 
176
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
+
177
196
  case provider.to_s
197
+ when "Local"
198
+ nil
178
199
  when "OpenRouter"
179
200
  env["OPENROUTER_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openrouter_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
180
201
  when "Copilot"
@@ -187,38 +208,62 @@ module Kward
187
208
  end
188
209
 
189
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
+
190
216
  case provider.to_s.downcase
191
217
  when "openrouter" then "OpenRouter"
192
218
  when "copilot" then "Copilot"
193
219
  when "anthropic", "claude" then "Anthropic"
220
+ when "local" then "Local"
194
221
  when "codex", "openai" then "Codex"
195
222
  else provider.to_s
196
223
  end
197
224
  end
198
225
 
199
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
+
200
232
  case provider.to_s.downcase
201
233
  when "openrouter" then "openrouter"
202
234
  when "copilot" then "copilot"
203
235
  when "anthropic", "claude" then "anthropic"
236
+ when "local" then "local"
204
237
  else "codex"
205
238
  end
206
239
  end
207
240
 
208
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
+
209
247
  case provider.to_s.downcase
210
248
  when "openrouter" then "openrouter_model"
211
249
  when "copilot" then "copilot_model"
212
250
  when "anthropic", "claude" then "anthropic_model"
251
+ when "local" then "local_model"
213
252
  else "openai_model"
214
253
  end
215
254
  end
216
255
 
217
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
+
218
262
  case provider.to_s.downcase
219
263
  when "openrouter" then "openrouter_reasoning_effort"
220
264
  when "copilot" then "copilot_reasoning_effort"
221
265
  when "anthropic", "claude" then "anthropic_reasoning_effort"
266
+ when "local" then "local_reasoning_effort"
222
267
  else "openai_reasoning_effort"
223
268
  end
224
269
  end
@@ -236,6 +281,16 @@ module Kward
236
281
 
237
282
  def context_window(provider, id, openrouter_models: nil)
238
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)
239
294
  when "Codex"
240
295
  pattern_context_window(CODEX_CONTEXT_WINDOWS, id) ||
241
296
  openrouter_inferred_context_window(provider, id, openrouter_models: openrouter_models) ||
@@ -357,7 +412,9 @@ module Kward
357
412
  pattern_context_window(ANTHROPIC_CONTEXT_WINDOWS, normalize_anthropic_model(id))
358
413
  end
359
414
 
360
- def supports_images?(_provider, id)
415
+ def supports_images?(provider, id)
416
+ return false if provider == "Local"
417
+
361
418
  IMAGE_UNSUPPORTED_MODELS.none? { |pattern| id.to_s.match?(pattern) }
362
419
  end
363
420
 
@@ -417,6 +474,8 @@ module Kward
417
474
  reasoning: reasoning,
418
475
  reasoningEffort: reasoning_effort,
419
476
  contextWindow: model["contextWindow"] || model["context_window"] || context_window(provider, id),
477
+ supportedParameters: model["supportedParameters"] || model["supported_parameters"],
478
+ source: model["source"],
420
479
  current: boolean_value(model["current"], default: provider == current_provider && id == current_model)
421
480
  }.compact
422
481
  end
@@ -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
@@ -113,8 +114,67 @@ module Kward
113
114
  payload
114
115
  end
115
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
+
116
166
  def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
117
- 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)
118
178
  payload = {
119
179
  model: parts[:model],
120
180
  instructions: parts[:instructions],
@@ -127,10 +187,10 @@ module Kward
127
187
  include: []
128
188
  }
129
189
  unless reasoning == false
130
- effort = reasoning || reasoning_effort("Codex")
190
+ effort = reasoning || reasoning_effort(provider)
131
191
  payload[:reasoning] = { effort: effort, summary: "auto" }
132
192
  # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
133
- 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"
134
194
  end
135
195
  payload
136
196
  end
@@ -160,7 +220,7 @@ module Kward
160
220
  input: input,
161
221
  tools: tools.map { |tool| codex_tool_schema(tool) }
162
222
  }
163
- elsif provider == "Codex"
223
+ elsif ["Codex", "OpenAI"].include?(provider)
164
224
  instructions, input = codex_messages(messages)
165
225
  {
166
226
  provider: provider,
@@ -0,0 +1,114 @@
1
+ # Namespace for Kward model-provider definitions.
2
+ module Kward
3
+ # Stable, frontend-neutral descriptions of providers Kward can present to users.
4
+ # Runtime request and authentication mechanics remain in their respective clients.
5
+ module ProviderCatalog
6
+ Definition = Struct.new(
7
+ :id,
8
+ :name,
9
+ :api_key_env,
10
+ :protocol,
11
+ :oauth_name,
12
+ :requires_setup,
13
+ keyword_init: true
14
+ ) do
15
+ def api_key?
16
+ !api_key_env.empty?
17
+ end
18
+
19
+ def oauth?
20
+ !oauth_name.nil?
21
+ end
22
+ end
23
+
24
+ Runtime = Struct.new(:id, :model_config_key, :reasoning_config_key, :chat_url, :model_list_url, :model_discovery, keyword_init: true) do
25
+ def automatic_model_discovery?
26
+ model_discovery == :live
27
+ end
28
+ end
29
+
30
+ CHAT_URLS = {
31
+ "anthropic" => "https://api.anthropic.com/v1/messages",
32
+ "cerebras" => "https://api.cerebras.ai/v1/chat/completions",
33
+ "deepseek" => "https://api.deepseek.com/chat/completions",
34
+ "fireworks" => "https://api.fireworks.ai/inference/v1/chat/completions",
35
+ "gemini" => "https://generativelanguage.googleapis.com/v1beta",
36
+ "groq" => "https://api.groq.com/openai/v1/chat/completions",
37
+ "mistral" => "https://api.mistral.ai/v1/chat/completions",
38
+ "nvidia" => "https://integrate.api.nvidia.com/v1/chat/completions",
39
+ "openai" => "https://api.openai.com/v1/responses",
40
+ "openrouter" => "https://openrouter.ai/api/v1/chat/completions",
41
+ "together" => "https://api.together.xyz/v1/chat/completions",
42
+ "xai" => "https://api.x.ai/v1/chat/completions"
43
+ }.freeze
44
+ MODEL_LIST_URLS = {
45
+ "anthropic" => "https://api.anthropic.com/v1/models",
46
+ "cerebras" => "https://api.cerebras.ai/v1/models",
47
+ "deepseek" => "https://api.deepseek.com/models",
48
+ "fireworks" => "https://api.fireworks.ai/inference/v1/models",
49
+ "gemini" => "https://generativelanguage.googleapis.com/v1beta/models",
50
+ "groq" => "https://api.groq.com/openai/v1/models",
51
+ "mistral" => "https://api.mistral.ai/v1/models",
52
+ "nvidia" => "https://integrate.api.nvidia.com/v1/models",
53
+ "openai" => "https://api.openai.com/v1/models",
54
+ "together" => "https://api.together.xyz/v1/models",
55
+ "xai" => "https://api.x.ai/v1/models"
56
+ }.freeze
57
+
58
+ DEFINITIONS = [
59
+ Definition.new(id: "anthropic", name: "Anthropic", api_key_env: ["ANTHROPIC_API_KEY"], protocol: "anthropic_messages", oauth_name: "Anthropic Claude"),
60
+ Definition.new(id: "azure_openai", name: "Azure OpenAI", api_key_env: ["AZURE_OPENAI_API_KEY"], protocol: "azure_openai", requires_setup: true),
61
+ Definition.new(id: "cerebras", name: "Cerebras", api_key_env: ["CEREBRAS_API_KEY"], protocol: "openai_chat"),
62
+ Definition.new(id: "deepseek", name: "DeepSeek", api_key_env: ["DEEPSEEK_API_KEY"], protocol: "openai_chat"),
63
+ Definition.new(id: "fireworks", name: "Fireworks AI", api_key_env: ["FIREWORKS_API_KEY"], protocol: "openai_chat"),
64
+ Definition.new(id: "gemini", name: "Google Gemini", api_key_env: ["GEMINI_API_KEY"], protocol: "gemini"),
65
+ Definition.new(id: "groq", name: "Groq", api_key_env: ["GROQ_API_KEY"], protocol: "openai_chat"),
66
+ Definition.new(id: "mistral", name: "Mistral", api_key_env: ["MISTRAL_API_KEY"], protocol: "openai_chat"),
67
+ Definition.new(id: "nvidia", name: "NVIDIA NIM", api_key_env: ["NVIDIA_API_KEY", "NGC_API_KEY"], protocol: "openai_chat"),
68
+ Definition.new(id: "openai", name: "OpenAI", api_key_env: ["OPENAI_API_KEY"], protocol: "openai_responses", oauth_name: "ChatGPT"),
69
+ Definition.new(id: "openrouter", name: "OpenRouter", api_key_env: ["OPENROUTER_API_KEY"], protocol: "openai_chat", oauth_name: "OpenRouter"),
70
+ Definition.new(id: "together", name: "Together AI", api_key_env: ["TOGETHER_API_KEY"], protocol: "openai_chat"),
71
+ Definition.new(id: "xai", name: "xAI", api_key_env: ["XAI_API_KEY"], protocol: "openai_chat", oauth_name: "xAI Grok"),
72
+ Definition.new(id: "copilot", name: "GitHub Copilot", api_key_env: [], protocol: "copilot", oauth_name: "GitHub Copilot")
73
+ ].freeze
74
+
75
+ module_function
76
+
77
+ def all
78
+ DEFINITIONS
79
+ end
80
+
81
+ def fetch(id)
82
+ find(id) || raise(ArgumentError, "Unknown provider: #{id}")
83
+ end
84
+
85
+ def find(id)
86
+ DEFINITIONS.find { |provider| provider.id == id.to_s }
87
+ end
88
+
89
+ def find_by_name(name)
90
+ DEFINITIONS.find { |provider| provider.name.casecmp?(name.to_s) }
91
+ end
92
+
93
+ def runtime(id)
94
+ provider = fetch(id)
95
+ runtime_id = provider.id == "openai" ? "openai_api" : provider.id
96
+ Runtime.new(
97
+ id: runtime_id,
98
+ model_config_key: "#{runtime_id}_model",
99
+ reasoning_config_key: "#{runtime_id}_reasoning_effort",
100
+ chat_url: CHAT_URLS[provider.id],
101
+ model_list_url: MODEL_LIST_URLS[provider.id],
102
+ model_discovery: provider.id == "azure_openai" ? :configured : (MODEL_LIST_URLS.key?(provider.id) ? :live : :curated)
103
+ )
104
+ end
105
+
106
+ def api_key_providers
107
+ DEFINITIONS.select(&:api_key?)
108
+ end
109
+
110
+ def oauth_providers
111
+ DEFINITIONS.select(&:oauth?)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,70 @@
1
+ require_relative "catalog"
2
+ require_relative "model_info"
3
+ require_relative "provider_catalog"
4
+
5
+ # Namespace for Kward model catalogs.
6
+ module Kward
7
+ # Combines live, cached, and curated provider models for selection UIs.
8
+ class ModelSources
9
+ CURATED_MODELS = {
10
+ "anthropic" => %w[claude-sonnet-5 claude-opus-4-8 claude-haiku-4-5],
11
+ "azure_openai" => [],
12
+ "cerebras" => [],
13
+ "deepseek" => %w[deepseek-chat deepseek-reasoner],
14
+ "fireworks" => [],
15
+ "gemini" => %w[gemini-2.5-pro gemini-2.5-flash],
16
+ "groq" => [],
17
+ "mistral" => %w[mistral-large-latest],
18
+ "nvidia" => [],
19
+ "openai" => ModelInfo::OPENAI_MODEL_CHOICES,
20
+ "openrouter" => [],
21
+ "together" => [],
22
+ "xai" => %w[grok-3 grok-3-mini],
23
+ "copilot" => ModelInfo::COPILOT_MODEL_CHOICES
24
+ }.freeze
25
+
26
+ def initialize(provider_id:, api_key: nil, catalog: nil)
27
+ @provider = ProviderCatalog.fetch(provider_id)
28
+ @catalog = catalog || ModelCatalog.new(provider_id: @provider.id, api_key: api_key)
29
+ end
30
+
31
+ def models
32
+ merge(cached_models, curated_models)
33
+ end
34
+
35
+ def refresh
36
+ merge(live_models, curated_models)
37
+ rescue StandardError
38
+ models
39
+ end
40
+
41
+ def manual(id)
42
+ value = id.to_s.strip
43
+ raise ArgumentError, "Model ID must be a non-empty string" if value.empty?
44
+
45
+ { "provider" => @provider.id, "id" => value, "name" => value, "source" => "manual" }
46
+ end
47
+
48
+ private
49
+
50
+ def live_models
51
+ @catalog.refresh.map { |model| model.merge("source" => "live") }
52
+ end
53
+
54
+ def cached_models
55
+ @catalog.models.map { |model| model.merge("source" => "cached") }
56
+ end
57
+
58
+ def curated_models
59
+ CURATED_MODELS.fetch(@provider.id, []).map do |id|
60
+ { "provider" => @provider.id, "id" => id, "name" => id, "source" => "curated" }
61
+ end
62
+ end
63
+
64
+ def merge(*groups)
65
+ groups.flatten.each_with_object({}) do |model, result|
66
+ result[model.fetch("id")] ||= model
67
+ end.values.sort_by { |model| model.fetch("id") }
68
+ end
69
+ end
70
+ end