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