llm_gateway 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +544 -186
- data/Rakefile +1 -2
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +173 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
- data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
- data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/responses/output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
- data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
- data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
- data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
- data/lib/llm_gateway/adapters/structs.rb +145 -0
- data/lib/llm_gateway/base_client.rb +62 -1
- data/lib/llm_gateway/client.rb +45 -129
- data/lib/llm_gateway/clients/anthropic.rb +167 -0
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
- data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
- data/lib/llm_gateway/clients/groq.rb +54 -0
- data/lib/llm_gateway/clients/openai.rb +208 -0
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
- data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
- data/lib/llm_gateway/errors.rb +21 -0
- data/lib/llm_gateway/prompt.rb +12 -1
- data/lib/llm_gateway/provider_registry.rb +37 -0
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +165 -14
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- data/scripts/generate_handoff_live_fixture.rb +169 -0
- data/scripts/generate_handoff_media_fixture.rb +167 -0
- metadata +64 -28
- data/lib/llm_gateway/adapters/claude/client.rb +0 -60
- data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
- data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
- data/sample/claude_code_clone/agent.rb +0 -65
- data/sample/claude_code_clone/claude_code_clone.rb +0 -40
- data/sample/claude_code_clone/prompt.rb +0 -79
- data/sample/claude_code_clone/run.rb +0 -47
- data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
- data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
- data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
- data/sample/claude_code_clone/tools/read_tool.rb +0 -61
- data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
|
@@ -4,9 +4,9 @@ require "base64"
|
|
|
4
4
|
|
|
5
5
|
module LlmGateway
|
|
6
6
|
module Adapters
|
|
7
|
-
module
|
|
7
|
+
module OpenAI
|
|
8
8
|
module Responses
|
|
9
|
-
class BidirectionalMessageMapper <
|
|
9
|
+
class BidirectionalMessageMapper < OpenAI::ChatCompletions::BidirectionalMessageMapper
|
|
10
10
|
def map_content(content)
|
|
11
11
|
# Convert string content to text format
|
|
12
12
|
#
|
|
@@ -15,6 +15,8 @@ module LlmGateway
|
|
|
15
15
|
case content[:type]
|
|
16
16
|
when "text"
|
|
17
17
|
map_text_content(content)
|
|
18
|
+
when "image"
|
|
19
|
+
map_image_content(content)
|
|
18
20
|
when "message"
|
|
19
21
|
map_messages(content)
|
|
20
22
|
when "output_text"
|
|
@@ -25,6 +27,8 @@ module LlmGateway
|
|
|
25
27
|
map_tool_use_content(content)
|
|
26
28
|
when "tool_result"
|
|
27
29
|
map_tool_result_content(content)
|
|
30
|
+
when "reasoning"
|
|
31
|
+
map_reasoning_content(content)
|
|
28
32
|
else
|
|
29
33
|
content
|
|
30
34
|
end
|
|
@@ -37,10 +41,21 @@ module LlmGateway
|
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
def map_tool_result_content(content)
|
|
44
|
+
output = content[:content]
|
|
45
|
+
if output.is_a?(Array)
|
|
46
|
+
output = output.map do |item|
|
|
47
|
+
if item.is_a?(Hash)
|
|
48
|
+
map_content(item.transform_keys(&:to_sym))
|
|
49
|
+
else
|
|
50
|
+
item
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
40
55
|
{
|
|
41
56
|
"type": "function_call_output",
|
|
42
57
|
"call_id": content[:tool_use_id],
|
|
43
|
-
"output":
|
|
58
|
+
"output": output
|
|
44
59
|
}
|
|
45
60
|
end
|
|
46
61
|
|
|
@@ -54,17 +69,50 @@ module LlmGateway
|
|
|
54
69
|
|
|
55
70
|
def map_output_text_content(content)
|
|
56
71
|
{
|
|
57
|
-
type: "text",
|
|
72
|
+
type: direction == LlmGateway::DIRECTION_IN ? "input_text" : "text",
|
|
58
73
|
text: content[:text]
|
|
59
74
|
}
|
|
60
75
|
end
|
|
61
76
|
|
|
77
|
+
def map_reasoning_content(content)
|
|
78
|
+
if direction == LlmGateway::DIRECTION_IN
|
|
79
|
+
return { id: content[:id] } if content[:id]
|
|
80
|
+
|
|
81
|
+
content
|
|
82
|
+
else
|
|
83
|
+
{
|
|
84
|
+
type: "reasoning",
|
|
85
|
+
reasoning: normalize_reasoning_text(content[:summary]),
|
|
86
|
+
signature: content[:signature]
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def map_image_content(content)
|
|
92
|
+
{
|
|
93
|
+
type: "input_image",
|
|
94
|
+
image_url: "data:#{content[:media_type]};base64,#{content[:data]}"
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
62
98
|
def map_text_content(content)
|
|
63
99
|
{
|
|
64
100
|
type: "input_text",
|
|
65
101
|
text: content[:text]
|
|
66
102
|
}
|
|
67
103
|
end
|
|
104
|
+
|
|
105
|
+
def normalize_reasoning_text(summary)
|
|
106
|
+
return summary if summary.is_a?(String)
|
|
107
|
+
return nil unless summary.is_a?(Array)
|
|
108
|
+
return nil if summary.empty?
|
|
109
|
+
|
|
110
|
+
summary.filter_map do |item|
|
|
111
|
+
next item if item.is_a?(String)
|
|
112
|
+
|
|
113
|
+
item[:text] || item[:summary_text] || item[:reasoning]
|
|
114
|
+
end.join("\n")
|
|
115
|
+
end
|
|
68
116
|
end
|
|
69
117
|
end
|
|
70
118
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require_relative "bidirectional_message_mapper"
|
|
5
|
+
|
|
6
|
+
module LlmGateway
|
|
7
|
+
module Adapters
|
|
8
|
+
module OpenAI
|
|
9
|
+
module Responses
|
|
10
|
+
class InputMapper < OpenAI::ChatCompletions::InputMapper
|
|
11
|
+
def self.message_mapper
|
|
12
|
+
BidirectionalMessageMapper.new(LlmGateway::DIRECTION_IN)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.map_tools(tools)
|
|
16
|
+
return tools unless tools
|
|
17
|
+
mapper = message_mapper
|
|
18
|
+
|
|
19
|
+
tools.map do |tool|
|
|
20
|
+
mapped_tool = {
|
|
21
|
+
type: "function",
|
|
22
|
+
name: tool[:name],
|
|
23
|
+
description: tool[:description],
|
|
24
|
+
parameters: tool[:input_schema]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
[ :contents, :content ].each do |key|
|
|
28
|
+
next unless tool[key].is_a?(Array)
|
|
29
|
+
|
|
30
|
+
mapped_tool[key] = tool[key].map do |entry|
|
|
31
|
+
entry.is_a?(Hash) ? mapper.map_content(entry.transform_keys(&:to_sym)) : entry
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
mapped_tool
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.map_messages(messages)
|
|
40
|
+
return messages unless messages
|
|
41
|
+
mapper = message_mapper
|
|
42
|
+
|
|
43
|
+
messages.flat_map do |msg|
|
|
44
|
+
if msg[:id] && msg[:content].is_a?(Array)
|
|
45
|
+
# Full AssistantMessage#to_h — expand content for stateless multi-turn
|
|
46
|
+
map_assistant_history_message(msg)
|
|
47
|
+
elsif msg[:id]
|
|
48
|
+
# Bare item-reference (e.g. manually constructed { id: "item_xxx" })
|
|
49
|
+
msg.slice(:id)
|
|
50
|
+
else
|
|
51
|
+
content = if msg[:content].is_a?(Array)
|
|
52
|
+
msg[:content].map do |content|
|
|
53
|
+
mapper.map_content(content)
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
[ mapper.map_content(msg[:content]) ]
|
|
57
|
+
end
|
|
58
|
+
if msg.dig(:content).is_a?(Array) && msg.dig(:content, 0, :type) == "tool_result"
|
|
59
|
+
content
|
|
60
|
+
else
|
|
61
|
+
{
|
|
62
|
+
role: msg[:role],
|
|
63
|
+
content: content
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Map a full AssistantMessage#to_h into Responses API input items for
|
|
71
|
+
# stateless multi-turn conversations.
|
|
72
|
+
#
|
|
73
|
+
# text blocks → { role: "assistant", content: [{ type: "output_text", ... }] }
|
|
74
|
+
# tool_use blocks → top-level function_call items
|
|
75
|
+
# thinking blocks → omitted (model handles reasoning internally)
|
|
76
|
+
def self.map_assistant_history_message(msg)
|
|
77
|
+
blocks = (msg[:content] || []).map { |b| b.transform_keys(&:to_sym) }
|
|
78
|
+
|
|
79
|
+
text_blocks = blocks.select { |b| b[:type] == "text" }
|
|
80
|
+
tool_use_blocks = blocks.select { |b| b[:type] == "tool_use" }
|
|
81
|
+
|
|
82
|
+
result = []
|
|
83
|
+
|
|
84
|
+
if text_blocks.any?
|
|
85
|
+
result << {
|
|
86
|
+
role: "assistant",
|
|
87
|
+
content: text_blocks.map { |b| { type: "output_text", text: b[:text] } }
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
tool_use_blocks.each do |b|
|
|
92
|
+
result << {
|
|
93
|
+
type: "function_call",
|
|
94
|
+
call_id: b[:id],
|
|
95
|
+
name: b[:name],
|
|
96
|
+
arguments: b[:input].is_a?(Hash) ? b[:input].to_json : (b[:input] || {}).to_json
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmGateway
|
|
4
|
+
module Adapters
|
|
5
|
+
module OpenAI
|
|
6
|
+
module Responses
|
|
7
|
+
module OptionMapper
|
|
8
|
+
include LlmGateway::Adapters::OpenAI::PromptCacheOptionMapper
|
|
9
|
+
|
|
10
|
+
VALID_REASONING_LEVELS = %w[low medium high xhigh].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def map(options)
|
|
15
|
+
mapped_options = options.dup
|
|
16
|
+
|
|
17
|
+
max_completion_tokens = mapped_options.delete(:max_completion_tokens)
|
|
18
|
+
mapped_options[:max_output_tokens] = max_completion_tokens || mapped_options[:max_output_tokens] || 20_480
|
|
19
|
+
|
|
20
|
+
map_cache_key!(mapped_options)
|
|
21
|
+
map_prompt_cache_retention!(mapped_options)
|
|
22
|
+
|
|
23
|
+
return mapped_options unless mapped_options.key?(:reasoning)
|
|
24
|
+
|
|
25
|
+
reasoning = mapped_options.delete(:reasoning)
|
|
26
|
+
return mapped_options if reasoning.nil? || reasoning.to_s == "none"
|
|
27
|
+
|
|
28
|
+
mapped_options.merge(reasoning: normalize_reasoning(reasoning))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def normalize_reasoning(reasoning)
|
|
32
|
+
effort = reasoning.to_s
|
|
33
|
+
return { effort: effort, summary: "detailed" } if VALID_REASONING_LEVELS.include?(effort)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'."
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../structs"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Adapters
|
|
7
|
+
module OpenAI
|
|
8
|
+
module Responses
|
|
9
|
+
class StreamMapper
|
|
10
|
+
def map(chunk)
|
|
11
|
+
queued_event = shift_queued_event
|
|
12
|
+
return queued_event if queued_event
|
|
13
|
+
|
|
14
|
+
event_type = chunk[:event]
|
|
15
|
+
data = chunk[:data] || {}
|
|
16
|
+
raise_stream_error!(data) if event_type == "error" || data[:error] || data[:type] == "error"
|
|
17
|
+
|
|
18
|
+
case event_type
|
|
19
|
+
when "response.created"
|
|
20
|
+
stash_response(data[:response])
|
|
21
|
+
nil
|
|
22
|
+
when "response.output_item.added"
|
|
23
|
+
map_output_item_added(data)
|
|
24
|
+
when "response.output_item.done"
|
|
25
|
+
map_output_item_done(data)
|
|
26
|
+
when "response.content_part.added"
|
|
27
|
+
map_content_part_added(data)
|
|
28
|
+
when "response.content_part.done", "response.output_text.done"
|
|
29
|
+
map_text_done(data)
|
|
30
|
+
when "response.output_text.delta"
|
|
31
|
+
AssistantStreamEvent.new(
|
|
32
|
+
type: :text_delta,
|
|
33
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
34
|
+
delta: data[:delta] || ""
|
|
35
|
+
)
|
|
36
|
+
when "response.function_call_arguments.delta"
|
|
37
|
+
AssistantStreamEvent.new(
|
|
38
|
+
type: :tool_delta,
|
|
39
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
40
|
+
delta: data[:delta] || ""
|
|
41
|
+
)
|
|
42
|
+
when "response.function_call_arguments.done"
|
|
43
|
+
map_tool_done(data)
|
|
44
|
+
when "response.reasoning_summary_text.delta"
|
|
45
|
+
output_index = data[:output_index] || 0
|
|
46
|
+
mark_reasoning_has_content(output_index)
|
|
47
|
+
AssistantStreamReasoningEvent.new(
|
|
48
|
+
type: :reasoning_delta,
|
|
49
|
+
content_index: content_index_for(output_index),
|
|
50
|
+
delta: data[:delta] || "",
|
|
51
|
+
signature: ""
|
|
52
|
+
)
|
|
53
|
+
when "response.completed"
|
|
54
|
+
map_response_completed(data[:response])
|
|
55
|
+
else
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def map_output_item_added(data)
|
|
63
|
+
item = data[:item] || {}
|
|
64
|
+
output_index = data[:output_index] || 0
|
|
65
|
+
|
|
66
|
+
case item[:type]
|
|
67
|
+
when "reasoning"
|
|
68
|
+
mark_reasoning_started(output_index)
|
|
69
|
+
AssistantStreamReasoningEvent.new(
|
|
70
|
+
type: :reasoning_start,
|
|
71
|
+
content_index: register_content_index(output_index),
|
|
72
|
+
delta: "",
|
|
73
|
+
signature: ""
|
|
74
|
+
)
|
|
75
|
+
when "message"
|
|
76
|
+
register_content_index(output_index)
|
|
77
|
+
ensure_message_started(role: item[:role] || "assistant")
|
|
78
|
+
when "function_call"
|
|
79
|
+
stash_role("assistant")
|
|
80
|
+
mark_tool_started(output_index)
|
|
81
|
+
AssistantToolStartEvent.new(
|
|
82
|
+
type: :tool_start,
|
|
83
|
+
content_index: register_content_index(output_index),
|
|
84
|
+
delta: "",
|
|
85
|
+
id: item[:call_id] || item[:id],
|
|
86
|
+
name: item[:name]
|
|
87
|
+
)
|
|
88
|
+
else
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def map_output_item_done(data)
|
|
94
|
+
item = data[:item] || {}
|
|
95
|
+
output_index = data[:output_index] || 0
|
|
96
|
+
|
|
97
|
+
case item[:type]
|
|
98
|
+
when "reasoning"
|
|
99
|
+
map_reasoning_done(output_index, item)
|
|
100
|
+
when "function_call"
|
|
101
|
+
map_function_call_done(output_index, item)
|
|
102
|
+
else
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def map_reasoning_done(output_index, item)
|
|
108
|
+
content_index = content_index_for(output_index)
|
|
109
|
+
summary_text = extract_reasoning_summary_text(item)
|
|
110
|
+
|
|
111
|
+
if reasoning_started_without_content?(output_index) && !summary_text.empty?
|
|
112
|
+
queue_event(
|
|
113
|
+
AssistantStreamReasoningEvent.new(
|
|
114
|
+
type: :reasoning_end,
|
|
115
|
+
content_index:,
|
|
116
|
+
delta: "",
|
|
117
|
+
signature: ""
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
mark_reasoning_completed(output_index)
|
|
121
|
+
return AssistantStreamReasoningEvent.new(
|
|
122
|
+
type: :reasoning_delta,
|
|
123
|
+
content_index:,
|
|
124
|
+
delta: summary_text,
|
|
125
|
+
signature: ""
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
mark_reasoning_completed(output_index)
|
|
130
|
+
AssistantStreamReasoningEvent.new(
|
|
131
|
+
type: :reasoning_end,
|
|
132
|
+
content_index:,
|
|
133
|
+
delta: "",
|
|
134
|
+
signature: ""
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def map_function_call_done(output_index, item)
|
|
139
|
+
return nil if tool_started?(output_index)
|
|
140
|
+
|
|
141
|
+
mark_tool_started(output_index)
|
|
142
|
+
queue_event(
|
|
143
|
+
AssistantStreamEvent.new(
|
|
144
|
+
type: :tool_end,
|
|
145
|
+
content_index: content_index_for(output_index),
|
|
146
|
+
delta: ""
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
AssistantToolStartEvent.new(
|
|
151
|
+
type: :tool_start,
|
|
152
|
+
content_index: register_content_index(output_index),
|
|
153
|
+
delta: "",
|
|
154
|
+
id: item[:call_id] || item[:id],
|
|
155
|
+
name: item[:name]
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def map_content_part_added(data)
|
|
160
|
+
part = data[:part] || {}
|
|
161
|
+
return nil unless part[:type] == "output_text"
|
|
162
|
+
|
|
163
|
+
AssistantStreamEvent.new(
|
|
164
|
+
type: :text_start,
|
|
165
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
166
|
+
delta: ""
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def map_text_done(data)
|
|
171
|
+
AssistantStreamEvent.new(
|
|
172
|
+
type: :text_end,
|
|
173
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
174
|
+
delta: ""
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def map_tool_done(data)
|
|
179
|
+
AssistantStreamEvent.new(
|
|
180
|
+
type: :tool_end,
|
|
181
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
182
|
+
delta: ""
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def map_response_completed(response)
|
|
187
|
+
stash_response(response)
|
|
188
|
+
AssistantStreamMessageEvent.new(
|
|
189
|
+
type: message_started? ? :message_delta : :message_start,
|
|
190
|
+
delta: pending_message_attributes.merge(role: pending_message_attributes[:role] || "assistant", stop_reason: stop_reason_for(response)),
|
|
191
|
+
usage_increment: usage_increment(response)
|
|
192
|
+
).tap do
|
|
193
|
+
@message_started = true
|
|
194
|
+
clear_pending_message_attributes
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def usage_increment(response)
|
|
199
|
+
usage = response[:usage] || {}
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
input_tokens: usage[:input_tokens] || 0,
|
|
203
|
+
cache_creation_input_tokens: 0,
|
|
204
|
+
cache_read_input_tokens: usage.dig(:input_tokens_details, :cached_tokens) || 0,
|
|
205
|
+
output_tokens: usage[:output_tokens] || 0,
|
|
206
|
+
reasoning_tokens: usage.dig(:output_tokens_details, :reasoning_tokens) || 0
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def stop_reason_for(response)
|
|
211
|
+
output = response[:output] || []
|
|
212
|
+
last_item = output.last || {}
|
|
213
|
+
|
|
214
|
+
tool_state.any? || last_item[:type] == "function_call" ? "tool_use" : "stop"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def ensure_message_started(role: "assistant")
|
|
218
|
+
return nil if message_started?
|
|
219
|
+
|
|
220
|
+
@message_started = true
|
|
221
|
+
AssistantStreamMessageEvent.new(
|
|
222
|
+
type: :message_start,
|
|
223
|
+
delta: pending_message_attributes.merge(role: role).compact,
|
|
224
|
+
usage_increment: {}
|
|
225
|
+
).tap do
|
|
226
|
+
clear_pending_message_attributes
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def extract_reasoning_summary_text(item)
|
|
231
|
+
Array(item[:summary]).filter_map do |summary|
|
|
232
|
+
next summary[:text] if summary.is_a?(Hash) && summary[:text]
|
|
233
|
+
next summary[:summary] if summary.is_a?(Hash) && summary[:summary]
|
|
234
|
+
next summary if summary.is_a?(String)
|
|
235
|
+
end.join
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def mark_reasoning_started(output_index)
|
|
239
|
+
reasoning_state[output_index] = :started
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def mark_reasoning_has_content(output_index)
|
|
243
|
+
reasoning_state[output_index] = :has_content
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def mark_reasoning_completed(output_index)
|
|
247
|
+
reasoning_state[output_index] = :completed
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def reasoning_started_without_content?(output_index)
|
|
251
|
+
reasoning_state[output_index] == :started
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def reasoning_state
|
|
255
|
+
@reasoning_state ||= {}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def mark_tool_started(output_index)
|
|
259
|
+
tool_state[output_index] = :started
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def tool_started?(output_index)
|
|
263
|
+
tool_state[output_index] == :started
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def tool_state
|
|
267
|
+
@tool_state ||= {}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def stash_response(response)
|
|
271
|
+
response ||= {}
|
|
272
|
+
@pending_message_attributes = pending_message_attributes.merge(
|
|
273
|
+
id: response[:id],
|
|
274
|
+
model: response[:model]
|
|
275
|
+
).compact
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def stash_role(role)
|
|
279
|
+
@pending_message_attributes = pending_message_attributes.merge(role:)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def pending_message_attributes
|
|
283
|
+
@pending_message_attributes ||= {}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def clear_pending_message_attributes
|
|
287
|
+
@pending_message_attributes = {}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def register_content_index(output_index)
|
|
291
|
+
content_index_map[output_index] ||= next_content_index!
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def content_index_for(output_index)
|
|
295
|
+
content_index_map.fetch(output_index) { register_content_index(output_index) }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def next_content_index!
|
|
299
|
+
@next_content_index ||= 0
|
|
300
|
+
current = @next_content_index
|
|
301
|
+
@next_content_index += 1
|
|
302
|
+
current
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def content_index_map
|
|
306
|
+
@content_index_map ||= {}
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def message_started?
|
|
310
|
+
@message_started ||= false
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def queue_event(event)
|
|
314
|
+
queued_events << event
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def shift_queued_event
|
|
318
|
+
queued_events.shift
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def queued_events
|
|
322
|
+
@queued_events ||= []
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def raise_stream_error!(data)
|
|
326
|
+
error = data[:error].is_a?(Hash) ? data[:error] : data
|
|
327
|
+
message = error[:message] || "Stream error"
|
|
328
|
+
code = error[:code] || error[:type]
|
|
329
|
+
|
|
330
|
+
if LlmGateway::Errors.context_overflow_message?(message)
|
|
331
|
+
raise LlmGateway::Errors::PromptTooLong.new(message, code)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
raise LlmGateway::Errors::APIStatusError.new(message, code)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../adapter"
|
|
4
|
+
require_relative "acts_like_responses"
|
|
5
|
+
require_relative "../input_message_sanitizer"
|
|
6
|
+
require_relative "responses/input_mapper"
|
|
7
|
+
require_relative "responses/output_mapper"
|
|
8
|
+
require_relative "responses/option_mapper"
|
|
9
|
+
require_relative "file_output_mapper"
|
|
10
|
+
require_relative "responses/stream_mapper"
|
|
11
|
+
|
|
12
|
+
module LlmGateway
|
|
13
|
+
module Adapters
|
|
14
|
+
module OpenAI
|
|
15
|
+
class ResponsesAdapter < Adapter
|
|
16
|
+
include ActsLikeOpenAIResponses
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|