solid_loop 0.0.4

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 (137) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +156 -0
  3. data/CODE_OF_CONDUCT.md +67 -0
  4. data/CONTRIBUTING.md +82 -0
  5. data/MIT-LICENSE +21 -0
  6. data/README.md +483 -0
  7. data/Rakefile +11 -0
  8. data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
  9. data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
  10. data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
  11. data/app/assets/stylesheets/solid_loop/application.css +15 -0
  12. data/app/controllers/solid_loop/application_controller.rb +29 -0
  13. data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
  14. data/app/controllers/solid_loop/events_controller.rb +12 -0
  15. data/app/controllers/solid_loop/loops_controller.rb +77 -0
  16. data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
  17. data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
  18. data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
  19. data/app/controllers/solid_loop/messages_controller.rb +12 -0
  20. data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
  21. data/app/helpers/solid_loop/application_helper.rb +125 -0
  22. data/app/jobs/solid_loop/application_job.rb +13 -0
  23. data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
  24. data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
  25. data/app/jobs/solid_loop/reaper_job.rb +20 -0
  26. data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
  27. data/app/models/solid_loop/application_record.rb +5 -0
  28. data/app/models/solid_loop/base.rb +190 -0
  29. data/app/models/solid_loop/event.rb +7 -0
  30. data/app/models/solid_loop/loop.rb +225 -0
  31. data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
  32. data/app/models/solid_loop/mcp_session.rb +12 -0
  33. data/app/models/solid_loop/mcp_tool.rb +26 -0
  34. data/app/models/solid_loop/message.rb +69 -0
  35. data/app/models/solid_loop/tool_call.rb +42 -0
  36. data/app/queries/solid_loop/admin/base_query.rb +23 -0
  37. data/app/queries/solid_loop/admin/events_query.rb +31 -0
  38. data/app/queries/solid_loop/admin/loops_query.rb +38 -0
  39. data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
  40. data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
  41. data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
  42. data/app/queries/solid_loop/admin/messages_query.rb +37 -0
  43. data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
  44. data/app/services/solid_loop/adapters/native.rb +170 -0
  45. data/app/services/solid_loop/dialects/anthropic.rb +160 -0
  46. data/app/services/solid_loop/dialects/gemini.rb +122 -0
  47. data/app/services/solid_loop/dialects/open_ai.rb +62 -0
  48. data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
  49. data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
  50. data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
  51. data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
  52. data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
  53. data/app/services/solid_loop/llm_usage_parser.rb +19 -0
  54. data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
  55. data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
  56. data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
  57. data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
  58. data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
  59. data/app/services/solid_loop/middlewares/message_building.rb +92 -0
  60. data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
  61. data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
  62. data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
  63. data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
  64. data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
  65. data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
  66. data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
  67. data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
  68. data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
  69. data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
  70. data/app/views/layouts/solid_loop/admin.html.erb +416 -0
  71. data/app/views/layouts/solid_loop/application.html.erb +17 -0
  72. data/app/views/solid_loop/dashboard/index.html.erb +184 -0
  73. data/app/views/solid_loop/events/index.html.erb +47 -0
  74. data/app/views/solid_loop/events/show.html.erb +76 -0
  75. data/app/views/solid_loop/loops/index.html.erb +83 -0
  76. data/app/views/solid_loop/loops/show.html.erb +148 -0
  77. data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
  78. data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
  79. data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
  80. data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
  81. data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
  82. data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
  83. data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
  84. data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
  85. data/app/views/solid_loop/messages/_message.html.erb +75 -0
  86. data/app/views/solid_loop/messages/index.html.erb +80 -0
  87. data/app/views/solid_loop/messages/show.html.erb +121 -0
  88. data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
  89. data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
  90. data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
  91. data/config/routes.rb +27 -0
  92. data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
  93. data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
  94. data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
  95. data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
  96. data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
  97. data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
  98. data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
  99. data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
  100. data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
  101. data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
  102. data/docs/contributing/coverage.md +64 -0
  103. data/docs/decisions/durable_attempt_lease.md +364 -0
  104. data/docs/decisions/mcp-only-tooling.md +135 -0
  105. data/docs/decisions/mcp-server.md +223 -0
  106. data/docs/decisions/reasoning_persistence.md +51 -0
  107. data/docs/decisions/ruby_llm_rejected.md +35 -0
  108. data/docs/guides/dialects.md +55 -0
  109. data/docs/guides/llm_middlewares.md +294 -0
  110. data/docs/guides/mcp_transports.md +374 -0
  111. data/docs/guides/tool_middlewares.md +148 -0
  112. data/lib/solid_loop/configuration.rb +175 -0
  113. data/lib/solid_loop/engine.rb +14 -0
  114. data/lib/solid_loop/lease_heartbeat.rb +94 -0
  115. data/lib/solid_loop/lease_renewer.rb +347 -0
  116. data/lib/solid_loop/llm_metrics.rb +16 -0
  117. data/lib/solid_loop/mcp/call_context.rb +19 -0
  118. data/lib/solid_loop/mcp/client_factory.rb +61 -0
  119. data/lib/solid_loop/mcp/http_transport.rb +52 -0
  120. data/lib/solid_loop/mcp/principal.rb +82 -0
  121. data/lib/solid_loop/mcp/result.rb +13 -0
  122. data/lib/solid_loop/mcp/server.rb +246 -0
  123. data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
  124. data/lib/solid_loop/mcp/toolset.rb +347 -0
  125. data/lib/solid_loop/mcp/transport.rb +20 -0
  126. data/lib/solid_loop/mcp.rb +25 -0
  127. data/lib/solid_loop/mcp_client.rb +176 -0
  128. data/lib/solid_loop/pipeline/builder.rb +38 -0
  129. data/lib/solid_loop/pipeline/context.rb +61 -0
  130. data/lib/solid_loop/pipeline/tool_context.rb +53 -0
  131. data/lib/solid_loop/pipeline.rb +40 -0
  132. data/lib/solid_loop/reaper.rb +313 -0
  133. data/lib/solid_loop/version.rb +3 -0
  134. data/lib/solid_loop.rb +94 -0
  135. data/lib/tasks/coverage.rake +206 -0
  136. data/lib/tasks/solid_loop_tasks.rake +4 -0
  137. metadata +228 -0
@@ -0,0 +1,38 @@
1
+ module SolidLoop
2
+ module Admin
3
+ class LoopsQuery < BaseQuery
4
+ def initialize(params, scope: SolidLoop::Loop.order(created_at: :desc))
5
+ super
6
+ end
7
+
8
+ def call
9
+ filtered = scope
10
+ filtered = filtered.where(id: normalized_loop_id) if normalized_loop_id
11
+ filtered = filtered.where(status: status) if status
12
+ filtered = filtered.where(agent_class_name: agent_class_name) if agent_class_name
13
+ filtered
14
+ end
15
+
16
+ def exact_match
17
+ return unless normalized_loop_id
18
+
19
+ SolidLoop::Loop.find_by(id: normalized_loop_id)
20
+ end
21
+
22
+ private
23
+
24
+ def normalized_loop_id
25
+ @normalized_loop_id ||= present_param(:loop_id)&.delete_prefix("#")
26
+ end
27
+
28
+ def status
29
+ value = present_param(:status)
30
+ value if SolidLoop::Loop.statuses.key?(value)
31
+ end
32
+
33
+ def agent_class_name
34
+ present_param(:agent_class_name)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ module SolidLoop
2
+ module Admin
3
+ class McpInboundSessionsQuery < BaseQuery
4
+ def initialize(params, scope: SolidLoop::McpInboundSession.order(created_at: :desc))
5
+ super
6
+ end
7
+
8
+ def call
9
+ filtered = scope
10
+ filtered = filtered.where(server_name: server_name) if server_name
11
+ filtered = filtered.where("session_id ILIKE ?", "%#{session_id}%") if session_id
12
+ filtered = filtered.where("principal ILIKE ?", "%#{principal}%") if principal
13
+ filtered
14
+ end
15
+
16
+ private
17
+
18
+ def server_name
19
+ present_param(:server_name)
20
+ end
21
+
22
+ def session_id
23
+ present_param(:session_id)
24
+ end
25
+
26
+ def principal
27
+ present_param(:principal)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ module SolidLoop
2
+ module Admin
3
+ class McpSessionsQuery < BaseQuery
4
+ def initialize(params, scope: SolidLoop::McpSession.order(created_at: :desc))
5
+ super
6
+ end
7
+
8
+ def call
9
+ filtered = scope
10
+ filtered = filtered.where(mcp_name: mcp_name) if mcp_name
11
+ filtered = filtered.where("session_id ILIKE ?", "%#{session_id}%") if session_id
12
+ filtered
13
+ end
14
+
15
+ private
16
+
17
+ def mcp_name
18
+ present_param(:mcp_name)
19
+ end
20
+
21
+ def session_id
22
+ present_param(:session_id)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ module SolidLoop
2
+ module Admin
3
+ class McpToolsQuery < BaseQuery
4
+ def initialize(params, scope: SolidLoop::McpTool.order(created_at: :desc))
5
+ super
6
+ end
7
+
8
+ def call
9
+ filtered = scope
10
+ filtered = filtered.where(name: name) if name
11
+ filtered = filtered.where(mcp_session_id: mcp_session_id) if mcp_session_id
12
+ filtered
13
+ end
14
+
15
+ private
16
+
17
+ def name
18
+ present_param(:name)
19
+ end
20
+
21
+ def mcp_session_id
22
+ present_param(:mcp_session_id)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,37 @@
1
+ module SolidLoop
2
+ module Admin
3
+ class MessagesQuery < BaseQuery
4
+ def initialize(params, scope: SolidLoop::Message.order(created_at: :desc))
5
+ super
6
+ end
7
+
8
+ def call
9
+ filtered = scope
10
+ filtered = filtered.where(loop_id: loop_id) if loop_id
11
+ filtered = filtered.where(role: role) if role
12
+ filtered = filtered.where.not(tool_call_id: nil) if has_tool_call?
13
+ filtered = filtered.where.not(reasoning_content: [ nil, "" ]) if has_reasoning?
14
+ filtered
15
+ end
16
+
17
+ private
18
+
19
+ def loop_id
20
+ present_param(:loop_id)
21
+ end
22
+
23
+ def role
24
+ value = present_param(:role)
25
+ value if SolidLoop::Message.roles.key?(value)
26
+ end
27
+
28
+ def has_tool_call?
29
+ present_param(:has_tool_call) == "1"
30
+ end
31
+
32
+ def has_reasoning?
33
+ present_param(:has_reasoning) == "1"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ module SolidLoop
2
+ module Admin
3
+ class ToolCallsQuery < BaseQuery
4
+ def initialize(params, scope: SolidLoop::ToolCall.order(created_at: :desc))
5
+ super
6
+ end
7
+
8
+ def call
9
+ filtered = scope
10
+ filtered = filtered.joins(:message).where(solid_loop_messages: { loop_id: loop_id }) if loop_id
11
+ filtered = filtered.where(function_name: function_name) if function_name
12
+ filtered = filtered.where(is_success: success) unless success.nil?
13
+ filtered
14
+ end
15
+
16
+ private
17
+
18
+ def loop_id
19
+ present_param(:loop_id)
20
+ end
21
+
22
+ def function_name
23
+ present_param(:function_name)
24
+ end
25
+
26
+ def success
27
+ case present_param(:success)
28
+ when "1", "true", "success" then true
29
+ when "0", "false", "failed" then false
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,170 @@
1
+ module SolidLoop
2
+ module Adapters
3
+ class Native
4
+ attr_writer :on_chunk_proc
5
+
6
+ def initialize(base_url:, api_token:, payload:, streaming: false, reasoning_strategies: [], dialect: nil, model_name: nil, fallback_messages_text: "", read_timeout: nil, cancellation_check: nil)
7
+ @base_url = base_url
8
+ @api_token = api_token
9
+ @payload = payload
10
+ @streaming = streaming
11
+ @reasoning_strategies = Array(reasoning_strategies)
12
+ @model_name = model_name
13
+ @fallback_messages_text = fallback_messages_text
14
+ # Single coherent read-timeout source: when no explicit
15
+ # read_timeout is passed, fall back to the SAME `default_read_timeout` the
16
+ # lease is derived from — NOT the unrelated LlmCompletionJob::READ_TIMEOUT
17
+ # constant. Previously a configured `default_read_timeout = 60` yielded a
18
+ # 60s-derived lease but a 600s Faraday timeout, so a healthy turn could run
19
+ # ~600s while its lease assumed ~60s (invariant lease > client timeout
20
+ # broken). Reading the config here keeps the derived lease and the actual
21
+ # HTTP timeout coherent.
22
+ @read_timeout = read_timeout || SolidLoop.config.default_read_timeout
23
+ @cancellation_check = cancellation_check
24
+
25
+ # Use provided dialect or default to OpenAI
26
+ @dialect = dialect || SolidLoop::Dialects::OpenAi.new
27
+
28
+ @log_buffer = StringIO.new
29
+ @debug_buffer = StringIO.new
30
+
31
+ @last_ui_update_at = Time.current
32
+ @first_token_at = nil
33
+ @content_started_at = nil
34
+ @aggregator = SolidLoop::SseStreamAggregator.new
35
+ end
36
+
37
+ def call
38
+ @request_started_at = Time.current
39
+
40
+ @dialect.apply_reasoning_strategies!(@payload[:messages], @reasoning_strategies)
41
+ final_payload = @dialect.respond_to?(:render_payload) ? @dialect.render_payload(@payload) : @payload
42
+ url = build_url
43
+
44
+ headers = { "Content-Type" => "application/json" }
45
+ headers.merge!(@dialect.auth_headers(@api_token))
46
+
47
+ wire_logger = Logger.new(@log_buffer)
48
+ wire_logger.formatter = proc { |_severity, _datetime, _progname, msg| "#{msg}\n" }
49
+
50
+ conn = Faraday.new(url: url) do |f|
51
+ f.options.timeout = @read_timeout
52
+ f.options.open_timeout = 10
53
+ f.response :logger, wire_logger, bodies: { request: true, response: true }, headers: true
54
+ f.adapter Faraday.default_adapter
55
+ end
56
+
57
+ caller_thread = Thread.current
58
+ stop_watchdog = false
59
+ _watchdog = build_watchdog(caller_thread, stop_flag: -> { stop_watchdog })
60
+
61
+ begin
62
+ @response = conn.post do |req|
63
+ req.headers = headers
64
+ req.body = final_payload.to_json
65
+
66
+ if @streaming
67
+ req.options.on_data = proc do |chunk, _overall_received_bytes|
68
+ @debug_buffer.write(chunk)
69
+ @first_token_at ||= Time.current
70
+ data = parse_stream_chunk(chunk)
71
+ emit_chunk_event(data)
72
+ end
73
+ end
74
+ end
75
+ ensure
76
+ stop_watchdog = true
77
+ end
78
+
79
+ duration = (Time.current - @request_started_at).to_f
80
+ Rails.logger.info "LLM Request Finished (Duration: #{duration.round(2)}s)"
81
+
82
+ # Final aggregation result
83
+ raw_data =
84
+ if @streaming && @aggregator.result.present?
85
+ @aggregator.result
86
+ else
87
+ JSON.parse(@response.body) rescue {}
88
+ end
89
+
90
+ # DIALECT IS THE BOSS OF NORMALIZATION
91
+ # We pass fallback strings for token estimation if provider is silent
92
+ normalized_data = @dialect.normalize_response(
93
+ raw_data,
94
+ fallback_text: extract_full_text(raw_data),
95
+ fallback_messages_text: @fallback_messages_text
96
+ )
97
+
98
+ {
99
+ response: @response,
100
+ normalized_data: normalized_data,
101
+ duration: duration,
102
+ aggregator: @aggregator,
103
+ log: @log_buffer.string,
104
+ debug_log: @debug_buffer.string
105
+ }
106
+ end
107
+
108
+ def build_url
109
+ base = @dialect.completion_url(@base_url)
110
+ if @dialect.is_a?(SolidLoop::Dialects::Gemini)
111
+ method = @streaming ? "streamGenerateContent" : "generateContent"
112
+ "#{base}/#{@model_name}:#{method}?key=#{@api_token}"
113
+ else
114
+ base
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def build_watchdog(target_thread, stop_flag:)
121
+ return nil unless @cancellation_check
122
+
123
+ Thread.new do
124
+ until stop_flag.call
125
+ active = begin
126
+ @cancellation_check.call
127
+ rescue StandardError
128
+ true # can't determine status (e.g. test transactions) — assume active
129
+ end
130
+ unless active
131
+ target_thread.raise(SolidLoop::CancellationError, "Loop cancelled during prefill")
132
+ break
133
+ end
134
+ sleep 1
135
+ end
136
+ end
137
+ end
138
+
139
+ def extract_full_text(data)
140
+ msg = @dialect.extract_message_data(data)
141
+ msg[:content].to_s + msg[:reasoning].to_s
142
+ end
143
+
144
+ def parse_stream_chunk(chunk)
145
+ @aggregator.ingest(chunk)
146
+ data = @dialect.extract_message_data(@aggregator.result)
147
+ if @content_started_at.nil? && (data[:content].present? || data[:tool_calls].any?)
148
+ @content_started_at = Time.current
149
+ end
150
+
151
+ data
152
+ end
153
+
154
+ def emit_chunk_event(data)
155
+ return unless @streaming
156
+ return if Time.current - @last_ui_update_at < SolidLoop::LlmCompletionJob::HTTP_STREAMING_UPDATE_MODEL_INTERVAL
157
+
158
+ metrics = SolidLoop::LlmMetrics.new(
159
+ ttft: (@first_token_at ? (@first_token_at - @request_started_at).to_f : 0.0),
160
+ duration_thinking: ((@content_started_at && @first_token_at) ? (@content_started_at - @first_token_at).to_f : 0.0),
161
+ duration_generation: (Time.current - @request_started_at).to_f,
162
+ tokens_completion: ((data[:content]&.length || 0) + (data[:reasoning]&.length || 0)) / 4
163
+ )
164
+
165
+ @on_chunk_proc&.call(data[:content], data[:reasoning], data[:tool_calls], metrics)
166
+ @last_ui_update_at = Time.current
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ module Dialects
5
+ class Anthropic
6
+ def completion_url(base_url)
7
+ base_url.to_s.gsub(/\/+$/, "") + "/v1/messages"
8
+ end
9
+
10
+ def auth_headers(api_token)
11
+ h = { "anthropic-version" => "2023-06-01" }
12
+ h["x-api-key"] = api_token if api_token.present?
13
+ h
14
+ end
15
+
16
+ def apply_reasoning_strategies!(messages, strategies)
17
+ return unless messages.is_a?(Array)
18
+
19
+ filtered = strategies.reject { |s| s == :gemini_signed }
20
+ messages.each do |msg|
21
+ SolidLoop::Dialects::ReasoningPacker.pack(msg, filtered)
22
+ end
23
+ end
24
+
25
+ def render_payload(payload)
26
+ messages = payload[:messages] || []
27
+
28
+ # Extract system message — Anthropic puts it outside messages array
29
+ system_msg = messages.find { |m| m[:role] == "system" }
30
+ non_system = messages.reject { |m| m[:role] == "system" }
31
+
32
+ anthropic_payload = {
33
+ model: payload[:model],
34
+ max_tokens: payload[:max_tokens] || 8096,
35
+ messages: merge_tool_results(non_system.map { |m| map_message(m) })
36
+ }.compact
37
+
38
+ anthropic_payload[:system] = system_msg[:content] if system_msg
39
+ anthropic_payload[:temperature] = payload[:temperature] if payload[:temperature]
40
+
41
+ if payload[:tools]&.any?
42
+ anthropic_payload[:tools] = payload[:tools].map { |t| map_tool(t) }
43
+ end
44
+
45
+ anthropic_payload
46
+ end
47
+
48
+ def extract_message_data(data)
49
+ content_blocks = data["content"] || []
50
+
51
+ text_block = content_blocks.find { |b| b["type"] == "text" }
52
+ thinking_block = content_blocks.find { |b| b["type"] == "thinking" }
53
+ tool_blocks = content_blocks.select { |b| b["type"] == "tool_use" }
54
+
55
+ {
56
+ content: text_block&.dig("text"),
57
+ reasoning: thinking_block&.dig("thinking"),
58
+ tool_calls: map_tool_calls(tool_blocks)
59
+ }
60
+ end
61
+
62
+ def normalize_response(data, fallback_text: "", fallback_messages_text: "")
63
+ res = extract_message_data(data)
64
+
65
+ usage_data =
66
+ if data["usage"].is_a?(Hash)
67
+ SolidLoop::LlmUsageParser::Anthropic.call(data["usage"])
68
+ else
69
+ SolidLoop::LlmUsageParser.estimate(fallback_text, fallback_messages_text)
70
+ end
71
+
72
+ {
73
+ content: res[:content],
74
+ reasoning: res[:reasoning],
75
+ tool_calls: res[:tool_calls],
76
+ usage: usage_data,
77
+ metadata: { "model" => data["model"].presence }.compact
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ def map_message(m)
84
+ case m[:role]
85
+ when "tool"
86
+ # Tool result → Anthropic user message with tool_result block
87
+ {
88
+ role: "user",
89
+ content: [{
90
+ type: "tool_result",
91
+ tool_use_id: m[:tool_call_id] || m[:id],
92
+ content: m[:content].to_s
93
+ }]
94
+ }
95
+ when "assistant"
96
+ content = []
97
+ content << { type: "thinking", thinking: m[:_sl_reasoning] } if m[:_sl_reasoning].present?
98
+ content << { type: "text", text: m[:content] } if m[:content].present?
99
+
100
+ if m[:tool_calls].present?
101
+ m[:tool_calls].each do |tc|
102
+ args = tc.dig(:function, :arguments)
103
+ args = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
104
+ content << {
105
+ type: "tool_use",
106
+ id: tc[:id],
107
+ name: tc.dig(:function, :name),
108
+ input: args || {}
109
+ }
110
+ end
111
+ end
112
+
113
+ { role: "assistant", content: content }
114
+ else
115
+ # user message — simple string content
116
+ { role: m[:role], content: m[:content].to_s }
117
+ end
118
+ end
119
+
120
+ def merge_tool_results(messages)
121
+ messages.each_with_object([]) do |msg, result|
122
+ if tool_result_message?(msg) && tool_result_message?(result.last)
123
+ result.last[:content].concat(msg[:content])
124
+ else
125
+ result << msg
126
+ end
127
+ end
128
+ end
129
+
130
+ def tool_result_message?(msg)
131
+ return false unless msg
132
+ msg[:role] == "user" &&
133
+ msg[:content].is_a?(Array) &&
134
+ msg[:content].all? { |b| b[:type] == "tool_result" }
135
+ end
136
+
137
+ def map_tool(t)
138
+ func = t[:function] || {}
139
+ {
140
+ name: func[:name],
141
+ description: func[:description],
142
+ input_schema: func[:parameters] || { type: "object", properties: {} }
143
+ }
144
+ end
145
+
146
+ def map_tool_calls(blocks)
147
+ blocks.map do |b|
148
+ {
149
+ "id" => b["id"],
150
+ "type" => "function",
151
+ "function" => {
152
+ "name" => b["name"],
153
+ "arguments" => (b["input"] || {}).to_json
154
+ }
155
+ }
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ module Dialects
5
+ class Gemini
6
+ def completion_url(base_url)
7
+ base_url.to_s.gsub(/\/+$/, "") + "/v1beta/models"
8
+ end
9
+
10
+ def auth_headers(_api_token)
11
+ {} # Gemini uses ?key= query param, not a header
12
+ end
13
+
14
+ def render_payload(payload)
15
+ gemini_payload = {
16
+ contents: payload[:messages].map { |m| map_message(m) },
17
+ generationConfig: {
18
+ temperature: payload[:temperature],
19
+ maxOutputTokens: payload[:max_tokens]
20
+ }.compact
21
+ }
22
+ if payload[:tools]&.any?
23
+ gemini_payload[:tools] = [{ function_declarations: payload[:tools].map { |t| map_tool(t) } }]
24
+ end
25
+ gemini_payload
26
+ end
27
+
28
+ def apply_reasoning_strategies!(messages, strategies)
29
+ return unless messages.is_a?(Array)
30
+
31
+ # Gemini filter: only keep gemini-specific and universal XML strategies
32
+ filtered = strategies.select { |s| [:gemini_signed, :xml, :xml!].include?(s) }
33
+
34
+ messages.each do |msg|
35
+ SolidLoop::Dialects::ReasoningPacker.pack(msg, filtered)
36
+ end
37
+ end
38
+
39
+ def extract_message_data(data)
40
+ candidate = data.dig("candidates", 0) || {}
41
+ content_obj = candidate.dig("content") || {}
42
+ parts = content_obj.dig("parts") || []
43
+
44
+ text_part = parts.find { |p| p["text"].present? }
45
+ thought_part = parts.find { |p| p["thought"].present? }
46
+ tool_calls_part = parts.select { |p| p["functionCall"].present? }
47
+
48
+ {
49
+ content: text_part&.dig("text"),
50
+ reasoning: thought_part&.dig("thought"),
51
+ tool_calls: map_tool_calls_from_gemini(tool_calls_part),
52
+ metadata: { thought_signature: candidate.dig("thoughtSignature") }.compact
53
+ }
54
+ end
55
+
56
+ def normalize_response(data, fallback_text: "", fallback_messages_text: "")
57
+ res = extract_message_data(data)
58
+
59
+ usage_data =
60
+ if data["usageMetadata"].is_a?(Hash)
61
+ SolidLoop::LlmUsageParser::Gemini.call(data["usageMetadata"])
62
+ else
63
+ SolidLoop::LlmUsageParser.estimate(fallback_text, fallback_messages_text)
64
+ end
65
+
66
+ {
67
+ content: res[:content],
68
+ reasoning: res[:reasoning],
69
+ tool_calls: res[:tool_calls],
70
+ usage: usage_data,
71
+ metadata: res[:metadata].merge({ "model" => data["modelVersion"].presence }.compact)
72
+ }
73
+ end
74
+
75
+ private
76
+
77
+ def map_message(m)
78
+ role = (m[:role] == "assistant") ? "model" : m[:role]
79
+
80
+ if m[:role] == "tool"
81
+ parts = [{ functionResponse: { name: m[:name], response: { content: m[:content] } } }]
82
+ return { role: "user", parts: parts }
83
+ end
84
+
85
+ parts = []
86
+ thought = m[:thought] || m[:_sl_reasoning]
87
+ if thought.present?
88
+ thought_part = { thought: thought }
89
+ thought_part[:thoughtSignature] = m[:thought_signature] if m[:thought_signature].present?
90
+ parts << thought_part
91
+ end
92
+ parts << { text: m[:content] } if m[:content].present?
93
+
94
+ if m[:tool_calls].present?
95
+ m[:tool_calls].each do |tc|
96
+ args = tc.dig(:function, :arguments)
97
+ args = args.is_a?(String) ? JSON.parse(args) : args rescue args
98
+ parts << { functionCall: { name: tc.dig(:function, :name), args: args } }
99
+ end
100
+ end
101
+
102
+ { role: role, parts: parts }
103
+ end
104
+
105
+ def map_tool(t)
106
+ func = t.dig(:function) || {}
107
+ { name: func[:name], description: func[:description], parameters: func[:parameters] }
108
+ end
109
+
110
+ def map_tool_calls_from_gemini(parts)
111
+ parts.map do |p|
112
+ fc = p["functionCall"]
113
+ {
114
+ "id" => SecureRandom.hex(4),
115
+ "type" => "function",
116
+ "function" => { "name" => fc["name"], "arguments" => fc["args"].to_json }
117
+ }
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end