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