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.
Files changed (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Samagotchi
7
+ module LLM
8
+ # A request stopped by its CancellationController.
9
+ class RequestCancelled < StandardError
10
+ attr_reader :reason
11
+
12
+ def initialize(reason = nil)
13
+ @reason = reason
14
+ super("request cancelled")
15
+ end
16
+ end
17
+
18
+ # A model server refused or failed a request. Subclasses name the kind
19
+ # (#kind): the UIs print one line per kind, and #retryable? says whether
20
+ # asking again could help.
21
+ #
22
+ # ConnectionError no answer (refused, reset, timeout); RetryExhausted
23
+ # RateLimited 429, with retry_after when the server says
24
+ # ServerError 5xx or a server's error event mid-stream
25
+ # AuthError 401/403, or an API key variable that is not set
26
+ # BadRequest other 4xx (a context overflow is one, whatever status;
27
+ # so is a model that can't take tools)
28
+ # VisionUnsupported a model that can't see images (a BadRequest,
29
+ # whatever status; also raised before sending)
30
+ # ProtocolError a body that isn't what the API promises
31
+ class ProviderError < StandardError
32
+ attr_reader :host, :status, :retry_after
33
+ attr_accessor :attempts
34
+
35
+ # @param host [String, nil] the host (or server label) that failed
36
+ # @param status [Integer, nil] the HTTP status, when there was one
37
+ # @param retry_after [Float, nil] seconds the server asked to wait
38
+ def initialize(message = nil, host: nil, status: nil, retryable: nil, retry_after: nil, attempts: 1)
39
+ @host = host
40
+ @status = status
41
+ @retryable = retryable.nil? ? default_retryable? : retryable
42
+ @retry_after = retry_after
43
+ @attempts = attempts
44
+ super(message)
45
+ end
46
+
47
+ def retryable? = @retryable
48
+
49
+ def kind = :provider
50
+
51
+ # One line for the UIs, e.g. "auth failed for host fw: set FW_KEY".
52
+ def summary = "error from host #{host}: #{detail}"
53
+
54
+ private
55
+
56
+ def default_retryable? = false
57
+
58
+ # The message without its leading "host: ".
59
+ def detail
60
+ host && message.start_with?("#{host}: ") ? message.delete_prefix("#{host}: ") : message
61
+ end
62
+ end
63
+
64
+ class ConnectionError < ProviderError
65
+ def kind = :connection
66
+
67
+ def summary = "can't reach host #{host}: #{detail}"
68
+
69
+ private
70
+
71
+ def default_retryable? = true
72
+ end
73
+
74
+ class RateLimited < ProviderError
75
+ def kind = :rate_limited
76
+
77
+ def summary
78
+ "rate limited by host #{host}: #{detail}#{"; retry after #{retry_after.ceil}s" if retry_after}"
79
+ end
80
+
81
+ private
82
+
83
+ def default_retryable? = true
84
+ end
85
+
86
+ class ServerError < ProviderError
87
+ def kind = :server
88
+
89
+ def summary = "server error from host #{host}: #{detail}"
90
+ end
91
+
92
+ class AuthError < ProviderError
93
+ def kind = :auth
94
+
95
+ def summary = "auth failed for host #{host}: #{detail}"
96
+ end
97
+
98
+ class BadRequest < ProviderError
99
+ TOOLS_HINT = "this model can't use tools, and chi needs them: pick another model (/model) or host"
100
+
101
+ def initialize(message = nil, context_overflow: false, tools_unsupported: false, **options)
102
+ @context_overflow = context_overflow
103
+ @tools_unsupported = tools_unsupported
104
+ super(message, **options)
105
+ end
106
+
107
+ # The prompt is larger than the model's context window.
108
+ def context_overflow? = @context_overflow
109
+
110
+ # The model (or every endpoint serving it) refuses requests with tools,
111
+ # and chi always sends them.
112
+ def tools_unsupported? = @tools_unsupported
113
+
114
+ def kind = :bad_request
115
+
116
+ def summary
117
+ if context_overflow?
118
+ "the conversation is too long for host #{host}'s context window: #{detail}"
119
+ elsif tools_unsupported?
120
+ "host #{host} rejected the request: #{tools_detail}; #{TOOLS_HINT}"
121
+ else
122
+ "host #{host} rejected the request: #{detail}"
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ # The detail up to the end of the sentence that says the model takes no
129
+ # tools: what a provider adds after it (OpenRouter: "Try disabling
130
+ # "execute"…" and a routing docs link) is advice for its own UI.
131
+ def tools_detail
132
+ text = detail
133
+ match = ProviderErrors::TOOLS_UNSUPPORTED_RE.match(text)
134
+ return text unless match
135
+
136
+ stop = text.index(/[.!](\s|\z)/, match.end(0))
137
+ stop ? text[0...stop] : text
138
+ end
139
+ end
140
+
141
+ # The model or host can't take images: refused before sending when chi
142
+ # knows it (VisionSupport), or the provider's answer to an image part
143
+ # (OpenRouter 404 "No endpoints found that support image input",
144
+ # llama.cpp 500 "image input is not supported … mmproj"). Never retried.
145
+ class VisionUnsupported < BadRequest
146
+ HINT = "send text only, or pick a model that can see images (/model)"
147
+
148
+ def kind = :vision_unsupported
149
+
150
+ def summary = "host #{host} can't take images: #{detail}; #{HINT}"
151
+
152
+ private
153
+
154
+ def default_retryable? = false
155
+ end
156
+
157
+ class ProtocolError < ProviderError
158
+ def kind = :protocol
159
+
160
+ def summary = "unexpected response from host #{host}: #{detail}"
161
+ end
162
+
163
+ # A request that kept failing on network errors until the retry budget
164
+ # ran out (or failed mid-stream, where a retry would repeat output).
165
+ class RetryExhausted < ConnectionError
166
+ attr_reader :last_error
167
+
168
+ def initialize(attempts:, last_error:, label: "llama.cpp")
169
+ @last_error = last_error
170
+ super("#{label} request failed after #{attempts} attempts: #{last_error.class}: #{last_error.message}",
171
+ host: label, retryable: false, attempts: attempts)
172
+ end
173
+
174
+ def summary = "network error after #{attempts} attempts (host #{host}: #{last_error.class})"
175
+ end
176
+
177
+ # A stream that showed nothing (no text, reasoning or tool call) within
178
+ # the host's first-token limit. A queued request can stay open for many
179
+ # minutes on keep-alive comments alone, which reset the read timeout.
180
+ # Not retried: the wait was already long, and a queue would likely
181
+ # queue the retry too.
182
+ class FirstTokenTimeout < ConnectionError
183
+ attr_reader :limit
184
+
185
+ def initialize(limit:, host:)
186
+ @limit = limit
187
+ super("#{host}: no answer within #{format_seconds(limit)}s", host: host, retryable: false)
188
+ end
189
+
190
+ def kind = :first_token_timeout
191
+
192
+ def summary
193
+ "no answer from host #{host} within #{format_seconds(limit)}s (first_token_timeout); " \
194
+ "try again later or pick another model (/model)"
195
+ end
196
+
197
+ private
198
+
199
+ def format_seconds(seconds) = seconds == seconds.to_i ? seconds.to_i.to_s : seconds.to_s
200
+ end
201
+
202
+ # Builds the ProviderError for an HTTP error response or a server's error
203
+ # event.
204
+ module ProviderErrors
205
+ CONTEXT_OVERFLOW_RE = /exceeds? (the )?(available )?context (size|length|window)|context[_ ]length[_ ]exceeded|exceed_context_size|maximum context length/i
206
+ # A model that takes no tools: OpenRouter when no endpoint of the model
207
+ # does ("No endpoints found that support tool use", 404), Ollama ("…
208
+ # does not support tools"), vLLM started without a tool parser.
209
+ TOOLS_UNSUPPORTED_RE = /support tool use|(does not|doesn't) support tools|tool choice requires --enable-auto-tool-choice/i
210
+ # A model or server that can't take images (see VisionUnsupported).
211
+ VISION_UNSUPPORTED_RE = /support image input|image input is not supported|mmproj/i
212
+ RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
213
+
214
+ module_function
215
+
216
+ # @param status [Integer]
217
+ # @param body [String] the response body (JSON or text)
218
+ # @param retry_after [String, nil] the Retry-After header
219
+ def from_response(status:, body:, host:, retry_after: nil)
220
+ message = error_message(body)
221
+ text = "#{host}: HTTP #{status}: #{message}"
222
+ options = { host: host, status: status }
223
+ if CONTEXT_OVERFLOW_RE.match?(message) || CONTEXT_OVERFLOW_RE.match?(body.to_s)
224
+ return BadRequest.new(text, context_overflow: true, **options)
225
+ end
226
+ # Before the status: llama.cpp answers 500, which would be retried.
227
+ return VisionUnsupported.new(text, **options) if VISION_UNSUPPORTED_RE.match?(message)
228
+
229
+ case status
230
+ when 401, 403 then AuthError.new(text, **options)
231
+ when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
232
+ when 408 then ServerError.new(text, retryable: true, **options)
233
+ when 400..499 then BadRequest.new(text, tools_unsupported: TOOLS_UNSUPPORTED_RE.match?(message), **options)
234
+ when 500..599
235
+ ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
236
+ retry_after: parse_retry_after(retry_after), **options)
237
+ else ProtocolError.new(text, **options)
238
+ end
239
+ end
240
+
241
+ # The error an SSE line carries, or nil for any other line: llama.cpp's
242
+ # `error: {...}` event, or a `data:` line holding an `error` object.
243
+ def from_sse_line(line, host:)
244
+ if line.start_with?("error:")
245
+ raw = line.delete_prefix("error:").strip
246
+ elsif line.start_with?("data:") && line.include?('"error"')
247
+ raw = line.delete_prefix("data:").strip
248
+ parsed = parse_json(raw)
249
+ return nil unless parsed.is_a?(Hash) && parsed.key?("error")
250
+ else
251
+ return nil
252
+ end
253
+
254
+ parsed = parse_json(raw)
255
+ error = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
256
+ code = error.is_a?(Hash) ? error["code"] : nil
257
+ status = code.is_a?(Integer) && code.between?(400, 599) ? code : 500
258
+ from_response(status: status, body: raw, host: host)
259
+ end
260
+
261
+ # The human part of an error body: error.message, message, error (a
262
+ # string), or the body itself, shortened. A gateway that passes on an
263
+ # upstream failure (OpenRouter) often says only "Provider returned
264
+ # error" and puts the upstream's words in error.metadata.raw and its
265
+ # name in metadata.provider_name; both are added when present.
266
+ def error_message(body)
267
+ parsed = parse_json(body.to_s)
268
+ message =
269
+ if parsed.is_a?(Hash)
270
+ error = parsed["error"]
271
+ (error.is_a?(Hash) && error["message"]) || (error.is_a?(String) && error) || parsed["message"] || parsed["detail"]
272
+ end
273
+ message = body.to_s if message.nil? || message.to_s.strip.empty?
274
+ message = message.to_s.strip
275
+ message = with_upstream(message, parsed["error"]["metadata"]) if parsed.is_a?(Hash) && parsed["error"].is_a?(Hash)
276
+ message.empty? ? "(empty body)" : message[0, 500]
277
+ end
278
+
279
+ # +message+ followed by the upstream provider's name and words from
280
+ # +metadata+ (raw may be text or a JSON error body of its own).
281
+ def with_upstream(message, metadata)
282
+ return message unless metadata.is_a?(Hash)
283
+
284
+ provider = metadata["provider_name"].to_s.strip
285
+ raw = metadata["raw"]
286
+ raw = JSON.generate(raw) if raw.is_a?(Hash)
287
+ raw = raw.to_s.strip
288
+ raw = error_message(raw) if raw.start_with?("{")
289
+ message += " (#{provider})" unless provider.empty?
290
+ raw.empty? || message.include?(raw) ? message : "#{message}: #{raw}"
291
+ end
292
+
293
+ # Retry-After in seconds (delta-seconds or an HTTP date), or nil.
294
+ def parse_retry_after(value)
295
+ return nil if value.nil? || value.to_s.strip.empty?
296
+
297
+ text = value.to_s.strip
298
+ return text.to_f if text.match?(/\A\d+(\.\d+)?\z/)
299
+
300
+ seconds = Time.httpdate(text) - Time.now
301
+ seconds.positive? ? seconds : 0.0
302
+ rescue ArgumentError
303
+ nil
304
+ end
305
+
306
+ def parse_json(text)
307
+ JSON.parse(text)
308
+ rescue JSON::ParserError
309
+ nil
310
+ end
311
+ end
312
+
313
+ # Marks an error that ended a turn with the conversation the loop had
314
+ # built by then (the prompt plus completed tool iterations; a half
315
+ # streamed reply is not kept), so the caller can keep it, as a cancel's
316
+ # salvage does.
317
+ module FailedTurn
318
+ attr_accessor :partial_conversation
319
+
320
+ # The innermost loop's conversation wins.
321
+ # @return [Exception] +error+
322
+ def self.attach(error, conversation)
323
+ error.extend(self) unless error.is_a?(self)
324
+ error.partial_conversation ||= conversation
325
+ error
326
+ end
327
+ end
328
+ end
329
+ end