Route inference through Onde Cloud; retire direct-Anthropic path
siGit is an Onde Cloud customer. Add OndeCloudService + POST /api/v1/chat/completions that authenticates the user token and forwards OpenAI-compatible requests to Onde Cloud with siGit's Onde app credentials (ONDE_CLOUD_APP_ID/SECRET), streaming passthrough included. Remove the direct-Anthropic /api/v1/messages path and AnthropicService; .env.example now documents the Onde Cloud customer creds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seto Elkahfi committed
Jun 23, 2026 at 01:52 UTC
7c35b5ac5ddef8f9d7c7ad4d95404b3bbf88874d
6 files changed
+200
-216
.env.example
+9
-5
index 3c49344..59514a8 100644
--- a/.env.example
+++ b/.env.example
@@ -24,13 +24,17 @@ SMBCLOUD_ENVIRONMENT=production
# -----------------------------------------------------------------------------
-# Anthropic (required for the desktop coding agent)
-# sigit-si proxies the Messages API on behalf of the siGit Code & Deploy app so
-# the desktop binary never holds this key. Get one at https://console.anthropic.com
-# POST /api/v1/messages forwards native Messages API requests using this key.
+# Onde Cloud (inference for the siGit clients)
+# The siGit platform is an Onde Cloud customer. sigit-si authenticates the user,
+# then forwards OpenAI-compatible chat completions to Onde Cloud using these app
+# credentials, so the client never holds them. The model provider lives behind
+# Onde Cloud. POST /api/v1/chat/completions uses these.
# -----------------------------------------------------------------------------
-ANTHROPIC_API_KEY=your-anthropic-api-key-here
+ONDE_CLOUD_APP_ID=your-onde-app-id-here
+ONDE_CLOUD_APP_SECRET=your-onde-app-secret-here
+# Optional; defaults to https://cloud.ondeinference.com/v1
+# ONDE_CLOUD_BASE_URL=https://cloud.ondeinference.com/v1
# -----------------------------------------------------------------------------
app/controllers/api/v1/chat_completions_controller.rb
+72
new file mode 100644
index 0000000..47b12a4
--- /dev/null
+++ b/app/controllers/api/v1/chat_completions_controller.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ # OpenAI-compatible chat completions for the siGit clients (siGit Code, siGit
+ # Code Cloud). The client sends an OpenAI chat-completion request with its
+ # smbCloud access token as the bearer; we authenticate it, then forward the
+ # request to Onde Cloud through OndeCloudService using siGit's Onde app
+ # credentials. The client never sees those credentials or the model provider.
+ #
+ # POST /api/v1/chat/completions
+ # Header: Authorization: Bearer <access_token>
+ # Body: an OpenAI chat-completions request (model, messages, tools,
+ # max_tokens, …). Set "stream": true for an SSE response.
+ #
+ # Responses are OpenAI-compatible, forwarded verbatim:
+ # - non-stream → the chat.completion JSON object
+ # - stream → text/event-stream of chat.completion.chunk events
+ class ChatCompletionsController < Api::BaseController
+ include ActionController::Live
+
+ before_action :authenticate_token!
+
+ def create
+ if streaming_requested?
+ stream_completion
+ else
+ render json: OndeCloudService.create(completion_payload), status: :ok
+ end
+ rescue OndeCloudService::UpstreamError => e
+ render json: error_body(e.message), status: e.status
+ end
+
+ private
+
+ # Pipe Onde Cloud's SSE response straight through to the client.
+ def stream_completion
+ response.headers["Content-Type"] = "text/event-stream"
+ response.headers["Cache-Control"] = "no-cache"
+ response.headers["X-Accel-Buffering"] = "no" # disable nginx proxy buffering
+
+ OndeCloudService.stream(completion_payload) do |chunk|
+ response.stream.write(chunk)
+ end
+ rescue ActionController::Live::ClientDisconnected
+ # Client hung up — stop writing.
+ rescue OndeCloudService::UpstreamError => e
+ # Status line is already sent; surface the error in the stream.
+ response.stream.write("data: #{error_body(e.message).to_json}\n\n")
+ ensure
+ response.stream.close
+ end
+
+ # The OpenAI request body, forwarded verbatim. Read from the raw post so we
+ # send exactly what the client sent (no Rails parameter wrapping).
+ def completion_payload
+ @completion_payload ||= JSON.parse(request.raw_post)
+ rescue JSON::ParserError
+ {}
+ end
+
+ def streaming_requested?
+ ActiveModel::Type::Boolean.new.cast(completion_payload["stream"])
+ end
+
+ # Neutral OpenAI-style error envelope — no upstream provider detail.
+ def error_body(message)
+ { error: { message: message, type: "server_error" } }
+ end
+ end
+ end
+end
app/controllers/api/v1/messages_controller.rb
-99
deleted file mode 100644
index e88431e..0000000
--- a/app/controllers/api/v1/messages_controller.rb
+++ /dev/null
@@ -1,99 +0,0 @@
-# frozen_string_literal: true
-
-module Api
- module V1
- # Server-side proxy to the Anthropic Messages API for the desktop coding
- # agent. The desktop app sends a native Messages API request with its
- # smbCloud access token as the bearer; we authenticate it, then forward the
- # call through AnthropicService, which injects the confidential
- # `ANTHROPIC_API_KEY` server-side. The desktop never sees the key.
- #
- # POST /api/v1/messages
- # Header: Authorization: Bearer <access_token>
- # Body: a native Anthropic Messages API request (model, messages,
- # system, tools, thinking, max_tokens, …). Set "stream": true for
- # an SSE response.
- #
- # Responses use the native Anthropic shapes so the desktop can speak the
- # standard Messages protocol:
- # - non-stream → the Message JSON object
- # - stream → text/event-stream of Messages API events
- # (message_start, content_block_delta, message_stop, …)
- class MessagesController < Api::BaseController
- include ActionController::Live
-
- before_action :authenticate_token!
-
- def create
- if streaming_requested?
- stream_message
- else
- message = AnthropicService.create(message_payload)
- render json: message.to_h, status: :ok
- end
- rescue AnthropicService::InvalidRequestError => e
- render_anthropic_error("invalid_request_error", e.message, status: :bad_request)
- rescue Anthropic::Errors::APIStatusError => e
- render_upstream_error(e)
- rescue Anthropic::Errors::APIConnectionError => e
- Rails.logger.error("Anthropic connection error: #{e.message}")
- render_anthropic_error("api_error", "Could not reach the model provider.",
- status: :bad_gateway)
- end
-
- private
-
- # Streams the upstream response to the client as Server-Sent Events,
- # re-emitting each Messages API stream event with its native `type` as the
- # SSE event name and the event body as JSON `data`. Errors raised mid-stream
- # are surfaced as an `error` event because the status line is already sent.
- def stream_message
- response.headers["Content-Type"] = "text/event-stream"
- response.headers["Cache-Control"] = "no-cache"
- response.headers["X-Accel-Buffering"] = "no" # disable proxy buffering (nginx)
-
- sse = ActionController::Live::SSE.new(response.stream)
- AnthropicService.stream(message_payload) do |event|
- sse.write(event.to_h, event: event.type)
- end
- rescue AnthropicService::InvalidRequestError => e
- write_sse_error(sse, "invalid_request_error", e.message)
- rescue Anthropic::Errors::APIStatusError => e
- write_sse_error(sse, e.try(:type) || "api_error", e.message)
- rescue Anthropic::Errors::APIConnectionError => e
- Rails.logger.error("Anthropic stream connection error: #{e.message}")
- write_sse_error(sse, "api_error", "Could not reach the model provider.")
- rescue ActionController::Live::ClientDisconnected
- # Client hung up mid-stream — nothing to do but stop writing.
- ensure
- sse&.close
- end
-
- # The raw inbound request body (everything except Rails routing keys),
- # handed to AnthropicService for curation.
- def message_payload
- params.except(:controller, :action, :format, :message).to_unsafe_h
- end
-
- def streaming_requested?
- ActiveModel::Type::Boolean.new.cast(params[:stream])
- end
-
- # Forwards Anthropic's own status code and error shape so the desktop's
- # Messages client sees a native error rather than a translated one.
- def render_upstream_error(error)
- status = error.try(:status) || 502
- render_anthropic_error(error.try(:type) || "api_error", error.message, status: status)
- end
-
- # Native Anthropic error envelope: { "type": "error", "error": { type, message } }.
- def render_anthropic_error(type, message, status:)
- render json: { type: "error", error: { type: type, message: message } }, status: status
- end
-
- def write_sse_error(sse, type, message)
- sse&.write({ type: "error", error: { type: type, message: message } }, event: "error")
- end
- end
- end
-end
app/services/anthropic_service.rb
-111
deleted file mode 100644
index e85f469..0000000
--- a/app/services/anthropic_service.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-# frozen_string_literal: true
-
-require "anthropic"
-
-# Server-side wrapper around the Anthropic Messages API.
-#
-# sigit-si is the trust boundary for the siGit Code & Deploy desktop app's
-# coding agent: the desktop is a public client (a shipped binary), so it must
-# never hold the org's Anthropic API key. Instead the app sends a Messages API
-# request to sigit.si/api/v1/messages with its smbCloud access token as the
-# bearer; sigit-si verifies that token (Api::BaseController#authenticate_token!)
-# and then makes the upstream call here, injecting the confidential
-# `ANTHROPIC_API_KEY` that lives only on the server.
-#
-# The request/response shapes are the native Anthropic Messages API shapes — we
-# only curate the inbound parameters and forward the model's output verbatim, so
-# the desktop can speak the standard Messages protocol (including streaming SSE,
-# tool use, and adaptive thinking) to its own backend.
-#
-# Required env var:
-# ANTHROPIC_API_KEY — the org's Anthropic API key (confidential, server-only)
-class AnthropicService
- # Models the proxy is allowed to serve. The key is the org's, so this caps the
- # blast radius (and cost) of an arbitrary `model` value from a public client.
- # Defaults to Claude Opus 4.8 — the coding-agent default.
- ALLOWED_MODELS = %w[
- claude-opus-4-8
- claude-opus-4-7
- claude-sonnet-4-6
- claude-haiku-4-5
- ].freeze
-
- DEFAULT_MODEL = "claude-opus-4-8"
-
- # Required by the Messages API; a sane ceiling for a single agent turn when the
- # client doesn't specify one. Clients that need long outputs (and stream) can
- # override up to the model's limit.
- DEFAULT_MAX_TOKENS = 16_000
-
- # Inbound keys we forward to the Messages API. Anything else (including
- # `stream`, which the helpers set themselves) is dropped.
- ALLOWED_KEYS = %i[
- model messages system max_tokens tools tool_choice thinking
- stop_sequences metadata output_config service_tier
- ].freeze
-
- # Raised when the inbound request is malformed before it reaches Anthropic
- # (e.g. an unsupported model). Carries an HTTP-ish status for the controller.
- class InvalidRequestError < StandardError; end
-
- # A single, app-wide client. `Anthropic::Client` is threadsafe and keeps its
- # own connection pool, so the recommendation is one instance per process.
- def self.client
- @client ||= Anthropic::Client.new(api_key: api_key, max_retries: 2)
- end
-
- # Non-streaming completion. Returns the `Anthropic::Message` response object;
- # the controller serializes it with `#to_h` to forward the native shape.
- def self.create(payload)
- client.messages.create(**message_params(payload))
- end
-
- # Streaming completion. Yields each raw Messages-API stream event
- # (`message_start`, `content_block_delta`, `message_stop`, …) so the controller
- # can re-emit them verbatim as Server-Sent Events.
- #
- # Uses `stream_raw` (not the `stream` helper): the helper interleaves
- # SDK-specific accumulation events (`text`, `thinking`, …) that aren't part of
- # the wire protocol, whereas `stream_raw` yields only the native events — so a
- # client speaking the standard Messages SSE protocol sees an unpolluted stream.
- def self.stream(payload, &block)
- client.messages.stream_raw(**message_params(payload)).each(&block)
- end
-
- # Curates an inbound request hash into Messages API parameters: symbolizes
- # keys, slices to the allowed set, applies defaults, and validates the model.
- def self.message_params(payload)
- raw = (payload || {}).deep_symbolize_keys.slice(*ALLOWED_KEYS)
-
- raw[:model] = resolve_model(raw[:model])
- raw[:max_tokens] = (raw[:max_tokens] || DEFAULT_MAX_TOKENS).to_i
-
- if raw[:messages].blank?
- raise InvalidRequestError, "`messages` is required and must be a non-empty array."
- end
-
- raw
- end
-
- # Validates an inbound model string against the allowlist, defaulting when
- # absent. Rejects unknown models rather than silently swapping them, so a
- # client typo surfaces instead of quietly billing a different model.
- def self.resolve_model(model)
- return DEFAULT_MODEL if model.blank?
-
- model = model.to_s
- unless ALLOWED_MODELS.include?(model)
- raise InvalidRequestError,
- "Unsupported model #{model.inspect}. Allowed: #{ALLOWED_MODELS.join(', ')}."
- end
- model
- end
-
- def self.api_key
- ENV.fetch("ANTHROPIC_API_KEY") do
- raise KeyError, "Missing required env var: ANTHROPIC_API_KEY"
- end
- end
-
- private_class_method :client, :message_params, :resolve_model, :api_key
-end
app/services/onde_cloud_service.rb
+118
new file mode 100644
index 0000000..6c06624
--- /dev/null
+++ b/app/services/onde_cloud_service.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require "net/http"
+require "json"
+
+# Server-side client for Onde Cloud, the OpenAI-compatible inference API.
+#
+# The siGit platform is an Onde Cloud customer: it holds Onde app credentials
+# (`app_id:app_secret`) and uses them to run inference on Onde Cloud. sigit-si is
+# the trust boundary — it authenticates the end user (Api::BaseController#
+# authenticate_token!), then forwards the request here with the customer creds,
+# which the public client never sees. The model provider behind Onde Cloud is
+# Onde Cloud's concern, not ours.
+#
+# Requests and responses are OpenAI-compatible chat completions, forwarded
+# verbatim (no translation). Streaming responses pass through as Server-Sent
+# Events.
+#
+# Required env vars:
+# ONDE_CLOUD_APP_ID — the siGit Onde app id
+# ONDE_CLOUD_APP_SECRET — the siGit Onde app secret
+# Optional:
+# ONDE_CLOUD_BASE_URL — API root (default https://cloud.ondeinference.com/v1)
+class OndeCloudService
+ # Raised when Onde Cloud returns a non-success status. `status` is forwarded to
+ # the client; the message is neutral (no upstream provider detail).
+ class UpstreamError < StandardError
+ attr_reader :status
+
+ def initialize(message, status: 502)
+ super(message)
+ @status = status
+ end
+ end
+
+ DEFAULT_BASE_URL = "https://cloud.ondeinference.com/v1"
+ READ_TIMEOUT = 300 # seconds — long enough for a full agent turn
+
+ # Non-streaming completion. Returns the parsed JSON response (a Hash) verbatim.
+ def self.create(payload)
+ JSON.parse(post(payload.merge("stream" => false)))
+ end
+
+ # Streaming completion. Yields raw SSE byte chunks as Onde Cloud emits them, so
+ # the controller can write them straight to the client. Onde Cloud already
+ # speaks the OpenAI SSE protocol (`data: {…}` … `data: [DONE]`).
+ def self.stream(payload)
+ uri = endpoint
+ request = build_request(uri, payload.merge("stream" => true), accept: "text/event-stream")
+ http_start(uri) do |http|
+ http.request(request) do |response|
+ ensure_success!(response)
+ response.read_body { |chunk| yield chunk }
+ end
+ end
+ end
+
+ # ── internals ────────────────────────────────────────────────────────────────
+
+ def self.post(payload)
+ uri = endpoint
+ request = build_request(uri, payload)
+ response = http_start(uri) { |http| http.request(request) }
+ ensure_success!(response)
+ response.body
+ end
+
+ def self.ensure_success!(response)
+ return if response.is_a?(Net::HTTPSuccess)
+
+ Rails.logger.error("Onde Cloud upstream #{response.code}: #{response.body.to_s.byteslice(0, 500)}")
+ raise UpstreamError.new("Onde Cloud upstream error (#{response.code})", status: response.code.to_i)
+ end
+
+ def self.http_start(uri, &block)
+ Net::HTTP.start(
+ uri.hostname,
+ uri.port,
+ use_ssl: uri.scheme == "https",
+ read_timeout: READ_TIMEOUT,
+ &block
+ )
+ rescue StandardError => e
+ Rails.logger.error("Onde Cloud connection error: #{e.message}")
+ raise UpstreamError.new("Onde Cloud is temporarily unavailable", status: 502)
+ end
+
+ def self.build_request(uri, payload, accept: "application/json")
+ request = Net::HTTP::Post.new(uri)
+ request["Authorization"] = auth_header
+ request["Content-Type"] = "application/json"
+ request["Accept"] = accept
+ request.body = payload.to_json
+ request
+ end
+
+ def self.endpoint
+ URI("#{base_url}/chat/completions")
+ end
+
+ def self.base_url
+ ENV.fetch("ONDE_CLOUD_BASE_URL", DEFAULT_BASE_URL).chomp("/")
+ end
+
+ # `Bearer app_id:app_secret`, the Onde Cloud customer credential.
+ def self.auth_header
+ app_id = ENV.fetch("ONDE_CLOUD_APP_ID") do
+ raise KeyError, "Missing required env var: ONDE_CLOUD_APP_ID"
+ end
+ app_secret = ENV.fetch("ONDE_CLOUD_APP_SECRET") do
+ raise KeyError, "Missing required env var: ONDE_CLOUD_APP_SECRET"
+ end
+ "Bearer #{app_id}:#{app_secret}"
+ end
+
+ private_class_method :post, :ensure_success!, :http_start, :build_request,
+ :endpoint, :base_url, :auth_header
+end
config/routes.rb
+1
-1
index 3ffffe0..77c08e8 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -71,7 +71,7 @@ Rails.application.routes.draw do
get "repos", to: "repos#index"
post "repos", to: "repos#create"
post "git_credentials", to: "git_credentials#create"
- post "messages", to: "messages#create"
+ post "chat/completions", to: "chat_completions#create"
end
end