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.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- 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
|