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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +125 -31
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -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
- def initialize(api_key: ENV["OPENROUTER_API_KEY"], model: nil, openai_access_token: ENV["OPENAI_ACCESS_TOKEN"], oauth: OpenAIOAuth.new, github_oauth: GithubOAuth.new, config_path: OpenAIOAuth.default_config_path, telemetry_logger: TelemetryLogger.new(config_path: config_path))
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
- url, token, provider, account_id = credentials
87
- raise auth_error_for(provider) if token.nil? || token.empty?
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(provider, override_model: model)
90
- current_model = resolved_copilot_chat_model(current_model) if provider == "Copilot" && model.nil?
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!(provider, current_model, messages)
93
- request_body = JSON.dump(request_body_payload(provider, messages, tools, max_tokens: max_tokens, model: current_model, reasoning: reasoning))
94
- with_retries(provider, current_model, request_bytes: request_body.bytesize, on_retry: on_retry, cancellation: cancellation) do
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
- if provider == "Codex"
101
- message = codex_chat(url, token, account_id, messages, tools, request_body: request_body, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, max_tokens: max_tokens)
102
- message = attach_response_metadata(message, provider: provider, model: current_model)
103
- next message
104
- end
105
-
106
- if provider == "Copilot"
107
- message = if copilot_responses_model?(current_model)
108
- copilot_responses_chat(token, request_body: request_body, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
109
- else
110
- copilot_chat(url, token, messages, tools, request_body: request_body, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
111
- end
112
- message = attach_response_metadata(message, provider: provider, model: current_model)
113
- next message
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: 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]))
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
- model_for(current_provider)
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(current_provider)
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
- ModelInfo.context_window(current_provider, current_model)
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
- openai_model = model_for("Codex")
181
- openrouter_model = model_for("OpenRouter")
182
- copilot_model = model_for("Copilot")
183
- openrouter_choices = openrouter_model_choices
184
- copilot_choices = copilot_model_choices
185
- models = ModelInfo::OPENAI_MODEL_CHOICES.map do |id|
186
- { provider: "Codex", id: id, current: provider == "Codex" && openai_model == id }
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
- models += openrouter_choices.map do |id|
189
- { provider: "OpenRouter", id: id, current: provider == "OpenRouter" && openrouter_model == id }
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
- models += copilot_choices.map do |id|
192
- { provider: "Copilot", id: id, current: provider == "Copilot" && copilot_model == id }
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
- models << { provider: "Codex", id: openai_model, current: provider == "Codex" } unless ModelInfo::OPENAI_MODEL_CHOICES.include?(openai_model)
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
- data = JSON.parse(body.to_s)
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
- choices = live_models.empty? ? ModelInfo::COPILOT_MODEL_CHOICES : live_models
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
- data = JSON.parse(body.to_s)
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,4 +1,6 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Detects provider errors caused by context-window overflow.
2
4
  module ContextOverflow
3
5
  OVERFLOW_PATTERNS = [
4
6
  /prompt is too long/i,
@@ -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