diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 61e0bf50..0ace025a 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, Types::VelorumType, null: true, description: 'Get Velorum information' def application {} @@ -86,6 +87,10 @@ def global_runtimes Runtime.where(namespace: nil) end + def velorum + {} + 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 00000000..8e082db4 --- /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 00000000..17b8d519 --- /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/graphql/types/velorum_type.rb b/app/graphql/types/velorum_type.rb new file mode 100644 index 00000000..27ee6172 --- /dev/null +++ b/app/graphql/types/velorum_type.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +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' + + 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/policies/global_policy.rb b/app/policies/global_policy.rb index c4ee77e3..0d513afe 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/app/services/velorum/models_service.rb b/app/services/velorum/models_service.rb new file mode 100644 index 00000000..6dad34fa --- /dev/null +++ b/app/services/velorum/models_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Velorum + class ModelsService + def initialize(client: nil, config: Sagittarius::Configuration.config[:velorum]) + @client = client + @config = config + end + + def execute + return [] unless config[:enabled] + + client.models.models + end + + private + + attr_reader :config + + def client + @client ||= Sagittarius::Velorum::Client.new + end + end +end diff --git a/config/initializers/tucana.rb b/config/initializers/tucana.rb index d545a05d..3e1096c6 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 2648f9d3..121df208 100644 --- a/config/sagittarius.example.yml +++ b/config/sagittarius.example.yml @@ -26,4 +26,10 @@ rails: key_derivation_salt: Z6zcLTgobXLYjXUslRsLMKxvXKq3j6DJ secret_key_base: MVMD6CtQwEWrQ28TdokQakbG2FG5abOn +velorum: + enabled: false + host: 'localhost:50052' + jwt_secret: + jwt_ttl_minutes: 8 + application_setting_overrides: {} diff --git a/docs/graphql/enum/velorummodeltype.md b/docs/graphql/enum/velorummodeltype.md new file mode 100644 index 00000000..278b2d20 --- /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 12ecf208..d9fa2e8f 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 | +| `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 00000000..31ecdbd7 --- /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/docs/graphql/object/velorummodel.md b/docs/graphql/object/velorummodel.md new file mode 100644 index 00000000..6fd88c97 --- /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 00000000..aa9ffb6c --- /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). diff --git a/lib/sagittarius/configuration.rb b/lib/sagittarius/configuration.rb index 0dcb0eb8..c4315427 100644 --- a/lib/sagittarius/configuration.rb +++ b/lib/sagittarius/configuration.rb @@ -52,6 +52,12 @@ def self.defaults }, secret_key_base: 'MVMD6CtQwEWrQ28TdokQakbG2FG5abOn', }, + velorum: { + enabled: false, + host: 'localhost:50052', + jwt_secret: nil, + jwt_ttl_minutes: 8, + }, application_setting_overrides: {}, } end diff --git a/lib/sagittarius/velorum/client.rb b/lib/sagittarius/velorum/client.rb new file mode 100644 index 00000000..77973150 --- /dev/null +++ b/lib/sagittarius/velorum/client.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Sagittarius + module Velorum + class Client + def initialize( + host: Sagittarius::Configuration.config[:velorum][:host], + jwt_secret: Sagittarius::Configuration.config[:velorum][:jwt_secret], + jwt_ttl_minutes: Sagittarius::Configuration.config[:velorum][:jwt_ttl_minutes] + ) + @host = host + @jwt_secret = jwt_secret + @jwt_ttl_minutes = jwt_ttl_minutes + end + + def models + stub.models(Tucana::Velorum::ModelsRequest.new, metadata: authentication_metadata) + end + + private + + attr_reader :host, :jwt_secret, :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 + raise ArgumentError, 'velorum.jwt_secret must be configured' if jwt_secret.to_s.empty? + + 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', jwt_secret, 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/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index a260d2f9..f2ef2f3c 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 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 00000000..f3d21be1 --- /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/graphql/types/velorum_type_spec.rb b/spec/graphql/types/velorum_type_spec.rb new file mode 100644 index 00000000..b7be2a12 --- /dev/null +++ b/spec/graphql/types/velorum_type_spec.rb @@ -0,0 +1,16 @@ +# 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) } + it { expect(described_class).to require_graphql_authorizations(:read_velorum_config) } +end diff --git a/spec/lib/sagittarius/velorum/client_spec.rb b/spec/lib/sagittarius/velorum/client_spec.rb new file mode 100644 index 00000000..d9ed37a8 --- /dev/null +++ b/spec/lib/sagittarius/velorum/client_spec.rb @@ -0,0 +1,67 @@ +# 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 } + let(:jwt_secret) { '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', + jwt_secret: jwt_secret, + 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), + 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', + jwt_secret: jwt_secret, + 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', jwt_secret, 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 JWT secret is configured' do + expect do + 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_query_spec.rb b/spec/requests/graphql/query/velorum_query_spec.rb new file mode 100644 index 00000000..71baa6b9 --- /dev/null +++ b/spec/requests/graphql/query/velorum_query_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'velorum query' do + include GraphqlHelpers + + let(:query) do + <<~QUERY + query { + velorum { + enabled + models { + identifier + name + tokenCost + types + } + } + } + QUERY + end + + let(:current_user) { create(:user) } + 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::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' do + 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( + { + '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 + + context 'when Velorum is disabled' do + before do + allow(Sagittarius::Configuration).to receive(:config) + .and_return(velorum: { enabled: false }) + end + + it 'returns disabled state and an empty model list without creating a Velorum client' do + post_graphql(query, current_user: current_user) + + 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 +end