fast-completions 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.
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Completions
5
+ # Replay normalization + message conversion (Specification §9.4–§9.5;
6
+ # pi utils/transform-messages.ts + api/openai-completions.ts
7
+ # convertMessages :1300-1402).
8
+ #
9
+ # Codec.normalize ports transformMessages (thinking folding, redacted
10
+ # rules, orphaned tool-call synthetics, system hold-back). Codec
11
+ # .to_wire_messages ports convertMessages (the wire shapes). The two
12
+ # passes mirror pi's split: stream() resolves the transcript, then
13
+ # convertMessages shapes it.
14
+ module Codec
15
+ # pi OPENAI_COMPLETIONS_REASONING_FIELDS openai-completions.ts :273.
16
+ REASONING_FIELDS = %w[reasoning reasoning_content reasoning_text].freeze
17
+
18
+ module_function
19
+
20
+ # ── Pass 1: transform-messages.ts ──────────────────────────────
21
+
22
+ def normalize(messages, model)
23
+ transformed = messages.map { |msg| transform_message(msg, model) }
24
+ insert_orphan_synthetic_results(transformed)
25
+ end
26
+
27
+ # First pass (transform-messages.ts :80-130). Same-model is the
28
+ # three-way identity reduced to (provider, model) — the gem has no
29
+ # provider registry; the comparison is consumer-controlled
30
+ # (Specification §12.3 note; pi :97-99).
31
+ def transform_message(msg, model)
32
+ return msg unless msg.assistant?
33
+
34
+ same_model = msg.provider == model.provider && msg.model == model.id
35
+ content = msg.content.flat_map do |block|
36
+ case block[:type]
37
+ when :thinking
38
+ normalize_thinking_block(block, same_model)
39
+ when :text
40
+ # Same or cross: text stays text (pi :119-123).
41
+ {type: :text, text: block[:text]}
42
+ else
43
+ # tool_call: pi strips the thoughtSignature cross-model and
44
+ # keeps it same-model (:124-134). The gem's tool_call block
45
+ # hash may carry :signature — the openai wire never emits it,
46
+ # so the strip/keep is a no-op at the wire layer.
47
+ block
48
+ end
49
+ end
50
+ Message.new(role: :assistant, content: content, provider: msg.provider,
51
+ model: msg.model, api: msg.api, usage: msg.usage)
52
+ end
53
+
54
+ # Thinking-block rules (transform-messages.ts :103-118):
55
+ # redacted → same-model keep, cross-model drop; same-model keeps
56
+ # blocks WITH signatures even when the text is empty (OpenAI
57
+ # encrypted reasoning); empty/whitespace text drops; cross-model
58
+ # non-empty folds to a text block.
59
+ def normalize_thinking_block(block, same_model)
60
+ if block[:redacted]
61
+ return same_model ? [block] : []
62
+ end
63
+ return [block] if same_model && block[:signature]
64
+ return [] if block[:thinking].nil? || block[:thinking].strip.empty?
65
+ return [block] if same_model
66
+
67
+ [{type: :text, text: block[:thinking]}]
68
+ end
69
+
70
+ # Second pass (transform-messages.ts :143-201): synthetic results
71
+ # for orphaned tool calls; system messages landing between a call
72
+ # and its results are held back and emitted after them (:166-169).
73
+ def insert_orphan_synthetic_results(messages)
74
+ result = []
75
+ pending_tool_calls = []
76
+ satisfied_ids = []
77
+ held_system_messages = []
78
+
79
+ close_pending = lambda do
80
+ if pending_tool_calls.any?
81
+ pending_tool_calls.each do |tc|
82
+ next if satisfied_ids.include?(tc[:id])
83
+
84
+ result << Message.tool_result(
85
+ tool_call_id: tc[:id], tool_name: tc[:name],
86
+ content: "No result provided", is_error: true,
87
+ )
88
+ end
89
+ pending_tool_calls = []
90
+ satisfied_ids = []
91
+ end
92
+ result.concat(held_system_messages)
93
+ held_system_messages = []
94
+ end
95
+
96
+ messages.each do |msg|
97
+ if msg.assistant?
98
+ close_pending.call
99
+ calls = msg.content.select { |b| b[:type] == :tool_call }
100
+ unless calls.empty?
101
+ pending_tool_calls = calls
102
+ satisfied_ids = []
103
+ end
104
+ result << msg
105
+ elsif msg.tool_result?
106
+ satisfied_ids << msg.tool_call_id
107
+ result << msg
108
+ elsif msg.system?
109
+ if pending_tool_calls.any?
110
+ held_system_messages << msg
111
+ else
112
+ result << msg
113
+ end
114
+ else # user
115
+ close_pending.call
116
+ result << msg
117
+ end
118
+ end
119
+ close_pending.call
120
+ result
121
+ end
122
+
123
+ # ── Pass 2: convertMessages (openai-completions.ts :1293-1465) ──
124
+
125
+ # instructions is the ONE leading system/developer message (§9.1,
126
+ # pi :1233): role "developer" iff model.reasoning AND
127
+ # dialect.supports_developer_role, else "system".
128
+ def to_wire_messages(instructions, messages, model, dialect)
129
+ instruction_role = model.reasoning && dialect.supports_developer_role ? "developer" : "system"
130
+ params = []
131
+ params << {role: instruction_role, content: instructions} if instructions && !instructions.empty?
132
+
133
+ messages.each do |msg|
134
+ case msg.role
135
+ when :system
136
+ next if msg.content.to_s.empty?
137
+
138
+ params << {role: instruction_role, content: msg.content}
139
+ when :user
140
+ wire_user(params, msg)
141
+ when :assistant
142
+ wire_assistant(params, msg, model, dialect)
143
+ when :tool_result
144
+ wire_tool_result(params, msg)
145
+ end
146
+ end
147
+ params
148
+ end
149
+
150
+ # User: a String passes verbatim (fixture fact); a block array
151
+ # passes as text parts (non-empty text only — images cut, D7);
152
+ # all-empty blocks skip the message (pi :1319-1342).
153
+ def wire_user(params, msg)
154
+ if msg.content.is_a?(String)
155
+ params << {role: "user", content: msg.content}
156
+ return
157
+ end
158
+
159
+ parts = msg.content
160
+ .select { |block| block[:type] == :text && !block[:text].to_s.empty? }
161
+ .map { |block| {type: "text", text: block[:text]} }
162
+ return if parts.empty?
163
+
164
+ params << {role: "user", content: parts}
165
+ end
166
+
167
+ # Tool result (§9.1, pi :1404-1427): {role: "tool", content: joined,
168
+ # tool_call_id:}; empty → "(no tool output)"; NO name key
169
+ # (requires_tool_result_name unported, §14).
170
+ def wire_tool_result(params, msg)
171
+ text = text_block_texts(msg.content).join("\n")
172
+ text = "(no tool output)" if text.empty?
173
+ params << {role: "tool", content: text, tool_call_id: msg.tool_call_id}
174
+ end
175
+
176
+ # Assistant replay (§9.4, pi :1348-1402). Wire key order is pi's
177
+ # insertion order: role, content, [reasoning field], [tool_calls],
178
+ # [reasoning_details], [deepseek reasoning_content:""].
179
+ def wire_assistant(params, msg, model, dialect)
180
+ assistant = {role: "assistant", content: nil}
181
+ text = text_block_texts(msg.content, non_whitespace: true).join("")
182
+ thinking_blocks = msg.content.select { |b| b[:type] == :thinking }
183
+ tool_calls = msg.content.select { |b| b[:type] == :tool_call }
184
+
185
+ non_empty_thinking = thinking_blocks.reject { |b| b[:thinking].to_s.strip.empty? }
186
+ # pi computes preservedReasoningDetails over ALL thinking blocks
187
+ # (:1313-1316) and emits it independent of the non-empty branch
188
+ # (:1382-1386) — encrypted-only blocks replay too.
189
+ preserved_details = reasoning_details_for(thinking_blocks)
190
+ unless non_empty_thinking.empty?
191
+ if preserved_details.nil?
192
+ field = non_empty_thinking.first[:signature]
193
+ assistant[field] = non_empty_thinking.map { |b| b[:thinking] }.join("\n") if REASONING_FIELDS.include?(field)
194
+ end
195
+ end
196
+
197
+ assistant[:content] = text unless text.empty?
198
+ unless tool_calls.empty?
199
+ assistant[:tool_calls] = tool_calls.map do |tc|
200
+ {id: tc[:id], type: "function",
201
+ function: {name: tc[:name], arguments: JSON.generate(tc[:arguments])}}
202
+ end
203
+ end
204
+ assistant["reasoning_details"] = preserved_details if preserved_details
205
+ # deepseek: reasoning_content:"" on every assistant when required
206
+ # and none was emitted (§9.5, pi :1387-1393).
207
+ if dialect.requires_reasoning_content_on_assistant_messages && model.reasoning &&
208
+ !assistant.key?("reasoning_content")
209
+ assistant["reasoning_content"] = ""
210
+ end
211
+
212
+ # Skip empty assistants (no text, no tool_calls) — pi :1394-1402.
213
+ # A reasoning-field-only assistant (content null, no tool_calls)
214
+ # is skipped too: pi's skip predicate checks content+tool_calls.
215
+ has_content = assistant[:content].is_a?(String) && !assistant[:content].empty?
216
+ return if !has_content && !assistant[:tool_calls]
217
+
218
+ params << assistant
219
+ end
220
+
221
+ # §9.5: the FIRST thinking-block signature (over ALL thinking
222
+ # blocks, pi :1313-1316) that parses as a JSON array of valid
223
+ # reasoning_details wins — emit `reasoning_details` and no raw
224
+ # reasoning field.
225
+ def reasoning_details_for(thinking_blocks)
226
+ thinking_blocks.each do |block|
227
+ details = parse_reasoning_details(block[:signature])
228
+ return details if details
229
+ end
230
+ nil
231
+ end
232
+
233
+ # pi parseOpenAIReasoningDetails openai-completions.ts :220-228:
234
+ # signature → JSON array (non-empty) of valid detail objects, else
235
+ # nil.
236
+ def parse_reasoning_details(signature)
237
+ return nil if signature.nil? || signature.empty?
238
+
239
+ parsed = JSON.parse(signature)
240
+ return nil unless parsed.is_a?(Array) && !parsed.empty?
241
+ return nil unless parsed.all? { |detail| valid_reasoning_detail?(detail) }
242
+
243
+ parsed
244
+ rescue JSON::ParserError, TypeError
245
+ nil
246
+ end
247
+
248
+ # pi isOpenAIReasoningDetail :135-165.
249
+ def valid_reasoning_detail?(detail)
250
+ return false unless detail.is_a?(Hash)
251
+
252
+ id = detail["id"]
253
+ return false unless id.nil? || id.is_a?(String)
254
+ return false unless detail["format"].nil? || detail["format"].is_a?(String)
255
+ return false unless detail["index"].nil? || detail["index"].is_a?(Numeric)
256
+
257
+ case detail["type"]
258
+ when "reasoning.summary" then detail["summary"].is_a?(String)
259
+ when "reasoning.encrypted" then detail["data"].is_a?(String)
260
+ when "reasoning.text"
261
+ detail["text"].is_a?(String) &&
262
+ (detail["signature"].nil? || detail["signature"].is_a?(String))
263
+ else false
264
+ end
265
+ end
266
+
267
+ # pi appendOpenAIReasoningDetail :240-265 — consecutive text/text
268
+ # and summary/summary entries merge; encrypted entries stay opaque
269
+ # and discrete.
270
+ def append_reasoning_detail(details, detail)
271
+ last = details.last
272
+ case detail["type"]
273
+ when "reasoning.text"
274
+ if last.is_a?(Hash) && last["type"] == "reasoning.text"
275
+ last["text"] = last["text"] + detail["text"]
276
+ last["signature"] ||= detail["signature"]
277
+ fill_missing_common_fields(last, detail)
278
+ return
279
+ end
280
+ when "reasoning.summary"
281
+ if last.is_a?(Hash) && last["type"] == "reasoning.summary"
282
+ last["summary"] = last["summary"] + detail["summary"]
283
+ fill_missing_common_fields(last, detail)
284
+ return
285
+ end
286
+ end
287
+ details << detail.dup
288
+ end
289
+
290
+ def fill_missing_common_fields(target, source)
291
+ # JS ??= with an undefined source is a no-op on the wire (JSON
292
+ # drops undefined) — never introduce explicit nulls.
293
+ if target["id"].nil? && !source["id"].nil?
294
+ target["id"] = source["id"]
295
+ end
296
+ if (target["format"].nil? || target["format"].empty?) && !source["format"].nil? # JS ||= (falsy overwrite)
297
+ target["format"] = source["format"]
298
+ end
299
+ if target["index"].nil? && !source["index"].nil?
300
+ target["index"] = source["index"]
301
+ end
302
+ end
303
+
304
+ # ── §9.6 estimate ──────────────────────────────────────────────
305
+
306
+ # ceil(chars / 3.5) over the message texts (pi CHARS_PER_TOKEN 3.5,
307
+ # utils/estimate.ts :14). Texts = string contents, text/thinking
308
+ # block texts, tool-result content.
309
+ def estimate_tokens(instructions, messages)
310
+ chars = instructions.to_s.length
311
+ messages.each do |msg|
312
+ case msg.content
313
+ when String then chars += msg.content.length
314
+ when Array
315
+ msg.content.each do |block|
316
+ if block.is_a?(Hash) && (block[:type] == :text || block[:type] == :thinking)
317
+ chars += block[:text].to_s.length
318
+ end
319
+ end
320
+ end
321
+ end
322
+ (chars / 3.5).ceil
323
+ end
324
+
325
+ module_function
326
+
327
+ def text_block_texts(content, non_whitespace: false)
328
+ blocks = content.is_a?(String) ? [{type: :text, text: content}] : content
329
+ blocks.select { |b| b.is_a?(Hash) && b[:type] == :text }
330
+ .map { |b| b[:text].to_s }
331
+ .then { |texts| non_whitespace ? texts.reject { |t| t.strip.empty? } : texts }
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Completions
5
+ # Dialects are DATA (D2): a closed declaration vocabulary consumed by
6
+ # the codec arms. Nothing here is provider detection, a catalog, or a
7
+ # base-URL constant — the consumer declares, the codec obeys (§4).
8
+ #
9
+ # The vocabulary is CLOSED to the 13 keys declared below. Unknown keys
10
+ # and unknown enum values raise ArgumentError AT DECLARATION TIME —
11
+ # fail loud, never silently mistranslate (the no-universal-translation
12
+ # discipline).
13
+ class Dialect
14
+ # key => [default, allowed-values-or-nil] in canonical (to_h-stable)
15
+ # order; nil allowed-values = free boolean.
16
+ VOCABULARY = {
17
+ thinking_format: [:openai, %i[openai zai deepseek].freeze],
18
+ max_tokens_field: ["max_completion_tokens", %w[max_completion_tokens max_tokens].freeze],
19
+ supports_developer_role: [true, nil],
20
+ supports_reasoning_effort: [true, nil],
21
+ supports_store: [true, nil],
22
+ supports_usage_in_streaming: [true, nil],
23
+ supports_finish_reason: [true, nil],
24
+ requires_reasoning_content_on_assistant_messages: [false, nil],
25
+ zai_tool_stream: [false, nil],
26
+ thinking_token_budget_field: [nil, [nil, "thinking_token_budget", "thinking_budget", "thinking_budget_tokens"].freeze],
27
+ supports_thinking_token_budget: [false, nil],
28
+ send_session_affinity_headers: [false, nil],
29
+ session_affinity_format: [:openai, %i[openai openai_nosession openrouter].freeze],
30
+ }.freeze
31
+
32
+ attr_reader :name
33
+
34
+ def initialize(name = nil, **overrides)
35
+ values = {}
36
+ VOCABULARY.each_key { |key| values[key] = VOCABULARY[key][0] }
37
+ overrides.each do |key, value|
38
+ unless VOCABULARY.key?(key)
39
+ raise ArgumentError,
40
+ "unknown dialect key #{key.inspect}; the vocabulary is closed to #{VOCABULARY.keys.inspect}"
41
+ end
42
+
43
+ allowed = VOCABULARY[key][1]
44
+ unless allowed.nil? || allowed.include?(value)
45
+ raise ArgumentError, "invalid value #{value.inspect} for dialect key #{key.inspect}; allowed: #{allowed.inspect}"
46
+ end
47
+
48
+ values[key] = value
49
+ end
50
+ @name = name
51
+ @values = values.freeze
52
+ freeze
53
+ end
54
+
55
+ VOCABULARY.each_key do |key|
56
+ define_method(key) { @values[key] }
57
+ end
58
+
59
+ # Declaration-order-stable (§4): canonical key order regardless of
60
+ # the order the consumer declared in.
61
+ def to_h
62
+ @values.dup
63
+ end
64
+
65
+ def ==(other)
66
+ other.is_a?(Dialect) && other.to_h == to_h
67
+ end
68
+
69
+ def inspect
70
+ "#<Fast::Completions::Dialect #{(@name || :anonymous).inspect} #{to_h.inspect}>"
71
+ end
72
+
73
+ # The declaration DSL receiver: `thinking_format :zai`-style setters
74
+ # validating against the same closed vocabulary (§4).
75
+ class DSL
76
+ Dialect::VOCABULARY.each_key do |key|
77
+ define_method(key) { |value| @overrides[key] = value }
78
+ end
79
+
80
+ def initialize(name)
81
+ @name = name
82
+ @overrides = {}
83
+ end
84
+
85
+ attr_reader :name, :overrides
86
+
87
+ def build
88
+ Dialect.new(@name, **@overrides)
89
+ end
90
+ end
91
+ end
92
+
93
+ # The declaration registry + lookup (§4). Re-declaring a name
94
+ # OVERRIDES — last declaration wins (documented consumer contract).
95
+ module Dialects
96
+ module_function
97
+
98
+ # Declare with block DSL and/or keyword overrides; empty overrides
99
+ # declare the all-defaults dialect (the :fireworks shape). Lookup
100
+ # goes through Fast::Completions.dialect (the facade, §3).
101
+ def declare(name, **overrides, &block)
102
+ dialect =
103
+ if block
104
+ dsl = Dialect::DSL.new(name)
105
+ dsl.instance_eval(&block)
106
+ Dialect.new(name, **dsl.overrides.merge(overrides))
107
+ else
108
+ Dialect.new(name, **overrides)
109
+ end
110
+ registry[name] = dialect
111
+ end
112
+
113
+ def fetch(name)
114
+ registry.fetch(name) { raise KeyError, "no dialect declared under #{name.inspect}" }
115
+ end
116
+
117
+ def registered
118
+ registry.keys.freeze
119
+ end
120
+
121
+ # Frozen name→Dialect dup (§3 .dialects).
122
+ def all
123
+ registry.dup.freeze
124
+ end
125
+
126
+ # The plain arm: every default, thinking_format :openai (§4).
127
+ def default
128
+ @default ||= Dialect.new(nil).freeze
129
+ end
130
+
131
+ def reset! # test hook only
132
+ @registry = nil
133
+ end
134
+
135
+ def registry
136
+ @registry ||= {}
137
+ end
138
+ end
139
+
140
+ # The well-known trio, SHIPPED as declarations, registered at require
141
+ # time (§4):
142
+ # - :zai — the I46 five-key subset + pi's zai store suppression
143
+ # (pi openai-completions.ts detectCompat :1642-1694: zai is
144
+ # non-standard ⇒ no store, no developer role, max_tokens;
145
+ # supportsReasoningEffort re-enabled per the I46 live behavior).
146
+ # - :deepseek — pi's deepseek detection: non-standard, max_tokens,
147
+ # requiresReasoningContentOnAssistantMessages.
148
+ # - :fireworks — the empty declaration (fireworks detects nothing in
149
+ # pi — the plain arm).
150
+ Dialects.declare(:zai) do
151
+ thinking_format :zai
152
+ zai_tool_stream true
153
+ max_tokens_field "max_tokens"
154
+ supports_developer_role false
155
+ supports_reasoning_effort true
156
+ supports_store false
157
+ end
158
+
159
+ Dialects.declare(:deepseek) do
160
+ thinking_format :deepseek
161
+ max_tokens_field "max_tokens"
162
+ supports_developer_role false
163
+ supports_reasoning_effort true
164
+ requires_reasoning_content_on_assistant_messages true
165
+ supports_store false
166
+ end
167
+
168
+ Dialects.declare(:fireworks)
169
+ end
170
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Completions
5
+ # The frozen error taxonomy (Specification §8). CancelledError is
6
+ # deliberately NOT under Error so rescue ladders that catch the API
7
+ # error tree never swallow a cancellation (D8).
8
+ class Error < StandardError; end
9
+
10
+ # Non-2xx responses map here (pi maps the OpenAI SDK status error).
11
+ # #message is "<status>: <provider message>" — the provider's
12
+ # error.message when the body parses, else the raw body truncated.
13
+ class ApiError < Error
14
+ attr_reader :status, :body
15
+
16
+ def initialize(status:, body: "")
17
+ @status = status
18
+ @body = body.to_s
19
+ super(Errors.api_message(status, @body))
20
+ end
21
+ end
22
+
23
+ class BadRequestError < ApiError; end # 400
24
+ class ContextLengthExceededError < BadRequestError; end # 400 + overflow pattern
25
+ class AuthenticationError < ApiError; end # 401
26
+ class PermissionError < ApiError; end # 403
27
+ class NotFoundError < ApiError; end # 404
28
+ class RateLimitError < ApiError; end # 429
29
+ class ServerError < ApiError; end # 5xx
30
+
31
+ class TimeoutError < Error; end # TTFB deadline / read inactivity (D5)
32
+ class ConnectionError < Error; end # ECONNREFUSED/ECONNRESET/... (§6)
33
+
34
+ # Terminal stream errors (pi throws inside stream; the gem emits the
35
+ # :error event first, then raises this — §10.5). NOT the malformed-
36
+ # chunk path since I61: a malformed chunk is repaired at the seam,
37
+ # else dropped and counted while the stream continues. What remains
38
+ # here: a provider error finish_reason or a missing finish.
39
+ class StreamError < Error
40
+ attr_reader :stop_reason
41
+
42
+ def initialize(message, stop_reason:)
43
+ @stop_reason = stop_reason
44
+ super(message)
45
+ end
46
+ end
47
+
48
+ class CancelledError < StandardError; end # NOT < Error (D8)
49
+
50
+ # Internal: error classification helpers (Specification §8).
51
+ module Errors
52
+ # pi utils/overflow.ts :33-96 — the FULL pattern list, ported
53
+ # verbatim as data (regexes already case-insensitive).
54
+ OVERFLOW_PATTERNS = [
55
+ /prompt (?:is )?too long/i, # Anthropic and z.ai token overflow
56
+ /prompt exceeds max length/i, # z.ai CN endpoint token overflow
57
+ /request_too_large/i, # Anthropic request byte-size overflow (HTTP 413)
58
+ /input is too long for requested model/i, # Amazon Bedrock
59
+ /exceeds the context window/i, # OpenAI (Completions & Responses API)
60
+ /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, # LiteLLM
61
+ /input token count.*exceeds the maximum/i, # Google (Gemini)
62
+ /maximum prompt length is \d+/i, # xAI (Grok)
63
+ /reduce the length of the messages/i, # Groq
64
+ /maximum context length is \d+ tokens/i, # OpenRouter (most backends)
65
+ /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, # OpenRouter/Poolside
66
+ /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, # Together AI
67
+ /exceeds the limit of \d+/i, # GitHub Copilot
68
+ /exceeds the available context size/i, # llama.cpp server
69
+ /greater than the context length/i, # LM Studio
70
+ /context window exceeds limit/i, # MiniMax
71
+ /exceeded model token limit/i, # Kimi For Coding
72
+ /too large for model with \d+ maximum context length/i, # Mistral
73
+ /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, # DS4 server
74
+ /model_context_window_exceeded/i, # z.ai non-standard finish_reason as error text
75
+ /prompt too long; exceeded (?:max )?context length/i, # Ollama explicit overflow error
76
+ /range of input length should be/i, # DashScope / Qwen Token Plan
77
+ /context[_ ]length[_ ]exceeded/i, # Generic fallback
78
+ /too many tokens/i, # Generic fallback
79
+ /token limit exceeded/i, # Generic fallback
80
+ ].freeze
81
+
82
+ STATUS_CLASSES = {
83
+ 401 => AuthenticationError,
84
+ 403 => PermissionError,
85
+ 404 => NotFoundError,
86
+ 429 => RateLimitError,
87
+ }.freeze
88
+
89
+ module_function
90
+
91
+ # Map a non-2xx status + body to the frozen class. 400 classifies
92
+ # as ContextLengthExceededError when ANY string in the parsed body
93
+ # (or the raw body) matches any overflow pattern (§8).
94
+ def class_for_status(status, body)
95
+ case status
96
+ when 400
97
+ overflow?(body) ? ContextLengthExceededError : BadRequestError
98
+ when 500..599
99
+ ServerError
100
+ else
101
+ STATUS_CLASSES.fetch(status, ApiError)
102
+ end
103
+ end
104
+
105
+ def overflow?(body)
106
+ raw = body.to_s
107
+ strings_in(raw).any? do |text|
108
+ OVERFLOW_PATTERNS.any? { |pattern| pattern.match?(text) }
109
+ end
110
+ end
111
+
112
+ # ApiError#message: "<status>: <message>" — the provider's
113
+ # error.message when parseable, else the raw body truncated to
114
+ # ~500 chars (Specification §8).
115
+ def api_message(status, body)
116
+ "#{status}: #{provider_message(body)}"
117
+ end
118
+
119
+ def provider_message(body)
120
+ raw = body.to_s
121
+ parsed = JSON.parse(raw)
122
+ message = parsed["error"]["message"] if parsed.is_a?(Hash) && parsed["error"].is_a?(Hash)
123
+ message ||= parsed["message"] if parsed.is_a?(Hash)
124
+ message || raw[0, 500]
125
+ rescue JSON::ParserError, TypeError
126
+ raw[0, 500]
127
+ end
128
+
129
+ # Every string value in a (possibly JSON) body — the pattern set is
130
+ # matched against each, so a nested {"error":{"message": ...}} is
131
+ # covered without pinning one shape.
132
+ def strings_in(raw)
133
+ strings = [raw]
134
+ parsed = JSON.parse(raw)
135
+ walk = ->(node) do
136
+ case node
137
+ when String then strings << node
138
+ when Array then node.each { |item| walk.call(item) }
139
+ when Hash then node.each_value { |item| walk.call(item) }
140
+ end
141
+ end
142
+ walk.call(parsed) if parsed.is_a?(Hash) || parsed.is_a?(Array)
143
+ strings
144
+ rescue JSON::ParserError, TypeError
145
+ strings
146
+ end
147
+ end
148
+ end
149
+ end