kward 0.67.0 → 0.68.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/CHANGELOG.md +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +37 -10
- data/lib/kward/rpc/session_manager.rb +123 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +125 -31
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- metadata +43 -1
data/lib/kward/model/client.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "net/http"
|
|
3
3
|
require "uri"
|
|
4
|
+
require_relative "../auth/anthropic_oauth"
|
|
4
5
|
require_relative "../auth/github_oauth"
|
|
5
6
|
require_relative "../auth/openai_oauth"
|
|
6
7
|
require_relative "../cancellation"
|
|
@@ -11,15 +12,25 @@ require_relative "payloads"
|
|
|
11
12
|
require_relative "../telemetry/logger"
|
|
12
13
|
require_relative "stream_parser"
|
|
13
14
|
|
|
15
|
+
# Namespace for the Kward CLI agent runtime.
|
|
14
16
|
module Kward
|
|
17
|
+
# Provider-facing model client used by CLI, RPC, compaction, and memory flows.
|
|
18
|
+
#
|
|
19
|
+
# `Client` owns runtime provider selection, credential lookup, retry telemetry,
|
|
20
|
+
# and HTTP requests for the supported model backends. Provider-neutral payload
|
|
21
|
+
# construction and stream parsing live in `ModelPayloads` and
|
|
22
|
+
# `ModelStreamParser`; keep new provider mechanics there when they are reusable,
|
|
23
|
+
# and keep product policy such as configured provider/model selection here.
|
|
15
24
|
class Client
|
|
16
25
|
include ModelPayloads
|
|
17
26
|
OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
|
|
18
27
|
OPENROUTER_MODELS_URL = URI("https://openrouter.ai/api/v1/models")
|
|
19
28
|
CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
|
|
29
|
+
ANTHROPIC_URL = URI("https://api.anthropic.com/v1/messages")
|
|
20
30
|
AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
|
|
21
31
|
OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config."
|
|
22
32
|
COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
|
|
33
|
+
ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `ruby lib/main.rb login anthropic`."
|
|
23
34
|
DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
|
|
24
35
|
DEFAULT_OPENROUTER_MODEL = ModelInfo::DEFAULT_OPENROUTER_MODEL
|
|
25
36
|
DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
|
|
@@ -41,6 +52,7 @@ module Kward
|
|
|
41
52
|
RequestError = Class.new(StandardError) do
|
|
42
53
|
attr_reader :provider, :code, :body
|
|
43
54
|
|
|
55
|
+
# Creates an object for model provider requests.
|
|
44
56
|
def initialize(provider:, code:, body:)
|
|
45
57
|
@provider = provider
|
|
46
58
|
@code = code.to_i
|
|
@@ -67,11 +79,13 @@ module Kward
|
|
|
67
79
|
end
|
|
68
80
|
TRANSIENT_NETWORK_ERRORS = [IOError, EOFError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout].freeze
|
|
69
81
|
|
|
70
|
-
|
|
82
|
+
# Creates an object for model provider requests.
|
|
83
|
+
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))
|
|
71
84
|
@openrouter_api_key = presence(api_key)
|
|
72
85
|
@openai_access_token = presence(openai_access_token)
|
|
73
86
|
@oauth = oauth
|
|
74
87
|
@github_oauth = github_oauth
|
|
88
|
+
@anthropic_oauth = anthropic_oauth
|
|
75
89
|
@model = model
|
|
76
90
|
@config_path = File.expand_path(config_path)
|
|
77
91
|
@config = load_config
|
|
@@ -81,66 +95,48 @@ module Kward
|
|
|
81
95
|
@openrouter_catalog = nil
|
|
82
96
|
end
|
|
83
97
|
|
|
84
|
-
def chat(messages, tools: [], on_reasoning_delta: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, model: nil, reasoning: nil)
|
|
98
|
+
def chat(messages, tools: [], on_reasoning_delta: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
|
|
85
99
|
cancellation&.raise_if_cancelled!
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
requested_provider = provider
|
|
101
|
+
url, token, resolved_provider, account_id = credentials(provider: requested_provider)
|
|
102
|
+
if token.to_s.empty? && !requested_provider.to_s.empty?
|
|
103
|
+
url, token, resolved_provider, account_id = credentials
|
|
104
|
+
model = nil
|
|
105
|
+
reasoning = nil
|
|
106
|
+
end
|
|
107
|
+
raise auth_error_for(resolved_provider) if token.nil? || token.empty?
|
|
88
108
|
|
|
89
|
-
current_model = model_for(
|
|
90
|
-
current_model = resolved_copilot_chat_model(current_model) if
|
|
109
|
+
current_model = model_for(resolved_provider, override_model: model)
|
|
110
|
+
current_model = resolved_copilot_chat_model(current_model) if resolved_provider == "Copilot" && model.nil?
|
|
91
111
|
|
|
92
|
-
validate_image_support!(
|
|
93
|
-
request_body = JSON.dump(request_body_payload(
|
|
94
|
-
with_retries(
|
|
112
|
+
validate_image_support!(resolved_provider, current_model, messages)
|
|
113
|
+
request_body = JSON.dump(request_body_payload(resolved_provider, messages, tools, max_tokens: max_tokens, model: current_model, reasoning: reasoning))
|
|
114
|
+
with_retries(resolved_provider, current_model, request_bytes: request_body.bytesize, on_retry: on_retry, cancellation: cancellation) do
|
|
95
115
|
request_started_at = @telemetry_logger.monotonic_now
|
|
96
116
|
message = nil
|
|
97
117
|
status = "completed"
|
|
98
118
|
error = nil
|
|
99
119
|
begin
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
request = Net::HTTP::Post.new(url)
|
|
117
|
-
request["Authorization"] = "Bearer #{token}"
|
|
118
|
-
request["Content-Type"] = "application/json"
|
|
119
|
-
request.body = request_body
|
|
120
|
-
|
|
121
|
-
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
|
|
122
|
-
cancellation&.on_cancel { close_http(http) }
|
|
123
|
-
cancellation&.raise_if_cancelled!
|
|
124
|
-
http.request(request)
|
|
125
|
-
end
|
|
126
|
-
cancellation&.raise_if_cancelled!
|
|
127
|
-
|
|
128
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
129
|
-
raise RequestError.new(provider: provider, code: response.code, body: response.body)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
body = JSON.parse(response.body)
|
|
133
|
-
message = body.fetch("choices").first.fetch("message")
|
|
134
|
-
cancellation&.raise_if_cancelled!
|
|
135
|
-
on_assistant_delta&.call(message.fetch("content", ""))
|
|
136
|
-
message = attach_response_metadata(message, provider: provider, model: current_model, usage: normalized_usage(body["usage"]))
|
|
137
|
-
message
|
|
120
|
+
message = chat_provider_request(
|
|
121
|
+
provider: resolved_provider,
|
|
122
|
+
url: url,
|
|
123
|
+
token: token,
|
|
124
|
+
account_id: account_id,
|
|
125
|
+
messages: messages,
|
|
126
|
+
tools: tools,
|
|
127
|
+
request_body: request_body,
|
|
128
|
+
current_model: current_model,
|
|
129
|
+
on_reasoning_delta: on_reasoning_delta,
|
|
130
|
+
on_assistant_delta: on_assistant_delta,
|
|
131
|
+
cancellation: cancellation,
|
|
132
|
+
max_tokens: max_tokens
|
|
133
|
+
)
|
|
138
134
|
rescue StandardError => e
|
|
139
135
|
status = "failed"
|
|
140
136
|
error = e
|
|
141
137
|
raise e
|
|
142
138
|
ensure
|
|
143
|
-
log_model_request(provider:
|
|
139
|
+
log_model_request(provider: resolved_provider, model: current_model, request_bytes: request_body.bytesize, duration_ms: @telemetry_logger.duration_ms(request_started_at), status: status, error: error, usage: message && (message["usage"] || message[:usage]))
|
|
144
140
|
end
|
|
145
141
|
end
|
|
146
142
|
rescue *TRANSIENT_NETWORK_ERRORS => e
|
|
@@ -153,6 +149,7 @@ module Kward
|
|
|
153
149
|
raise e
|
|
154
150
|
end
|
|
155
151
|
|
|
152
|
+
# Returns the active provider label after applying env/config/credential fallback rules.
|
|
156
153
|
def current_provider
|
|
157
154
|
_url, _token, provider = credentials
|
|
158
155
|
provider
|
|
@@ -163,58 +160,90 @@ module Kward
|
|
|
163
160
|
openai_configured? ? "Codex" : "OpenRouter"
|
|
164
161
|
end
|
|
165
162
|
|
|
163
|
+
# Returns the model id that will be used for the next request.
|
|
166
164
|
def current_model
|
|
167
|
-
|
|
165
|
+
current_model_state[:model]
|
|
168
166
|
end
|
|
169
167
|
|
|
168
|
+
# Returns the configured reasoning effort for providers that support it.
|
|
170
169
|
def current_reasoning_effort
|
|
171
|
-
reasoning_effort
|
|
170
|
+
current_model_state[:reasoning_effort]
|
|
172
171
|
end
|
|
173
172
|
|
|
173
|
+
# Returns the known context window for the active provider/model pair.
|
|
174
174
|
def current_context_window
|
|
175
|
-
|
|
175
|
+
state = current_model_state
|
|
176
|
+
ModelInfo.context_window(state[:provider], state[:model])
|
|
176
177
|
end
|
|
177
178
|
|
|
179
|
+
# Returns model choices suitable for settings UIs.
|
|
180
|
+
#
|
|
181
|
+
# Only providers with configured credentials are listed. The active provider
|
|
182
|
+
# may use live catalog data. Inactive logged-in providers use static
|
|
183
|
+
# supported choices plus their configured model so listing models does not
|
|
184
|
+
# perform avoidable network calls for every configured credential.
|
|
178
185
|
def available_models
|
|
179
186
|
provider = current_provider
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
models = []
|
|
188
|
+
|
|
189
|
+
if provider_logged_in?("Codex")
|
|
190
|
+
openai_model = model_for("Codex")
|
|
191
|
+
models += ModelInfo::OPENAI_MODEL_CHOICES.map do |id|
|
|
192
|
+
{ provider: "Codex", id: id, current: provider == "Codex" && openai_model == id }
|
|
193
|
+
end
|
|
194
|
+
models << { provider: "Codex", id: openai_model, current: provider == "Codex" } unless ModelInfo::OPENAI_MODEL_CHOICES.include?(openai_model)
|
|
187
195
|
end
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
|
|
197
|
+
if provider_logged_in?("OpenRouter")
|
|
198
|
+
openrouter_model = model_for("OpenRouter")
|
|
199
|
+
openrouter_choices = provider == "OpenRouter" ? openrouter_model_choices : ModelInfo::OPENROUTER_MODEL_CHOICES
|
|
200
|
+
models += openrouter_choices.map do |id|
|
|
201
|
+
{ provider: "OpenRouter", id: id, current: provider == "OpenRouter" && openrouter_model == id }
|
|
202
|
+
end
|
|
203
|
+
models << { provider: "OpenRouter", id: openrouter_model, current: provider == "OpenRouter" } unless openrouter_choices.include?(openrouter_model)
|
|
190
204
|
end
|
|
191
|
-
|
|
192
|
-
|
|
205
|
+
|
|
206
|
+
if provider_logged_in?("Copilot")
|
|
207
|
+
copilot_model = model_for("Copilot")
|
|
208
|
+
copilot_choices = provider == "Copilot" ? copilot_model_choices : static_copilot_model_choices
|
|
209
|
+
models += copilot_choices.map do |id|
|
|
210
|
+
{ provider: "Copilot", id: id, current: provider == "Copilot" && copilot_model == id }
|
|
211
|
+
end
|
|
212
|
+
models << { provider: "Copilot", id: copilot_model, current: provider == "Copilot" } unless copilot_choices.include?(copilot_model)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
if provider_logged_in?("Anthropic")
|
|
216
|
+
anthropic_model = model_for("Anthropic")
|
|
217
|
+
models += ModelInfo::ANTHROPIC_MODEL_CHOICES.map do |id|
|
|
218
|
+
{ provider: "Anthropic", id: id, current: provider == "Anthropic" && anthropic_model == id }
|
|
219
|
+
end
|
|
220
|
+
models << { provider: "Anthropic", id: anthropic_model, current: provider == "Anthropic" } unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model)
|
|
193
221
|
end
|
|
194
|
-
|
|
195
|
-
models << { provider: "OpenRouter", id: openrouter_model, current: provider == "OpenRouter" } unless openrouter_choices.include?(openrouter_model)
|
|
196
|
-
models << { provider: "Copilot", id: copilot_model, current: provider == "Copilot" } unless copilot_choices.include?(copilot_model)
|
|
197
|
-
|
|
222
|
+
|
|
198
223
|
# Sort models by provider, then alphabetically by id
|
|
199
224
|
models.sort_by { |model| [model[:provider], model[:id]] }
|
|
200
225
|
end
|
|
201
226
|
|
|
227
|
+
# Fetches the full OpenRouter public model catalog for settings UIs.
|
|
202
228
|
def openrouter_catalog
|
|
203
229
|
fetch_openrouter_models(full_catalog: true).map do |id|
|
|
204
230
|
{ provider: "OpenRouter", id: id, current: current_provider == "OpenRouter" && model_for("OpenRouter") == id }
|
|
205
231
|
end.sort_by { |model| model[:id] }
|
|
206
232
|
end
|
|
207
233
|
|
|
234
|
+
# Projects messages/tools into the provider-specific context shape without sending it.
|
|
208
235
|
def current_context_parts(messages, tools)
|
|
209
236
|
build_context_parts(current_provider, messages, tools)
|
|
210
237
|
end
|
|
211
238
|
|
|
239
|
+
# Returns whether the active provider can accept steering while a turn is streaming.
|
|
212
240
|
def supports_in_flight_steer?
|
|
213
241
|
current_provider == "Codex"
|
|
214
242
|
rescue StandardError
|
|
215
243
|
false
|
|
216
244
|
end
|
|
217
245
|
|
|
246
|
+
# Reloads config-backed provider settings and clears live model catalog caches.
|
|
218
247
|
def reload_config
|
|
219
248
|
@config = load_config
|
|
220
249
|
@copilot_models = nil
|
|
@@ -224,12 +253,147 @@ module Kward
|
|
|
224
253
|
|
|
225
254
|
private
|
|
226
255
|
|
|
256
|
+
def current_model_state
|
|
257
|
+
provider = current_provider
|
|
258
|
+
{
|
|
259
|
+
provider: provider,
|
|
260
|
+
model: model_for(provider),
|
|
261
|
+
reasoning_effort: reasoning_effort(provider)
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def chat_provider_request(provider:, url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:, max_tokens:)
|
|
266
|
+
case provider
|
|
267
|
+
when "Codex"
|
|
268
|
+
chat_codex_provider(
|
|
269
|
+
url: url,
|
|
270
|
+
token: token,
|
|
271
|
+
account_id: account_id,
|
|
272
|
+
messages: messages,
|
|
273
|
+
tools: tools,
|
|
274
|
+
request_body: request_body,
|
|
275
|
+
current_model: current_model,
|
|
276
|
+
on_reasoning_delta: on_reasoning_delta,
|
|
277
|
+
on_assistant_delta: on_assistant_delta,
|
|
278
|
+
cancellation: cancellation,
|
|
279
|
+
max_tokens: max_tokens
|
|
280
|
+
)
|
|
281
|
+
when "Copilot"
|
|
282
|
+
chat_copilot_provider(
|
|
283
|
+
url: url,
|
|
284
|
+
token: token,
|
|
285
|
+
messages: messages,
|
|
286
|
+
tools: tools,
|
|
287
|
+
request_body: request_body,
|
|
288
|
+
current_model: current_model,
|
|
289
|
+
on_assistant_delta: on_assistant_delta,
|
|
290
|
+
cancellation: cancellation
|
|
291
|
+
)
|
|
292
|
+
when "Anthropic"
|
|
293
|
+
chat_anthropic_provider(
|
|
294
|
+
url: url,
|
|
295
|
+
token: token,
|
|
296
|
+
request_body: request_body,
|
|
297
|
+
current_model: current_model,
|
|
298
|
+
on_reasoning_delta: on_reasoning_delta,
|
|
299
|
+
on_assistant_delta: on_assistant_delta,
|
|
300
|
+
cancellation: cancellation
|
|
301
|
+
)
|
|
302
|
+
else
|
|
303
|
+
chat_openrouter_provider(
|
|
304
|
+
url: url,
|
|
305
|
+
token: token,
|
|
306
|
+
request_body: request_body,
|
|
307
|
+
current_model: current_model,
|
|
308
|
+
on_assistant_delta: on_assistant_delta,
|
|
309
|
+
cancellation: cancellation
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def chat_codex_provider(url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:, max_tokens:)
|
|
315
|
+
message = codex_chat(
|
|
316
|
+
url,
|
|
317
|
+
token,
|
|
318
|
+
account_id,
|
|
319
|
+
messages,
|
|
320
|
+
tools,
|
|
321
|
+
request_body: request_body,
|
|
322
|
+
on_reasoning_delta: on_reasoning_delta,
|
|
323
|
+
on_assistant_delta: on_assistant_delta,
|
|
324
|
+
cancellation: cancellation,
|
|
325
|
+
max_tokens: max_tokens
|
|
326
|
+
)
|
|
327
|
+
attach_response_metadata(message, provider: "Codex", model: current_model)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def chat_copilot_provider(url:, token:, messages:, tools:, request_body:, current_model:, on_assistant_delta:, cancellation:)
|
|
331
|
+
message = if copilot_responses_model?(current_model)
|
|
332
|
+
copilot_responses_chat(token, request_body: request_body, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
|
|
333
|
+
else
|
|
334
|
+
copilot_chat(url, token, messages, tools, request_body: request_body, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
|
|
335
|
+
end
|
|
336
|
+
attach_response_metadata(message, provider: "Copilot", model: current_model)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def chat_anthropic_provider(url:, token:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:)
|
|
340
|
+
request = Net::HTTP::Post.new(url)
|
|
341
|
+
request["Authorization"] = "Bearer #{token}"
|
|
342
|
+
request["Content-Type"] = "application/json"
|
|
343
|
+
request["Accept"] = "text/event-stream"
|
|
344
|
+
anthropic_headers.each { |key, value| request[key] = value }
|
|
345
|
+
request.body = request_body
|
|
346
|
+
|
|
347
|
+
message = nil
|
|
348
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
|
|
349
|
+
cancellation&.on_cancel { close_http(http) }
|
|
350
|
+
cancellation&.raise_if_cancelled!
|
|
351
|
+
http.request(request) do |response|
|
|
352
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
353
|
+
body = +""
|
|
354
|
+
response.read_body { |chunk| body << chunk }
|
|
355
|
+
raise RequestError.new(provider: "Anthropic", code: response.code, body: redact(body, token))
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
message = parse_anthropic_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
cancellation&.raise_if_cancelled!
|
|
362
|
+
attach_response_metadata(message, provider: "Anthropic", model: current_model)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def chat_openrouter_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
|
|
366
|
+
request = Net::HTTP::Post.new(url)
|
|
367
|
+
request["Authorization"] = "Bearer #{token}"
|
|
368
|
+
request["Content-Type"] = "application/json"
|
|
369
|
+
request.body = request_body
|
|
370
|
+
|
|
371
|
+
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
|
|
372
|
+
cancellation&.on_cancel { close_http(http) }
|
|
373
|
+
cancellation&.raise_if_cancelled!
|
|
374
|
+
http.request(request)
|
|
375
|
+
end
|
|
376
|
+
cancellation&.raise_if_cancelled!
|
|
377
|
+
|
|
378
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
379
|
+
raise RequestError.new(provider: "OpenRouter", code: response.code, body: response.body)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
body = JSON.parse(response.body)
|
|
383
|
+
message = body.fetch("choices").first.fetch("message")
|
|
384
|
+
cancellation&.raise_if_cancelled!
|
|
385
|
+
on_assistant_delta&.call(message.fetch("content", ""))
|
|
386
|
+
attach_response_metadata(message, provider: "OpenRouter", model: current_model, usage: normalized_usage(body["usage"]))
|
|
387
|
+
end
|
|
388
|
+
|
|
227
389
|
def auth_error_for(provider)
|
|
228
390
|
case provider
|
|
229
391
|
when "OpenRouter"
|
|
230
392
|
OPENROUTER_AUTH_ERROR
|
|
231
393
|
when "Copilot"
|
|
232
394
|
COPILOT_AUTH_ERROR
|
|
395
|
+
when "Anthropic"
|
|
396
|
+
ANTHROPIC_AUTH_ERROR
|
|
233
397
|
else
|
|
234
398
|
AUTH_ERROR
|
|
235
399
|
end
|
|
@@ -313,8 +477,12 @@ module Kward
|
|
|
313
477
|
end
|
|
314
478
|
|
|
315
479
|
def request_body_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
|
|
480
|
+
reasoning = false unless ModelInfo.reasoning_supported?(provider, model)
|
|
481
|
+
|
|
316
482
|
if provider == "Codex"
|
|
317
483
|
codex_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
|
|
484
|
+
elsif provider == "Anthropic"
|
|
485
|
+
anthropic_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
|
|
318
486
|
elsif provider == "Copilot" && copilot_responses_model?(model)
|
|
319
487
|
copilot_responses_payload(messages, tools, max_tokens: max_tokens, model: model, reasoning: reasoning)
|
|
320
488
|
else
|
|
@@ -357,9 +525,7 @@ module Kward
|
|
|
357
525
|
end
|
|
358
526
|
|
|
359
527
|
def parse_openrouter_models(body)
|
|
360
|
-
|
|
361
|
-
entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
|
|
362
|
-
Array(entries).filter_map do |entry|
|
|
528
|
+
model_catalog_entries(body).filter_map do |entry|
|
|
363
529
|
if entry.is_a?(Hash)
|
|
364
530
|
entry["id"] || entry[:id] || entry["slug"] || entry[:slug]
|
|
365
531
|
else
|
|
@@ -370,7 +536,16 @@ module Kward
|
|
|
370
536
|
|
|
371
537
|
def copilot_model_choices
|
|
372
538
|
live_models = fetch_copilot_models
|
|
373
|
-
|
|
539
|
+
return static_copilot_model_choices if live_models.empty?
|
|
540
|
+
|
|
541
|
+
supported_copilot_model_choices(live_models)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def static_copilot_model_choices
|
|
545
|
+
supported_copilot_model_choices(ModelInfo::COPILOT_MODEL_CHOICES)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def supported_copilot_model_choices(choices)
|
|
374
549
|
choices.select { |model| copilot_supported_model?(model) }.uniq
|
|
375
550
|
end
|
|
376
551
|
|
|
@@ -410,15 +585,19 @@ module Kward
|
|
|
410
585
|
end
|
|
411
586
|
|
|
412
587
|
def parse_copilot_models(body)
|
|
413
|
-
|
|
414
|
-
entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
|
|
415
|
-
Array(entries).filter_map do |entry|
|
|
588
|
+
model_catalog_entries(body).filter_map do |entry|
|
|
416
589
|
copilot_model_id(entry)
|
|
417
590
|
end.uniq
|
|
418
591
|
rescue JSON::ParserError
|
|
419
592
|
[]
|
|
420
593
|
end
|
|
421
594
|
|
|
595
|
+
def model_catalog_entries(body)
|
|
596
|
+
data = JSON.parse(body.to_s)
|
|
597
|
+
entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
|
|
598
|
+
Array(entries)
|
|
599
|
+
end
|
|
600
|
+
|
|
422
601
|
def copilot_model_id(entry)
|
|
423
602
|
return entry.to_s.strip unless entry.is_a?(Hash)
|
|
424
603
|
return nil if entry.key?("model_picker_enabled") && entry["model_picker_enabled"] == false
|
|
@@ -495,6 +674,16 @@ module Kward
|
|
|
495
674
|
ModelStreamParser.parse_openai_chat_sse(body, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage))
|
|
496
675
|
end
|
|
497
676
|
|
|
677
|
+
def anthropic_headers
|
|
678
|
+
{
|
|
679
|
+
"anthropic-version" => "2023-06-01",
|
|
680
|
+
"anthropic-beta" => "claude-code-20250219,oauth-2025-04-20",
|
|
681
|
+
"anthropic-dangerous-direct-browser-access" => "true",
|
|
682
|
+
"user-agent" => "claude-cli/2.1.75",
|
|
683
|
+
"x-app" => "cli"
|
|
684
|
+
}
|
|
685
|
+
end
|
|
686
|
+
|
|
498
687
|
def copilot_headers(messages)
|
|
499
688
|
headers = GithubOAuth::COPILOT_HEADERS.dup
|
|
500
689
|
headers["X-Initiator"] = copilot_initiator(messages)
|
|
@@ -549,6 +738,10 @@ module Kward
|
|
|
549
738
|
ModelStreamParser.parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
|
|
550
739
|
end
|
|
551
740
|
|
|
741
|
+
def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
|
|
742
|
+
ModelStreamParser.parse_anthropic_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
|
|
743
|
+
end
|
|
744
|
+
|
|
552
745
|
def close_http(http)
|
|
553
746
|
http.finish if http&.started?
|
|
554
747
|
rescue IOError
|
|
@@ -572,9 +765,9 @@ module Kward
|
|
|
572
765
|
cache_read_tokens = positive_integer(
|
|
573
766
|
nested_value(usage, "input_tokens_details", "cached_tokens") ||
|
|
574
767
|
nested_value(usage, "prompt_tokens_details", "cached_tokens") ||
|
|
575
|
-
usage["cache_read_tokens"] || usage[:cache_read_tokens] || usage["cacheReadTokens"] || usage[:cacheReadTokens]
|
|
768
|
+
usage["cache_read_tokens"] || usage[:cache_read_tokens] || usage["cacheReadTokens"] || usage[:cacheReadTokens] || usage["cache_read_input_tokens"] || usage[:cache_read_input_tokens]
|
|
576
769
|
)
|
|
577
|
-
cache_write_tokens = integer_value(usage, "cache_write_tokens", "cacheWriteTokens")
|
|
770
|
+
cache_write_tokens = integer_value(usage, "cache_write_tokens", "cacheWriteTokens", "cache_creation_input_tokens")
|
|
578
771
|
total_tokens = integer_value(usage, "total_tokens", "totalTokens")
|
|
579
772
|
total_tokens ||= [input_tokens, output_tokens, cache_read_tokens, cache_write_tokens].compact.sum
|
|
580
773
|
return nil unless total_tokens&.positive? || input_tokens&.positive? || output_tokens&.positive?
|
|
@@ -610,12 +803,16 @@ module Kward
|
|
|
610
803
|
integer.positive? ? integer : nil
|
|
611
804
|
end
|
|
612
805
|
|
|
613
|
-
def credentials
|
|
614
|
-
provider = ModelInfo.provider_label(configured_provider)
|
|
806
|
+
def credentials(provider: nil)
|
|
807
|
+
provider = provider.to_s.empty? ? ModelInfo.provider_label(configured_provider) : ModelInfo.provider_label(provider)
|
|
615
808
|
if provider == "Copilot"
|
|
616
809
|
return [copilot_chat_url, github_access_token, provider, nil]
|
|
617
810
|
end
|
|
618
811
|
|
|
812
|
+
if provider == "Anthropic"
|
|
813
|
+
return [ANTHROPIC_URL, anthropic_access_token, provider, nil]
|
|
814
|
+
end
|
|
815
|
+
|
|
619
816
|
if provider == "OpenRouter"
|
|
620
817
|
return [OPENROUTER_URL, openrouter_api_key, provider, nil]
|
|
621
818
|
end
|
|
@@ -638,6 +835,23 @@ module Kward
|
|
|
638
835
|
ModelInfo.reasoning_effort(config: @config, provider: provider)
|
|
639
836
|
end
|
|
640
837
|
|
|
838
|
+
def provider_logged_in?(provider)
|
|
839
|
+
case provider
|
|
840
|
+
when "Codex"
|
|
841
|
+
openai_configured?
|
|
842
|
+
when "OpenRouter"
|
|
843
|
+
!openrouter_api_key.to_s.empty?
|
|
844
|
+
when "Copilot"
|
|
845
|
+
!github_access_token.to_s.empty?
|
|
846
|
+
when "Anthropic"
|
|
847
|
+
!anthropic_access_token.to_s.empty?
|
|
848
|
+
else
|
|
849
|
+
false
|
|
850
|
+
end
|
|
851
|
+
rescue StandardError
|
|
852
|
+
false
|
|
853
|
+
end
|
|
854
|
+
|
|
641
855
|
def openai_configured?
|
|
642
856
|
!@openai_access_token.to_s.empty? || @oauth.access_token.to_s != ""
|
|
643
857
|
rescue StandardError
|
|
@@ -652,6 +866,10 @@ module Kward
|
|
|
652
866
|
@github_oauth.access_token
|
|
653
867
|
end
|
|
654
868
|
|
|
869
|
+
def anthropic_access_token
|
|
870
|
+
@anthropic_oauth.access_token
|
|
871
|
+
end
|
|
872
|
+
|
|
655
873
|
def copilot_chat_url
|
|
656
874
|
URI("#{@github_oauth.base_url}/chat/completions")
|
|
657
875
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require_relative "../message_access"
|
|
3
3
|
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
5
|
module Kward
|
|
6
|
+
# Estimates provider context usage and compaction pressure.
|
|
5
7
|
class ContextUsage
|
|
6
8
|
OPENAI_CONTEXT_PROVIDERS = ["Codex", "OpenAI"].freeze
|
|
7
9
|
|
|
@@ -78,6 +80,7 @@ module Kward
|
|
|
78
80
|
end
|
|
79
81
|
end
|
|
80
82
|
|
|
83
|
+
# Structured context usage result returned to frontends.
|
|
81
84
|
class TiktokenTokenCounter
|
|
82
85
|
def count(text, model:)
|
|
83
86
|
encoding(model).encode(text.to_s).length
|