Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/graphql/types/query_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
{}
Expand Down Expand Up @@ -86,6 +87,10 @@ def global_runtimes
Runtime.where(namespace: nil)
end

def velorum
{}
end

def current_authentication
super.authentication
end
Expand Down
15 changes: 15 additions & 0 deletions app/graphql/types/velorum_model_type.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions app/graphql/types/velorum_model_type_enum.rb
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions app/graphql/types/velorum_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Types
class VelorumType < Types::BaseObject
Comment thread
raphael-goetz marked this conversation as resolved.
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
1 change: 1 addition & 0 deletions app/policies/global_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions app/services/velorum/models_service.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions config/initializers/tucana.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

Rails.application.config.to_prepare do
Tucana.load_protocol(:sagittarius)
Tucana.load_protocol(:velorum)
end
6 changes: 6 additions & 0 deletions config/sagittarius.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
11 changes: 11 additions & 0 deletions docs/graphql/enum/velorummodeltype.md
Original file line number Diff line number Diff line change
@@ -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 |
1 change: 1 addition & 0 deletions docs/graphql/object/query.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions docs/graphql/object/velorum.md
Original file line number Diff line number Diff line change
@@ -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 |
14 changes: 14 additions & 0 deletions docs/graphql/object/velorummodel.md
Original file line number Diff line number Diff line change
@@ -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 |
5 changes: 5 additions & 0 deletions docs/graphql/scalar/float.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 6 additions & 0 deletions lib/sagittarius/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions lib/sagittarius/velorum/client.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/graphql/types/query_type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
users
user
global_runtimes
velorum
namespace
]
end
Expand Down
17 changes: 17 additions & 0 deletions spec/graphql/types/velorum_model_type_spec.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions spec/graphql/types/velorum_type_spec.rb
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions spec/lib/sagittarius/velorum/client_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading