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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +156 -0
- data/CODE_OF_CONDUCT.md +67 -0
- data/CONTRIBUTING.md +82 -0
- data/MIT-LICENSE +21 -0
- data/README.md +483 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
- data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
- data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
- data/app/assets/stylesheets/solid_loop/application.css +15 -0
- data/app/controllers/solid_loop/application_controller.rb +29 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
- data/app/controllers/solid_loop/events_controller.rb +12 -0
- data/app/controllers/solid_loop/loops_controller.rb +77 -0
- data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
- data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
- data/app/controllers/solid_loop/messages_controller.rb +12 -0
- data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
- data/app/helpers/solid_loop/application_helper.rb +125 -0
- data/app/jobs/solid_loop/application_job.rb +13 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
- data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
- data/app/jobs/solid_loop/reaper_job.rb +20 -0
- data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
- data/app/models/solid_loop/application_record.rb +5 -0
- data/app/models/solid_loop/base.rb +190 -0
- data/app/models/solid_loop/event.rb +7 -0
- data/app/models/solid_loop/loop.rb +225 -0
- data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
- data/app/models/solid_loop/mcp_session.rb +12 -0
- data/app/models/solid_loop/mcp_tool.rb +26 -0
- data/app/models/solid_loop/message.rb +69 -0
- data/app/models/solid_loop/tool_call.rb +42 -0
- data/app/queries/solid_loop/admin/base_query.rb +23 -0
- data/app/queries/solid_loop/admin/events_query.rb +31 -0
- data/app/queries/solid_loop/admin/loops_query.rb +38 -0
- data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
- data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
- data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
- data/app/queries/solid_loop/admin/messages_query.rb +37 -0
- data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
- data/app/services/solid_loop/adapters/native.rb +170 -0
- data/app/services/solid_loop/dialects/anthropic.rb +160 -0
- data/app/services/solid_loop/dialects/gemini.rb +122 -0
- data/app/services/solid_loop/dialects/open_ai.rb +62 -0
- data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
- data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
- data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
- data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser.rb +19 -0
- data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
- data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
- data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
- data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
- data/app/services/solid_loop/middlewares/message_building.rb +92 -0
- data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
- data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
- data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
- data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
- data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
- data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
- data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
- data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
- data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
- data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
- data/app/views/layouts/solid_loop/admin.html.erb +416 -0
- data/app/views/layouts/solid_loop/application.html.erb +17 -0
- data/app/views/solid_loop/dashboard/index.html.erb +184 -0
- data/app/views/solid_loop/events/index.html.erb +47 -0
- data/app/views/solid_loop/events/show.html.erb +76 -0
- data/app/views/solid_loop/loops/index.html.erb +83 -0
- data/app/views/solid_loop/loops/show.html.erb +148 -0
- data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
- data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
- data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
- data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
- data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
- data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
- data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
- data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
- data/app/views/solid_loop/messages/_message.html.erb +75 -0
- data/app/views/solid_loop/messages/index.html.erb +80 -0
- data/app/views/solid_loop/messages/show.html.erb +121 -0
- data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
- data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
- data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
- data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
- data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
- data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
- data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
- data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
- data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
- data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
- data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
- data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
- data/docs/contributing/coverage.md +64 -0
- data/docs/decisions/durable_attempt_lease.md +364 -0
- data/docs/decisions/mcp-only-tooling.md +135 -0
- data/docs/decisions/mcp-server.md +223 -0
- data/docs/decisions/reasoning_persistence.md +51 -0
- data/docs/decisions/ruby_llm_rejected.md +35 -0
- data/docs/guides/dialects.md +55 -0
- data/docs/guides/llm_middlewares.md +294 -0
- data/docs/guides/mcp_transports.md +374 -0
- data/docs/guides/tool_middlewares.md +148 -0
- data/lib/solid_loop/configuration.rb +175 -0
- data/lib/solid_loop/engine.rb +14 -0
- data/lib/solid_loop/lease_heartbeat.rb +94 -0
- data/lib/solid_loop/lease_renewer.rb +347 -0
- data/lib/solid_loop/llm_metrics.rb +16 -0
- data/lib/solid_loop/mcp/call_context.rb +19 -0
- data/lib/solid_loop/mcp/client_factory.rb +61 -0
- data/lib/solid_loop/mcp/http_transport.rb +52 -0
- data/lib/solid_loop/mcp/principal.rb +82 -0
- data/lib/solid_loop/mcp/result.rb +13 -0
- data/lib/solid_loop/mcp/server.rb +246 -0
- data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
- data/lib/solid_loop/mcp/toolset.rb +347 -0
- data/lib/solid_loop/mcp/transport.rb +20 -0
- data/lib/solid_loop/mcp.rb +25 -0
- data/lib/solid_loop/mcp_client.rb +176 -0
- data/lib/solid_loop/pipeline/builder.rb +38 -0
- data/lib/solid_loop/pipeline/context.rb +61 -0
- data/lib/solid_loop/pipeline/tool_context.rb +53 -0
- data/lib/solid_loop/pipeline.rb +40 -0
- data/lib/solid_loop/reaper.rb +313 -0
- data/lib/solid_loop/version.rb +3 -0
- data/lib/solid_loop.rb +94 -0
- data/lib/tasks/coverage.rake +206 -0
- data/lib/tasks/solid_loop_tasks.rake +4 -0
- 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
|