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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +13 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +82 -1
- data/Gemfile.lock +2 -2
- data/README.md +33 -38
- data/doc/agent-tools.md +10 -11
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +136 -27
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +9 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +3 -3
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +130 -0
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +15 -5
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +11 -3
- data/doc/rpc.md +65 -31
- data/doc/sandboxing.md +142 -0
- data/doc/security.md +31 -10
- data/doc/session-management.md +2 -4
- data/doc/skills.md +10 -0
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +4 -4
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/kward.gemspec +1 -1
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +162 -36
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +22 -1
- data/lib/kward/config_files.rb +106 -4
- data/lib/kward/conversation.rb +27 -8
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +364 -23
- data/lib/kward/model/model_info.rb +60 -1
- data/lib/kward/model/payloads.rb +64 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +141 -39
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +81 -8
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +43 -10
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +71 -12
- data/lib/kward/prompts/commands.rb +3 -1
- data/lib/kward/prompts.rb +22 -10
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +102 -14
- data/lib/kward/rpc/session_manager.rb +151 -38
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +110 -24
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +26 -6
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +34 -0
- data/templates/default/fulldoc/html/js/kward.js +3 -0
- data/templates/default/kward_navigation.rb +8 -1
- data/templates/default/layout/html/setup.rb +5 -0
- 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?(
|
|
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
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Kward
|
|
|
26
26
|
|
|
27
27
|
payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
|
|
28
28
|
payload[:reasoning] = { effort: reasoning || reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
|
|
29
|
+
payload[:stream] = true if provider == "Local"
|
|
29
30
|
payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
|
|
30
31
|
payload
|
|
31
32
|
end
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|