main
rb 133 lines 4.85 KB
Raw
1 # frozen_string_literal: true
2
3 require "net/http"
4 require "json"
5
6 # Server-side client for Onde Cloud, the OpenAI-compatible inference API.
7 #
8 # The siGit platform is an Onde Cloud customer: it holds Onde app credentials
9 # (`app_id:app_secret`) and uses them to run inference on Onde Cloud. sigit-si is
10 # the trust boundary — it authenticates the end user (Api::BaseController#
11 # authenticate_token!), then forwards the request here with the customer creds,
12 # which the public client never sees. The model provider behind Onde Cloud is
13 # Onde Cloud's concern, not ours.
14 #
15 # Requests and responses are OpenAI-compatible chat completions, forwarded
16 # verbatim (no translation). Streaming responses pass through as Server-Sent
17 # Events.
18 #
19 # Required env vars:
20 # ONDE_CLOUD_APP_ID — the siGit Onde app id
21 # ONDE_CLOUD_APP_SECRET — the siGit Onde app secret
22 # Optional:
23 # ONDE_CLOUD_BASE_URL — API root (default https://cloud.ondeinference.com/v1)
24 class OndeCloudService
25 # Raised when Onde Cloud returns a non-success status. `status` is forwarded to
26 # the client; the message is neutral (no upstream provider detail).
27 class UpstreamError < StandardError
28 attr_reader :status
29
30 def initialize(message, status: 502)
31 super(message)
32 @status = status
33 end
34 end
35
36 DEFAULT_BASE_URL = "https://cloud.ondeinference.com/v1"
37
38 # Net::HTTP timeouts. These bound how long a single request can pin a puma
39 # thread — load-bearing for availability: a hung/slow Onde Cloud must never be
40 # able to exhaust the web pool and take the whole app down (it did, 2026-06-24,
41 # when an Onde app was pinned to a server-side GGUF that stalled on CPU
42 # inference).
43 #
44 # read_timeout is PER-READ, not total, so streaming agent turns of any length
45 # stay safe as long as tokens keep flowing; it only fires when the upstream
46 # goes silent. Keep it short enough that a stall fails fast as a 504 instead of
47 # blocking a worker.
48 OPEN_TIMEOUT = 5 # connect
49 WRITE_TIMEOUT = 15 # sending the request body
50 READ_TIMEOUT = 90 # silence between reads before we give up
51
52 # Non-streaming completion. Returns the parsed JSON response (a Hash) verbatim.
53 def self.create(payload)
54 JSON.parse(post(payload.merge("stream" => false)))
55 end
56
57 # Streaming completion. Yields raw SSE byte chunks as Onde Cloud emits them, so
58 # the controller can write them straight to the client. Onde Cloud already
59 # speaks the OpenAI SSE protocol (`data: {…}` … `data: [DONE]`).
60 def self.stream(payload)
61 uri = endpoint
62 request = build_request(uri, payload.merge("stream" => true), accept: "text/event-stream")
63 http_start(uri) do |http|
64 http.request(request) do |response|
65 ensure_success!(response)
66 response.read_body { |chunk| yield chunk }
67 end
68 end
69 end
70
71 # ── internals ────────────────────────────────────────────────────────────────
72
73 def self.post(payload)
74 uri = endpoint
75 request = build_request(uri, payload)
76 response = http_start(uri) { |http| http.request(request) }
77 ensure_success!(response)
78 response.body
79 end
80
81 def self.ensure_success!(response)
82 return if response.is_a?(Net::HTTPSuccess)
83
84 Rails.logger.error("Onde Cloud upstream #{response.code}: #{response.body.to_s.byteslice(0, 500)}")
85 raise UpstreamError.new("Onde Cloud upstream error (#{response.code})", status: response.code.to_i)
86 end
87
88 def self.http_start(uri, &block)
89 Net::HTTP.start(
90 uri.hostname,
91 uri.port,
92 use_ssl: uri.scheme == "https",
93 open_timeout: OPEN_TIMEOUT,
94 write_timeout: WRITE_TIMEOUT,
95 read_timeout: READ_TIMEOUT,
96 &block
97 )
98 rescue StandardError => e
99 Rails.logger.error("Onde Cloud connection error: #{e.message}")
100 raise UpstreamError.new("Onde Cloud is temporarily unavailable", status: 502)
101 end
102
103 def self.build_request(uri, payload, accept: "application/json")
104 request = Net::HTTP::Post.new(uri)
105 request["Authorization"] = auth_header
106 request["Content-Type"] = "application/json"
107 request["Accept"] = accept
108 request.body = payload.to_json
109 request
110 end
111
112 def self.endpoint
113 URI("#{base_url}/chat/completions")
114 end
115
116 def self.base_url
117 ENV.fetch("ONDE_CLOUD_BASE_URL", DEFAULT_BASE_URL).chomp("/")
118 end
119
120 # `Bearer app_id:app_secret`, the Onde Cloud customer credential.
121 def self.auth_header
122 app_id = ENV.fetch("ONDE_CLOUD_APP_ID") do
123 raise KeyError, "Missing required env var: ONDE_CLOUD_APP_ID"
124 end
125 app_secret = ENV.fetch("ONDE_CLOUD_APP_SECRET") do
126 raise KeyError, "Missing required env var: ONDE_CLOUD_APP_SECRET"
127 end
128 "Bearer #{app_id}:#{app_secret}"
129 end
130
131 private_class_method :post, :ensure_success!, :http_start, :build_request,
132 :endpoint, :base_url, :auth_header
133 end