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
data/lib/kward/model/client.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 == "
|
|
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
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|