misarmail 1.0.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,855 @@
1
+ require "erb"
2
+ require "net/http"
3
+ require "uri"
4
+ require "json"
5
+
6
+ require_relative "core/query"
7
+
8
+ module MisarMail
9
+ BASE_URL = "https://api.misar.io/mail/v1"
10
+ API_BASE = "https://api.misar.io/mail"
11
+ BILLING_BASE = "https://api.misar.io/mail"
12
+ RETRYABLE = [429, 500, 502, 503, 504].freeze
13
+ RETRY_BASE_S = 0.3
14
+
15
+ # ── Resource base ────────────────────────────────────────────────────────────
16
+
17
+ class Resource
18
+ def initialize(client)
19
+ @client = client
20
+ end
21
+
22
+ private
23
+
24
+ # Every generated method that takes optional filters builds its path as
25
+ # query(compact(...)), but neither helper had a definition anywhere in the
26
+ # gem — so DmarcResource#check, EmailsResource#list, RevenueResource
27
+ # #attribution, SegmentsResource#members, SubscriptionResource#get,
28
+ # TeamMembersResource#get and DmarcResource#remove_domain all raised
29
+ # NoMethodError the moment they were called. Core::Query.encode was written
30
+ # to be "shared by every generated GET/DELETE method" and already drops
31
+ # nils; it was simply never wired up.
32
+ def compact(params)
33
+ params.reject { |_, v| v.nil? }
34
+ end
35
+
36
+ def query(params)
37
+ Core::Query.encode(params)
38
+ end
39
+ end
40
+
41
+ # ── Resource: Email ──────────────────────────────────────────────────────────
42
+
43
+ class EmailResource < Resource
44
+ def send(data)
45
+ @client.request(:post, "/send", data)
46
+ end
47
+ end
48
+
49
+ # ── Resource: Contacts ───────────────────────────────────────────────────────
50
+
51
+ class ContactsResource < Resource
52
+ def list(page: 1, limit: 20)
53
+ @client.request(:get, "/contacts?#{URI.encode_www_form(page: page, limit: limit)}")
54
+ end
55
+
56
+ def create(data)
57
+ @client.request(:post, "/contacts", data)
58
+ end
59
+
60
+ def get(id)
61
+ @client.request(:get, "/contacts?id=#{ERB::Util.url_encode(id)}")
62
+ end
63
+
64
+ def update(email, data)
65
+ @client.request(:patch, "/contacts", data.merge(email: email))
66
+ end
67
+
68
+ def delete(id)
69
+ @client.request(:delete, "/contacts?id=#{ERB::Util.url_encode(id)}")
70
+ end
71
+
72
+ def import_contacts(data)
73
+ @client.request(:post, "/contacts/import", data)
74
+ end
75
+ end
76
+
77
+ # ── Resource: Campaigns ──────────────────────────────────────────────────────
78
+
79
+ class CampaignsResource < Resource
80
+ def list(params = {})
81
+ qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
82
+ @client.request(:get, "/campaigns#{qs}")
83
+ end
84
+
85
+ def create(data)
86
+ @client.request(:post, "/campaigns", data)
87
+ end
88
+
89
+ def get(id)
90
+ @client.request(:get, "/campaigns/#{id}")
91
+ end
92
+
93
+ def update(id, data)
94
+ @client.request(:patch, "/campaigns/#{id}", data)
95
+ end
96
+
97
+ def send(id)
98
+ @client.request(:post, "/campaigns/#{id}/send")
99
+ end
100
+
101
+ def delete(id)
102
+ @client.request(:delete, "/campaigns/#{id}")
103
+ end
104
+ end
105
+
106
+ # ── Resource: Templates ──────────────────────────────────────────────────────
107
+
108
+ class TemplatesResource < Resource
109
+ def list
110
+ @client.request(:get, "/templates")
111
+ end
112
+
113
+ def create(data)
114
+ @client.request(:post, "/templates", data)
115
+ end
116
+
117
+ def get(id)
118
+ @client.request(:get, "/templates/#{id}")
119
+ end
120
+
121
+ def update(id, data)
122
+ @client.request(:patch, "/templates/#{id}", data)
123
+ end
124
+
125
+ def delete(id)
126
+ @client.request(:delete, "/templates/#{id}")
127
+ end
128
+
129
+ def render(data)
130
+ @client.request(:post, "/templates/render", data)
131
+ end
132
+ end
133
+
134
+ # ── Resource: Automations ────────────────────────────────────────────────────
135
+
136
+ class AutomationsResource < Resource
137
+ def list(params = {})
138
+ qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
139
+ @client.request(:get, "/automations#{qs}")
140
+ end
141
+
142
+ def create(data)
143
+ @client.request(:post, "/automations", data)
144
+ end
145
+
146
+ def get(id)
147
+ @client.request(:get, "/automations/#{id}")
148
+ end
149
+
150
+ def update(id, data)
151
+ @client.request(:patch, "/automations/#{id}", data)
152
+ end
153
+
154
+ def delete(id)
155
+ @client.request(:delete, "/automations/#{id}")
156
+ end
157
+
158
+ def activate(id, active:)
159
+ @client.request(:post, "/automations/#{id}/activate", { active: active })
160
+ end
161
+ end
162
+
163
+ # ── Resource: Domains ────────────────────────────────────────────────────────
164
+
165
+ class DomainsResource < Resource
166
+ def list
167
+ @client.request(:get, "/domains", {}, API_BASE)
168
+ end
169
+
170
+ def create(data)
171
+ @client.request(:post, "/domains", data, API_BASE)
172
+ end
173
+
174
+ def get(id)
175
+ @client.request(:get, "/domains/#{id}", {}, API_BASE)
176
+ end
177
+
178
+ def verify(id)
179
+ @client.request(:post, "/domains/#{id}/verify", {}, API_BASE)
180
+ end
181
+
182
+ def delete(id)
183
+ @client.request(:delete, "/domains/#{id}", {}, API_BASE)
184
+ end
185
+ end
186
+
187
+
188
+ # ── Resource: Dedicated IPs ──────────────────────────────────────────────────
189
+
190
+ class DedicatedIpsResource < Resource
191
+ def list
192
+ @client.request(:get, "/dedicated-ips")
193
+ end
194
+
195
+ def create(data)
196
+ @client.request(:post, "/dedicated-ips", data)
197
+ end
198
+
199
+ def update(id, data)
200
+ @client.request(:patch, "/dedicated-ips/#{id}", data)
201
+ end
202
+
203
+ def delete(id)
204
+ @client.request(:delete, "/dedicated-ips/#{id}")
205
+ end
206
+ end
207
+
208
+
209
+ # ── Resource: A/B Tests ──────────────────────────────────────────────────────
210
+
211
+ class AbTestsResource < Resource
212
+ def list
213
+ @client.request(:get, "/ab-tests")
214
+ end
215
+
216
+ def create(data)
217
+ @client.request(:post, "/ab-tests", data)
218
+ end
219
+
220
+ def get(id)
221
+ @client.request(:get, "/ab-tests/#{id}")
222
+ end
223
+
224
+ def set_winner(id, variant_id)
225
+ @client.request(:post, "/ab-tests/#{id}/winner", { variant_id: variant_id })
226
+ end
227
+ end
228
+
229
+ # ── Resource: Sandbox ────────────────────────────────────────────────────────
230
+
231
+ class SandboxResource < Resource
232
+ def send(data)
233
+ @client.request(:post, "/sandbox/send", data)
234
+ end
235
+
236
+ def list(params = {})
237
+ qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
238
+ @client.request(:get, "/sandbox#{qs}")
239
+ end
240
+
241
+ def delete(id)
242
+ @client.request(:delete, "/sandbox/#{id}")
243
+ end
244
+ end
245
+
246
+ # ── Resource: Inbound ────────────────────────────────────────────────────────
247
+
248
+ class InboundResource < Resource
249
+ def list(params = {})
250
+ qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
251
+ @client.request(:get, "/inbound#{qs}")
252
+ end
253
+
254
+ def create(data)
255
+ @client.request(:post, "/inbound", data)
256
+ end
257
+
258
+ def get(id)
259
+ @client.request(:get, "/inbound/#{id}")
260
+ end
261
+
262
+ def delete(id)
263
+ @client.request(:delete, "/inbound/#{id}")
264
+ end
265
+ end
266
+
267
+ # ── Resource: Analytics ──────────────────────────────────────────────────────
268
+
269
+ class AnalyticsResource < Resource
270
+ def overview(params = {})
271
+ qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
272
+ @client.request(:get, "/analytics#{qs}")
273
+ end
274
+ end
275
+
276
+ # ── Resource: Track ──────────────────────────────────────────────────────────
277
+
278
+ class TrackResource < Resource
279
+ def event(data)
280
+ @client.request(:post, "/track/event", data)
281
+ end
282
+
283
+ def purchase(data)
284
+ @client.request(:post, "/track/purchase", data)
285
+ end
286
+ end
287
+
288
+ # ── Resource: API Keys ───────────────────────────────────────────────────────
289
+
290
+ class KeysResource < Resource
291
+ def list
292
+ @client.request(:get, "/keys")
293
+ end
294
+
295
+ def create(data)
296
+ @client.request(:post, "/keys", data)
297
+ end
298
+
299
+ def get(id)
300
+ @client.request(:get, "/keys/#{id}")
301
+ end
302
+
303
+ def revoke(id)
304
+ @client.request(:delete, "/keys/#{id}")
305
+ end
306
+ end
307
+
308
+ # ── Resource: Validate ───────────────────────────────────────────────────────
309
+
310
+ class ValidateResource < Resource
311
+ def email(address)
312
+ @client.request(:post, "/validate", { email: address })
313
+ end
314
+ end
315
+
316
+ # ── Resource: Webhooks ───────────────────────────────────────────────────────
317
+
318
+ class WebhooksResource < Resource
319
+ def list
320
+ @client.request(:get, "/webhooks")
321
+ end
322
+
323
+ def create(data)
324
+ @client.request(:post, "/webhooks", data)
325
+ end
326
+
327
+ def get(id)
328
+ @client.request(:get, "/webhooks/#{id}")
329
+ end
330
+
331
+ def update(id, data)
332
+ @client.request(:patch, "/webhooks/#{id}", data)
333
+ end
334
+
335
+ def delete(id)
336
+ @client.request(:delete, "/webhooks/#{id}")
337
+ end
338
+
339
+ def test(id)
340
+ @client.request(:post, "/webhooks/#{id}/test")
341
+ end
342
+ end
343
+
344
+ # ── Resource: Usage ──────────────────────────────────────────────────────────
345
+
346
+ class UsageResource < Resource
347
+ def get(params = {})
348
+ qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
349
+ @client.request(:get, "/usage#{qs}")
350
+ end
351
+ end
352
+
353
+ # ── Resource: Billing ────────────────────────────────────────────────────────
354
+
355
+ class BillingResource < Resource
356
+ def subscription
357
+ @client.request(:get, "/billing/subscription", {}, BILLING_BASE)
358
+ end
359
+
360
+ def checkout(data)
361
+ @client.request(:post, "/billing/checkout", data, BILLING_BASE)
362
+ end
363
+ end
364
+
365
+
366
+ # ── Main Client ──────────────────────────────────────────────────────────────
367
+
368
+ # Live subscription standing for the key's owner.
369
+ #
370
+ # Read this before an expensive call rather than discovering the ceiling
371
+ # through a PlanLimitError: +usage+ reports every metered feature and
372
+ # +upgrade+ is non-nil as soon as one of them is spent.
373
+ class PlanResource < Resource
374
+ # GET /plan — plan, sending allowances, per-feature usage, upgrade offer.
375
+ def get
376
+ @client.request(:get, "/plan")
377
+ end
378
+
379
+ # GET /monetization/stats — revenue and monetization counters.
380
+ def monetization
381
+ @client.request(:get, "/monetization/stats")
382
+ end
383
+ end
384
+
385
+
386
+ # ── Generated from scripts/sdk-endpoint-spec.json ──────────────────────────
387
+ #
388
+ # These twenty endpoints were missing from every SDK except TypeScript.
389
+
390
+ class AiResource < Resource
391
+ # POST /ai/subject-lines
392
+ def subject_lines(data: {})
393
+ @client.request(:post, "/ai/subject-lines", data)
394
+ end
395
+
396
+ end
397
+
398
+ class CreditRatesResource < Resource
399
+ # GET /credit-rates
400
+ def list
401
+ @client.request(:get, "/credit-rates")
402
+ end
403
+
404
+ end
405
+
406
+ class DeliverabilityResource < Resource
407
+ # GET /deliverability/audit
408
+ def audit
409
+ @client.request(:get, "/deliverability/audit")
410
+ end
411
+
412
+ # GET /deliverability/score
413
+ def score
414
+ @client.request(:get, "/deliverability/score")
415
+ end
416
+
417
+ end
418
+
419
+ class DmarcResource < Resource
420
+ # GET /dmarc/check
421
+ def check(domain: nil, dkim_selector: nil)
422
+ @client.request(:get, "/dmarc/check#{query(compact(domain: domain, dkim_selector: dkim_selector))}")
423
+ end
424
+
425
+ # GET /dmarc/domains
426
+ def list_domains
427
+ @client.request(:get, "/dmarc/domains")
428
+ end
429
+
430
+ # POST /dmarc/domains
431
+ def add_domain(data: {})
432
+ @client.request(:post, "/dmarc/domains", data)
433
+ end
434
+
435
+ # DELETE /dmarc/domains
436
+ def remove_domain(domain_id: nil)
437
+ @client.request(:delete, "/dmarc/domains#{query(compact(domain_id: domain_id))}")
438
+ end
439
+
440
+ end
441
+
442
+ class EmailAccountsResource < Resource
443
+ # GET /email-accounts
444
+ def list
445
+ @client.request(:get, "/email-accounts")
446
+ end
447
+
448
+ end
449
+
450
+ class EmailsResource < Resource
451
+ # GET /emails
452
+ def list(folder: nil, search: nil, limit: nil)
453
+ @client.request(:get, "/emails#{query(compact(folder: folder, search: search, limit: limit))}")
454
+ end
455
+
456
+ # GET /emails/:id
457
+ def get(id)
458
+ @client.request(:get, "/emails/#{id}")
459
+ end
460
+
461
+ # PATCH /emails/:id
462
+ def update(id, data: {})
463
+ @client.request(:patch, "/emails/#{id}", data)
464
+ end
465
+
466
+ end
467
+
468
+ class LandingPagesResource < Resource
469
+ # POST /landing-pages
470
+ def create(data: {})
471
+ @client.request(:post, "/landing-pages", data)
472
+ end
473
+
474
+ end
475
+
476
+ class MonetizationResource < Resource
477
+ # POST /monetization/tip
478
+ def tip(data: {})
479
+ @client.request(:post, "/monetization/tip", data)
480
+ end
481
+
482
+ end
483
+
484
+ class RevenueResource < Resource
485
+ # GET /revenue/attribution
486
+ def attribution(campaign_id: nil, period: nil)
487
+ @client.request(:get, "/revenue/attribution#{query(compact(campaign_id: campaign_id, period: period))}")
488
+ end
489
+
490
+ end
491
+
492
+ class SegmentsResource < Resource
493
+ # GET /segments/:id/members
494
+ def members(id, page: nil, limit: nil)
495
+ @client.request(:get, "/segments/#{id}/members#{query(compact(page: page, limit: limit))}")
496
+ end
497
+
498
+ end
499
+
500
+ class SubscriptionResource < Resource
501
+ # GET /subscription
502
+ def get(product: nil)
503
+ @client.request(:get, "/subscription#{query(compact(product: product))}")
504
+ end
505
+
506
+ # POST /subscription
507
+ def upsert(data: {})
508
+ @client.request(:post, "/subscription", data)
509
+ end
510
+
511
+ # DELETE /subscription
512
+ def cancel(data: {})
513
+ @client.request(:delete, "/subscription", data)
514
+ end
515
+
516
+ end
517
+
518
+ class TeamMembersResource < Resource
519
+ # GET /team-members
520
+ def get(owner_id: nil)
521
+ @client.request(:get, "/team-members#{query(compact(owner_id: owner_id))}")
522
+ end
523
+
524
+ end
525
+
526
+ class WalletResource < Resource
527
+ # GET /wallet
528
+ def get
529
+ @client.request(:get, "/wallet")
530
+ end
531
+
532
+ # POST /wallet/credit
533
+ def credit(data: {})
534
+ @client.request(:post, "/wallet/credit", data)
535
+ end
536
+
537
+ # POST /wallet/debit
538
+ def debit(data: {})
539
+ @client.request(:post, "/wallet/debit", data)
540
+ end
541
+
542
+ end
543
+
544
+ class WarmupResource < Resource
545
+ # GET /warmup
546
+ def get
547
+ @client.request(:get, "/warmup")
548
+ end
549
+
550
+ end
551
+
552
+ # One decoded SSE frame.
553
+ #
554
+ # MisarMail emits unnamed frames — +data: {...}+ with no +event:+ line — so
555
+ # +event+ is normally nil. +data+ is the decoded JSON; +raw+ is always the
556
+ # payload exactly as received.
557
+ StreamEvent = Struct.new(:event, :data, :raw)
558
+
559
+ # The two Server-Sent Events endpoints.
560
+ #
561
+ # Both live outside +/v1+, so they use API_BASE. Both are API-key
562
+ # authenticated and metered, so a plan refusal on open raises PlanLimitError
563
+ # exactly as a non-streaming call would.
564
+ class StreamingResource < Resource
565
+ # POST /api/ai/generate-email/stream — token-by-token generation.
566
+ #
567
+ # mail.streaming.generate_email(prompt: "...") do |frame|
568
+ # print frame.data["delta"]
569
+ # end
570
+ #
571
+ # Returns an Enumerator when no block is given.
572
+ def generate_email(**options, &block)
573
+ @client.sse_stream(:post, "/ai/generate-email/stream", options, &block)
574
+ end
575
+
576
+ # GET /api/campaigns/{id}/send-stream — live send progress.
577
+ def campaign_send(campaign_id, &block)
578
+ path = "/campaigns/#{ERB::Util.url_encode(campaign_id)}/send-stream"
579
+ @client.sse_stream(:get, path, nil, &block)
580
+ end
581
+ end
582
+
583
+ class Client
584
+ attr_reader :plan, :streaming, :ai, :credit_rates, :deliverability, :dmarc, :email_accounts, :emails, :landing_pages, :monetization, :revenue, :segments, :subscription, :team_members, :wallet, :warmup
585
+ attr_reader :email, :contacts, :campaigns, :templates, :automations,
586
+ :domains, :dedicated_ips, :ab_tests,
587
+ :sandbox, :inbound, :analytics, :track, :keys, :validate,
588
+ :webhooks, :usage,
589
+ :billing
590
+
591
+ # @param base_url [String] override the API host — point it at a stub
592
+ # server in tests, or at a self-hosted deployment. Defaults to BASE_URL.
593
+ def initialize(api_key:, timeout: 30, max_retries: 3, base_url: BASE_URL)
594
+ @api_key = api_key
595
+ @timeout = timeout
596
+ @max_retries = max_retries
597
+ @base_url = base_url
598
+
599
+ @plan = PlanResource.new(self)
600
+ @streaming = StreamingResource.new(self)
601
+ @ai = AiResource.new(self)
602
+ @credit_rates = CreditRatesResource.new(self)
603
+ @deliverability = DeliverabilityResource.new(self)
604
+ @dmarc = DmarcResource.new(self)
605
+ @email_accounts = EmailAccountsResource.new(self)
606
+ @emails = EmailsResource.new(self)
607
+ @landing_pages = LandingPagesResource.new(self)
608
+ @monetization = MonetizationResource.new(self)
609
+ @revenue = RevenueResource.new(self)
610
+ @segments = SegmentsResource.new(self)
611
+ @subscription = SubscriptionResource.new(self)
612
+ @team_members = TeamMembersResource.new(self)
613
+ @wallet = WalletResource.new(self)
614
+ @warmup = WarmupResource.new(self)
615
+ @email = EmailResource.new(self)
616
+ @contacts = ContactsResource.new(self)
617
+ @campaigns = CampaignsResource.new(self)
618
+ @templates = TemplatesResource.new(self)
619
+ @automations = AutomationsResource.new(self)
620
+ @domains = DomainsResource.new(self)
621
+ @dedicated_ips = DedicatedIpsResource.new(self)
622
+ @ab_tests = AbTestsResource.new(self)
623
+ @sandbox = SandboxResource.new(self)
624
+ @inbound = InboundResource.new(self)
625
+ @analytics = AnalyticsResource.new(self)
626
+ @track = TrackResource.new(self)
627
+ @keys = KeysResource.new(self)
628
+ @validate = ValidateResource.new(self)
629
+ @webhooks = WebhooksResource.new(self)
630
+ @usage = UsageResource.new(self)
631
+ @billing = BillingResource.new(self)
632
+ end
633
+
634
+ # @param method [Symbol] :get, :post, :patch, :put, :delete
635
+ # @param path [String] e.g. "/contacts"
636
+ # @param data [Hash] request body (post/put/patch only)
637
+ # @param base [String] override base URL (for billing/workspaces)
638
+ # @return [Hash]
639
+ # @raise [ApiError, NetworkError]
640
+ def request(method, path, data = {}, base = nil)
641
+ base ||= @base_url
642
+ url = URI.parse(base.chomp("/") + "/" + path.delete_prefix("/"))
643
+ has_body = !data.empty? && %i[post put patch].include?(method)
644
+ json_body = has_body ? JSON.generate(data) : nil
645
+
646
+ last_status = 0
647
+
648
+ @max_retries.times do |attempt|
649
+ begin
650
+ http = Net::HTTP.new(url.host, url.port)
651
+ http.use_ssl = url.scheme == "https"
652
+ http.open_timeout = 10
653
+ http.read_timeout = @timeout
654
+
655
+ req = build_request(method, url, json_body)
656
+ resp = http.request(req)
657
+ last_status = resp.code.to_i
658
+
659
+ # A rate-limit 429 and a spent-allowance 429 are identical by
660
+ # status, so the body decides. Only the first is worth retrying.
661
+ if plan_limit?(resp)
662
+ raise PlanLimitError.new(
663
+ last_status,
664
+ plan_limit_message(resp),
665
+ parse_body(resp),
666
+ response_headers(resp),
667
+ )
668
+ end
669
+
670
+ if RETRYABLE.include?(last_status) && attempt < @max_retries - 1
671
+ sleep(RETRY_BASE_S * (2**attempt))
672
+ next
673
+ end
674
+
675
+ return parse_response(resp, last_status)
676
+
677
+ rescue Net::OpenTimeout, Net::ReadTimeout,
678
+ Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
679
+ if attempt < @max_retries - 1
680
+ sleep(RETRY_BASE_S * (2**attempt))
681
+ next
682
+ end
683
+ raise NetworkError.new(e.message, e)
684
+ end
685
+ end
686
+
687
+ raise ApiError.new(last_status, "Max retries exceeded")
688
+ end
689
+
690
+
691
+ # Opens an SSE connection and yields each decoded frame.
692
+ #
693
+ # Deliberately not retried: replaying a stream that failed mid-flight would
694
+ # duplicate whatever the caller already consumed.
695
+ #
696
+ # @yieldparam [StreamEvent] frame
697
+ # @return [Enumerator] when no block is given
698
+ def sse_stream(method, path, data = nil, &block)
699
+ return enum_for(:sse_stream, method, path, data) unless block_given?
700
+
701
+ url = URI.parse(API_BASE + path)
702
+ http = Net::HTTP.new(url.host, url.port)
703
+ http.use_ssl = url.scheme == "https"
704
+ http.read_timeout = @timeout
705
+
706
+ req = build_request(method, url, data.nil? ? nil : JSON.generate(data))
707
+ req["Accept"] = "text/event-stream"
708
+
709
+ http.request(req) do |resp|
710
+ status = resp.code.to_i
711
+ unless (200..299).cover?(status)
712
+ raw = resp.read_body.to_s
713
+ body = begin
714
+ JSON.parse(raw)
715
+ rescue JSON::ParserError
716
+ {}
717
+ end
718
+ if body["code"] == "plan_limit_exceeded" || body["upgrade"].is_a?(Hash)
719
+ raise PlanLimitError.new(status, body["error"] || "plan limit exceeded",
720
+ body, response_headers(resp))
721
+ end
722
+ raise ApiError.new(status, body["error"] || (raw.empty? ? "stream error" : raw))
723
+ end
724
+
725
+ buffer = +""
726
+ event_name = nil
727
+ data_lines = []
728
+
729
+ flush = lambda do
730
+ next nil if data_lines.empty?
731
+
732
+ raw = data_lines.join("\n")
733
+ name = event_name
734
+ data_lines = []
735
+ event_name = nil
736
+ next :done if raw == "[DONE]"
737
+
738
+ decoded = begin
739
+ JSON.parse(raw)
740
+ rescue JSON::ParserError
741
+ nil
742
+ end
743
+ StreamEvent.new(name, decoded, raw)
744
+ end
745
+
746
+ resp.read_body do |chunk|
747
+ buffer << chunk
748
+ # A frame ends at a blank line; split on it and keep the remainder.
749
+ while (idx = buffer.index("\n\n") || buffer.index("\r\n\r\n"))
750
+ frame = buffer.slice!(0, idx)
751
+ buffer.sub!(/\A(\r?\n){1,2}/, "")
752
+
753
+ frame.split(/\r?\n/).each do |line|
754
+ next if line.empty? || line.start_with?(":") # keepalive
755
+
756
+ if line.start_with?("event:")
757
+ event_name = line.delete_prefix("event:").strip
758
+ elsif line.start_with?("data:")
759
+ data_lines << line.delete_prefix("data:").delete_prefix(" ")
760
+ end
761
+ end
762
+
763
+ result = flush.call
764
+ return if result == :done
765
+
766
+ yield result if result
767
+ end
768
+ end
769
+
770
+ # A trailing frame with no closing blank line.
771
+ buffer.split(/\r?\n/).each do |line|
772
+ next if line.empty? || line.start_with?(":")
773
+
774
+ if line.start_with?("event:")
775
+ event_name = line.delete_prefix("event:").strip
776
+ elsif line.start_with?("data:")
777
+ data_lines << line.delete_prefix("data:").delete_prefix(" ")
778
+ end
779
+ end
780
+ tail = flush.call
781
+ yield tail if tail && tail != :done
782
+ end
783
+ end
784
+
785
+ private
786
+
787
+ def build_request(method, url, json_body)
788
+ klass = case method
789
+ when :get then Net::HTTP::Get
790
+ when :post then Net::HTTP::Post
791
+ when :patch then Net::HTTP::Patch
792
+ when :put then Net::HTTP::Put
793
+ when :delete then Net::HTTP::Delete
794
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
795
+ end
796
+
797
+ req = klass.new(url.request_uri)
798
+ req["Authorization"] = "Bearer #{@api_key}"
799
+ req["Content-Type"] = "application/json"
800
+ req["Accept"] = "application/json"
801
+ req.body = json_body if json_body
802
+ req
803
+ end
804
+
805
+ # True when the body carries the API's plan-refusal marker.
806
+ def plan_limit?(resp)
807
+ body = parse_body(resp)
808
+ body["code"] == "plan_limit_exceeded" ||
809
+ body["error_type"] == "plan_limit_exceeded" ||
810
+ body["upgrade"].is_a?(Hash)
811
+ end
812
+
813
+ def plan_limit_message(resp)
814
+ parse_body(resp)["error"] || "plan limit exceeded"
815
+ end
816
+
817
+ # Always a Hash. Both callers index it by string key, and a top-level JSON
818
+ # array is a shape this API really returns — parse_response has always had
819
+ # a branch for it. Returning the raw Array here made plan_limit? raise
820
+ # "TypeError: no implicit conversion of String into Integer" before
821
+ # parse_response ever got to use that branch, so an array response could
822
+ # not be delivered at all.
823
+ def parse_body(resp)
824
+ return {} if resp.body.nil? || resp.body.empty?
825
+
826
+ decoded = JSON.parse(resp.body)
827
+ decoded.is_a?(Hash) ? decoded : {}
828
+ rescue JSON::ParserError
829
+ {}
830
+ end
831
+
832
+ def response_headers(resp)
833
+ resp.each_header.to_h
834
+ rescue StandardError
835
+ {}
836
+ end
837
+
838
+ def parse_response(resp, status)
839
+ return {} if status == 204 || resp.body.nil? || resp.body.empty?
840
+
841
+ decoded = begin
842
+ JSON.parse(resp.body)
843
+ rescue JSON::ParserError
844
+ nil
845
+ end
846
+
847
+ if status >= 400
848
+ msg = decoded.is_a?(Hash) ? (decoded["error"] || decoded["message"] || resp.body) : resp.body
849
+ raise ApiError.new(status, msg)
850
+ end
851
+
852
+ decoded.is_a?(Hash) ? decoded : { "data" => decoded }
853
+ end
854
+ end
855
+ end