kward 0.66.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -0,0 +1,55 @@
1
+ module Kward
2
+ module ContextOverflow
3
+ OVERFLOW_PATTERNS = [
4
+ /prompt is too long/i,
5
+ /request_too_large/i,
6
+ /input is too long for requested model/i,
7
+ /exceeds the context window/i,
8
+ /exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i,
9
+ /input token count.*exceeds the maximum/i,
10
+ /maximum prompt length is \d+/i,
11
+ /reduce the length of the messages/i,
12
+ /maximum context length is \d+ tokens/i,
13
+ /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
14
+ /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
15
+ /exceeds the limit of \d+/i,
16
+ /exceeds the available context size/i,
17
+ /greater than the context length/i,
18
+ /context window exceeds limit/i,
19
+ /exceeded model token limit/i,
20
+ /too large for model with \d+ maximum context length/i,
21
+ /model_context_window_exceeded/i,
22
+ /prompt too long; exceeded (?:max )?context length/i,
23
+ /context[_ ]length[_ ]exceeded/i,
24
+ /too many tokens/i,
25
+ /token limit exceeded/i
26
+ ].freeze
27
+
28
+ NON_OVERFLOW_PATTERNS = [
29
+ /^(Throttling error|Service unavailable):/i,
30
+ /rate limit/i,
31
+ /too many requests/i
32
+ ].freeze
33
+
34
+ module_function
35
+
36
+ def error?(error)
37
+ text = error_text(error)
38
+ return false if text.empty?
39
+ return false if NON_OVERFLOW_PATTERNS.any? { |pattern| text.match?(pattern) }
40
+ return true if request_too_large?(error)
41
+
42
+ OVERFLOW_PATTERNS.any? { |pattern| text.match?(pattern) }
43
+ end
44
+
45
+ def error_text(error)
46
+ parts = [error.message]
47
+ parts << error.body if error.respond_to?(:body)
48
+ parts.compact.join("\n")
49
+ end
50
+
51
+ def request_too_large?(error)
52
+ error.respond_to?(:code) && error.code.to_i == 413
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,104 @@
1
+ require "json"
2
+ require_relative "../message_access"
3
+
4
+ module Kward
5
+ class ContextUsage
6
+ OPENAI_CONTEXT_PROVIDERS = ["Codex", "OpenAI"].freeze
7
+
8
+ def initialize(token_counter: TiktokenTokenCounter.new)
9
+ @token_counter = token_counter
10
+ end
11
+
12
+ def call(provider:, model:, context_window:, context_parts:)
13
+ return nil unless OPENAI_CONTEXT_PROVIDERS.include?(provider.to_s)
14
+ return nil unless context_window
15
+ return nil if contains_image?(context_parts)
16
+
17
+ parts = stringify_keys(context_parts || {})
18
+ return nil unless contains_session_content?(parts)
19
+
20
+ payload = prompt_payload(parts)
21
+ return nil if payload.empty?
22
+
23
+ tokens = @token_counter.count(JSON.generate(payload), model: model)
24
+ {
25
+ tokens: tokens,
26
+ contextWindow: context_window,
27
+ percent: ((tokens.to_f / context_window.to_i) * 100).round(2),
28
+ estimated: true
29
+ }
30
+ rescue LoadError
31
+ nil
32
+ end
33
+
34
+ private
35
+
36
+ def prompt_payload(parts)
37
+ payload = {}
38
+ if parts.key?("instructions")
39
+ payload[:instructions] = parts["instructions"]
40
+ elsif parts.key?("messages")
41
+ payload[:messages] = parts["messages"]
42
+ end
43
+ payload[:input] = parts["input"] if parts.key?("input")
44
+ payload[:tools] = parts["tools"] if parts.key?("tools")
45
+ payload.compact
46
+ end
47
+
48
+ def contains_session_content?(parts)
49
+ input = parts["input"]
50
+ return !input.empty? if input.is_a?(Array)
51
+ return !input.to_s.empty? if parts.key?("input")
52
+
53
+ messages = parts["messages"]
54
+ return messages.any? { |message| MessageAccess.role(message) != "system" } if messages.is_a?(Array)
55
+
56
+ false
57
+ end
58
+
59
+ def contains_image?(value)
60
+ case value
61
+ when Hash
62
+ type = value[:type] || value["type"]
63
+ return true if ["image", "input_image", "image_url"].include?(type.to_s)
64
+ return true if value.key?(:image_url) || value.key?("image_url")
65
+
66
+ value.any? { |_key, item| contains_image?(item) }
67
+ when Array
68
+ value.any? { |item| contains_image?(item) }
69
+ else
70
+ false
71
+ end
72
+ end
73
+
74
+ def stringify_keys(value)
75
+ return value unless value.is_a?(Hash)
76
+
77
+ value.each_with_object({}) { |(key, item), result| result[key.to_s] = item }
78
+ end
79
+ end
80
+
81
+ class TiktokenTokenCounter
82
+ def count(text, model:)
83
+ encoding(model).encode(text.to_s).length
84
+ end
85
+
86
+ private
87
+
88
+ def encoding(model)
89
+ require "tiktoken_ruby"
90
+
91
+ Tiktoken.encoding_for_model(model.to_s)
92
+ rescue StandardError
93
+ Tiktoken.get_encoding(encoding_name_for_model(model))
94
+ end
95
+
96
+ def encoding_name_for_model(model)
97
+ id = model.to_s
98
+ return "o200k_base" if id.start_with?("gpt-5", "gpt-4.1", "gpt-4o", "o3", "o4")
99
+ return "cl100k_base" if id.start_with?("gpt-4", "gpt-3.5")
100
+
101
+ "o200k_base"
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,188 @@
1
+ require_relative "../config_files"
2
+
3
+ module Kward
4
+ module ModelInfo
5
+ DEFAULT_OPENAI_MODEL = "gpt-5.5"
6
+ DEFAULT_OPENROUTER_MODEL = "openai/gpt-5.5"
7
+ DEFAULT_COPILOT_MODEL = "gpt-5-mini"
8
+ DEFAULT_REASONING_EFFORT = "medium"
9
+ OPENAI_MODEL_CHOICES = %w[gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
10
+ OPENROUTER_MODEL_CHOICES = OPENAI_MODEL_CHOICES.map { |model| "openai/#{model}" }.freeze
11
+ COPILOT_MODEL_CHOICES = %w[
12
+ gpt-5-mini
13
+ gpt-5.3-codex
14
+ gpt-5.4
15
+ gpt-5.4-mini
16
+ gpt-5.5
17
+ claude-haiku-4.5
18
+ claude-opus-4.5
19
+ claude-opus-4.7
20
+ claude-opus-4.8
21
+ claude-sonnet-4.5
22
+ claude-sonnet-4.6
23
+ gemini-2.5-pro
24
+ gemini-3-flash-preview
25
+ gemini-3.1-pro-preview
26
+ gemini-3.5-flash
27
+ oswe-vscode-prime
28
+ ].freeze
29
+ REASONING_EFFORT_CHOICES = [
30
+ ["low", "Low"],
31
+ ["medium", "Medium"],
32
+ ["high", "High"],
33
+ ["xhigh", "Extra High"]
34
+ ].freeze
35
+
36
+ IMAGE_UNSUPPORTED_MODELS = [
37
+ /(?:\A|\/)gpt-5\.3-codex-spark\z/
38
+ ].freeze
39
+
40
+ OPENAI_CONTEXT_WINDOWS = [
41
+ [/\Agpt-5\.5/, 400_000],
42
+ [/\Agpt-5-codex/, 400_000],
43
+ [/\Agpt-5\.3-codex-spark/, 128_000],
44
+ [/\Agpt-5\.3-codex/, 400_000],
45
+ [/\Agpt-5\.2-codex/, 400_000],
46
+ [/\Agpt-5/, 400_000],
47
+ [/\Agpt-4\.1/, 1_047_576],
48
+ [/\Agpt-4o/, 128_000],
49
+ [/\Ao3/, 200_000],
50
+ [/\Ao4/, 200_000],
51
+ [/\Agpt-4/, 128_000],
52
+ [/\Agpt-3\.5-turbo/, 16_385]
53
+ ].freeze
54
+
55
+ module_function
56
+
57
+ def model_for(provider, config:, override_model: nil, env: ENV)
58
+ return override_model if override_model
59
+
60
+ case provider
61
+ when "OpenRouter"
62
+ env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model") || DEFAULT_OPENROUTER_MODEL
63
+ when "Copilot"
64
+ normalize_copilot_model(env["COPILOT_MODEL"] || ConfigFiles.config_value(config, "copilot_model", "model") || DEFAULT_COPILOT_MODEL)
65
+ else
66
+ env["OPENAI_MODEL"] || ConfigFiles.config_value(config, "openai_model", "model") || DEFAULT_OPENAI_MODEL
67
+ end
68
+ end
69
+
70
+ def normalize_copilot_model(id)
71
+ text = id.to_s.strip
72
+ return DEFAULT_COPILOT_MODEL if text.empty?
73
+ text = text.delete_prefix("copilot/")
74
+
75
+ {
76
+ "claude-haiku-4-5" => "claude-haiku-4.5",
77
+ "claude-opus-4-5" => "claude-opus-4.5",
78
+ "claude-opus-4-6" => "claude-opus-4.6",
79
+ "claude-opus-4-6-fast" => "claude-opus-4.6-fast",
80
+ "claude-opus-4-7" => "claude-opus-4.7",
81
+ "claude-opus-4-8" => "claude-opus-4.8",
82
+ "claude-sonnet-4-5" => "claude-sonnet-4.5",
83
+ "claude-sonnet-4-6" => "claude-sonnet-4.6",
84
+ "gemini-3.1-pro" => "gemini-3.1-pro-preview",
85
+ "gemini-3-flash" => "gemini-3-flash-preview"
86
+ }.fetch(text, text)
87
+ end
88
+
89
+ def reasoning_effort(config:, env: ENV, provider: nil)
90
+ case provider.to_s
91
+ when "OpenRouter"
92
+ env["OPENROUTER_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openrouter_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
93
+ when "Copilot"
94
+ env["COPILOT_REASONING_EFFORT"] || ConfigFiles.config_value(config, "copilot_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
95
+ else
96
+ env["OPENAI_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openai_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
97
+ end
98
+ end
99
+
100
+ def provider_label(provider)
101
+ case provider.to_s.downcase
102
+ when "openrouter" then "OpenRouter"
103
+ when "copilot" then "Copilot"
104
+ when "codex", "openai" then "Codex"
105
+ else provider.to_s
106
+ end
107
+ end
108
+
109
+ def provider_config_value(provider)
110
+ case provider.to_s
111
+ when "OpenRouter" then "openrouter"
112
+ when "Copilot" then "copilot"
113
+ else "codex"
114
+ end
115
+ end
116
+
117
+ def config_key_for_provider(provider)
118
+ case provider.to_s
119
+ when "OpenRouter" then "openrouter_model"
120
+ when "Copilot" then "copilot_model"
121
+ else "openai_model"
122
+ end
123
+ end
124
+
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"
129
+ else "openai_reasoning_effort"
130
+ end
131
+ end
132
+
133
+ def config_provider_for_provider(provider)
134
+ provider_config_value(provider)
135
+ end
136
+
137
+ def config_values_for_selection(provider, model)
138
+ {
139
+ config_key_for_provider(provider) => model,
140
+ "provider" => config_provider_for_provider(provider)
141
+ }
142
+ end
143
+
144
+ def context_window(provider, id)
145
+ return nil unless provider == "Codex"
146
+
147
+ match = OPENAI_CONTEXT_WINDOWS.find { |pattern, _window| id.to_s.match?(pattern) }
148
+ match&.last
149
+ end
150
+
151
+ def supports_images?(_provider, id)
152
+ IMAGE_UNSUPPORTED_MODELS.none? { |pattern| id.to_s.match?(pattern) }
153
+ end
154
+
155
+ def reasoning_supported?(provider, id)
156
+ provider == "Codex" || provider == "OpenRouter" || (provider == "Copilot" && id.to_s.match?(/\Agpt-5(?:\.|-|\z)/))
157
+ end
158
+
159
+ def normalize(model, current_provider: nil, current_model: nil, current_reasoning_effort: nil)
160
+ model = stringify_keys(model || {})
161
+ provider = model["provider"]
162
+ id = model["id"] || model["model"]
163
+ reasoning = boolean_value(model["reasoning"], default: reasoning_supported?(provider, id))
164
+ reasoning_effort = model["reasoningEffort"] || model["reasoning_effort"] || (current_reasoning_effort if reasoning)
165
+ {
166
+ provider: provider,
167
+ id: id,
168
+ name: model["name"] || id,
169
+ model: model["model"] || id,
170
+ reasoning: reasoning,
171
+ reasoningEffort: reasoning_effort,
172
+ contextWindow: model["contextWindow"] || model["context_window"] || context_window(provider, id),
173
+ current: boolean_value(model["current"], default: provider == current_provider && id == current_model)
174
+ }.compact
175
+ end
176
+
177
+ def boolean_value(value, default: false)
178
+ return default if value.nil?
179
+ return value if value == true || value == false
180
+
181
+ value.to_s == "true"
182
+ end
183
+
184
+ def stringify_keys(value)
185
+ value.each_with_object({}) { |(key, item), result| result[key.to_s] = item }
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,11 @@
1
+ module Kward
2
+ module RetryMessage
3
+ module_function
4
+
5
+ def format(event)
6
+ provider = event.provider.to_s.empty? ? "model" : event.provider
7
+ payload = event.request_bytes ? " with #{event.request_bytes} byte payload" : ""
8
+ "Retrying #{provider} request after transient failure (attempt #{event.attempt}/#{event.max_attempts}) in #{event.delay_seconds}s#{payload}: #{event.error}"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,205 @@
1
+ require "json"
2
+
3
+ module Kward
4
+ module ModelStreamParser
5
+ module_function
6
+
7
+ def parse_openai_chat_sse(body, on_assistant_delta: nil, usage_normalizer: nil)
8
+ content = +""
9
+ tool_calls = []
10
+ usage = nil
11
+ body.split(/\r?\n\r?\n/).each do |block|
12
+ data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
13
+ next if data.empty? || data == "[DONE]"
14
+
15
+ event = JSON.parse(data)
16
+ usage ||= usage_normalizer&.call(event["usage"])
17
+ choice = Array(event["choices"]).first || {}
18
+ delta = choice["delta"] || {}
19
+ if delta["content"]
20
+ text = delta["content"].to_s
21
+ content << text
22
+ on_assistant_delta&.call(text)
23
+ end
24
+ Array(delta["tool_calls"]).each do |tool_call|
25
+ merge_streaming_tool_call(tool_calls, tool_call)
26
+ end
27
+ message = choice["message"] || {}
28
+ content << message["content"].to_s if content.empty? && message["content"]
29
+ Array(message["tool_calls"]).each { |tool_call| merge_streaming_tool_call(tool_calls, tool_call) }
30
+ end
31
+ result = { "role" => "assistant", "content" => content }
32
+ result["tool_calls"] = finalized_streaming_tool_calls(tool_calls) unless tool_calls.empty?
33
+ result["usage"] = usage if usage
34
+ result
35
+ rescue JSON::ParserError => e
36
+ raise "Copilot returned invalid SSE JSON: #{e.message}"
37
+ end
38
+
39
+ def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
40
+ state = codex_sse_state
41
+ body.split(/\r?\n\r?\n/).each do |block|
42
+ process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
43
+ end
44
+ codex_sse_message(state)
45
+ rescue JSON::ParserError => e
46
+ raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
47
+ end
48
+
49
+ def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
50
+ state = codex_sse_state
51
+ buffer = +""
52
+
53
+ response.read_body do |chunk|
54
+ cancellation&.raise_if_cancelled!
55
+ buffer << chunk
56
+ while (index = buffer.index(/\r?\n\r?\n/))
57
+ delimiter = Regexp.last_match[0]
58
+ block = buffer[0...index]
59
+ buffer = buffer[(index + delimiter.length)..] || +""
60
+ process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
61
+ end
62
+ end
63
+ cancellation&.raise_if_cancelled!
64
+ process_codex_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
65
+ codex_sse_message(state)
66
+ rescue JSON::ParserError => e
67
+ raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
68
+ end
69
+
70
+ def merge_streaming_tool_call(tool_calls, delta)
71
+ index = (delta["index"] || tool_calls.length).to_i
72
+ tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => "", "arguments" => "" } }
73
+ current = tool_calls[index]
74
+ current["id"] = delta["id"] if delta["id"]
75
+ current["type"] = delta["type"] if delta["type"]
76
+ function = delta["function"] || {}
77
+ current["function"]["name"] << function["name"].to_s if function["name"]
78
+ current["function"]["arguments"] << function["arguments"].to_s if function["arguments"]
79
+ end
80
+
81
+ def finalized_streaming_tool_calls(tool_calls)
82
+ tool_calls.compact.each_with_index.map do |tool_call, index|
83
+ tool_call["id"] ||= "call_#{index}"
84
+ tool_call["type"] ||= "function"
85
+ tool_call["function"] ||= { "name" => "", "arguments" => "{}" }
86
+ tool_call["function"]["arguments"] = "{}" if tool_call["function"]["arguments"].to_s.empty?
87
+ tool_call
88
+ end
89
+ end
90
+
91
+ def codex_sse_state
92
+ { content: +"", reasoning_summary: +"", tool_calls: [], final_output: [], usage: nil }
93
+ end
94
+
95
+ def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
96
+ data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
97
+ return if data.empty? || data == "[DONE]"
98
+
99
+ event = JSON.parse(data)
100
+ case event["type"]
101
+ when "response.output_text.delta"
102
+ delta = event["delta"].to_s
103
+ state[:content] << delta
104
+ on_assistant_delta&.call(delta)
105
+ when "response.reasoning_summary_text.delta"
106
+ delta = event["delta"].to_s
107
+ state[:reasoning_summary] << delta
108
+ on_reasoning_delta&.call(delta)
109
+ when "response.output_item.done"
110
+ item = event["item"]
111
+ state[:final_output] << item if item.is_a?(Hash)
112
+ tool_call = codex_tool_call(item)
113
+ state[:tool_calls] << tool_call if tool_call
114
+ when "response.completed"
115
+ response = event["response"]
116
+ state[:usage] ||= usage_normalizer&.call(response["usage"]) if response.is_a?(Hash)
117
+ state[:usage] ||= usage_normalizer&.call(event["usage"])
118
+ if state[:content].empty? && response.is_a?(Hash) && response["output"].is_a?(Array)
119
+ state[:final_output] = response["output"]
120
+ text = text_from_codex_items(state[:final_output])
121
+ state[:content] << text
122
+ on_assistant_delta&.call(text) unless text.empty?
123
+ if state[:reasoning_summary].empty?
124
+ state[:reasoning_summary] << reasoning_summary_from_codex_items(state[:final_output])
125
+ end
126
+ end
127
+ when "response.failed", "response.incomplete"
128
+ raise codex_sse_error(event, request_error_class: request_error_class)
129
+ end
130
+ end
131
+
132
+ def codex_sse_error(event, request_error_class: nil)
133
+ response = event["response"]
134
+ error = event["error"] || (response["error"] if response.is_a?(Hash)) || {}
135
+ message = if error.is_a?(Hash)
136
+ [error["code"], error["message"]].compact.join(": ")
137
+ else
138
+ error.to_s
139
+ end
140
+ message = event["type"].to_s if message.empty?
141
+ code = error.is_a?(Hash) && error["code"].to_s == "server_error" ? 500 : 400
142
+ if request_error_class
143
+ request_error_class.new(provider: "Codex", code: code, body: "#{event["type"]}: #{message}")
144
+ else
145
+ "#{event["type"]}: #{message}"
146
+ end
147
+ end
148
+
149
+ def codex_sse_message(state)
150
+ if state[:tool_calls].empty?
151
+ state[:final_output].each do |item|
152
+ tool_call = codex_tool_call(item)
153
+ state[:tool_calls] << tool_call if tool_call
154
+ end
155
+ end
156
+
157
+ message = { "role" => "assistant", "content" => state[:content] }
158
+ message["reasoning_summary"] = state[:reasoning_summary] unless state[:reasoning_summary].empty?
159
+ message["tool_calls"] = state[:tool_calls] unless state[:tool_calls].empty?
160
+ message["usage"] = state[:usage] if state[:usage]
161
+ message
162
+ end
163
+
164
+ def codex_tool_call(item)
165
+ return nil unless item.is_a?(Hash) && ["function_call", "custom_tool_call"].include?(item["type"])
166
+
167
+ name = item["name"].to_s
168
+ return nil if name.empty?
169
+
170
+ arguments = item["arguments"] || item["input"] || "{}"
171
+ arguments = JSON.dump(arguments) unless arguments.is_a?(String)
172
+ {
173
+ "id" => (item["call_id"] || item["id"] || "call_#{name}"),
174
+ "type" => "function",
175
+ "function" => { "name" => name, "arguments" => arguments }
176
+ }
177
+ end
178
+
179
+ def text_from_codex_items(items)
180
+ items.flat_map do |item|
181
+ next [] unless item.is_a?(Hash)
182
+
183
+ if ["output_text", "text"].include?(item["type"])
184
+ item["text"].to_s
185
+ elsif item["type"] == "message" && item["content"].is_a?(Array)
186
+ item["content"].filter_map { |part| part["text"] if part.is_a?(Hash) && ["output_text", "text"].include?(part["type"]) }
187
+ else
188
+ []
189
+ end
190
+ end.join
191
+ end
192
+
193
+ def reasoning_summary_from_codex_items(items)
194
+ items.flat_map do |item|
195
+ next [] unless item.is_a?(Hash)
196
+
197
+ if item["type"] == "reasoning" && item["summary"].is_a?(Array)
198
+ item["summary"].filter_map { |part| part["text"] if part.is_a?(Hash) }
199
+ else
200
+ []
201
+ end
202
+ end.join
203
+ end
204
+ end
205
+ end