phaseo_sdk 2.1.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.
data/lib/index.rb ADDED
@@ -0,0 +1,974 @@
1
+ require "json"
2
+ require "time"
3
+ require "fileutils"
4
+ require "securerandom"
5
+ require "cgi"
6
+ require "uri"
7
+ require_relative "phaseo_sdk/model_ids"
8
+ require_relative "gen/client"
9
+ require_relative "gen/models"
10
+ require_relative "gen/operations"
11
+
12
+ module PhaseoSdk
13
+ class AsyncJobsResource
14
+ def initialize(parent)
15
+ @parent = parent
16
+ end
17
+
18
+ def websocket_url(kind, job_id, interval_ms: nil, close_on_terminal: nil)
19
+ @parent.get_async_job_websocket_url(
20
+ kind,
21
+ job_id,
22
+ interval_ms: interval_ms,
23
+ close_on_terminal: close_on_terminal
24
+ )
25
+ end
26
+ end
27
+
28
+ # Thin wrapper around the in-house generated Ruby SDK.
29
+ # Regenerate with: `pnpm openapi:gen:ruby`
30
+ class Phaseo
31
+ ACTIVE_MODEL_SOURCE_STATUSES = %w[active available].freeze
32
+ INACTIVE_MODEL_SOURCE_STATUSES = %w[
33
+ deprecated
34
+ retired
35
+ withheld
36
+ announced
37
+ rumoured
38
+ rumored
39
+ unavailable
40
+ disabled
41
+ internal
42
+ private
43
+ removed
44
+ sunset
45
+ eol
46
+ end_of_life
47
+ end-of-life
48
+ ].freeze
49
+
50
+ attr_reader :raw_client, :async_jobs
51
+
52
+ def initialize(
53
+ api_key: nil,
54
+ base_path: "https://api.phaseo.app/v1",
55
+ enable_deprecation_warnings: true,
56
+ warnings_as_errors: false,
57
+ logger: nil,
58
+ lifecycle_resolver: nil,
59
+ devtools: nil
60
+ )
61
+ api_key ||= ENV["PHASEO_API_KEY"]
62
+ raise ArgumentError, "Missing API key. Pass api_key or set PHASEO_API_KEY." if api_key.to_s.empty?
63
+
64
+ @raw_client = ::Phaseo::Gen::Client.new(
65
+ base_url: base_path,
66
+ headers: { "Authorization" => "Bearer #{api_key}" }
67
+ )
68
+ @base_path = base_path.sub(%r{/+\z}, "")
69
+ @enable_deprecation_warnings = enable_deprecation_warnings
70
+ @warnings_as_errors = warnings_as_errors
71
+ @logger = logger
72
+ @lifecycle_resolver = lifecycle_resolver
73
+ @warned_models = {}
74
+ @model_lifecycle_cache = {}
75
+ @telemetry_recorder = TelemetryRecorder.new(devtools, "2.1.0")
76
+ @async_jobs = AsyncJobsResource.new(self)
77
+ end
78
+
79
+ def get_model_deprecation_info(model_id)
80
+ normalized = as_trimmed_string(model_id)
81
+ return nil unless normalized
82
+ return @model_lifecycle_cache[normalized] if @model_lifecycle_cache.key?(normalized)
83
+
84
+ info = resolve_model_lifecycle(normalized)
85
+ @model_lifecycle_cache[normalized] = info
86
+ info
87
+ end
88
+
89
+ def validate_model(model_id)
90
+ info = get_model_deprecation_info(model_id)
91
+ return { ok: true, info: nil } unless info
92
+ unless is_model_requestable_for_inference?(info)
93
+ return { ok: false, info: info, reason: build_inactive_model_request_message(info) }
94
+ end
95
+
96
+ { ok: true, info: info }
97
+ end
98
+
99
+ def generate_text(payload)
100
+ with_lifecycle_and_telemetry(endpoint: "chat.completions", payload: payload, check_lifecycle: true) do
101
+ ::Phaseo::Gen::Operations.createChatCompletion(@raw_client, body: payload)
102
+ end
103
+ end
104
+
105
+ def create_chat_completion(payload)
106
+ generate_text(payload)
107
+ end
108
+
109
+ def generate_response(payload)
110
+ with_lifecycle_and_telemetry(endpoint: "responses", payload: payload, check_lifecycle: true) do
111
+ ::Phaseo::Gen::Operations.createResponse(@raw_client, body: payload)
112
+ end
113
+ end
114
+
115
+ def create_response(payload)
116
+ generate_response(payload)
117
+ end
118
+
119
+ def stream_response(payload)
120
+ @raw_client.request_stream(method:"post",path:"/responses",body:payload.merge(stream:true))
121
+ end
122
+
123
+ def create_anthropic_message(payload)
124
+ with_lifecycle_and_telemetry(endpoint: "messages", payload: payload, check_lifecycle: true) do
125
+ ::Phaseo::Gen::Operations.createAnthropicMessage(@raw_client, body: payload)
126
+ end
127
+ end
128
+
129
+ def generate_image(payload)
130
+ with_lifecycle_and_telemetry(endpoint: "images.generations", payload: payload, check_lifecycle: true) do
131
+ ::Phaseo::Gen::Operations.createImage(@raw_client, body: payload)
132
+ end
133
+ end
134
+
135
+ def create_image(payload)
136
+ generate_image(payload)
137
+ end
138
+
139
+ def generate_video(payload)
140
+ with_lifecycle_and_telemetry(endpoint: "video.generations", payload: payload, check_lifecycle: true) do
141
+ ::Phaseo::Gen::Operations.createVideo(@raw_client, body: payload)
142
+ end
143
+ end
144
+
145
+ def create_video(payload)
146
+ generate_video(payload)
147
+ end
148
+
149
+ def get_video(video_id)
150
+ with_lifecycle_and_telemetry(endpoint: "video.retrieve", payload: { "video_id" => video_id }, check_lifecycle: false) do
151
+ ::Phaseo::Gen::Operations.getVideo(@raw_client, path: { "video_id" => video_id })
152
+ end
153
+ end
154
+
155
+ def cancel_video(video_id)
156
+ with_lifecycle_and_telemetry(endpoint: "video.cancel", payload: { "video_id" => video_id }, check_lifecycle: false) do
157
+ ::Phaseo::Gen::Operations.cancelVideo(@raw_client, path: { "video_id" => video_id })
158
+ end
159
+ end
160
+
161
+ def delete_video(video_id)
162
+ with_lifecycle_and_telemetry(endpoint: "video.delete", payload: { "video_id" => video_id }, check_lifecycle: false) do
163
+ ::Phaseo::Gen::Operations.deleteVideo(@raw_client, path: { "video_id" => video_id })
164
+ end
165
+ end
166
+
167
+ def list_video_models
168
+ with_lifecycle_and_telemetry(endpoint: "video.models", payload: nil, check_lifecycle: false) do
169
+ ::Phaseo::Gen::Operations.listVideoModels(@raw_client)
170
+ end
171
+ end
172
+
173
+ def list_videos(options = {})
174
+ with_lifecycle_and_telemetry(endpoint: "video.list", payload: options, check_lifecycle: false) do
175
+ ::Phaseo::Gen::Operations.listVideos(@raw_client, query: options)
176
+ end
177
+ end
178
+
179
+ def generate_image_edit(payload)
180
+ with_lifecycle_and_telemetry(endpoint: "images.edits", payload: payload, check_lifecycle: true) do
181
+ ::Phaseo::Gen::Operations.createImageEdit(@raw_client, body: payload)
182
+ end
183
+ end
184
+
185
+ def create_image_edit(payload)
186
+ generate_image_edit(payload)
187
+ end
188
+
189
+ def generate_embedding(payload)
190
+ with_lifecycle_and_telemetry(endpoint: "embeddings", payload: payload, check_lifecycle: true) do
191
+ ::Phaseo::Gen::Operations.createEmbedding(@raw_client, body: payload)
192
+ end
193
+ end
194
+
195
+ def create_embedding(payload)
196
+ generate_embedding(payload)
197
+ end
198
+
199
+ def generate_moderation(payload)
200
+ with_lifecycle_and_telemetry(endpoint: "moderations", payload: payload, check_lifecycle: true) do
201
+ ::Phaseo::Gen::Operations.createModeration(@raw_client, body: payload)
202
+ end
203
+ end
204
+
205
+ def create_moderation(payload)
206
+ generate_moderation(payload)
207
+ end
208
+
209
+ def generate_speech(payload)
210
+ with_lifecycle_and_telemetry(endpoint: "audio.speech", payload: payload, check_lifecycle: true) do
211
+ ::Phaseo::Gen::Operations.createSpeech(@raw_client, body: payload)
212
+ end
213
+ end
214
+
215
+ def create_speech(payload)
216
+ generate_speech(payload)
217
+ end
218
+
219
+ def generate_transcription(payload)
220
+ with_lifecycle_and_telemetry(endpoint: "audio.transcriptions", payload: payload, check_lifecycle: true) do
221
+ ::Phaseo::Gen::Operations.createTranscription(@raw_client, body: payload)
222
+ end
223
+ end
224
+
225
+ def create_transcription(payload)
226
+ generate_transcription(payload)
227
+ end
228
+
229
+ def generate_translation(payload)
230
+ with_lifecycle_and_telemetry(endpoint: "audio.translations", payload: payload, check_lifecycle: true) do
231
+ ::Phaseo::Gen::Operations.createTranslation(@raw_client, body: payload)
232
+ end
233
+ end
234
+
235
+ def create_translation(payload)
236
+ generate_translation(payload)
237
+ end
238
+
239
+ def create_batch(payload)
240
+ with_lifecycle_and_telemetry(endpoint: "batches.create", payload: payload, check_lifecycle: true) do
241
+ ::Phaseo::Gen::Operations.createBatch(@raw_client, body: payload)
242
+ end
243
+ end
244
+
245
+ def retrieve_batch(batch_id)
246
+ with_lifecycle_and_telemetry(endpoint: "batches.retrieve", payload: { "batch_id" => batch_id }, check_lifecycle: false) do
247
+ ::Phaseo::Gen::Operations.retrieveBatch(@raw_client, path: { "batch_id" => batch_id })
248
+ end
249
+ end
250
+
251
+ def cancel_batch(batch_id)
252
+ with_lifecycle_and_telemetry(endpoint: "batches.cancel", payload: { "batch_id" => batch_id }, check_lifecycle: false) do
253
+ ::Phaseo::Gen::Operations.cancelBatch(@raw_client, path: { "batch_id" => batch_id })
254
+ end
255
+ end
256
+
257
+ def get_async_job_websocket_url(kind, job_id, interval_ms: nil, close_on_terminal: nil)
258
+ normalized_kind = kind.to_s.strip
259
+ normalized_job_id = job_id.to_s.strip
260
+ raise ArgumentError, "kind is required" if normalized_kind.empty?
261
+ raise ArgumentError, "job_id is required" if normalized_job_id.empty?
262
+
263
+ uri = URI.parse(@base_path)
264
+ uri.scheme = uri.scheme == "https" ? "wss" : "ws"
265
+ uri.path = "#{uri.path.sub(%r{/+\z}, "")}/async/#{escape_path_segment(normalized_kind)}/#{escape_path_segment(normalized_job_id)}/ws"
266
+ query = {}
267
+ query["interval_ms"] = interval_ms.to_s unless interval_ms.nil?
268
+ query["close_on_terminal"] = close_on_terminal ? "true" : "false" unless close_on_terminal.nil?
269
+ uri.query = query.empty? ? nil : URI.encode_www_form(query)
270
+ uri.to_s
271
+ end
272
+
273
+ def batch_websocket_url(batch_id, interval_ms: nil, close_on_terminal: nil)
274
+ get_async_job_websocket_url("batch", batch_id, interval_ms: interval_ms, close_on_terminal: close_on_terminal)
275
+ end
276
+
277
+ def video_websocket_url(video_id, interval_ms: nil, close_on_terminal: nil)
278
+ get_async_job_websocket_url("video", video_id, interval_ms: interval_ms, close_on_terminal: close_on_terminal)
279
+ end
280
+
281
+ def escape_path_segment(value)
282
+ CGI.escape(value).gsub("+", "%20")
283
+ end
284
+
285
+ private :escape_path_segment
286
+
287
+ def list_files(options = {})
288
+ with_lifecycle_and_telemetry(endpoint: "files.list", payload: options, check_lifecycle: false) do
289
+ ::Phaseo::Gen::Operations.listFiles(@raw_client, query: options)
290
+ end
291
+ end
292
+
293
+ def retrieve_file(file_id)
294
+ with_lifecycle_and_telemetry(endpoint: "files.retrieve", payload: { "file_id" => file_id }, check_lifecycle: false) do
295
+ ::Phaseo::Gen::Operations.retrieveFile(@raw_client, path: { "file_id" => file_id })
296
+ end
297
+ end
298
+
299
+ def retrieve_file_content(file_id)
300
+ with_lifecycle_and_telemetry(endpoint: "files.content", payload: { "file_id" => file_id }, check_lifecycle: false) do
301
+ @raw_client.request_bytes(method: "get", path: "/files/#{file_id}/content")
302
+ end
303
+ end
304
+
305
+ def retrieve_video_content(video_id)
306
+ with_lifecycle_and_telemetry(endpoint: "video.content", payload: { "video_id" => video_id }, check_lifecycle: false) do
307
+ @raw_client.request_bytes(method: "get", path: "/videos/#{video_id}/content")
308
+ end
309
+ end
310
+
311
+ def get_video_download_url(video_id, params = {})
312
+ with_lifecycle_and_telemetry(
313
+ endpoint: "video.download_url",
314
+ payload: { "video_id" => video_id, "body" => params },
315
+ check_lifecycle: false
316
+ ) do
317
+ @raw_client.request(method: "POST", path: "/videos/#{video_id}/download_url", body: params)
318
+ end
319
+ end
320
+
321
+ def upload_file(payload)
322
+ with_lifecycle_and_telemetry(endpoint: "files.upload", payload: payload, check_lifecycle: false) do
323
+ ::Phaseo::Gen::Operations.uploadFile(@raw_client, body: payload)
324
+ end
325
+ end
326
+
327
+ def list_models(options = {})
328
+ with_lifecycle_and_telemetry(endpoint: "models.list", payload: options, check_lifecycle: false) do
329
+ ::Phaseo::Gen::Operations.listModels(@raw_client, query: options)
330
+ end
331
+ end
332
+
333
+ def list_providers(options = {})
334
+ with_lifecycle_and_telemetry(endpoint: "providers", payload: options, check_lifecycle: false) do
335
+ ::Phaseo::Gen::Operations.listProviders(@raw_client, query: options)
336
+ end
337
+ end
338
+
339
+ def get_analytics(options = {})
340
+ with_lifecycle_and_telemetry(endpoint: "analytics", payload: options, check_lifecycle: false) do
341
+ ::Phaseo::Gen::Operations.getActivityAlias(@raw_client, query: options)
342
+ end
343
+ end
344
+
345
+ def get_credits(options = {})
346
+ with_lifecycle_and_telemetry(endpoint: "credits", payload: options, check_lifecycle: false) do
347
+ ::Phaseo::Gen::Operations.getCredits(@raw_client, query: options)
348
+ end
349
+ end
350
+
351
+ def get_activity(options = {})
352
+ with_lifecycle_and_telemetry(endpoint: "activity", payload: options, check_lifecycle: false) do
353
+ ::Phaseo::Gen::Operations.getActivity(@raw_client, query: options)
354
+ end
355
+ end
356
+
357
+ def get_generation(generation_id)
358
+ with_lifecycle_and_telemetry(endpoint: "generations.retrieve", payload: { "id" => generation_id }, check_lifecycle: false) do
359
+ ::Phaseo::Gen::Operations.getGeneration(@raw_client, query: { "id" => generation_id })
360
+ end
361
+ end
362
+
363
+ def list_endpoints
364
+ with_lifecycle_and_telemetry(endpoint: "endpoints.list", payload: {}, check_lifecycle: false) do
365
+ ::Phaseo::Gen::Operations.listEndpoints(@raw_client)
366
+ end
367
+ end
368
+
369
+ def list_organisations(options = {})
370
+ with_lifecycle_and_telemetry(endpoint: "organisations.list", payload: options, check_lifecycle: false) do
371
+ ::Phaseo::Gen::Operations.listOrganisations(@raw_client, query: options)
372
+ end
373
+ end
374
+
375
+ def list_pricing_models(options = {})
376
+ with_lifecycle_and_telemetry(endpoint: "pricing.models", payload: options, check_lifecycle: false) do
377
+ ::Phaseo::Gen::Operations.listPricingModels(@raw_client, query: options)
378
+ end
379
+ end
380
+
381
+ def calculate_pricing(payload)
382
+ with_lifecycle_and_telemetry(endpoint: "pricing.calculate", payload: payload, check_lifecycle: false) do
383
+ ::Phaseo::Gen::Operations.calculatePricing(@raw_client, body: payload)
384
+ end
385
+ end
386
+
387
+ def list_api_keys(options = {})
388
+ with_lifecycle_and_telemetry(endpoint: "provisioning.keys.list", payload: options, check_lifecycle: false) do
389
+ ::Phaseo::Gen::Operations.listApiKeys(@raw_client, query: options)
390
+ end
391
+ end
392
+
393
+ def create_api_key(payload)
394
+ with_lifecycle_and_telemetry(endpoint: "provisioning.keys.create", payload: payload, check_lifecycle: false) do
395
+ ::Phaseo::Gen::Operations.createApiKey(@raw_client, body: payload)
396
+ end
397
+ end
398
+
399
+ def get_api_key(id)
400
+ with_lifecycle_and_telemetry(endpoint: "provisioning.keys.get", payload: { "id" => id }, check_lifecycle: false) do
401
+ ::Phaseo::Gen::Operations.getApiKey(@raw_client, path: { "id" => id })
402
+ end
403
+ end
404
+
405
+ def update_api_key(id, payload)
406
+ with_lifecycle_and_telemetry(endpoint: "provisioning.keys.update", payload: { "id" => id, "body" => payload }, check_lifecycle: false) do
407
+ ::Phaseo::Gen::Operations.updateApiKey(@raw_client, path: { "id" => id }, body: payload)
408
+ end
409
+ end
410
+
411
+ def delete_api_key(id)
412
+ with_lifecycle_and_telemetry(endpoint: "provisioning.keys.delete", payload: { "id" => id }, check_lifecycle: false) do
413
+ ::Phaseo::Gen::Operations.deleteApiKey(@raw_client, path: { "id" => id })
414
+ end
415
+ end
416
+
417
+ def list_workspaces(options = {})
418
+ with_lifecycle_and_telemetry(endpoint: "provisioning.workspaces.list", payload: options, check_lifecycle: false) do
419
+ ::Phaseo::Gen::Operations.listWorkspaces(@raw_client, query: options)
420
+ end
421
+ end
422
+
423
+ def get_workspace(id)
424
+ with_lifecycle_and_telemetry(endpoint: "provisioning.workspaces.get", payload: { "id" => id }, check_lifecycle: false) do
425
+ ::Phaseo::Gen::Operations.getWorkspace(@raw_client, path: { "id" => id })
426
+ end
427
+ end
428
+
429
+ def create_workspace(payload)
430
+ with_lifecycle_and_telemetry(endpoint: "provisioning.workspaces.create", payload: payload, check_lifecycle: false) do
431
+ ::Phaseo::Gen::Operations.createWorkspace(@raw_client, body: payload)
432
+ end
433
+ end
434
+
435
+ def update_workspace(id, payload)
436
+ with_lifecycle_and_telemetry(
437
+ endpoint: "provisioning.workspaces.update",
438
+ payload: { "id" => id, **payload },
439
+ check_lifecycle: false
440
+ ) do
441
+ ::Phaseo::Gen::Operations.updateWorkspace(@raw_client, path: { "id" => id }, body: payload)
442
+ end
443
+ end
444
+
445
+ def delete_workspace(id)
446
+ with_lifecycle_and_telemetry(endpoint: "provisioning.workspaces.delete", payload: { "id" => id }, check_lifecycle: false) do
447
+ ::Phaseo::Gen::Operations.deleteWorkspace(@raw_client, path: { "id" => id })
448
+ end
449
+ end
450
+
451
+ def get_current_api_key
452
+ with_lifecycle_and_telemetry(endpoint: "key.current", payload: {}, check_lifecycle: false) do
453
+ ::Phaseo::Gen::Operations.getCurrentApiKey(@raw_client)
454
+ end
455
+ end
456
+
457
+ def health
458
+ with_lifecycle_and_telemetry(endpoint: "health", payload: nil, check_lifecycle: false) do
459
+ @raw_client.request(method: "GET", path: "/health")
460
+ end
461
+ end
462
+
463
+ def healthz
464
+ health
465
+ end
466
+
467
+ private
468
+
469
+ def maybe_warn_for_payload(payload)
470
+ model_id = extract_model_id(payload)
471
+ return unless model_id
472
+ ensure_model_requestable(model_id)
473
+ maybe_warn_for_model(model_id)
474
+ end
475
+
476
+ def ensure_model_requestable(model_id)
477
+ normalized = as_trimmed_string(model_id)
478
+ return unless normalized
479
+
480
+ lifecycle = get_model_deprecation_info(normalized)
481
+ return unless lifecycle
482
+ return if is_model_requestable_for_inference?(lifecycle)
483
+
484
+ raise RuntimeError, build_inactive_model_request_message(lifecycle)
485
+ end
486
+
487
+ def with_lifecycle_and_telemetry(endpoint:, payload:, check_lifecycle:)
488
+ started_at = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
489
+ begin
490
+ maybe_warn_for_payload(payload) if check_lifecycle
491
+ result = yield
492
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i - started_at
493
+ @telemetry_recorder.capture_success(endpoint: endpoint, request: payload, response: result, duration_ms: duration)
494
+ result
495
+ rescue StandardError => e
496
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i - started_at
497
+ @telemetry_recorder.capture_error(endpoint: endpoint, request: payload, error: e, duration_ms: duration)
498
+ raise
499
+ end
500
+ end
501
+
502
+ def maybe_warn_for_model(model_id)
503
+ return unless @enable_deprecation_warnings
504
+ normalized = as_trimmed_string(model_id)
505
+ return unless normalized
506
+
507
+ lifecycle = get_model_deprecation_info(normalized)
508
+ return unless lifecycle
509
+ return if lifecycle[:status] == "active"
510
+
511
+ message = lifecycle[:message] || build_lifecycle_message(
512
+ lifecycle[:status],
513
+ lifecycle[:model_id],
514
+ lifecycle[:deprecation_date],
515
+ lifecycle[:retirement_date],
516
+ lifecycle[:replacement_model_id]
517
+ )
518
+
519
+ raise RuntimeError, message if @warnings_as_errors
520
+ return if @warned_models[normalized]
521
+ @warned_models[normalized] = true
522
+
523
+ meta = {
524
+ model_id: lifecycle[:model_id],
525
+ status: lifecycle[:status],
526
+ deprecation_date: lifecycle[:deprecation_date],
527
+ retirement_date: lifecycle[:retirement_date],
528
+ replacement_model_id: lifecycle[:replacement_model_id]
529
+ }
530
+
531
+ if @logger.respond_to?(:call)
532
+ @logger.call("warn", message, meta)
533
+ else
534
+ warn(message)
535
+ end
536
+ end
537
+
538
+ def resolve_model_lifecycle(model_id)
539
+ if @lifecycle_resolver.respond_to?(:call)
540
+ resolved = @lifecycle_resolver.call(model_id)
541
+ return normalize_hash(resolved)
542
+ end
543
+ fetch_model_lifecycle(model_id)
544
+ end
545
+
546
+ def fetch_model_lifecycle(model_id)
547
+ response = ::Phaseo::Gen::Operations.listModels(
548
+ @raw_client,
549
+ query: { "model_id" => model_id, "limit" => "1" }
550
+ )
551
+ decoded = normalize_hash(response)
552
+ return nil unless decoded
553
+ models = decoded[:models]
554
+ return nil unless models.is_a?(Array)
555
+
556
+ models.each do |entry|
557
+ model = normalize_hash(entry)
558
+ next unless model
559
+ next unless as_trimmed_string(model[:model_id]) == model_id
560
+
561
+ return to_model_lifecycle_info(model, model_id)
562
+ end
563
+ nil
564
+ rescue StandardError
565
+ nil
566
+ end
567
+
568
+ def to_model_lifecycle_info(model, fallback_model_id)
569
+ lifecycle = normalize_hash(model[:lifecycle]) || {}
570
+ model_id = first_non_empty(as_trimmed_string(model[:model_id]), fallback_model_id) || fallback_model_id
571
+ source_status = first_non_empty(as_trimmed_string(model[:status]), as_trimmed_string(lifecycle[:status]))
572
+ deprecation_date = first_non_empty(as_trimmed_string(lifecycle[:deprecation_date]), as_trimmed_string(model[:deprecation_date]))
573
+ retirement_date = first_non_empty(as_trimmed_string(lifecycle[:retirement_date]), as_trimmed_string(model[:retirement_date]))
574
+ status = normalize_lifecycle_status(
575
+ first_non_empty(as_trimmed_string(lifecycle[:status]), as_trimmed_string(model[:status])),
576
+ deprecation_date,
577
+ retirement_date
578
+ )
579
+ replacement_model_id = first_non_empty(as_trimmed_string(lifecycle[:replacement_model_id]))
580
+ message = first_non_empty(
581
+ as_trimmed_string(lifecycle[:message]),
582
+ build_lifecycle_message(status, model_id, deprecation_date, retirement_date, replacement_model_id)
583
+ )
584
+
585
+ {
586
+ model_id: model_id,
587
+ status: status,
588
+ source_status: source_status,
589
+ deprecation_date: deprecation_date,
590
+ retirement_date: retirement_date,
591
+ replacement_model_id: replacement_model_id,
592
+ message: message
593
+ }
594
+ end
595
+
596
+ def normalize_lifecycle_status(status, deprecation_date, retirement_date)
597
+ normalized = as_trimmed_string(status)&.downcase
598
+ return normalized if %w[active deprecated retired].include?(normalized)
599
+
600
+ now = Time.now.utc
601
+ retirement_time = parse_iso_time(retirement_date)
602
+ return "retired" if retirement_time && retirement_time <= now
603
+
604
+ deprecation_time = parse_iso_time(deprecation_date)
605
+ return "deprecated" if deprecation_time && deprecation_time <= now
606
+
607
+ "active"
608
+ end
609
+
610
+ def parse_iso_time(value)
611
+ trimmed = as_trimmed_string(value)
612
+ return nil unless trimmed
613
+ Time.iso8601(trimmed)
614
+ rescue ArgumentError
615
+ nil
616
+ end
617
+
618
+ def build_lifecycle_message(status, model_id, deprecation_date, retirement_date, replacement_model_id)
619
+ replacement = replacement_model_id ? %( Use "#{replacement_model_id}" instead.) : ""
620
+ if status == "retired"
621
+ return %[ [phaseo] Model "#{model_id}" is retired as of #{retirement_date}.#{replacement} ].strip if retirement_date
622
+ return %[ [phaseo] Model "#{model_id}" is retired.#{replacement} ].strip
623
+ end
624
+ if status == "deprecated"
625
+ return %[ [phaseo] Model "#{model_id}" is deprecated and scheduled for retirement on #{retirement_date}.#{replacement} ].strip if retirement_date
626
+ return %[ [phaseo] Model "#{model_id}" has been deprecated since #{deprecation_date}.#{replacement} ].strip if deprecation_date
627
+ return %[ [phaseo] Model "#{model_id}" is deprecated.#{replacement} ].strip
628
+ end
629
+ ""
630
+ end
631
+
632
+ def normalize_source_status(value)
633
+ normalized = as_trimmed_string(value)
634
+ normalized&.downcase
635
+ end
636
+
637
+ def is_model_requestable_for_inference?(info)
638
+ return false unless info[:status] == "active"
639
+
640
+ source_status = normalize_source_status(info[:source_status])
641
+ return true unless source_status
642
+ return true if ACTIVE_MODEL_SOURCE_STATUSES.include?(source_status)
643
+ return false if INACTIVE_MODEL_SOURCE_STATUSES.include?(source_status)
644
+
645
+ false
646
+ end
647
+
648
+ def build_inactive_model_request_message(info)
649
+ if info[:status] != "active"
650
+ fallback = build_lifecycle_message(
651
+ info[:status] || "retired",
652
+ info[:model_id] || "unknown-model",
653
+ info[:deprecation_date],
654
+ info[:retirement_date],
655
+ info[:replacement_model_id]
656
+ )
657
+ return info[:message] if as_trimmed_string(info[:message])
658
+ return fallback if as_trimmed_string(fallback)
659
+
660
+ return %[ [phaseo] Model "#{info[:model_id]}" is not active for inference. ].strip
661
+ end
662
+
663
+ source_status = normalize_source_status(info[:source_status]) || "unknown"
664
+ replacement = info[:replacement_model_id] ? %( Use "#{info[:replacement_model_id]}" instead.) : ""
665
+ %[ [phaseo] Model "#{info[:model_id]}" is not active for inference (status: #{source_status}).#{replacement} ].strip
666
+ end
667
+
668
+ def extract_model_id(payload)
669
+ decoded = normalize_hash(payload)
670
+ return nil unless decoded
671
+ as_trimmed_string(decoded[:model])
672
+ end
673
+
674
+ def normalize_hash(value)
675
+ return symbolize_keys(value) if value.is_a?(Hash)
676
+ if value.is_a?(String)
677
+ parsed = JSON.parse(value)
678
+ return symbolize_keys(parsed) if parsed.is_a?(Hash)
679
+ end
680
+ if value.respond_to?(:to_h)
681
+ parsed = value.to_h
682
+ return symbolize_keys(parsed) if parsed.is_a?(Hash)
683
+ end
684
+ nil
685
+ rescue JSON::ParserError, TypeError
686
+ nil
687
+ end
688
+
689
+ def symbolize_keys(hash)
690
+ hash.each_with_object({}) do |(k, v), out|
691
+ key = k.is_a?(Symbol) ? k : k.to_s.to_sym
692
+ out[key] =
693
+ case v
694
+ when Hash
695
+ symbolize_keys(v)
696
+ when Array
697
+ v.map { |item| item.is_a?(Hash) ? symbolize_keys(item) : item }
698
+ else
699
+ v
700
+ end
701
+ end
702
+ end
703
+
704
+ def as_trimmed_string(value)
705
+ return nil if value.nil?
706
+ trimmed = value.to_s.strip
707
+ trimmed.empty? ? nil : trimmed
708
+ end
709
+
710
+ def first_non_empty(*values)
711
+ values.each do |value|
712
+ trimmed = as_trimmed_string(value)
713
+ return trimmed if trimmed
714
+ end
715
+ nil
716
+ end
717
+ end
718
+
719
+ module Devtools
720
+ module_function
721
+
722
+ def create(enabled: true, directory: nil, capture_headers: false, save_assets: true)
723
+ {
724
+ enabled: enabled,
725
+ directory: directory,
726
+ capture_headers: capture_headers,
727
+ save_assets: save_assets
728
+ }
729
+ end
730
+ end
731
+
732
+ class TelemetryRecorder
733
+ def initialize(config = nil, sdk_version = "2.1.0")
734
+ config ||= {}
735
+ enabled = config.fetch(:enabled, false)
736
+ directory = config.fetch(:directory, ".phaseo-devtools")
737
+ directory = ".phaseo-devtools" if directory.to_s.strip.empty?
738
+
739
+ env_enabled = ENV["PHASEO_DEVTOOLS"] || ENV["PHASEO_DEVTOOLS"]
740
+ unless env_enabled.to_s.strip.empty?
741
+ enabled = %w[1 true yes on].include?(env_enabled.to_s.strip.downcase)
742
+ end
743
+
744
+ env_directory = ENV["PHASEO_DEVTOOLS_DIR"] || ENV["PHASEO_DEVTOOLS_DIR"]
745
+ directory = env_directory.to_s.strip unless env_directory.to_s.strip.empty?
746
+
747
+ @enabled = enabled
748
+ @directory = directory
749
+ @capture_headers = config.fetch(:capture_headers, false)
750
+ @save_assets = config.fetch(:save_assets, true)
751
+ @sdk_version = sdk_version
752
+
753
+ return unless @enabled
754
+ ensure_layout
755
+ write_metadata_if_missing
756
+ end
757
+
758
+ def capture_success(endpoint:, request:, response:, duration_ms:)
759
+ return unless @enabled
760
+
761
+ metadata = {
762
+ sdk: "ruby",
763
+ sdk_version: @sdk_version,
764
+ stream: false
765
+ }
766
+ usage = extract_usage(response)
767
+ metadata[:usage] = usage if usage
768
+
769
+ model, provider = extract_model_provider(response, request)
770
+ metadata[:model] = model if model
771
+ metadata[:provider] = provider if provider
772
+ enrich_metadata_from_response!(metadata, normalize_hash(response))
773
+ metadata.delete(:headers) unless @capture_headers
774
+
775
+ entry = {
776
+ id: new_entry_id,
777
+ type: endpoint,
778
+ timestamp: (Time.now.to_f * 1000).to_i,
779
+ duration_ms: duration_ms,
780
+ request: normalize_json_value(request),
781
+ response: normalize_json_value(response),
782
+ error: nil,
783
+ metadata: metadata
784
+ }
785
+ append_entry(entry)
786
+ end
787
+
788
+ def capture_error(endpoint:, request:, error:, duration_ms:)
789
+ return unless @enabled
790
+
791
+ error_response = extract_error_response(error)
792
+ model, provider = extract_model_provider(nil, request)
793
+ metadata = {
794
+ sdk: "ruby",
795
+ sdk_version: @sdk_version,
796
+ stream: false
797
+ }
798
+ metadata[:model] = model if model
799
+ metadata[:provider] = provider if provider
800
+ enrich_metadata_from_response!(metadata, error_response)
801
+
802
+ entry = {
803
+ id: new_entry_id,
804
+ type: endpoint,
805
+ timestamp: (Time.now.to_f * 1000).to_i,
806
+ duration_ms: duration_ms,
807
+ request: normalize_json_value(request),
808
+ response: normalize_json_value(error_response),
809
+ error: {
810
+ message: error.message,
811
+ status_code: extract_error_status_code(error)
812
+ }.compact,
813
+ metadata: metadata
814
+ }
815
+ append_entry(entry)
816
+ end
817
+
818
+ private
819
+
820
+ def append_entry(entry)
821
+ ensure_layout
822
+ File.open(File.join(@directory, "generations.jsonl"), "a:utf-8") do |file|
823
+ file.puts(JSON.generate(entry))
824
+ end
825
+ rescue StandardError
826
+ nil
827
+ end
828
+
829
+ def ensure_layout
830
+ FileUtils.mkdir_p(@directory)
831
+ return unless @save_assets
832
+ FileUtils.mkdir_p(File.join(@directory, "assets", "images"))
833
+ FileUtils.mkdir_p(File.join(@directory, "assets", "audio"))
834
+ FileUtils.mkdir_p(File.join(@directory, "assets", "video"))
835
+ end
836
+
837
+ def write_metadata_if_missing
838
+ path = File.join(@directory, "metadata.json")
839
+ return if File.exist?(path)
840
+
841
+ payload = {
842
+ session_id: new_entry_id,
843
+ started_at: (Time.now.to_f * 1000).to_i,
844
+ sdk: "ruby",
845
+ sdk_version: @sdk_version,
846
+ platform: RUBY_PLATFORM,
847
+ ruby_version: RUBY_VERSION
848
+ }
849
+ File.write(path, JSON.pretty_generate(payload))
850
+ rescue StandardError
851
+ nil
852
+ end
853
+
854
+ def extract_usage(response)
855
+ payload = normalize_hash(response)
856
+ return nil unless payload
857
+ usage = payload[:usage]
858
+ return nil unless usage.is_a?(Hash)
859
+
860
+ prompt = usage[:prompt_tokens] || usage[:input_tokens]
861
+ completion = usage[:completion_tokens] || usage[:output_tokens]
862
+ total = usage[:total_tokens]
863
+
864
+ out = {}
865
+ out[:prompt_tokens] = prompt unless prompt.nil?
866
+ out[:completion_tokens] = completion unless completion.nil?
867
+ out[:total_tokens] = total unless total.nil?
868
+ out.empty? ? nil : out
869
+ end
870
+
871
+ def extract_model_provider(response, request)
872
+ response_payload = normalize_hash(response) || {}
873
+ request_payload = normalize_hash(request) || {}
874
+
875
+ model = as_trimmed_string(response_payload[:model]) || as_trimmed_string(request_payload[:model])
876
+ provider = as_trimmed_string(response_payload[:provider])
877
+ [model, provider]
878
+ end
879
+
880
+ def extract_error_response(error)
881
+ if error.is_a?(::Phaseo::Gen::RequestError)
882
+ parsed = normalize_hash(error.response_body)
883
+ return parsed if parsed
884
+ return {
885
+ status_code: error.status_code,
886
+ error: as_trimmed_string(error.response_body)
887
+ }.compact
888
+ end
889
+
890
+ response = error.respond_to?(:response) ? error.response : nil
891
+ return symbolize_keys(response) if response.is_a?(Hash)
892
+
893
+ body = error.respond_to?(:body) ? error.body : nil
894
+ normalize_hash(body)
895
+ end
896
+
897
+ def extract_error_status_code(error)
898
+ return error.status_code if error.is_a?(::Phaseo::Gen::RequestError)
899
+ return error.status_code if error.respond_to?(:status_code)
900
+ nil
901
+ end
902
+
903
+ def enrich_metadata_from_response!(metadata, payload)
904
+ return unless payload.is_a?(Hash)
905
+
906
+ %i[
907
+ request_id
908
+ session_id
909
+ upstream_request_id
910
+ native_response_id
911
+ status_code
912
+ latency_ms
913
+ generation_ms
914
+ throughput
915
+ provider_attempts
916
+ pricing_lines
917
+ request_counts
918
+ billing
919
+ ].each do |key|
920
+ metadata[key] = payload[key] if payload.key?(key) && !payload[key].nil?
921
+ end
922
+
923
+ finish_reason = payload[:finish_reason] || payload[:stop_reason]
924
+ metadata[:finish_reason] = finish_reason if finish_reason
925
+ end
926
+
927
+ def normalize_json_value(value)
928
+ JSON.parse(JSON.generate(value), symbolize_names: false)
929
+ rescue StandardError
930
+ value.to_s
931
+ end
932
+
933
+ def normalize_hash(value)
934
+ return symbolize_keys(value) if value.is_a?(Hash)
935
+ if value.is_a?(String)
936
+ parsed = JSON.parse(value)
937
+ return symbolize_keys(parsed) if parsed.is_a?(Hash)
938
+ end
939
+ if value.respond_to?(:to_h)
940
+ parsed = value.to_h
941
+ return symbolize_keys(parsed) if parsed.is_a?(Hash)
942
+ end
943
+ nil
944
+ rescue JSON::ParserError, TypeError
945
+ nil
946
+ end
947
+
948
+ def symbolize_keys(hash)
949
+ hash.each_with_object({}) do |(k, v), out|
950
+ key = k.is_a?(Symbol) ? k : k.to_s.to_sym
951
+ out[key] =
952
+ case v
953
+ when Hash
954
+ symbolize_keys(v)
955
+ when Array
956
+ v.map { |item| item.is_a?(Hash) ? symbolize_keys(item) : item }
957
+ else
958
+ v
959
+ end
960
+ end
961
+ end
962
+
963
+ def as_trimmed_string(value)
964
+ return nil if value.nil?
965
+ trimmed = value.to_s.strip
966
+ trimmed.empty? ? nil : trimmed
967
+ end
968
+
969
+ def new_entry_id
970
+ "#{(Time.now.to_f * 1000).to_i}-#{SecureRandom.hex(4)}"
971
+ end
972
+ end
973
+
974
+ end