kward 0.79.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 +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- 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 +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- 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 +2 -2
- 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/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/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 +97 -33
- 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 +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -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 +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- 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 +7 -7
- 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 +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- 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 +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- 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_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -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
|
|
@@ -77,15 +77,15 @@ module Kward
|
|
|
77
77
|
result
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
|
|
80
|
+
def parse_codex_sse(body, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
|
|
81
81
|
state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
|
|
82
82
|
body.split(/\r?\n\r?\n/).each do |block|
|
|
83
|
-
process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
|
|
83
|
+
process_codex_sse_block(block, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
|
|
84
84
|
end
|
|
85
85
|
flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
|
|
86
86
|
codex_sse_message(state)
|
|
87
87
|
rescue JSON::ParserError => e
|
|
88
|
-
raise "
|
|
88
|
+
raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
# Incrementally parses a Codex/Responses SSE HTTP response body.
|
|
@@ -93,7 +93,7 @@ module Kward
|
|
|
93
93
|
# Deltas are yielded as soon as complete SSE blocks arrive so interactive
|
|
94
94
|
# frontends can render streamed assistant and reasoning text without waiting
|
|
95
95
|
# for the provider to close the response.
|
|
96
|
-
def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
|
|
96
|
+
def parse_codex_sse_stream(response, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
|
|
97
97
|
state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
|
|
98
98
|
buffer = +""
|
|
99
99
|
|
|
@@ -104,15 +104,15 @@ module Kward
|
|
|
104
104
|
delimiter = Regexp.last_match[0]
|
|
105
105
|
block = buffer[0...index]
|
|
106
106
|
buffer = buffer[(index + delimiter.length)..] || +""
|
|
107
|
-
process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
|
|
107
|
+
process_codex_sse_block(block, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
cancellation&.raise_if_cancelled!
|
|
111
|
-
process_codex_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
|
|
111
|
+
process_codex_sse_block(buffer, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
|
|
112
112
|
flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
|
|
113
113
|
codex_sse_message(state)
|
|
114
114
|
rescue JSON::ParserError => e
|
|
115
|
-
raise "
|
|
115
|
+
raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
|
|
@@ -135,6 +135,71 @@ module Kward
|
|
|
135
135
|
raise "Anthropic returned invalid SSE JSON: #{e.message}"
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
def parse_gemini_sse_stream(response, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil)
|
|
139
|
+
state = { content: +"", calls: {}, usage: nil }
|
|
140
|
+
buffer = +""
|
|
141
|
+
response.read_body do |chunk|
|
|
142
|
+
cancellation&.raise_if_cancelled!
|
|
143
|
+
buffer << chunk
|
|
144
|
+
while (index = buffer.index(/\r?\n\r?\n/))
|
|
145
|
+
delimiter = Regexp.last_match[0]
|
|
146
|
+
block = buffer[0...index]
|
|
147
|
+
buffer = buffer[(index + delimiter.length)..] || +""
|
|
148
|
+
process_gemini_sse_block(block, state, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: usage_normalizer)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
cancellation&.raise_if_cancelled!
|
|
152
|
+
process_gemini_sse_block(buffer, state, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: usage_normalizer) unless buffer.empty?
|
|
153
|
+
gemini_sse_message(state)
|
|
154
|
+
rescue JSON::ParserError => e
|
|
155
|
+
raise "Google Gemini returned invalid SSE JSON: #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def process_gemini_sse_block(block, state, on_assistant_delta:, cancellation:, usage_normalizer:)
|
|
159
|
+
data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
|
|
160
|
+
return if data.empty? || data == "[DONE]"
|
|
161
|
+
|
|
162
|
+
cancellation&.raise_if_cancelled!
|
|
163
|
+
event = JSON.parse(data)
|
|
164
|
+
Array(event["candidates"]).each_with_index do |candidate, candidate_index|
|
|
165
|
+
Array(candidate.dig("content", "parts")).each_with_index do |part, part_index|
|
|
166
|
+
text = part["text"].to_s
|
|
167
|
+
unless text.empty?
|
|
168
|
+
state[:content] << text
|
|
169
|
+
on_assistant_delta&.call(text)
|
|
170
|
+
end
|
|
171
|
+
merge_gemini_function_call(state[:calls], part["functionCall"], candidate_index: candidate_index, part_index: part_index)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
state[:usage] = usage_normalizer&.call(event["usageMetadata"]) || state[:usage]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def merge_gemini_function_call(calls, function_call, candidate_index:, part_index:)
|
|
178
|
+
return unless function_call.is_a?(Hash)
|
|
179
|
+
|
|
180
|
+
key = function_call["id"] || "#{candidate_index}:#{part_index}:#{function_call["name"]}"
|
|
181
|
+
call = calls[key] ||= { "id" => function_call["id"] || "call_gemini_#{candidate_index}_#{part_index}", "name" => function_call["name"].to_s, "args" => {}, "args_buffer" => +"" }
|
|
182
|
+
call["name"] = function_call["name"].to_s unless function_call["name"].to_s.empty?
|
|
183
|
+
args = function_call["args"]
|
|
184
|
+
if args.is_a?(Hash)
|
|
185
|
+
call["args"].merge!(args)
|
|
186
|
+
elsif !args.nil?
|
|
187
|
+
call["args_buffer"] << args.to_s
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def gemini_sse_message(state)
|
|
192
|
+
message = { "role" => "assistant", "content" => state[:content] }
|
|
193
|
+
unless state[:calls].empty?
|
|
194
|
+
message["tool_calls"] = state[:calls].values.map do |call|
|
|
195
|
+
arguments = call["args_buffer"].empty? ? JSON.dump(call["args"]) : call["args_buffer"]
|
|
196
|
+
{ "id" => call["id"], "type" => "function", "function" => { "name" => call["name"], "arguments" => arguments } }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
message["usage"] = state[:usage] if state[:usage]
|
|
200
|
+
message
|
|
201
|
+
end
|
|
202
|
+
|
|
138
203
|
def anthropic_sse_state
|
|
139
204
|
{ content: +"", reasoning_summary: +"", blocks: {}, tool_calls: [], usage: nil }
|
|
140
205
|
end
|
|
@@ -233,7 +298,7 @@ module Kward
|
|
|
233
298
|
|
|
234
299
|
def merge_streaming_tool_call(tool_calls, delta)
|
|
235
300
|
index = (delta["index"] || tool_calls.length).to_i
|
|
236
|
-
tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => "", "arguments" => "" } }
|
|
301
|
+
tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => +"", "arguments" => +"" } }
|
|
237
302
|
current = tool_calls[index]
|
|
238
303
|
current["id"] = delta["id"] if delta["id"]
|
|
239
304
|
current["type"] = delta["type"] if delta["type"]
|
|
@@ -271,7 +336,7 @@ module Kward
|
|
|
271
336
|
}
|
|
272
337
|
end
|
|
273
338
|
|
|
274
|
-
def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
|
|
339
|
+
def process_codex_sse_block(block, state, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
|
|
275
340
|
data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
|
|
276
341
|
return if data.empty? || data == "[DONE]"
|
|
277
342
|
|
|
@@ -309,11 +374,11 @@ module Kward
|
|
|
309
374
|
end
|
|
310
375
|
end
|
|
311
376
|
when "response.failed", "response.incomplete"
|
|
312
|
-
raise codex_sse_error(event, request_error_class: request_error_class)
|
|
377
|
+
raise codex_sse_error(event, provider_label: provider_label, request_error_class: request_error_class)
|
|
313
378
|
end
|
|
314
379
|
end
|
|
315
380
|
|
|
316
|
-
def codex_sse_error(event, request_error_class: nil)
|
|
381
|
+
def codex_sse_error(event, provider_label: "Codex", request_error_class: nil)
|
|
317
382
|
response = event["response"]
|
|
318
383
|
error = event["error"] || (response["error"] if response.is_a?(Hash)) || {}
|
|
319
384
|
message = if error.is_a?(Hash)
|
|
@@ -324,7 +389,7 @@ module Kward
|
|
|
324
389
|
message = event["type"].to_s if message.empty?
|
|
325
390
|
code = error.is_a?(Hash) && error["code"].to_s == "server_error" ? 500 : 400
|
|
326
391
|
if request_error_class
|
|
327
|
-
request_error_class.new(provider:
|
|
392
|
+
request_error_class.new(provider: provider_label, code: code, body: "#{event["type"]}: #{message}")
|
|
328
393
|
else
|
|
329
394
|
"#{event["type"]}: #{message}"
|
|
330
395
|
end
|
|
@@ -364,6 +429,10 @@ module Kward
|
|
|
364
429
|
part["type"] ||= "output_text"
|
|
365
430
|
text_key = part["type"] == "refusal" ? "refusal" : "text"
|
|
366
431
|
part[text_key] = part[text_key].to_s + text
|
|
432
|
+
if codex_streamable_message_item?(item)
|
|
433
|
+
on_assistant_delta&.call(text)
|
|
434
|
+
state[:emitted_message_keys] << codex_item_key(item)
|
|
435
|
+
end
|
|
367
436
|
end
|
|
368
437
|
state[:raw_content] << text
|
|
369
438
|
end
|
|
@@ -701,7 +770,8 @@ module Kward
|
|
|
701
770
|
end
|
|
702
771
|
|
|
703
772
|
def codex_streamable_message_item?(item)
|
|
704
|
-
codex_message_phase(item)
|
|
773
|
+
phase = codex_message_phase(item)
|
|
774
|
+
phase.empty? || phase == "final_answer"
|
|
705
775
|
end
|
|
706
776
|
|
|
707
777
|
def codex_message_phase(item)
|
|
@@ -13,6 +13,7 @@ module Kward
|
|
|
13
13
|
MODES = %w[ask read-only workspace-write deny-by-default].freeze
|
|
14
14
|
NETWORK_TOOLS = %w[web_search fetch_content fetch_raw].freeze
|
|
15
15
|
FILE_CHANGE_TOOLS = %w[write_file edit_file].freeze
|
|
16
|
+
MUTATING_TOOLS = (FILE_CHANGE_TOOLS + ["git_commit"]).freeze
|
|
16
17
|
|
|
17
18
|
Decision = Struct.new(:action, :reason, keyword_init: true) do
|
|
18
19
|
def allowed?
|
|
@@ -96,13 +97,15 @@ module Kward
|
|
|
96
97
|
def request_for(tool_name, arguments, source:)
|
|
97
98
|
name = tool_name.to_s
|
|
98
99
|
args = arguments.to_h
|
|
99
|
-
{
|
|
100
|
+
request = {
|
|
100
101
|
"tool" => name,
|
|
101
102
|
"path" => args["path"] || args[:path],
|
|
102
103
|
"host" => request_host(name, args),
|
|
103
104
|
"command" => args["command"] || args[:command],
|
|
104
105
|
"source" => source.to_s
|
|
105
106
|
}.compact.transform_values(&:to_s)
|
|
107
|
+
request["paths"] = args["paths"] || args[:paths] if name == "git_commit"
|
|
108
|
+
request
|
|
106
109
|
end
|
|
107
110
|
|
|
108
111
|
def request_host(name, arguments)
|
|
@@ -135,11 +138,11 @@ module Kward
|
|
|
135
138
|
return Decision.new(action: :deny, reason: "read-only mode") if @mode == "read-only" && risky?(request)
|
|
136
139
|
return Decision.new(action: :deny, reason: "deny-by-default mode") if @mode == "deny-by-default" && risky?(request)
|
|
137
140
|
|
|
138
|
-
if
|
|
141
|
+
if mutating?(request) && outside_write_scopes?(request)
|
|
139
142
|
return Decision.new(action: :deny, reason: "path outside write scopes")
|
|
140
143
|
end
|
|
141
144
|
|
|
142
|
-
if @mode == "workspace-write" &&
|
|
145
|
+
if @mode == "workspace-write" && mutating?(request)
|
|
143
146
|
return Decision.new(action: :allow, reason: "workspace write mode")
|
|
144
147
|
end
|
|
145
148
|
|
|
@@ -152,17 +155,28 @@ module Kward
|
|
|
152
155
|
FILE_CHANGE_TOOLS.include?(request.fetch("tool"))
|
|
153
156
|
end
|
|
154
157
|
|
|
158
|
+
def mutating?(request)
|
|
159
|
+
MUTATING_TOOLS.include?(request.fetch("tool"))
|
|
160
|
+
end
|
|
161
|
+
|
|
155
162
|
def network?(request)
|
|
156
163
|
NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
|
|
157
164
|
end
|
|
158
165
|
|
|
159
166
|
def risky?(request)
|
|
160
|
-
|
|
167
|
+
mutating?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
|
|
161
168
|
end
|
|
162
169
|
|
|
163
170
|
def outside_write_scopes?(request)
|
|
164
171
|
return false if @write_scopes.nil?
|
|
165
172
|
|
|
173
|
+
if request.fetch("tool") == "git_commit"
|
|
174
|
+
paths = request["paths"]
|
|
175
|
+
return true unless paths.is_a?(Array) && !paths.empty?
|
|
176
|
+
|
|
177
|
+
return paths.any? { |path| !@write_scopes.any? { |scope| glob_match?(scope, path) } }
|
|
178
|
+
end
|
|
179
|
+
|
|
166
180
|
path = request["path"]
|
|
167
181
|
path.nil? || !@write_scopes.any? { |scope| glob_match?(scope, path) }
|
|
168
182
|
end
|