kward 0.66.0 → 0.67.1

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.
@@ -6,13 +6,14 @@ require_relative "../auth/openai_oauth"
6
6
  require_relative "../cancellation"
7
7
  require_relative "../config_files"
8
8
  require_relative "context_overflow"
9
- require_relative "../image_attachments"
10
9
  require_relative "model_info"
10
+ require_relative "payloads"
11
11
  require_relative "../telemetry/logger"
12
12
  require_relative "stream_parser"
13
13
 
14
14
  module Kward
15
15
  class Client
16
+ include ModelPayloads
16
17
  OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
17
18
  OPENROUTER_MODELS_URL = URI("https://openrouter.ai/api/v1/models")
18
19
  CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
@@ -629,200 +630,6 @@ module Kward
629
630
  end
630
631
  end
631
632
 
632
- def request_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
633
- parts = build_context_parts(provider, messages, tools, model: model)
634
- payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
635
- payload[:reasoning] = { effort: reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
636
- payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
637
- payload
638
- end
639
-
640
- def validate_image_support!(provider, model, messages)
641
- return if ModelInfo.supports_images?(provider, model)
642
- return unless messages_include_images?(messages)
643
-
644
- raise "Model '#{model}' does not support image inputs. Switch to a vision-capable model or remove the image attachment."
645
- end
646
-
647
- def messages_include_images?(messages)
648
- messages.any? do |message|
649
- content = message[:content] || message["content"]
650
- content.is_a?(Array) && content.any? { |part| (part[:type] || part["type"]).to_s == "image" }
651
- end
652
- end
653
-
654
- def chat_messages(messages)
655
- messages.map do |message|
656
- role = message[:role] || message["role"]
657
- content = message[:content] || message["content"]
658
- case role.to_s
659
- when "compactionSummary"
660
- { role: "assistant", content: message[:summary] || message["summary"] || content.to_s }
661
- when "assistant"
662
- api_message(message, role: "assistant", content: content.is_a?(Array) ? plain_content(content) : content, keys: ["tool_calls", :tool_calls, "name", :name])
663
- when "toolResult"
664
- api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "toolCallId", :toolCallId, "name", :name, "toolName", :toolName])
665
- when "tool"
666
- api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "name", :name])
667
- when "user"
668
- api_message(message, role: "user", content: content.is_a?(Array) ? chat_user_content(content) : content, keys: ["name", :name])
669
- else
670
- api_message(message, role: role, content: content, keys: ["name", :name])
671
- end
672
- end
673
- end
674
-
675
- def api_message(message, role:, content:, keys: [])
676
- result = { role: role, content: content }
677
- keys.each_slice(2) do |string_key, symbol_key|
678
- value = message[string_key] || message[symbol_key]
679
- next if value.nil?
680
-
681
- target_key = case string_key.to_s
682
- when "toolCallId" then :tool_call_id
683
- when "toolName" then :name
684
- else string_key.to_sym
685
- end
686
- result[target_key] = value
687
- end
688
- result
689
- end
690
-
691
- def chat_user_content(content)
692
- content.filter_map do |part|
693
- type = part[:type] || part["type"]
694
- if type == "text"
695
- { type: "text", text: part[:text] || part["text"] || "" }
696
- elsif type == "image"
697
- { type: "image_url", image_url: { url: ImageAttachments.data_url(part) } }
698
- end
699
- end
700
- end
701
-
702
- def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
703
- parts = build_context_parts("Codex", messages, tools, model: model)
704
- payload = {
705
- model: parts[:model],
706
- instructions: parts[:instructions],
707
- input: parts[:input],
708
- tools: parts[:tools],
709
- tool_choice: "auto",
710
- parallel_tool_calls: false,
711
- stream: true,
712
- store: false,
713
- include: []
714
- }
715
- payload[:reasoning] = { effort: reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
716
- payload
717
- end
718
-
719
- def build_context_parts(provider, messages, tools, model: nil)
720
- if provider == "CopilotResponses"
721
- instructions, input = codex_messages(messages)
722
- {
723
- provider: provider,
724
- model: model_for("Copilot", override_model: model),
725
- instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
726
- input: input,
727
- tools: tools.map { |tool| codex_tool_schema(tool) }
728
- }
729
- elsif provider == "Codex"
730
- instructions, input = codex_messages(messages)
731
- {
732
- provider: provider,
733
- model: model_for(provider, override_model: model),
734
- instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
735
- input: input,
736
- tools: tools.map { |tool| codex_tool_schema(tool) }
737
- }
738
- else
739
- {
740
- provider: provider,
741
- model: model_for(provider, override_model: model),
742
- messages: chat_messages(messages),
743
- tools: tools
744
- }
745
- end
746
- end
747
-
748
- def codex_messages(messages)
749
- instructions = []
750
- input = []
751
-
752
- messages.each do |message|
753
- role = message[:role] || message["role"]
754
- content = message[:content] || message["content"] || ""
755
- case role.to_s
756
- when "system"
757
- instructions << plain_content(content).to_s
758
- when "tool", "toolResult"
759
- input << {
760
- type: "function_call_output",
761
- call_id: message[:tool_call_id] || message["tool_call_id"] || message[:toolCallId] || message["toolCallId"] || message[:name] || message["name"] || message[:toolName] || message["toolName"] || "tool-call",
762
- output: plain_content(content).to_s
763
- }
764
- when "assistant"
765
- content = plain_content(content)
766
- input << codex_message("assistant", content.to_s) unless content.to_s.empty?
767
- (message[:tool_calls] || message["tool_calls"] || []).each do |tool_call|
768
- function = tool_call[:function] || tool_call["function"] || {}
769
- input << {
770
- type: "function_call",
771
- call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
772
- name: function[:name] || function["name"],
773
- arguments: function[:arguments] || function["arguments"] || "{}"
774
- }
775
- end
776
- when "compactionSummary"
777
- summary = message[:summary] || message["summary"] || content
778
- input << codex_message("assistant", summary.to_s) unless summary.to_s.empty?
779
- else
780
- input << codex_user_message(content)
781
- end
782
- end
783
-
784
- [instructions.join("\n\n"), input]
785
- end
786
-
787
- def codex_user_message(content)
788
- return codex_message("user", content.to_s) unless content.is_a?(Array)
789
-
790
- parts = content.filter_map do |part|
791
- type = part[:type] || part["type"]
792
- if type == "text"
793
- { type: "input_text", text: part[:text] || part["text"] || "" }
794
- elsif type == "image"
795
- { type: "input_image", image_url: ImageAttachments.data_url(part) }
796
- end
797
- end
798
- { type: "message", role: "user", content: parts }
799
- end
800
-
801
- def codex_message(role, text)
802
- type = role == "assistant" ? "output_text" : "input_text"
803
- { type: "message", role: role, content: [{ type: type, text: text }] }
804
- end
805
-
806
- def plain_content(content)
807
- return content unless content.is_a?(Array)
808
-
809
- content.filter_map do |part|
810
- type = part[:type] || part["type"]
811
- part[:text] || part["text"] if type == "text"
812
- end.join
813
- end
814
-
815
- def codex_tool_schema(tool)
816
- function = tool[:function] || tool["function"] || {}
817
- {
818
- type: "function",
819
- name: function[:name] || function["name"],
820
- description: function[:description] || function["description"] || "",
821
- parameters: function[:parameters] || function["parameters"] || {},
822
- strict: false
823
- }
824
- end
825
-
826
633
  def model_for(provider, override_model: nil)
827
634
  ModelInfo.model_for(provider, config: @config, override_model: override_model || @model)
828
635
  end
@@ -107,25 +107,25 @@ module Kward
107
107
  end
108
108
 
109
109
  def provider_config_value(provider)
110
- case provider.to_s
111
- when "OpenRouter" then "openrouter"
112
- when "Copilot" then "copilot"
110
+ case provider.to_s.downcase
111
+ when "openrouter" then "openrouter"
112
+ when "copilot" then "copilot"
113
113
  else "codex"
114
114
  end
115
115
  end
116
116
 
117
117
  def config_key_for_provider(provider)
118
- case provider.to_s
119
- when "OpenRouter" then "openrouter_model"
120
- when "Copilot" then "copilot_model"
118
+ case provider.to_s.downcase
119
+ when "openrouter" then "openrouter_model"
120
+ when "copilot" then "copilot_model"
121
121
  else "openai_model"
122
122
  end
123
123
  end
124
124
 
125
125
  def reasoning_config_key_for_provider(provider)
126
- case provider.to_s
127
- when "OpenRouter" then "openrouter_reasoning_effort"
128
- when "Copilot" then "copilot_reasoning_effort"
126
+ case provider.to_s.downcase
127
+ when "openrouter" then "openrouter_reasoning_effort"
128
+ when "copilot" then "copilot_reasoning_effort"
129
129
  else "openai_reasoning_effort"
130
130
  end
131
131
  end
@@ -166,7 +166,6 @@ module Kward
166
166
  provider: provider,
167
167
  id: id,
168
168
  name: model["name"] || id,
169
- model: model["model"] || id,
170
169
  reasoning: reasoning,
171
170
  reasoningEffort: reasoning_effort,
172
171
  contextWindow: model["contextWindow"] || model["context_window"] || context_window(provider, id),
@@ -0,0 +1,203 @@
1
+ require_relative "../image_attachments"
2
+ require_relative "model_info"
3
+
4
+ module Kward
5
+ module ModelPayloads
6
+ private
7
+
8
+ def request_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
9
+ parts = build_context_parts(provider, messages, tools, model: model)
10
+ payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
11
+ payload[:reasoning] = { effort: reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
12
+ payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
13
+ payload
14
+ end
15
+
16
+ def validate_image_support!(provider, model, messages)
17
+ return if ModelInfo.supports_images?(provider, model)
18
+ return unless messages_include_images?(messages)
19
+
20
+ raise "Model '#{model}' does not support image inputs. Switch to a vision-capable model or remove the image attachment."
21
+ end
22
+
23
+ def messages_include_images?(messages)
24
+ messages.any? do |message|
25
+ content = message[:content] || message["content"]
26
+ content.is_a?(Array) && content.any? { |part| (part[:type] || part["type"]).to_s == "image" }
27
+ end
28
+ end
29
+
30
+ def chat_messages(messages)
31
+ messages.map do |message|
32
+ role = message[:role] || message["role"]
33
+ content = message[:content] || message["content"]
34
+ case role.to_s
35
+ when "compactionSummary"
36
+ { role: "assistant", content: message[:summary] || message["summary"] || content.to_s }
37
+ when "assistant"
38
+ api_message(message, role: "assistant", content: content.is_a?(Array) ? plain_content(content) : content, keys: ["tool_calls", :tool_calls, "name", :name])
39
+ when "toolResult"
40
+ api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "toolCallId", :toolCallId, "name", :name, "toolName", :toolName])
41
+ when "tool"
42
+ api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "name", :name])
43
+ when "user"
44
+ api_message(message, role: "user", content: content.is_a?(Array) ? chat_user_content(content) : content, keys: ["name", :name])
45
+ else
46
+ api_message(message, role: role, content: content, keys: ["name", :name])
47
+ end
48
+ end
49
+ end
50
+
51
+ def api_message(message, role:, content:, keys: [])
52
+ result = { role: role, content: content }
53
+ keys.each_slice(2) do |string_key, symbol_key|
54
+ value = message[string_key] || message[symbol_key]
55
+ next if value.nil?
56
+
57
+ target_key = case string_key.to_s
58
+ when "toolCallId" then :tool_call_id
59
+ when "toolName" then :name
60
+ else string_key.to_sym
61
+ end
62
+ result[target_key] = value
63
+ end
64
+ result
65
+ end
66
+
67
+ def chat_user_content(content)
68
+ content.filter_map do |part|
69
+ type = part[:type] || part["type"]
70
+ if type == "text"
71
+ { type: "text", text: part[:text] || part["text"] || "" }
72
+ elsif type == "image"
73
+ { type: "image_url", image_url: { url: ImageAttachments.data_url(part) } }
74
+ end
75
+ end
76
+ end
77
+
78
+ def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
79
+ parts = build_context_parts("Codex", messages, tools, model: model)
80
+ payload = {
81
+ model: parts[:model],
82
+ instructions: parts[:instructions],
83
+ input: parts[:input],
84
+ tools: parts[:tools],
85
+ tool_choice: "auto",
86
+ parallel_tool_calls: false,
87
+ stream: true,
88
+ store: false,
89
+ include: []
90
+ }
91
+ payload[:reasoning] = { effort: reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
92
+ payload
93
+ end
94
+
95
+ def build_context_parts(provider, messages, tools, model: nil)
96
+ if provider == "CopilotResponses"
97
+ instructions, input = codex_messages(messages)
98
+ {
99
+ provider: provider,
100
+ model: model_for("Copilot", override_model: model),
101
+ instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
102
+ input: input,
103
+ tools: tools.map { |tool| codex_tool_schema(tool) }
104
+ }
105
+ elsif provider == "Codex"
106
+ instructions, input = codex_messages(messages)
107
+ {
108
+ provider: provider,
109
+ model: model_for(provider, override_model: model),
110
+ instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
111
+ input: input,
112
+ tools: tools.map { |tool| codex_tool_schema(tool) }
113
+ }
114
+ else
115
+ {
116
+ provider: provider,
117
+ model: model_for(provider, override_model: model),
118
+ messages: chat_messages(messages),
119
+ tools: tools
120
+ }
121
+ end
122
+ end
123
+
124
+ def codex_messages(messages)
125
+ instructions = []
126
+ input = []
127
+
128
+ messages.each do |message|
129
+ role = message[:role] || message["role"]
130
+ content = message[:content] || message["content"] || ""
131
+ case role.to_s
132
+ when "system"
133
+ instructions << plain_content(content).to_s
134
+ when "tool", "toolResult"
135
+ input << {
136
+ type: "function_call_output",
137
+ call_id: message[:tool_call_id] || message["tool_call_id"] || message[:toolCallId] || message["toolCallId"] || message[:name] || message["name"] || message[:toolName] || message["toolName"] || "tool-call",
138
+ output: plain_content(content).to_s
139
+ }
140
+ when "assistant"
141
+ content = plain_content(content)
142
+ input << codex_message("assistant", content.to_s) unless content.to_s.empty?
143
+ (message[:tool_calls] || message["tool_calls"] || []).each do |tool_call|
144
+ function = tool_call[:function] || tool_call["function"] || {}
145
+ input << {
146
+ type: "function_call",
147
+ call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
148
+ name: function[:name] || function["name"],
149
+ arguments: function[:arguments] || function["arguments"] || "{}"
150
+ }
151
+ end
152
+ when "compactionSummary"
153
+ summary = message[:summary] || message["summary"] || content
154
+ input << codex_message("assistant", summary.to_s) unless summary.to_s.empty?
155
+ else
156
+ input << codex_user_message(content)
157
+ end
158
+ end
159
+
160
+ [instructions.join("\n\n"), input]
161
+ end
162
+
163
+ def codex_user_message(content)
164
+ return codex_message("user", content.to_s) unless content.is_a?(Array)
165
+
166
+ parts = content.filter_map do |part|
167
+ type = part[:type] || part["type"]
168
+ if type == "text"
169
+ { type: "input_text", text: part[:text] || part["text"] || "" }
170
+ elsif type == "image"
171
+ { type: "input_image", image_url: ImageAttachments.data_url(part) }
172
+ end
173
+ end
174
+ { type: "message", role: "user", content: parts }
175
+ end
176
+
177
+ def codex_message(role, text)
178
+ type = role == "assistant" ? "output_text" : "input_text"
179
+ { type: "message", role: role, content: [{ type: type, text: text }] }
180
+ end
181
+
182
+ def plain_content(content)
183
+ return content unless content.is_a?(Array)
184
+
185
+ content.filter_map do |part|
186
+ type = part[:type] || part["type"]
187
+ part[:text] || part["text"] if type == "text"
188
+ end.join
189
+ end
190
+
191
+ def codex_tool_schema(tool)
192
+ function = tool[:function] || tool["function"] || {}
193
+ {
194
+ type: "function",
195
+ name: function[:name] || function["name"],
196
+ description: function[:description] || function["description"] || "",
197
+ parameters: function[:parameters] || function["parameters"] || {},
198
+ strict: false
199
+ }
200
+ end
201
+
202
+ end
203
+ end
@@ -0,0 +1,77 @@
1
+ require_relative "../ansi"
2
+ require_relative "../resources/avatar_kward_logo"
3
+ require_relative "../resources/pixel_logo"
4
+
5
+ module Kward
6
+ class PromptInterface
7
+ class Banner
8
+ LOGO_WIDTH = 32
9
+ LOGO_PIXEL_HEIGHT = 32
10
+ MIN_LOGO_HEIGHT = 4
11
+ LOGO_PIXELS = Kward::Resources::AvatarKwardLogo::PIXELS
12
+ MESSAGE = "State your business.".freeze
13
+
14
+ def initialize(message:, pixels:, screen_height:, minimum_composer_rows: 3)
15
+ @message = message.to_s
16
+ @pixels = pixels
17
+ @screen_height = screen_height
18
+ @minimum_composer_rows = minimum_composer_rows
19
+ @logo_cache = {}
20
+ end
21
+
22
+ def rows(width)
23
+ return [] unless visible?(width)
24
+
25
+ rows = []
26
+ rows.concat(centered_image_rows(width)) if image_visible?(width)
27
+ rows << align_plain_row(@message, width) unless @message.empty?
28
+ rows << ""
29
+ rows
30
+ end
31
+
32
+ def logo_rows(width)
33
+ logo_width, logo_height = logo_dimensions(width)
34
+ return [] unless @pixels && max_logo_height >= MIN_LOGO_HEIGHT
35
+
36
+ key = [logo_width, logo_height]
37
+ @logo_cache[key] ||= Kward::PixelLogo.half_block_rows_from_pixels(@pixels, width: logo_width, pixel_height: logo_height)
38
+ end
39
+
40
+ private
41
+
42
+ def visible?(width)
43
+ !@message.empty? || image_visible?(width)
44
+ end
45
+
46
+ def image_visible?(width)
47
+ !logo_rows(width).empty?
48
+ end
49
+
50
+ def centered_image_rows(width)
51
+ logo_width, = logo_dimensions(width)
52
+ padding = [[(width - logo_width) / 2, 0].max, width - 1].min
53
+ logo_rows(width).map { |row| (" " * padding) + row }
54
+ end
55
+
56
+ def logo_dimensions(width)
57
+ logo_width = [LOGO_WIDTH, [width - 2, 1].max].min
58
+ logo_height = [LOGO_PIXEL_HEIGHT, max_logo_height * 2].min
59
+ [logo_width, logo_height]
60
+ end
61
+
62
+ def max_logo_height
63
+ message_rows = @message.empty? ? 0 : 1
64
+ blank_after_banner = 1
65
+ transcript_row = 1
66
+ reserved_rows = message_rows + blank_after_banner + @minimum_composer_rows + transcript_row
67
+ [@screen_height.call - reserved_rows, 0].max
68
+ end
69
+
70
+ def align_plain_row(text, width)
71
+ plain_length = ANSI.strip(text).length
72
+ padding = [width - plain_length, 0].max / 2
73
+ (" " * padding) + text.to_s
74
+ end
75
+ end
76
+ end
77
+ end