From 8c6799479c20b54a7b7f816a3e116b8d5e96ad6f Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 8 Jun 2026 18:31:04 +0200 Subject: [PATCH 1/7] feat: wip velorum client --- app/graphql/types/query_type.rb | 5 ++ app/graphql/types/velorum_model_type.rb | 15 +++++ app/graphql/types/velorum_model_type_enum.rb | 11 ++++ app/services/velorum/models_service.rb | 17 +++++ config/initializers/tucana.rb | 1 + config/sagittarius.example.yml | 4 ++ lib/sagittarius/configuration.rb | 5 ++ lib/sagittarius/velorum/client.rb | 23 +++++++ spec/graphql/types/query_type_spec.rb | 1 + spec/graphql/types/velorum_model_type_spec.rb | 17 +++++ spec/lib/sagittarius/velorum/client_spec.rb | 22 +++++++ .../query/velorum_models_query_spec.rb | 66 +++++++++++++++++++ 12 files changed, 187 insertions(+) create mode 100644 app/graphql/types/velorum_model_type.rb create mode 100644 app/graphql/types/velorum_model_type_enum.rb create mode 100644 app/services/velorum/models_service.rb create mode 100644 lib/sagittarius/velorum/client.rb create mode 100644 spec/graphql/types/velorum_model_type_spec.rb create mode 100644 spec/lib/sagittarius/velorum/client_spec.rb create mode 100644 spec/requests/graphql/query/velorum_models_query_spec.rb diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 61e0bf505..83a4b407e 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -43,6 +43,7 @@ class QueryType < Types::BaseObject field :users, Types::UserType.connection_type, null: false, description: 'Find users' field :global_runtimes, Types::RuntimeType.connection_type, null: false, description: 'Find runtimes' + field :velorum_models, [Types::VelorumModelType], null: false, description: 'Find models available through Velorum' def application {} @@ -86,6 +87,10 @@ def global_runtimes Runtime.where(namespace: nil) end + def velorum_models + ::Velorum::ModelsService.new.execute + end + def current_authentication super.authentication end diff --git a/app/graphql/types/velorum_model_type.rb b/app/graphql/types/velorum_model_type.rb new file mode 100644 index 000000000..8e082db46 --- /dev/null +++ b/app/graphql/types/velorum_model_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + class VelorumModelType < Types::BaseObject + description 'Represents a model available through Velorum' + + field :identifier, String, null: false, description: 'Unique model identifier' + field :name, String, null: false, description: 'Human-readable model name' + field :token_cost, Float, null: false, description: 'Token cost for using this model' + field :types, [Types::VelorumModelTypeEnum], + null: false, + description: 'Capabilities supported by this model', + method: :type + end +end diff --git a/app/graphql/types/velorum_model_type_enum.rb b/app/graphql/types/velorum_model_type_enum.rb new file mode 100644 index 000000000..17b8d5191 --- /dev/null +++ b/app/graphql/types/velorum_model_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class VelorumModelTypeEnum < Types::BaseEnum + description 'Supported Velorum model capabilities' + + value :UNKNOWN, 'Unknown model capability', value: :UNKNOWN + value :EXPLAIN, 'Model can explain flows', value: :EXPLAIN + value :GENERATE, 'Model can generate flows', value: :GENERATE + end +end diff --git a/app/services/velorum/models_service.rb b/app/services/velorum/models_service.rb new file mode 100644 index 000000000..cdba5a21f --- /dev/null +++ b/app/services/velorum/models_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Velorum + class ModelsService + def initialize(client: Sagittarius::Velorum::Client.new) + @client = client + end + + def execute + client.models.models + end + + private + + attr_reader :client + end +end diff --git a/config/initializers/tucana.rb b/config/initializers/tucana.rb index d545a05dc..3e1096c66 100644 --- a/config/initializers/tucana.rb +++ b/config/initializers/tucana.rb @@ -2,4 +2,5 @@ Rails.application.config.to_prepare do Tucana.load_protocol(:sagittarius) + Tucana.load_protocol(:velorum) end diff --git a/config/sagittarius.example.yml b/config/sagittarius.example.yml index 2648f9d3e..59cb1d040 100644 --- a/config/sagittarius.example.yml +++ b/config/sagittarius.example.yml @@ -26,4 +26,8 @@ rails: key_derivation_salt: Z6zcLTgobXLYjXUslRsLMKxvXKq3j6DJ secret_key_base: MVMD6CtQwEWrQ28TdokQakbG2FG5abOn +velorum: + grpc: + host: 'localhost:50052' + application_setting_overrides: {} diff --git a/lib/sagittarius/configuration.rb b/lib/sagittarius/configuration.rb index 0dcb0eb88..0ab52958d 100644 --- a/lib/sagittarius/configuration.rb +++ b/lib/sagittarius/configuration.rb @@ -52,6 +52,11 @@ def self.defaults }, secret_key_base: 'MVMD6CtQwEWrQ28TdokQakbG2FG5abOn', }, + velorum: { + grpc: { + host: 'localhost:50052', + }, + }, application_setting_overrides: {}, } end diff --git a/lib/sagittarius/velorum/client.rb b/lib/sagittarius/velorum/client.rb new file mode 100644 index 000000000..462c12a38 --- /dev/null +++ b/lib/sagittarius/velorum/client.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Sagittarius + module Velorum + class Client + def initialize(host: Sagittarius::Configuration.config[:velorum][:grpc][:host]) + @host = host + end + + def models + stub.models(Tucana::Velorum::ModelsRequest.new) + end + + private + + attr_reader :host + + def stub + @stub ||= Tucana::Velorum::InfoService::Stub.new(host, :this_channel_is_insecure) + end + end + end +end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index a260d2f98..068095ede 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -14,6 +14,7 @@ users user global_runtimes + velorum_models namespace ] end diff --git a/spec/graphql/types/velorum_model_type_spec.rb b/spec/graphql/types/velorum_model_type_spec.rb new file mode 100644 index 000000000..f3d21be14 --- /dev/null +++ b/spec/graphql/types/velorum_model_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Types::VelorumModelType do + let(:fields) do + %w[ + identifier + name + token_cost + types + ] + end + + it { expect(described_class.graphql_name).to eq('VelorumModel') } + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/spec/lib/sagittarius/velorum/client_spec.rb b/spec/lib/sagittarius/velorum/client_spec.rb new file mode 100644 index 000000000..1fd3cc03e --- /dev/null +++ b/spec/lib/sagittarius/velorum/client_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Sagittarius::Velorum::Client do + let(:stub) { instance_double(Tucana::Velorum::InfoService::Stub) } + let(:response) { Tucana::Velorum::ModelsResponse.new } + + before do + allow(Tucana::Velorum::InfoService::Stub).to receive(:new).and_return(stub) + allow(stub).to receive(:models).and_return(response) + end + + it 'uses the configured Velorum gRPC host to request models' do + described_class.new(host: 'velorum.example:50052').models + + expect(Tucana::Velorum::InfoService::Stub) + .to have_received(:new) + .with('velorum.example:50052', :this_channel_is_insecure) + expect(stub).to have_received(:models).with(an_instance_of(Tucana::Velorum::ModelsRequest)) + end +end diff --git a/spec/requests/graphql/query/velorum_models_query_spec.rb b/spec/requests/graphql/query/velorum_models_query_spec.rb new file mode 100644 index 000000000..fbfd08e2a --- /dev/null +++ b/spec/requests/graphql/query/velorum_models_query_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'velorumModels Query' do + include GraphqlHelpers + + let(:query) do + <<~QUERY + query { + velorumModels { + identifier + name + tokenCost + types + } + } + QUERY + end + + let(:client) { instance_double(Sagittarius::Velorum::Client) } + let(:models_response) do + Tucana::Velorum::ModelsResponse.new( + models: [ + Tucana::Velorum::Model.new( + identifier: 'gpt-5', + name: 'GPT-5', + token_cost: 1.5, + type: %i[EXPLAIN GENERATE] + ), + Tucana::Velorum::Model.new( + identifier: 'explainer', + name: 'Explainer', + token_cost: 0.5, + type: [:EXPLAIN] + ) + ] + ) + end + + before do + allow(Sagittarius::Velorum::Client).to receive(:new).and_return(client) + allow(client).to receive(:models).and_return(models_response) + end + + it 'proxies models from Velorum through gRPC without persisting runtime models' do + expect { post_graphql(query) } + .not_to change { Runtime.count } + + expect(graphql_data_at(:velorum_models)).to contain_exactly( + { + 'identifier' => 'gpt-5', + 'name' => 'GPT-5', + 'tokenCost' => 1.5, + 'types' => %w[EXPLAIN GENERATE], + }, + { + 'identifier' => 'explainer', + 'name' => 'Explainer', + 'tokenCost' => 0.5, + 'types' => ['EXPLAIN'], + } + ) + expect(client).to have_received(:models) + end +end From b58b8b98133bf331449f23f50fba0a9417932577 Mon Sep 17 00:00:00 2001 From: Raphael Date: Mon, 8 Jun 2026 20:27:33 +0200 Subject: [PATCH 2/7] docs: generated docs --- docs/graphql/enum/velorummodeltype.md | 11 +++++++++++ docs/graphql/object/query.md | 1 + docs/graphql/object/velorummodel.md | 14 ++++++++++++++ docs/graphql/scalar/float.md | 5 +++++ 4 files changed, 31 insertions(+) create mode 100644 docs/graphql/enum/velorummodeltype.md create mode 100644 docs/graphql/object/velorummodel.md create mode 100644 docs/graphql/scalar/float.md diff --git a/docs/graphql/enum/velorummodeltype.md b/docs/graphql/enum/velorummodeltype.md new file mode 100644 index 000000000..278b2d209 --- /dev/null +++ b/docs/graphql/enum/velorummodeltype.md @@ -0,0 +1,11 @@ +--- +title: VelorumModelType +--- + +Supported Velorum model capabilities + +| Value | Description | +|-------|-------------| +| `EXPLAIN` | Model can explain flows | +| `GENERATE` | Model can generate flows | +| `UNKNOWN` | Unknown model capability | diff --git a/docs/graphql/object/query.md b/docs/graphql/object/query.md index 12ecf208f..537631927 100644 --- a/docs/graphql/object/query.md +++ b/docs/graphql/object/query.md @@ -14,6 +14,7 @@ Root Query type | `globalRuntimes` | [`RuntimeConnection!`](../object/runtimeconnection.md) | Find runtimes | | `organizations` | [`OrganizationConnection!`](../object/organizationconnection.md) | Find organizations | | `users` | [`UserConnection!`](../object/userconnection.md) | Find users | +| `velorumModels` | [`[VelorumModel!]!`](../object/velorummodel.md) | Find models available through Velorum | ## Fields with arguments diff --git a/docs/graphql/object/velorummodel.md b/docs/graphql/object/velorummodel.md new file mode 100644 index 000000000..6fd88c97f --- /dev/null +++ b/docs/graphql/object/velorummodel.md @@ -0,0 +1,14 @@ +--- +title: VelorumModel +--- + +Represents a model available through Velorum + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `identifier` | [`String!`](../scalar/string.md) | Unique model identifier | +| `name` | [`String!`](../scalar/string.md) | Human-readable model name | +| `tokenCost` | [`Float!`](../scalar/float.md) | Token cost for using this model | +| `types` | [`[VelorumModelType!]!`](../enum/velorummodeltype.md) | Capabilities supported by this model | diff --git a/docs/graphql/scalar/float.md b/docs/graphql/scalar/float.md new file mode 100644 index 000000000..aa9ffb6c7 --- /dev/null +++ b/docs/graphql/scalar/float.md @@ -0,0 +1,5 @@ +--- +title: Float +--- + +Represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). From fd95c3833860b33d14868f2029a1ac3bc88e6d83 Mon Sep 17 00:00:00 2001 From: Raphael Date: Fri, 12 Jun 2026 18:22:20 +0200 Subject: [PATCH 3/7] feat: correct authentication for velorum --- config/sagittarius.example.yml | 5 ++- lib/sagittarius/configuration.rb | 6 +-- lib/sagittarius/velorum/client.rb | 46 +++++++++++++++++-- spec/lib/sagittarius/velorum/client_spec.rb | 49 ++++++++++++++++++++- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/config/sagittarius.example.yml b/config/sagittarius.example.yml index 59cb1d040..6e38fa404 100644 --- a/config/sagittarius.example.yml +++ b/config/sagittarius.example.yml @@ -27,7 +27,8 @@ rails: secret_key_base: MVMD6CtQwEWrQ28TdokQakbG2FG5abOn velorum: - grpc: - host: 'localhost:50052' + host: 'localhost:50052' + security_token: + jwt_ttl_minutes: 8 application_setting_overrides: {} diff --git a/lib/sagittarius/configuration.rb b/lib/sagittarius/configuration.rb index 0ab52958d..28dd11d48 100644 --- a/lib/sagittarius/configuration.rb +++ b/lib/sagittarius/configuration.rb @@ -53,9 +53,9 @@ def self.defaults secret_key_base: 'MVMD6CtQwEWrQ28TdokQakbG2FG5abOn', }, velorum: { - grpc: { - host: 'localhost:50052', - }, + host: 'localhost:50052', + security_token: nil, + jwt_ttl_minutes: 8, }, application_setting_overrides: {}, } diff --git a/lib/sagittarius/velorum/client.rb b/lib/sagittarius/velorum/client.rb index 462c12a38..94971f550 100644 --- a/lib/sagittarius/velorum/client.rb +++ b/lib/sagittarius/velorum/client.rb @@ -1,23 +1,63 @@ # frozen_string_literal: true +require 'base64' +require 'json' +require 'openssl' + module Sagittarius module Velorum class Client - def initialize(host: Sagittarius::Configuration.config[:velorum][:grpc][:host]) + def initialize( + host: Sagittarius::Configuration.config[:velorum][:host], + security_token: ENV.fetch('VELORUM_SECURITY_TOKEN', Sagittarius::Configuration.config[:velorum][:security_token]), + jwt_ttl_minutes: Sagittarius::Configuration.config[:velorum][:jwt_ttl_minutes] + ) @host = host + @security_token = security_token + @jwt_ttl_minutes = jwt_ttl_minutes end def models - stub.models(Tucana::Velorum::ModelsRequest.new) + stub.models(Tucana::Velorum::ModelsRequest.new, metadata: authentication_metadata) end private - attr_reader :host + attr_reader :host, :security_token, :jwt_ttl_minutes def stub @stub ||= Tucana::Velorum::InfoService::Stub.new(host, :this_channel_is_insecure) end + + def authentication_metadata + { + authorization: jwt, + } + end + + def jwt + if security_token.to_s.empty? + raise ArgumentError, 'VELORUM_SECURITY_TOKEN or velorum.security_token must be configured' + end + + header = { + alg: 'HS256', + typ: 'JWT', + } + now = Time.now.to_i + payload = { + iat: now - 60, + exp: now + jwt_ttl_minutes.to_i.minutes.to_i, + } + body = [header, payload].map { |part| base64_url_encode(part.to_json) }.join('.') + signature = OpenSSL::HMAC.digest('SHA256', security_token, body) + + "#{body}.#{base64_url_encode(signature)}" + end + + def base64_url_encode(value) + Base64.urlsafe_encode64(value, padding: false) + end end end end diff --git a/spec/lib/sagittarius/velorum/client_spec.rb b/spec/lib/sagittarius/velorum/client_spec.rb index 1fd3cc03e..1e2d69f6b 100644 --- a/spec/lib/sagittarius/velorum/client_spec.rb +++ b/spec/lib/sagittarius/velorum/client_spec.rb @@ -5,18 +5,63 @@ RSpec.describe Sagittarius::Velorum::Client do let(:stub) { instance_double(Tucana::Velorum::InfoService::Stub) } let(:response) { Tucana::Velorum::ModelsResponse.new } + let(:security_token) { 'velorum-secret' } + let(:jwt_ttl_minutes) { 15 } + let(:time) { Time.zone.local(2026, 6, 12, 10, 0, 0) } before do + allow(Time).to receive(:now).and_return(time) allow(Tucana::Velorum::InfoService::Stub).to receive(:new).and_return(stub) allow(stub).to receive(:models).and_return(response) end it 'uses the configured Velorum gRPC host to request models' do - described_class.new(host: 'velorum.example:50052').models + described_class.new( + host: 'velorum.example:50052', + security_token: security_token, + jwt_ttl_minutes: jwt_ttl_minutes + ).models expect(Tucana::Velorum::InfoService::Stub) .to have_received(:new) .with('velorum.example:50052', :this_channel_is_insecure) - expect(stub).to have_received(:models).with(an_instance_of(Tucana::Velorum::ModelsRequest)) + expect(stub).to have_received(:models).with( + an_instance_of(Tucana::Velorum::ModelsRequest), + metadata: a_hash_including(authorization: kind_of(String)) + ) + end + + it 'passes a signed JWT in the authentication metadata expected by Velorum' do + described_class.new( + host: 'velorum.example:50052', + security_token: security_token, + jwt_ttl_minutes: jwt_ttl_minutes + ).models + + expect(stub).to have_received(:models) do |_, options| + token = options.fetch(:metadata).fetch(:authorization) + encoded_header, encoded_payload, encoded_signature = token.split('.') + signature_body = [encoded_header, encoded_payload].join('.') + expected_signature = Base64.urlsafe_encode64( + OpenSSL::HMAC.digest('SHA256', security_token, signature_body), + padding: false + ) + + expect(JSON.parse(Base64.urlsafe_decode64(encoded_header))).to include( + 'alg' => 'HS256', + 'typ' => 'JWT' + ) + expect(JSON.parse(Base64.urlsafe_decode64(encoded_payload))).to include( + 'iat' => time.to_i - 60, + 'exp' => time.to_i + jwt_ttl_minutes.minutes.to_i + ) + expect(encoded_signature).to eq(expected_signature) + end + end + + it 'raises a clear error when no Velorum security token is configured' do + expect do + described_class.new(host: 'velorum.example:50052', security_token: nil).models + end.to raise_error(ArgumentError, 'VELORUM_SECURITY_TOKEN or velorum.security_token must be configured') end end From 207bff1d7f036222dc0339fe018f6f18db236905 Mon Sep 17 00:00:00 2001 From: Raphael Date: Fri, 12 Jun 2026 18:32:10 +0200 Subject: [PATCH 4/7] feat: made velorum de-/activatable --- app/services/velorum/models_service.rb | 16 +++++++++++++-- config/sagittarius.example.yml | 1 + lib/sagittarius/configuration.rb | 1 + .../query/velorum_models_query_spec.rb | 20 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/services/velorum/models_service.rb b/app/services/velorum/models_service.rb index cdba5a21f..55a957ab6 100644 --- a/app/services/velorum/models_service.rb +++ b/app/services/velorum/models_service.rb @@ -2,16 +2,28 @@ module Velorum class ModelsService - def initialize(client: Sagittarius::Velorum::Client.new) + def initialize(client: nil, config: Sagittarius::Configuration.config[:velorum]) @client = client + @config = config end def execute + unless config[:enabled] + raise GraphQL::ExecutionError.new( + 'Velorum is disabled', + extensions: { code: 'VELORUM_DISABLED' } + ) + end + client.models.models end private - attr_reader :client + attr_reader :config + + def client + @client ||= Sagittarius::Velorum::Client.new + end end end diff --git a/config/sagittarius.example.yml b/config/sagittarius.example.yml index 6e38fa404..0520cad65 100644 --- a/config/sagittarius.example.yml +++ b/config/sagittarius.example.yml @@ -27,6 +27,7 @@ rails: secret_key_base: MVMD6CtQwEWrQ28TdokQakbG2FG5abOn velorum: + enabled: true host: 'localhost:50052' security_token: jwt_ttl_minutes: 8 diff --git a/lib/sagittarius/configuration.rb b/lib/sagittarius/configuration.rb index 28dd11d48..f736c2636 100644 --- a/lib/sagittarius/configuration.rb +++ b/lib/sagittarius/configuration.rb @@ -53,6 +53,7 @@ def self.defaults secret_key_base: 'MVMD6CtQwEWrQ28TdokQakbG2FG5abOn', }, velorum: { + enabled: true, host: 'localhost:50052', security_token: nil, jwt_ttl_minutes: 8, diff --git a/spec/requests/graphql/query/velorum_models_query_spec.rb b/spec/requests/graphql/query/velorum_models_query_spec.rb index fbfd08e2a..7cfc81a41 100644 --- a/spec/requests/graphql/query/velorum_models_query_spec.rb +++ b/spec/requests/graphql/query/velorum_models_query_spec.rb @@ -63,4 +63,24 @@ ) expect(client).to have_received(:models) end + + context 'when Velorum is disabled' do + before do + allow(Sagittarius::Configuration).to receive(:config) + .and_return(velorum: { enabled: false }) + end + + it 'returns a GraphQL error without creating a Velorum client' do + post_graphql(query) + + expect(graphql_data_at(:velorum_models)).to be_nil + expect(graphql_errors).to contain_exactly( + a_hash_including( + 'message' => 'Velorum is disabled', + 'extensions' => a_hash_including('code' => 'VELORUM_DISABLED') + ) + ) + expect(Sagittarius::Velorum::Client).not_to have_received(:new) + end + end end From c9178363e46e58e67faec1ac1399099db49b3c50 Mon Sep 17 00:00:00 2001 From: Raphael Date: Fri, 12 Jun 2026 19:20:07 +0200 Subject: [PATCH 5/7] feat: changes from code review --- app/graphql/types/query_type.rb | 6 +-- app/graphql/types/velorum_type.rb | 24 ++++++++++++ app/services/velorum/models_service.rb | 7 +--- config/sagittarius.example.yml | 2 +- docs/graphql/object/query.md | 2 +- docs/graphql/object/velorum.md | 12 ++++++ lib/sagittarius/configuration.rb | 2 +- lib/sagittarius/velorum/client.rb | 16 +++----- spec/graphql/types/query_type_spec.rb | 2 +- spec/graphql/types/velorum_type_spec.rb | 15 ++++++++ spec/lib/sagittarius/velorum/client_spec.rb | 14 +++---- ...ls_query_spec.rb => velorum_query_spec.rb} | 37 ++++++++++--------- 12 files changed, 90 insertions(+), 49 deletions(-) create mode 100644 app/graphql/types/velorum_type.rb create mode 100644 docs/graphql/object/velorum.md create mode 100644 spec/graphql/types/velorum_type_spec.rb rename spec/requests/graphql/query/{velorum_models_query_spec.rb => velorum_query_spec.rb} (66%) diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 83a4b407e..ba5c3f295 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -43,7 +43,7 @@ class QueryType < Types::BaseObject field :users, Types::UserType.connection_type, null: false, description: 'Find users' field :global_runtimes, Types::RuntimeType.connection_type, null: false, description: 'Find runtimes' - field :velorum_models, [Types::VelorumModelType], null: false, description: 'Find models available through Velorum' + field :velorum, Types::VelorumType, null: false, description: 'Get Velorum information' def application {} @@ -87,8 +87,8 @@ def global_runtimes Runtime.where(namespace: nil) end - def velorum_models - ::Velorum::ModelsService.new.execute + def velorum + {} end def current_authentication diff --git a/app/graphql/types/velorum_type.rb b/app/graphql/types/velorum_type.rb new file mode 100644 index 000000000..7412f9c66 --- /dev/null +++ b/app/graphql/types/velorum_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + class VelorumType < Types::BaseObject + description 'Represents Velorum integration information' + + field :enabled, Boolean, null: false, description: 'Whether Velorum is enabled' + field :models, [Types::VelorumModelType], null: false, description: 'Find models available through Velorum' + + def enabled + config[:enabled] + end + + def models + ::Velorum::ModelsService.new(config: config).execute + end + + private + + def config + Sagittarius::Configuration.config[:velorum] + end + end +end diff --git a/app/services/velorum/models_service.rb b/app/services/velorum/models_service.rb index 55a957ab6..6dad34fa2 100644 --- a/app/services/velorum/models_service.rb +++ b/app/services/velorum/models_service.rb @@ -8,12 +8,7 @@ def initialize(client: nil, config: Sagittarius::Configuration.config[:velorum]) end def execute - unless config[:enabled] - raise GraphQL::ExecutionError.new( - 'Velorum is disabled', - extensions: { code: 'VELORUM_DISABLED' } - ) - end + return [] unless config[:enabled] client.models.models end diff --git a/config/sagittarius.example.yml b/config/sagittarius.example.yml index 0520cad65..b87f03be3 100644 --- a/config/sagittarius.example.yml +++ b/config/sagittarius.example.yml @@ -29,7 +29,7 @@ rails: velorum: enabled: true host: 'localhost:50052' - security_token: + jwt_secret: jwt_ttl_minutes: 8 application_setting_overrides: {} diff --git a/docs/graphql/object/query.md b/docs/graphql/object/query.md index 537631927..431d5a393 100644 --- a/docs/graphql/object/query.md +++ b/docs/graphql/object/query.md @@ -14,7 +14,7 @@ Root Query type | `globalRuntimes` | [`RuntimeConnection!`](../object/runtimeconnection.md) | Find runtimes | | `organizations` | [`OrganizationConnection!`](../object/organizationconnection.md) | Find organizations | | `users` | [`UserConnection!`](../object/userconnection.md) | Find users | -| `velorumModels` | [`[VelorumModel!]!`](../object/velorummodel.md) | Find models available through Velorum | +| `velorum` | [`Velorum!`](../object/velorum.md) | Get Velorum information | ## Fields with arguments diff --git a/docs/graphql/object/velorum.md b/docs/graphql/object/velorum.md new file mode 100644 index 000000000..31ecdbd78 --- /dev/null +++ b/docs/graphql/object/velorum.md @@ -0,0 +1,12 @@ +--- +title: Velorum +--- + +Represents Velorum integration information + +## Fields without arguments + +| Name | Type | Description | +|------|------|-------------| +| `enabled` | [`Boolean!`](../scalar/boolean.md) | Whether Velorum is enabled | +| `models` | [`[VelorumModel!]!`](../object/velorummodel.md) | Find models available through Velorum | diff --git a/lib/sagittarius/configuration.rb b/lib/sagittarius/configuration.rb index f736c2636..8d042e243 100644 --- a/lib/sagittarius/configuration.rb +++ b/lib/sagittarius/configuration.rb @@ -55,7 +55,7 @@ def self.defaults velorum: { enabled: true, host: 'localhost:50052', - security_token: nil, + jwt_secret: nil, jwt_ttl_minutes: 8, }, application_setting_overrides: {}, diff --git a/lib/sagittarius/velorum/client.rb b/lib/sagittarius/velorum/client.rb index 94971f550..77973150f 100644 --- a/lib/sagittarius/velorum/client.rb +++ b/lib/sagittarius/velorum/client.rb @@ -1,19 +1,15 @@ # frozen_string_literal: true -require 'base64' -require 'json' -require 'openssl' - module Sagittarius module Velorum class Client def initialize( host: Sagittarius::Configuration.config[:velorum][:host], - security_token: ENV.fetch('VELORUM_SECURITY_TOKEN', Sagittarius::Configuration.config[:velorum][:security_token]), + jwt_secret: Sagittarius::Configuration.config[:velorum][:jwt_secret], jwt_ttl_minutes: Sagittarius::Configuration.config[:velorum][:jwt_ttl_minutes] ) @host = host - @security_token = security_token + @jwt_secret = jwt_secret @jwt_ttl_minutes = jwt_ttl_minutes end @@ -23,7 +19,7 @@ def models private - attr_reader :host, :security_token, :jwt_ttl_minutes + attr_reader :host, :jwt_secret, :jwt_ttl_minutes def stub @stub ||= Tucana::Velorum::InfoService::Stub.new(host, :this_channel_is_insecure) @@ -36,9 +32,7 @@ def authentication_metadata end def jwt - if security_token.to_s.empty? - raise ArgumentError, 'VELORUM_SECURITY_TOKEN or velorum.security_token must be configured' - end + raise ArgumentError, 'velorum.jwt_secret must be configured' if jwt_secret.to_s.empty? header = { alg: 'HS256', @@ -50,7 +44,7 @@ def jwt exp: now + jwt_ttl_minutes.to_i.minutes.to_i, } body = [header, payload].map { |part| base64_url_encode(part.to_json) }.join('.') - signature = OpenSSL::HMAC.digest('SHA256', security_token, body) + signature = OpenSSL::HMAC.digest('SHA256', jwt_secret, body) "#{body}.#{base64_url_encode(signature)}" end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index 068095ede..f2ef2f3c1 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -14,7 +14,7 @@ users user global_runtimes - velorum_models + velorum namespace ] end diff --git a/spec/graphql/types/velorum_type_spec.rb b/spec/graphql/types/velorum_type_spec.rb new file mode 100644 index 000000000..c7d00a648 --- /dev/null +++ b/spec/graphql/types/velorum_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Types::VelorumType do + let(:fields) do + %w[ + enabled + models + ] + end + + it { expect(described_class.graphql_name).to eq('Velorum') } + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/spec/lib/sagittarius/velorum/client_spec.rb b/spec/lib/sagittarius/velorum/client_spec.rb index 1e2d69f6b..d9ed37a8d 100644 --- a/spec/lib/sagittarius/velorum/client_spec.rb +++ b/spec/lib/sagittarius/velorum/client_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Sagittarius::Velorum::Client do let(:stub) { instance_double(Tucana::Velorum::InfoService::Stub) } let(:response) { Tucana::Velorum::ModelsResponse.new } - let(:security_token) { 'velorum-secret' } + let(:jwt_secret) { 'velorum-secret' } let(:jwt_ttl_minutes) { 15 } let(:time) { Time.zone.local(2026, 6, 12, 10, 0, 0) } @@ -18,7 +18,7 @@ it 'uses the configured Velorum gRPC host to request models' do described_class.new( host: 'velorum.example:50052', - security_token: security_token, + jwt_secret: jwt_secret, jwt_ttl_minutes: jwt_ttl_minutes ).models @@ -34,7 +34,7 @@ it 'passes a signed JWT in the authentication metadata expected by Velorum' do described_class.new( host: 'velorum.example:50052', - security_token: security_token, + jwt_secret: jwt_secret, jwt_ttl_minutes: jwt_ttl_minutes ).models @@ -43,7 +43,7 @@ encoded_header, encoded_payload, encoded_signature = token.split('.') signature_body = [encoded_header, encoded_payload].join('.') expected_signature = Base64.urlsafe_encode64( - OpenSSL::HMAC.digest('SHA256', security_token, signature_body), + OpenSSL::HMAC.digest('SHA256', jwt_secret, signature_body), padding: false ) @@ -59,9 +59,9 @@ end end - it 'raises a clear error when no Velorum security token is configured' do + it 'raises a clear error when no Velorum JWT secret is configured' do expect do - described_class.new(host: 'velorum.example:50052', security_token: nil).models - end.to raise_error(ArgumentError, 'VELORUM_SECURITY_TOKEN or velorum.security_token must be configured') + described_class.new(host: 'velorum.example:50052', jwt_secret: nil).models + end.to raise_error(ArgumentError, 'velorum.jwt_secret must be configured') end end diff --git a/spec/requests/graphql/query/velorum_models_query_spec.rb b/spec/requests/graphql/query/velorum_query_spec.rb similarity index 66% rename from spec/requests/graphql/query/velorum_models_query_spec.rb rename to spec/requests/graphql/query/velorum_query_spec.rb index 7cfc81a41..d3f3bda55 100644 --- a/spec/requests/graphql/query/velorum_models_query_spec.rb +++ b/spec/requests/graphql/query/velorum_query_spec.rb @@ -2,17 +2,20 @@ require 'rails_helper' -RSpec.describe 'velorumModels Query' do +RSpec.describe 'velorum query' do include GraphqlHelpers let(:query) do <<~QUERY query { - velorumModels { - identifier - name - tokenCost - types + velorum { + enabled + models { + identifier + name + tokenCost + types + } } } QUERY @@ -39,15 +42,17 @@ end before do + allow(Sagittarius::Configuration).to receive(:config) + .and_return(velorum: { enabled: true }) allow(Sagittarius::Velorum::Client).to receive(:new).and_return(client) allow(client).to receive(:models).and_return(models_response) end - it 'proxies models from Velorum through gRPC without persisting runtime models' do - expect { post_graphql(query) } - .not_to change { Runtime.count } + it 'proxies models from Velorum through gRPC' do + post_graphql(query) - expect(graphql_data_at(:velorum_models)).to contain_exactly( + expect(graphql_data_at(:velorum, :enabled)).to be(true) + expect(graphql_data_at(:velorum, :models)).to contain_exactly( { 'identifier' => 'gpt-5', 'name' => 'GPT-5', @@ -70,16 +75,12 @@ .and_return(velorum: { enabled: false }) end - it 'returns a GraphQL error without creating a Velorum client' do + it 'returns disabled state and an empty model list without creating a Velorum client' do post_graphql(query) - expect(graphql_data_at(:velorum_models)).to be_nil - expect(graphql_errors).to contain_exactly( - a_hash_including( - 'message' => 'Velorum is disabled', - 'extensions' => a_hash_including('code' => 'VELORUM_DISABLED') - ) - ) + expect(graphql_data_at(:velorum, :enabled)).to be(false) + expect(graphql_data_at(:velorum, :models)).to eq([]) + expect(graphql_errors).to be_nil expect(Sagittarius::Velorum::Client).not_to have_received(:new) end end From 98643e50281b0c51a1cf6356880b2dfe54946ca9 Mon Sep 17 00:00:00 2001 From: Raphael Date: Fri, 12 Jun 2026 20:56:20 +0200 Subject: [PATCH 6/7] feat: added authorization to velorum query object --- app/graphql/types/query_type.rb | 2 +- app/graphql/types/velorum_type.rb | 3 +++ app/policies/global_policy.rb | 1 + docs/graphql/object/query.md | 2 +- spec/graphql/types/velorum_type_spec.rb | 1 + spec/requests/graphql/query/velorum_query_spec.rb | 5 +++-- 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index ba5c3f295..0ace025ab 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -43,7 +43,7 @@ class QueryType < Types::BaseObject field :users, Types::UserType.connection_type, null: false, description: 'Find users' field :global_runtimes, Types::RuntimeType.connection_type, null: false, description: 'Find runtimes' - field :velorum, Types::VelorumType, null: false, description: 'Get Velorum information' + field :velorum, Types::VelorumType, null: true, description: 'Get Velorum information' def application {} diff --git a/app/graphql/types/velorum_type.rb b/app/graphql/types/velorum_type.rb index 7412f9c66..27ee6172e 100644 --- a/app/graphql/types/velorum_type.rb +++ b/app/graphql/types/velorum_type.rb @@ -4,6 +4,9 @@ module Types class VelorumType < Types::BaseObject description 'Represents Velorum integration information' + authorize :read_velorum_config + declarative_policy_subject { :global } + field :enabled, Boolean, null: false, description: 'Whether Velorum is enabled' field :models, [Types::VelorumModelType], null: false, description: 'Find models available through Velorum' diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index c4ee77e3b..0d513afe9 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -12,6 +12,7 @@ class GlobalPolicy < BasePolicy enable :read_flow_type enable :read_flow_type_setting enable :read_metadata + enable :read_velorum_config end rule { admin }.policy do diff --git a/docs/graphql/object/query.md b/docs/graphql/object/query.md index 431d5a393..d9fa2e8f5 100644 --- a/docs/graphql/object/query.md +++ b/docs/graphql/object/query.md @@ -14,7 +14,7 @@ Root Query type | `globalRuntimes` | [`RuntimeConnection!`](../object/runtimeconnection.md) | Find runtimes | | `organizations` | [`OrganizationConnection!`](../object/organizationconnection.md) | Find organizations | | `users` | [`UserConnection!`](../object/userconnection.md) | Find users | -| `velorum` | [`Velorum!`](../object/velorum.md) | Get Velorum information | +| `velorum` | [`Velorum`](../object/velorum.md) | Get Velorum information | ## Fields with arguments diff --git a/spec/graphql/types/velorum_type_spec.rb b/spec/graphql/types/velorum_type_spec.rb index c7d00a648..b7be2a123 100644 --- a/spec/graphql/types/velorum_type_spec.rb +++ b/spec/graphql/types/velorum_type_spec.rb @@ -12,4 +12,5 @@ it { expect(described_class.graphql_name).to eq('Velorum') } it { expect(described_class).to have_graphql_fields(fields) } + it { expect(described_class).to require_graphql_authorizations(:read_velorum_config) } end diff --git a/spec/requests/graphql/query/velorum_query_spec.rb b/spec/requests/graphql/query/velorum_query_spec.rb index d3f3bda55..71baa6b9b 100644 --- a/spec/requests/graphql/query/velorum_query_spec.rb +++ b/spec/requests/graphql/query/velorum_query_spec.rb @@ -21,6 +21,7 @@ QUERY end + let(:current_user) { create(:user) } let(:client) { instance_double(Sagittarius::Velorum::Client) } let(:models_response) do Tucana::Velorum::ModelsResponse.new( @@ -49,7 +50,7 @@ end it 'proxies models from Velorum through gRPC' do - post_graphql(query) + post_graphql(query, current_user: current_user) expect(graphql_data_at(:velorum, :enabled)).to be(true) expect(graphql_data_at(:velorum, :models)).to contain_exactly( @@ -76,7 +77,7 @@ end it 'returns disabled state and an empty model list without creating a Velorum client' do - post_graphql(query) + post_graphql(query, current_user: current_user) expect(graphql_data_at(:velorum, :enabled)).to be(false) expect(graphql_data_at(:velorum, :models)).to eq([]) From 66132b8f4e0053b63b0374aebc41fe66ae7dd584 Mon Sep 17 00:00:00 2001 From: Raphael Date: Fri, 12 Jun 2026 21:00:56 +0200 Subject: [PATCH 7/7] feat: deactivated velorum by default --- config/sagittarius.example.yml | 2 +- lib/sagittarius/configuration.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/sagittarius.example.yml b/config/sagittarius.example.yml index b87f03be3..121df2081 100644 --- a/config/sagittarius.example.yml +++ b/config/sagittarius.example.yml @@ -27,7 +27,7 @@ rails: secret_key_base: MVMD6CtQwEWrQ28TdokQakbG2FG5abOn velorum: - enabled: true + enabled: false host: 'localhost:50052' jwt_secret: jwt_ttl_minutes: 8 diff --git a/lib/sagittarius/configuration.rb b/lib/sagittarius/configuration.rb index 8d042e243..c4315427f 100644 --- a/lib/sagittarius/configuration.rb +++ b/lib/sagittarius/configuration.rb @@ -53,7 +53,7 @@ def self.defaults secret_key_base: 'MVMD6CtQwEWrQ28TdokQakbG2FG5abOn', }, velorum: { - enabled: true, + enabled: false, host: 'localhost:50052', jwt_secret: nil, jwt_ttl_minutes: 8,