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,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
|