samagotchi 0.2.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/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../tools/execute"
|
|
5
|
+
require_relative "../tools/read"
|
|
6
|
+
require_relative "../tools/write"
|
|
7
|
+
require_relative "../tools/memory"
|
|
8
|
+
require_relative "../tools/edit"
|
|
9
|
+
require_relative "../tools/task_create"
|
|
10
|
+
require_relative "../tools/task_get"
|
|
11
|
+
require_relative "../tools/task_list"
|
|
12
|
+
require_relative "../tools/task_stop"
|
|
13
|
+
require_relative "../tools/task_wait"
|
|
14
|
+
require_relative "../tools/web_fetch"
|
|
15
|
+
require_relative "../tools/register_reminder"
|
|
16
|
+
require_relative "../tools/cancel_reminder"
|
|
17
|
+
require_relative "../tools/list_reminders"
|
|
18
|
+
require_relative "../tools/list_sessions"
|
|
19
|
+
require_relative "../tools/send_note"
|
|
20
|
+
require_relative "../tools/delegate"
|
|
21
|
+
require_relative "../tools/delegate_result"
|
|
22
|
+
require_relative "../tools/ask_user_question"
|
|
23
|
+
|
|
24
|
+
module Samagotchi
|
|
25
|
+
module LLM
|
|
26
|
+
# Bridges a native tool call (an LLM::ToolCall from the chat adapter; any
|
|
27
|
+
# object with #name and #arguments) into samagotchi's internal tool-call shape that
|
|
28
|
+
# `KernelLoop#dispatch` consumes:
|
|
29
|
+
#
|
|
30
|
+
# { name:, content:, path:, scope:, start_line:, end_line:, cwd:, env:, description: }
|
|
31
|
+
#
|
|
32
|
+
# The adapter hands you a *structured* `arguments` Hash (`.arguments` is a parsed
|
|
33
|
+
# Hash), while the internal `content` is a per-tool serialized text blob
|
|
34
|
+
# (e.g. `Edit` wants "<old>…</old><new>…</new>"; `Read` wants a `path`;
|
|
35
|
+
# `Execute` wants the command). Rather than re-architect every tool to accept
|
|
36
|
+
# structured args (huge blast radius), we rebuild the exact blob per tool so
|
|
37
|
+
# `dispatch` and every tool stay unchanged.
|
|
38
|
+
#
|
|
39
|
+
# Unknown tool names pass through untouched so `dispatch` renders its standard
|
|
40
|
+
# "unknown tool … available: …" error — the loop feeds that back like any tool
|
|
41
|
+
# result.
|
|
42
|
+
#
|
|
43
|
+
# This is the Phase-3 crux from Spike #4: the native→internal mapping is
|
|
44
|
+
# independent of how tools were driven in the request body.
|
|
45
|
+
class NativeToolNormalizer
|
|
46
|
+
# Map each known tool NAME to a proc(args => internal-call-hash).
|
|
47
|
+
# Each proc receives the tool call's arguments (`.arguments` is a Hash of string→value)
|
|
48
|
+
# and returns the internal dispatch shape with every expected key present
|
|
49
|
+
# (missing values are nil).
|
|
50
|
+
#
|
|
51
|
+
# Args come back as strings from the wire (e.g. "start_line" => "1"); tools
|
|
52
|
+
# that read them do `.to_s.strip`, so we leave them as-is here and let the
|
|
53
|
+
# dispatch site coerce.
|
|
54
|
+
MAP = {
|
|
55
|
+
Tools::Execute::NAME => lambda { |args|
|
|
56
|
+
{
|
|
57
|
+
name: Tools::Execute::NAME,
|
|
58
|
+
content: args["command"].to_s,
|
|
59
|
+
path: nil, scope: nil,
|
|
60
|
+
start_line: nil, end_line: nil,
|
|
61
|
+
cwd: args["cwd"], env: args["env"]
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
Tools::Read::NAME => lambda { |args|
|
|
65
|
+
{
|
|
66
|
+
name: Tools::Read::NAME,
|
|
67
|
+
content: args["path"].to_s,
|
|
68
|
+
path: nil, scope: nil,
|
|
69
|
+
start_line: args["start_line"], end_line: args["end_line"],
|
|
70
|
+
cwd: nil, env: nil, description: nil,
|
|
71
|
+
args: args.is_a?(Hash) ? args : {}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
Tools::Write::NAME => lambda { |args|
|
|
75
|
+
{
|
|
76
|
+
name: Tools::Write::NAME,
|
|
77
|
+
content: args["content"] || args["text"],
|
|
78
|
+
path: args["path"], scope: nil,
|
|
79
|
+
start_line: nil, end_line: nil,
|
|
80
|
+
cwd: nil, env: nil, description: nil
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
Tools::MemoryRead::NAME => lambda { |args|
|
|
84
|
+
{
|
|
85
|
+
name: Tools::MemoryRead::NAME,
|
|
86
|
+
content: args["name"].to_s,
|
|
87
|
+
path: nil, scope: args["scope"],
|
|
88
|
+
start_line: nil, end_line: nil,
|
|
89
|
+
cwd: nil, env: nil, description: nil
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
Tools::MemoryWrite::NAME => lambda { |args|
|
|
93
|
+
{
|
|
94
|
+
name: Tools::MemoryWrite::NAME,
|
|
95
|
+
content: args["content"] || args["text"] || args["body"],
|
|
96
|
+
path: args["name"], scope: args["scope"],
|
|
97
|
+
description: args["description"],
|
|
98
|
+
current_model_only: args["current_model_only"],
|
|
99
|
+
start_line: nil, end_line: nil,
|
|
100
|
+
cwd: nil, env: nil
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
Tools::Edit::NAME => lambda { |args|
|
|
104
|
+
old_text = args["old_text"] || args["old"]
|
|
105
|
+
new_text = args["new_text"] || args["new"]
|
|
106
|
+
{
|
|
107
|
+
name: Tools::Edit::NAME,
|
|
108
|
+
content: "<old>#{old_text}</old><new>#{new_text}</new>",
|
|
109
|
+
path: args["path"],
|
|
110
|
+
start_line: args["start_line"], end_line: args["end_line"],
|
|
111
|
+
scope: nil, cwd: nil, env: nil, description: nil
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
Tools::TaskCreate::NAME => lambda { |args|
|
|
115
|
+
{
|
|
116
|
+
name: Tools::TaskCreate::NAME,
|
|
117
|
+
content: args["command"].to_s,
|
|
118
|
+
path: nil, scope: nil,
|
|
119
|
+
start_line: nil, end_line: nil,
|
|
120
|
+
cwd: args["cwd"], env: args["env"], description: nil
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
Tools::TaskGet::NAME => lambda { |args|
|
|
124
|
+
{
|
|
125
|
+
name: Tools::TaskGet::NAME,
|
|
126
|
+
content: (args["id"] || args["task_id"]).to_s,
|
|
127
|
+
path: nil, scope: nil,
|
|
128
|
+
start_line: nil, end_line: nil,
|
|
129
|
+
cwd: nil, env: nil, description: nil
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
Tools::TaskStop::NAME => lambda { |args|
|
|
133
|
+
{
|
|
134
|
+
name: Tools::TaskStop::NAME,
|
|
135
|
+
content: (args["id"] || args["task_id"]).to_s,
|
|
136
|
+
path: nil, scope: nil,
|
|
137
|
+
start_line: nil, end_line: nil,
|
|
138
|
+
cwd: nil, env: nil, description: nil
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
Tools::TaskWait::NAME => lambda { |args|
|
|
142
|
+
{
|
|
143
|
+
name: Tools::TaskWait::NAME,
|
|
144
|
+
content: (args["id"] || args["task_id"]).to_s,
|
|
145
|
+
path: nil, scope: nil,
|
|
146
|
+
start_line: nil, end_line: nil,
|
|
147
|
+
cwd: nil, env: nil,
|
|
148
|
+
timeout: args["timeout"], tail_lines: args["tail_lines"], done_pattern: args["done_pattern"],
|
|
149
|
+
description: nil
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
Tools::TaskList::NAME => lambda { |_args|
|
|
153
|
+
{
|
|
154
|
+
name: Tools::TaskList::NAME,
|
|
155
|
+
content: "", path: nil, scope: nil,
|
|
156
|
+
start_line: nil, end_line: nil,
|
|
157
|
+
cwd: nil, env: nil, description: nil
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
Tools::WebFetch::NAME => lambda { |args|
|
|
161
|
+
{
|
|
162
|
+
name: Tools::WebFetch::NAME,
|
|
163
|
+
content: args["url"].to_s,
|
|
164
|
+
path: nil, scope: nil,
|
|
165
|
+
start_line: nil, end_line: nil,
|
|
166
|
+
cwd: nil, env: nil, description: nil
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
Tools::RegisterReminder::NAME => lambda { |args|
|
|
170
|
+
{
|
|
171
|
+
name: Tools::RegisterReminder::NAME,
|
|
172
|
+
content: args["name"].to_s,
|
|
173
|
+
path: nil, scope: nil,
|
|
174
|
+
description: args["description"],
|
|
175
|
+
interval_minutes: args["interval_minutes"]
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
Tools::CancelReminder::NAME => lambda { |args|
|
|
179
|
+
{ name: Tools::CancelReminder::NAME, content: args["name"].to_s, path: nil, scope: nil }
|
|
180
|
+
},
|
|
181
|
+
Tools::ListReminders::NAME => lambda { |_args|
|
|
182
|
+
{ name: Tools::ListReminders::NAME, content: "", path: nil, scope: nil }
|
|
183
|
+
},
|
|
184
|
+
Tools::ListSessions::NAME => lambda { |args|
|
|
185
|
+
{ name: Tools::ListSessions::NAME, content: "", path: nil, scope: nil, cwd: args["cwd"] }
|
|
186
|
+
},
|
|
187
|
+
Tools::SendNote::NAME => lambda { |args|
|
|
188
|
+
{ name: Tools::SendNote::NAME, content: args["text"].to_s, path: nil, scope: nil, session: args["session"].to_s }
|
|
189
|
+
},
|
|
190
|
+
Tools::Delegate::NAME => lambda { |args|
|
|
191
|
+
{ name: Tools::Delegate::NAME, content: args["task"].to_s, path: nil, scope: nil,
|
|
192
|
+
model: args["model"], session: args["session"], wait: args["wait"], timeout: args["timeout"] }
|
|
193
|
+
},
|
|
194
|
+
Tools::DelegateResult::NAME => lambda { |args|
|
|
195
|
+
{ name: Tools::DelegateResult::NAME, content: "", path: nil, scope: nil,
|
|
196
|
+
session: args["session"], timeout: args["timeout"] }
|
|
197
|
+
},
|
|
198
|
+
Tools::AskUserQuestion::NAME => lambda { |args|
|
|
199
|
+
raw_opts = args["options"]
|
|
200
|
+
norm_opts = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts)
|
|
201
|
+
norm_opts = raw_opts if norm_opts.empty? && raw_opts.is_a?(Array)
|
|
202
|
+
{
|
|
203
|
+
name: Tools::AskUserQuestion::NAME,
|
|
204
|
+
content: args["question"].to_s,
|
|
205
|
+
path: nil, scope: nil,
|
|
206
|
+
question: args["question"].to_s,
|
|
207
|
+
options: norm_opts.empty? ? raw_opts : norm_opts,
|
|
208
|
+
header: args["header"],
|
|
209
|
+
multi_select: args["multi_select"],
|
|
210
|
+
allow_freeform: args["allow_freeform"]
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}.freeze
|
|
214
|
+
|
|
215
|
+
class << self
|
|
216
|
+
# Map a single native tool call (LLM::ToolCall) to the internal call hash.
|
|
217
|
+
#
|
|
218
|
+
# `call.arguments` is a parsed Hash (string keys). If it is nil or a bare
|
|
219
|
+
# string we defensively coerce (string → JSON.parse when possible) so the
|
|
220
|
+
# mapping never explodes on an odd wire shape.
|
|
221
|
+
def normalize(call)
|
|
222
|
+
return nil if call.nil?
|
|
223
|
+
|
|
224
|
+
name = call.name.to_s
|
|
225
|
+
args = args_for(call)
|
|
226
|
+
proc = MAP[name]
|
|
227
|
+
return passthrough(name, args) unless proc
|
|
228
|
+
|
|
229
|
+
proc.call(args).merge(name: name)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Map an Array of tool calls to internal call hashes (nils dropped).
|
|
233
|
+
def normalize_all(calls)
|
|
234
|
+
Array(calls).map { |call| normalize(call) }.compact
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
# args_for: arguments is normally a Hash. Defensively handle nil /
|
|
240
|
+
# JSON-string / other shapes without raising.
|
|
241
|
+
def args_for(call)
|
|
242
|
+
raw = call.respond_to?(:arguments) ? call.arguments : nil
|
|
243
|
+
case raw
|
|
244
|
+
when Hash then raw
|
|
245
|
+
when String
|
|
246
|
+
raw.strip.empty? ? {} : safe_json_parse(raw)
|
|
247
|
+
when nil
|
|
248
|
+
{}
|
|
249
|
+
else
|
|
250
|
+
{ "__raw__" => raw.to_s }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def safe_json_parse(str)
|
|
255
|
+
JSON.parse(str)
|
|
256
|
+
rescue JSON::ParserError
|
|
257
|
+
{ "__raw__" => str }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# A registry (plugin) tool, or an unknown one: the arguments as args:
|
|
261
|
+
# (typed by the schema at dispatch), plus enough for `dispatch` to
|
|
262
|
+
# render the standard "unknown tool" error. `content` is the joined
|
|
263
|
+
# argument values (harmless; the unknown-tool branch ignores it).
|
|
264
|
+
def passthrough(name, args)
|
|
265
|
+
{
|
|
266
|
+
name: name,
|
|
267
|
+
content: args.is_a?(Hash) ? args.values.join(" ") : name,
|
|
268
|
+
path: nil, scope: nil,
|
|
269
|
+
start_line: nil, end_line: nil,
|
|
270
|
+
cwd: nil, env: nil, description: nil,
|
|
271
|
+
args: args.is_a?(Hash) ? args : {}
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../config"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "http"
|
|
8
|
+
require_relative "usage"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
module LLM
|
|
12
|
+
# A model a host lists. Unknown fields are nil.
|
|
13
|
+
ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
|
|
14
|
+
# true when the list says the model takes images (OpenRouter's
|
|
15
|
+
# architecture.input_modalities, llama.cpp's "multimodal" capability),
|
|
16
|
+
# false when it lists input modalities without "image", else nil.
|
|
17
|
+
def image_input
|
|
18
|
+
return nil unless raw.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
modalities = raw.dig("architecture", "input_modalities")
|
|
21
|
+
return modalities.include?("image") if modalities.is_a?(Array)
|
|
22
|
+
return true if Array(raw["capabilities"]).include?("multimodal")
|
|
23
|
+
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A tool call the model made. arguments: the parsed Hash, or the raw
|
|
29
|
+
# String when it isn't valid JSON (the tool then reports the error).
|
|
30
|
+
ToolCall = Data.define(:id, :name, :arguments)
|
|
31
|
+
|
|
32
|
+
# One chat completion: text and reasoning ("" when none), tool calls
|
|
33
|
+
# ([] when none), usage (never nil) and the finish reason.
|
|
34
|
+
# +model+ is the model the server says answered (the body's `model`),
|
|
35
|
+
# which can differ from the one asked for; nil when it says nothing.
|
|
36
|
+
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model) do
|
|
37
|
+
def initialize(model: nil, **fields) = super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
|
|
41
|
+
# provider) on our own HTTP layer: one request per #chat, streamed by
|
|
42
|
+
# default, with errors mapped to ProviderError. It speaks the wire format
|
|
43
|
+
# only; the chat loop owns the conversation and the tools.
|
|
44
|
+
class OpenAIChat
|
|
45
|
+
MAX_MODEL_PAGES = 20
|
|
46
|
+
DEFAULT_MODELS_TTL = 60
|
|
47
|
+
|
|
48
|
+
attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout
|
|
49
|
+
|
|
50
|
+
# @param entry [HostRegistry::HostEntry]
|
|
51
|
+
def self.for(entry, **options)
|
|
52
|
+
new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
|
|
53
|
+
remote: entry.respond_to?(:remote?) && entry.remote?, **options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
|
|
57
|
+
def self.parse_arguments(raw)
|
|
58
|
+
return raw if raw.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
text = raw.to_s
|
|
61
|
+
return {} if text.strip.empty?
|
|
62
|
+
|
|
63
|
+
parsed = JSON.parse(text)
|
|
64
|
+
parsed.is_a?(Hash) ? parsed : text
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
text
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param base_url [String] the API base, e.g. http://host:8081/v1
|
|
70
|
+
# @param host_name [String] names the host in errors
|
|
71
|
+
# @param api_key_env [String, nil] the variable holding the API key;
|
|
72
|
+
# nil sends no Authorization header (a local server)
|
|
73
|
+
# @param stream [Boolean] stream the reply (false: one JSON body)
|
|
74
|
+
# @param retries [Boolean] false: one attempt
|
|
75
|
+
# @param timeout [Numeric, nil] read timeout in seconds (default
|
|
76
|
+
# server.read_timeout); the connect timeout is capped by it
|
|
77
|
+
# @param models_ttl [Numeric] how long #context_window reuses the list
|
|
78
|
+
# @param remote [Boolean] a provider on the network, not a local server
|
|
79
|
+
# @param first_token_timeout [Numeric, nil] seconds a streamed answer
|
|
80
|
+
# may take to show something (LLM::HTTP); nil: no limit
|
|
81
|
+
def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
|
|
82
|
+
env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
|
|
83
|
+
first_token_timeout: nil, purpose: "chat")
|
|
84
|
+
@remote = remote
|
|
85
|
+
# What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
|
|
86
|
+
@purpose = purpose
|
|
87
|
+
@first_token_timeout = first_token_timeout
|
|
88
|
+
@base_url = base_url.to_s.chomp("/")
|
|
89
|
+
@host_name = host_name.to_s
|
|
90
|
+
@api_key_env = api_key_env
|
|
91
|
+
@stream = stream
|
|
92
|
+
@env = env
|
|
93
|
+
@models_ttl = models_ttl
|
|
94
|
+
@models_mutex = Mutex.new
|
|
95
|
+
open_timeout, read_timeout = timeouts(timeout)
|
|
96
|
+
policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
|
|
97
|
+
@http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
|
|
98
|
+
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param messages [Array<Hash>] wire messages (role, content, tool_calls,
|
|
102
|
+
# tool_call_id); content is a String or an Array of parts
|
|
103
|
+
# @param tools [Array<Hash>] function definitions ({type:, function:})
|
|
104
|
+
# @param options [Hash] extra request fields (max_tokens, ...)
|
|
105
|
+
# @param on_delta [Proc, nil] called per streamed chunk with content:,
|
|
106
|
+
# reasoning: and payload: (the parsed chunk)
|
|
107
|
+
# @param on_retry [Proc, nil] see LLM::HTTP#stream_lines
|
|
108
|
+
# @param session_id [String, nil] sent as a Session-Id header so a
|
|
109
|
+
# gateway that spreads requests over providers keeps one
|
|
110
|
+
# conversation on one of them (prompt caches, one served model)
|
|
111
|
+
# @return [ChatResponse]
|
|
112
|
+
def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
|
|
113
|
+
session_id: nil)
|
|
114
|
+
request = post_request("#{@base_url}/chat/completions", request_body(messages, tools, model, options),
|
|
115
|
+
session_id: session_id)
|
|
116
|
+
log_fields = { model: model, purpose: @purpose }
|
|
117
|
+
return chat_once(request, cancel_controller, log_fields) unless @stream
|
|
118
|
+
|
|
119
|
+
assembly = Assembly.new
|
|
120
|
+
events = 0
|
|
121
|
+
other = +""
|
|
122
|
+
# A retry streams the answer from the start again.
|
|
123
|
+
restart = lambda do |**event|
|
|
124
|
+
assembly = Assembly.new
|
|
125
|
+
on_retry&.call(**event)
|
|
126
|
+
end
|
|
127
|
+
@http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
|
|
128
|
+
log_fields: log_fields) do |line, shown|
|
|
129
|
+
events += 1 if line.start_with?("data:")
|
|
130
|
+
other << line[0, 200] if !line.start_with?("data:") && other.length < 200
|
|
131
|
+
payload = parse_line(line)
|
|
132
|
+
next unless payload
|
|
133
|
+
|
|
134
|
+
content, reasoning = assembly.add(payload)
|
|
135
|
+
shown.call if !content.empty? || !reasoning.empty? || Assembly.tool_call_delta?(payload)
|
|
136
|
+
on_delta&.call(content: content, reasoning: reasoning, payload: payload)
|
|
137
|
+
end
|
|
138
|
+
# A server that ignores stream: true, or answers with something else
|
|
139
|
+
# entirely, would otherwise end the turn with no answer and no error.
|
|
140
|
+
raise ProtocolError.new("#{@host_name}: the response had no stream events: #{other}", host: @host_name) if events.zero?
|
|
141
|
+
|
|
142
|
+
assembly.response
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A provider on the network: no llama.cpp /props to ask for the window.
|
|
146
|
+
def remote? = @remote
|
|
147
|
+
|
|
148
|
+
# @return [Array<ModelInfo>] the host's models (every page)
|
|
149
|
+
def list_models
|
|
150
|
+
models = []
|
|
151
|
+
after = nil
|
|
152
|
+
MAX_MODEL_PAGES.times do
|
|
153
|
+
uri = URI("#{@base_url}/models#{after ? "?after=#{URI.encode_www_form_component(after)}" : ""}")
|
|
154
|
+
body = parse_json(@http.fetch(uri, get_request(uri), log_fields: { purpose: "models" }).body, "model list")
|
|
155
|
+
models.concat(model_entries(body).map { |raw| model_info(raw) })
|
|
156
|
+
break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
|
|
157
|
+
|
|
158
|
+
after = body["last_id"].to_s
|
|
159
|
+
end
|
|
160
|
+
models
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The window (tokens) the host lists for +model+, or nil. The list is
|
|
164
|
+
# read once per models_ttl; a failed listing answers nil.
|
|
165
|
+
def context_window(model:)
|
|
166
|
+
info = cached_models.find { |m| m.id == model.to_s } ||
|
|
167
|
+
cached_models.find { |m| m.id.casecmp?(model.to_s) }
|
|
168
|
+
info&.context_window
|
|
169
|
+
rescue StandardError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Whether the host's list says +model+ takes images (ModelInfo#image_input),
|
|
174
|
+
# or nil when it doesn't say or the listing fails.
|
|
175
|
+
def image_input(model:)
|
|
176
|
+
info = cached_models.find { |m| m.id == model.to_s } ||
|
|
177
|
+
cached_models.find { |m| m.id.casecmp?(model.to_s) }
|
|
178
|
+
info&.image_input
|
|
179
|
+
rescue StandardError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def timeouts(timeout)
|
|
186
|
+
open_timeout = config_integer("server.open_timeout", 10)
|
|
187
|
+
read_timeout = config_integer("server.read_timeout", 600)
|
|
188
|
+
return [open_timeout, read_timeout] unless timeout
|
|
189
|
+
|
|
190
|
+
[[open_timeout, timeout].min, timeout]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def config_integer(key, default)
|
|
194
|
+
value = Samagotchi::Config.get(key).to_i
|
|
195
|
+
value.positive? ? value : default
|
|
196
|
+
rescue StandardError
|
|
197
|
+
default
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def request_body(messages, tools, model, options)
|
|
201
|
+
body = {
|
|
202
|
+
model: model,
|
|
203
|
+
messages: Array(messages).map { |message| wire_message(message) },
|
|
204
|
+
temperature: 0.0,
|
|
205
|
+
stream: @stream
|
|
206
|
+
}
|
|
207
|
+
body[:stream_options] = { include_usage: true } if @stream
|
|
208
|
+
unless Array(tools).empty?
|
|
209
|
+
body[:tools] = tools
|
|
210
|
+
body[:tool_choice] = "auto"
|
|
211
|
+
end
|
|
212
|
+
body.merge(options || {})
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A String stays a String; an Array of parts passes as given.
|
|
216
|
+
def wire_message(message)
|
|
217
|
+
wire = message.to_h.transform_keys(&:to_sym)
|
|
218
|
+
wire[:content] = format_content(wire[:content]) if wire.key?(:content)
|
|
219
|
+
wire
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def format_content(content)
|
|
223
|
+
return content if content.is_a?(Array) || content.nil?
|
|
224
|
+
|
|
225
|
+
scrub(content.to_s)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Invalid UTF-8 (a tool's garbled output) would make to_json raise.
|
|
229
|
+
def scrub(text)
|
|
230
|
+
text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def chat_once(request, cancel_controller, log_fields)
|
|
234
|
+
response = @http.fetch(URI(request.uri.to_s), request, cancel_controller: cancel_controller, log_fields: log_fields)
|
|
235
|
+
body = parse_json(response.body, "chat response")
|
|
236
|
+
message = body.is_a?(Hash) ? body.dig("choices", 0, "message") : nil
|
|
237
|
+
raise ProtocolError.new("#{@host_name}: no message in the chat response", host: @host_name) unless message.is_a?(Hash)
|
|
238
|
+
|
|
239
|
+
calls = Array(message["tool_calls"]).map do |call|
|
|
240
|
+
function = call["function"] || {}
|
|
241
|
+
ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
|
|
242
|
+
end
|
|
243
|
+
ChatResponse.new(text: message["content"].to_s,
|
|
244
|
+
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
245
|
+
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
246
|
+
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def self.served_model(payload)
|
|
250
|
+
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
251
|
+
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def served_model(payload) = self.class.served_model(payload)
|
|
255
|
+
|
|
256
|
+
# The parsed payload of a `data:` line; nil for blank lines, comments
|
|
257
|
+
# and [DONE]. Error events raise their ProviderError.
|
|
258
|
+
def parse_line(line)
|
|
259
|
+
error = HTTP.sse_error(line, host: @host_name)
|
|
260
|
+
raise error if error
|
|
261
|
+
return nil unless line.start_with?("data:")
|
|
262
|
+
|
|
263
|
+
data = line.delete_prefix("data:").strip
|
|
264
|
+
return nil if data.empty? || data == "[DONE]"
|
|
265
|
+
|
|
266
|
+
payload = parse_json(data, "stream chunk")
|
|
267
|
+
payload.is_a?(Hash) ? payload : nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def parse_json(text, what)
|
|
271
|
+
JSON.parse(text.to_s)
|
|
272
|
+
rescue JSON::ParserError => e
|
|
273
|
+
raise ProtocolError.new("#{@host_name}: malformed #{what}: #{e.message[0, 200]}", host: @host_name)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def post_request(url, body, session_id: nil)
|
|
277
|
+
uri = URI(url)
|
|
278
|
+
Net::HTTP::Post.new(uri).tap do |request|
|
|
279
|
+
request["Content-Type"] = "application/json"
|
|
280
|
+
request["Session-Id"] = session_id.to_s unless session_id.to_s.empty?
|
|
281
|
+
authorize(request)
|
|
282
|
+
request.body = JSON.generate(body)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def get_request(uri)
|
|
287
|
+
Net::HTTP::Get.new(uri).tap { |request| authorize(request) }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def authorize(request)
|
|
291
|
+
return unless @api_key_env
|
|
292
|
+
|
|
293
|
+
key = @env[@api_key_env].to_s
|
|
294
|
+
if key.strip.empty?
|
|
295
|
+
raise AuthError.new("#{@host_name}: set #{@api_key_env} (the API key for host #{@host_name})",
|
|
296
|
+
host: @host_name)
|
|
297
|
+
end
|
|
298
|
+
request["Authorization"] = "Bearer #{key}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def model_entries(body)
|
|
302
|
+
return [] unless body.is_a?(Hash)
|
|
303
|
+
|
|
304
|
+
entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
|
|
305
|
+
Array(entries).select { |entry| entry.is_a?(Hash) }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def model_info(raw)
|
|
309
|
+
id = (raw["id"] || raw["model"] || raw["name"]).to_s
|
|
310
|
+
ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The running window when the list says: context_length, context_window,
|
|
314
|
+
# max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
|
|
315
|
+
def window_of(raw)
|
|
316
|
+
[raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
|
|
317
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
318
|
+
end
|
|
319
|
+
nil
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def tools_support(raw)
|
|
323
|
+
listed = raw["supported_parameters"] || raw["capabilities"]
|
|
324
|
+
return nil unless listed.is_a?(Array)
|
|
325
|
+
|
|
326
|
+
listed.include?("tools") ? true : nil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def cached_models
|
|
330
|
+
@models_mutex.synchronize do
|
|
331
|
+
fresh = @models_at && (monotonic - @models_at) < @models_ttl
|
|
332
|
+
return @models if fresh
|
|
333
|
+
end
|
|
334
|
+
models = list_models
|
|
335
|
+
@models_mutex.synchronize do
|
|
336
|
+
@models = models
|
|
337
|
+
@models_at = monotonic
|
|
338
|
+
end
|
|
339
|
+
models
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
343
|
+
|
|
344
|
+
# Streamed deltas put together: text, reasoning, tool calls by index,
|
|
345
|
+
# the finish reason, the usage chunk and the served model.
|
|
346
|
+
class Assembly
|
|
347
|
+
# The chunk carries part of a tool call.
|
|
348
|
+
def self.tool_call_delta?(payload)
|
|
349
|
+
choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
|
|
350
|
+
choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def initialize
|
|
354
|
+
@text = +""
|
|
355
|
+
@reasoning = +""
|
|
356
|
+
@calls = {}
|
|
357
|
+
@finish_reason = nil
|
|
358
|
+
@usage = nil
|
|
359
|
+
@model = nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# @return [Array(String, String)] this chunk's content and reasoning
|
|
363
|
+
def add(payload)
|
|
364
|
+
@usage = Usage.from_payload(payload) || @usage if payload.key?("usage")
|
|
365
|
+
@model = OpenAIChat.served_model(payload) || @model
|
|
366
|
+
choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
|
|
367
|
+
return ["", ""] unless choice.is_a?(Hash)
|
|
368
|
+
|
|
369
|
+
@finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
370
|
+
delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
|
|
371
|
+
content = delta["content"].to_s
|
|
372
|
+
reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
|
|
373
|
+
@text << content
|
|
374
|
+
@reasoning << reasoning
|
|
375
|
+
Array(delta["tool_calls"]).each { |call| add_call(call) }
|
|
376
|
+
[content, reasoning]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def response
|
|
380
|
+
calls = @calls.sort_by { |index, _| index }.map do |_, call|
|
|
381
|
+
ToolCall.new(id: call[:id], name: call[:name].to_s, arguments: OpenAIChat.parse_arguments(call[:arguments]))
|
|
382
|
+
end
|
|
383
|
+
ChatResponse.new(text: @text, reasoning: @reasoning, tool_calls: calls, usage: @usage || Usage.none,
|
|
384
|
+
finish_reason: @finish_reason, model: @model)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def add_call(delta)
|
|
390
|
+
return unless delta.is_a?(Hash)
|
|
391
|
+
|
|
392
|
+
index = delta["index"].is_a?(Integer) ? delta["index"] : @calls.size
|
|
393
|
+
call = (@calls[index] ||= { id: nil, name: nil, arguments: +"" })
|
|
394
|
+
call[:id] ||= delta["id"]
|
|
395
|
+
function = delta["function"].is_a?(Hash) ? delta["function"] : {}
|
|
396
|
+
call[:name] ||= function["name"] unless function["name"].to_s.empty?
|
|
397
|
+
call[:arguments] << function["arguments"].to_s
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
private_constant :Assembly
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|