octoryn-sdk 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d0390d88d9de20ba64377d0fe29464256cb295be1a77355f76b6876fb079b81f
4
+ data.tar.gz: c08fccf84d26274fdf0210e1a367b457d5893c1a243d9cabbb05640a001d15bb
5
+ SHA512:
6
+ metadata.gz: 8ec09df1938b63886f0fd1200930a7447110686bcf082953f1bf856afa0489fb6bf54b7e12e60f3c106c8939028f9379a07e9081a7a10acd6172e63762fd9881
7
+ data.tar.gz: 4e735f9f27fa42ade37dea4763445ad3b1f309c136d99a5cb4a1442d1d1d3545440a0685d38e4ae9b7de329e09a649b80ea29f7b7d57741c0e6cb4047416e1b1
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2026 Octopus Core Pty Ltd
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ https://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # Octoryn Ruby SDK
2
+
3
+ Governed model access for Ruby 3.2 and later.
4
+
5
+ Until `octoryn-sdk` is available from RubyGems.org, install the public release
6
+ artifact directly:
7
+
8
+ ```bash
9
+ curl -fLO \
10
+ https://github.com/octopusos/octoryn-ruby/releases/download/v0.1.1/octoryn-sdk-0.1.1.gem
11
+ gem install ./octoryn-sdk-0.1.1.gem
12
+ ```
13
+
14
+ ```ruby
15
+ client = Octoryn::Client.new(api_key: ENV.fetch("OCTORYN_API_KEY"))
16
+ result = client.generate_text(
17
+ model: "policy/au-enterprise",
18
+ prompt: "Explain this routing decision."
19
+ )
20
+
21
+ puts result.octoryn.evidence_hash
22
+ ```
23
+
24
+ The SDK supports synchronous and asynchronous text generation, replayable SSE
25
+ streams, split tool calls, JSON Schema-validated Structured Outputs, normalized
26
+ errors, cancellation, replaceable transports and Octoryn governance metadata.
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'json_schemer'
5
+
6
+ module Octoryn
7
+ # High-level governed client for text, tools, streaming, and JSON Schema output.
8
+ class Client
9
+ def initialize(api_key:, base_url: 'https://api.octoryn.dev/v1/',
10
+ transport: nil)
11
+ raise ArgumentError, 'Octoryn API key is required' if api_key.to_s.strip.empty?
12
+
13
+ @transport = transport || NetHTTPTransport.new(
14
+ base_url: base_url,
15
+ api_key: api_key
16
+ )
17
+ end
18
+
19
+ def generate_text(**options)
20
+ response = request(build_request(options, stream: false))
21
+ normalize(JSON.parse(response.body), governance(response))
22
+ end
23
+
24
+ def generate_text_async(**options)
25
+ Thread.new { generate_text(**options) }
26
+ end
27
+
28
+ def generate_object(schema:, schema_name: 'response',
29
+ schema_description: nil, **options)
30
+ payload = build_request(options, stream: false)
31
+ payload['response_format'] = {
32
+ 'type' => 'json_schema',
33
+ 'json_schema' => {
34
+ 'name' => schema_name,
35
+ 'description' => schema_description,
36
+ 'strict' => true,
37
+ 'schema' => schema
38
+ }
39
+ }
40
+ response = request(payload)
41
+ result = normalize(JSON.parse(response.body), governance(response))
42
+ object = parse_structured(result.text)
43
+ errors = JSONSchemer.schema(schema).validate(object).to_a
44
+ unless errors.empty?
45
+ raise StructuredOutputError.new(
46
+ 'Octoryn structured output does not match the JSON Schema',
47
+ raw_output: result.text,
48
+ validation_errors: errors
49
+ )
50
+ end
51
+ ObjectResult.new(object, result)
52
+ end
53
+
54
+ def stream_text(**options)
55
+ payload = build_request(options, stream: true)
56
+ producer = proc do |&on_chunk|
57
+ response = @transport.post('chat/completions', payload, stream: true) do |head, chunk|
58
+ if chunk.nil?
59
+ ensure_success!(head)
60
+ on_chunk.call(nil, governance(head))
61
+ else
62
+ on_chunk.call(chunk, nil)
63
+ end
64
+ end
65
+ ensure_success!(response)
66
+ end
67
+ TextStream.new(&producer)
68
+ end
69
+
70
+ private
71
+
72
+ def build_request(options, stream:)
73
+ model = options[:model]
74
+ raise ArgumentError, 'model is required' if model.to_s.strip.empty?
75
+
76
+ has_prompt = options.key?(:prompt)
77
+ has_messages = options.key?(:messages)
78
+ raise ArgumentError, 'pass exactly one of prompt or messages' if has_prompt == has_messages
79
+
80
+ messages = []
81
+ messages << { 'role' => 'system', 'content' => options[:system] } if options[:system]
82
+ if has_prompt
83
+ messages << { 'role' => 'user', 'content' => options[:prompt] }
84
+ else
85
+ messages.concat(Array(options[:messages]))
86
+ end
87
+ payload = { 'model' => model, 'messages' => messages, 'stream' => stream }
88
+ {
89
+ temperature: 'temperature',
90
+ top_p: 'top_p',
91
+ max_output_tokens: 'max_tokens',
92
+ tools: 'tools',
93
+ tool_choice: 'tool_choice',
94
+ metadata: 'metadata'
95
+ }.each do |input, output|
96
+ payload[output] = options[input] if options.key?(input)
97
+ end
98
+ payload
99
+ end
100
+
101
+ def request(payload)
102
+ response = @transport.post('chat/completions', payload, stream: false)
103
+ ensure_success!(response)
104
+ response
105
+ end
106
+
107
+ def normalize(response, metadata)
108
+ choice = Array(response['choices']).first
109
+ raise Error, 'Octoryn response contained no choices' unless choice
110
+
111
+ message = choice.fetch('message', {})
112
+ TextResult.new(
113
+ text_content(message['content']),
114
+ Array(message['tool_calls']).filter_map { |call| normalize_tool(call) },
115
+ choice['finish_reason'],
116
+ response['usage'],
117
+ metadata,
118
+ response
119
+ )
120
+ end
121
+
122
+ def normalize_tool(call)
123
+ function = call['function']
124
+ return unless function.is_a?(Hash)
125
+
126
+ ToolCall.new(
127
+ call.fetch('id', ''),
128
+ function.fetch('name', ''),
129
+ function.fetch('arguments', '{}'),
130
+ call.fetch('type', 'function')
131
+ )
132
+ end
133
+
134
+ def text_content(content)
135
+ return content if content.is_a?(String)
136
+ return '' unless content.is_a?(Array)
137
+
138
+ content.filter_map do |part|
139
+ part['text'] if part.is_a?(Hash) && part['type'] == 'text'
140
+ end.join
141
+ end
142
+
143
+ def governance(response)
144
+ header = ->(name) { response.headers[name.downcase] || response.headers[name] }
145
+ cost = header.call('x-octoryn-estimated-cost')
146
+ GovernanceMetadata.new(
147
+ header.call('x-octoryn-run-id'),
148
+ header.call('x-octoryn-upstream'),
149
+ header.call('x-octoryn-byok'),
150
+ header.call('x-octoryn-region'),
151
+ header.call('x-octoryn-route'),
152
+ header.call('x-octoryn-policy-decision'),
153
+ header.call('x-octoryn-evidence-hash'),
154
+ cost&.to_f
155
+ )
156
+ end
157
+
158
+ def ensure_success!(response)
159
+ return if response.status.between?(200, 299)
160
+
161
+ body = response.body ? JSON.parse(response.body) : {}
162
+ error = body.fetch('error', {})
163
+ raise APIError.new(
164
+ status: response.status,
165
+ message: error.fetch('message', 'Octoryn request failed'),
166
+ code: error['code'],
167
+ error_type: error['type'],
168
+ request_id: response.headers['x-request-id'],
169
+ retry_after: response.headers['retry-after']&.to_i
170
+ )
171
+ rescue JSON::ParserError
172
+ raise APIError.new(status: response.status, message: 'Octoryn request failed')
173
+ end
174
+
175
+ def parse_structured(raw)
176
+ JSON.parse(raw)
177
+ rescue JSON::ParserError => e
178
+ raise StructuredOutputError.new(
179
+ 'Octoryn structured output is not valid JSON',
180
+ raw_output: raw,
181
+ validation_errors: []
182
+ ), cause: e
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octoryn
4
+ class Error < StandardError; end
5
+
6
+ # A normalized non-success response from the Octoryn API.
7
+ class APIError < Error
8
+ attr_reader :status, :code, :error_type, :request_id, :retry_after
9
+
10
+ def initialize(status:, message:, code: nil, error_type: nil,
11
+ request_id: nil, retry_after: nil)
12
+ super(message)
13
+ @status = status
14
+ @code = code
15
+ @error_type = error_type
16
+ @request_id = request_id
17
+ @retry_after = retry_after
18
+ end
19
+ end
20
+
21
+ # Raised when structured output is invalid JSON or violates its schema.
22
+ class StructuredOutputError < Error
23
+ attr_reader :raw_output, :validation_errors
24
+
25
+ def initialize(message, raw_output:, validation_errors: [])
26
+ super(message)
27
+ @raw_output = raw_output
28
+ @validation_errors = validation_errors
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octoryn
4
+ # A lazy, cancellable, replayable stream of normalized Octoryn events.
5
+ class TextStream
6
+ include Enumerable
7
+
8
+ def initialize(governance: nil, &producer)
9
+ @governance = governance
10
+ @producer = producer
11
+ @events = []
12
+ @source = nil
13
+ @complete = false
14
+ @result = nil
15
+ @error = nil
16
+ @cancelled = false
17
+ end
18
+
19
+ def each(&)
20
+ return enum_for(:each) unless block_given?
21
+
22
+ @events.each(&)
23
+ return finish_replay if @complete
24
+
25
+ consume_events(&)
26
+ raise @error if @error
27
+ end
28
+
29
+ def result
30
+ each { nil } unless @complete
31
+ raise @error if @error
32
+
33
+ @result || raise(Error, 'Octoryn stream has no final result')
34
+ end
35
+
36
+ def cancel
37
+ @cancelled = true
38
+ @error = Error.new('Octoryn stream cancelled')
39
+ @complete = true
40
+ end
41
+
42
+ private
43
+
44
+ def finish_replay
45
+ raise @error if @error
46
+ end
47
+
48
+ def consume_events
49
+ @source ||= consume
50
+ loop do
51
+ streamed = @source.next
52
+ @events << streamed
53
+ yield streamed
54
+ end
55
+ rescue StopIteration
56
+ nil
57
+ end
58
+
59
+ def consume
60
+ Enumerator.new do |events|
61
+ state = stream_state
62
+ started = false
63
+ @producer.call do |chunk, metadata = nil|
64
+ raise Error, 'Octoryn stream cancelled' if @cancelled
65
+
66
+ if metadata
67
+ @governance = metadata
68
+ events << event('start', octoryn: @governance)
69
+ started = true
70
+ next
71
+ end
72
+ unless started
73
+ events << event('start', octoryn: @governance)
74
+ started = true
75
+ end
76
+ state[:buffer] << chunk
77
+ parse_lines(state, events)
78
+ end
79
+ finalize_stream(state, events)
80
+ rescue StandardError => e
81
+ @error = e
82
+ @complete = true
83
+ events << event('error', octoryn: @governance, error: e)
84
+ end
85
+ end
86
+
87
+ def stream_state
88
+ {
89
+ buffer: +'',
90
+ data: [],
91
+ text: +'',
92
+ tools: {},
93
+ usage: nil,
94
+ finish_reason: nil,
95
+ done: false
96
+ }
97
+ end
98
+
99
+ def parse_lines(state, events)
100
+ while (newline = state[:buffer].index("\n"))
101
+ line = state[:buffer].slice!(0..newline).sub(/\r?\n\z/, '')
102
+ if line.empty?
103
+ process_event(state, events)
104
+ elsif line.start_with?('data:')
105
+ state[:data] << line.delete_prefix('data:').lstrip
106
+ end
107
+ end
108
+ end
109
+
110
+ def process_event(state, events)
111
+ return if state[:data].empty?
112
+
113
+ payload = state[:data].join("\n")
114
+ state[:data].clear
115
+ if payload == '[DONE]'
116
+ state[:done] = true
117
+ return
118
+ end
119
+
120
+ apply(JSON.parse(payload), state, events)
121
+ end
122
+
123
+ def apply(chunk, state, events)
124
+ recognized = false
125
+ if chunk['usage']
126
+ state[:usage] = chunk['usage']
127
+ events << event('usage', usage: state[:usage])
128
+ recognized = true
129
+ end
130
+ Array(chunk['choices']).each do |choice|
131
+ recognized = apply_delta(choice, state, events) || recognized
132
+ state[:finish_reason] = choice['finish_reason'] if choice['finish_reason']
133
+ end
134
+ events << event('provider-event', provider_event: chunk) unless recognized
135
+ end
136
+
137
+ def apply_delta(choice, state, events)
138
+ delta = choice.fetch('delta', {})
139
+ content = delta['content']
140
+ recognized = content.is_a?(String)
141
+ append_text(content, state, events) if recognized
142
+ reasoning = delta['reasoning'] || delta['reasoning_content']
143
+ if reasoning.is_a?(String)
144
+ events << event('reasoning-delta', text: reasoning)
145
+ recognized = true
146
+ end
147
+ Array(delta['tool_calls']).each do |part|
148
+ append_tool(part, state, events)
149
+ recognized = true
150
+ end
151
+ recognized
152
+ end
153
+
154
+ def append_text(content, state, events)
155
+ state[:text] << content
156
+ events << event('text-delta', text: content)
157
+ end
158
+
159
+ def append_tool(part, state, events)
160
+ index = part.fetch('index', 0)
161
+ starting = !state[:tools].key?(index)
162
+ tool = state[:tools][index] ||= {
163
+ 'id' => '',
164
+ 'type' => 'function',
165
+ 'name' => +'',
166
+ 'arguments' => +''
167
+ }
168
+ tool['id'] = part['id'] if part['id']
169
+ tool['type'] = part['type'] if part['type']
170
+ function = part.fetch('function', {})
171
+ tool['name'] << function.fetch('name', '')
172
+ tool['arguments'] << function.fetch('arguments', '')
173
+ events << event('tool-call-start', provider_event: part) if starting
174
+ events << event('tool-call-delta', provider_event: part)
175
+ end
176
+
177
+ def finalize_stream(state, events)
178
+ process_trailing_data(state, events)
179
+ raise Error, 'Octoryn stream ended before [DONE]' unless state[:done]
180
+
181
+ calls = state[:tools].sort.map do |_index, tool|
182
+ call = ToolCall.new(tool['id'], tool['name'], tool['arguments'], tool['type'])
183
+ events << event('tool-call', tool_call: call)
184
+ call
185
+ end
186
+ events << event(
187
+ 'finish',
188
+ finish_reason: state[:finish_reason],
189
+ octoryn: @governance
190
+ )
191
+ @result = TextResult.new(
192
+ state[:text],
193
+ calls,
194
+ state[:finish_reason],
195
+ state[:usage],
196
+ @governance,
197
+ nil
198
+ )
199
+ @complete = true
200
+ end
201
+
202
+ def process_trailing_data(state, events)
203
+ unless state[:buffer].empty?
204
+ line = state[:buffer].sub(/\r?\n\z/, '')
205
+ state[:data] << line.delete_prefix('data:').lstrip if line.start_with?('data:')
206
+ end
207
+ process_event(state, events)
208
+ end
209
+
210
+ def event(type, **attributes)
211
+ StreamEvent.new(
212
+ type,
213
+ attributes[:text],
214
+ attributes[:tool_call],
215
+ attributes[:usage],
216
+ attributes[:finish_reason],
217
+ attributes[:octoryn],
218
+ attributes[:provider_event],
219
+ attributes[:error]
220
+ )
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module Octoryn
8
+ Response = Data.define(:status, :headers, :body)
9
+
10
+ # Standard-library HTTP transport with incremental response-body streaming.
11
+ class NetHTTPTransport
12
+ def initialize(base_url:, api_key:, open_timeout: 30, read_timeout: 600)
13
+ @base_url = base_url.end_with?('/') ? base_url : "#{base_url}/"
14
+ @api_key = api_key
15
+ @open_timeout = open_timeout
16
+ @read_timeout = read_timeout
17
+ end
18
+
19
+ def post(path, payload, stream: false, &block)
20
+ uri = URI.join(@base_url, path)
21
+ request = Net::HTTP::Post.new(uri)
22
+ request['Authorization'] = "Bearer #{@api_key}"
23
+ request['Content-Type'] = 'application/json'
24
+ request['Accept'] = stream ? 'text/event-stream' : 'application/json'
25
+ request['User-Agent'] = 'octoryn-ruby/0.1.1'
26
+ request['X-Octoryn-Sdk'] = 'ruby/0.1.1'
27
+ request.body = JSON.generate(payload)
28
+
29
+ Net::HTTP.start(
30
+ uri.hostname,
31
+ uri.port,
32
+ use_ssl: uri.scheme == 'https',
33
+ open_timeout: @open_timeout,
34
+ read_timeout: @read_timeout
35
+ ) do |http|
36
+ if stream
37
+ stream_response(http, request, &block)
38
+ else
39
+ response = http.request(request)
40
+ Response.new(response.code.to_i, headers(response), response.body)
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def stream_response(http, request)
48
+ final = nil
49
+ http.request(request) do |response|
50
+ final = Response.new(response.code.to_i, headers(response), nil)
51
+ yield final, nil
52
+ response.read_body { |chunk| yield final, chunk }
53
+ end
54
+ final
55
+ end
56
+
57
+ def headers(response)
58
+ response.each_header.to_h
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octoryn
4
+ GovernanceMetadata = Data.define(
5
+ :run_id,
6
+ :upstream,
7
+ :byok,
8
+ :region,
9
+ :route,
10
+ :policy_decision,
11
+ :evidence_hash,
12
+ :estimated_cost
13
+ )
14
+
15
+ ToolCall = Data.define(:id, :name, :arguments, :type) do
16
+ def decode_input
17
+ value = JSON.parse(arguments)
18
+ raise JSON::ParserError, 'tool arguments must be an object' unless value.is_a?(Hash)
19
+
20
+ value
21
+ end
22
+ end
23
+
24
+ TextResult = Data.define(
25
+ :text,
26
+ :tool_calls,
27
+ :finish_reason,
28
+ :usage,
29
+ :octoryn,
30
+ :response
31
+ )
32
+
33
+ ObjectResult = Data.define(:object, :result)
34
+
35
+ StreamEvent = Data.define(
36
+ :type,
37
+ :text,
38
+ :tool_call,
39
+ :usage,
40
+ :finish_reason,
41
+ :octoryn,
42
+ :provider_event,
43
+ :error
44
+ )
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Octoryn
4
+ VERSION = '0.1.1'
5
+ end
data/lib/octoryn.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'octoryn/version'
4
+ require_relative 'octoryn/errors'
5
+ require_relative 'octoryn/types'
6
+ require_relative 'octoryn/transport'
7
+ require_relative 'octoryn/text_stream'
8
+ require_relative 'octoryn/client'
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: octoryn-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Octopus Core Pty Ltd
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json_schemer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ email:
27
+ - support@octoryn.dev
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - LICENSE
33
+ - README.md
34
+ - lib/octoryn.rb
35
+ - lib/octoryn/client.rb
36
+ - lib/octoryn/errors.rb
37
+ - lib/octoryn/text_stream.rb
38
+ - lib/octoryn/transport.rb
39
+ - lib/octoryn/types.rb
40
+ - lib/octoryn/version.rb
41
+ homepage: https://octoryn.dev
42
+ licenses:
43
+ - Apache-2.0
44
+ metadata:
45
+ source_code_uri: https://github.com/octopusos/octoryn-ruby
46
+ documentation_uri: https://octoryn.dev/docs
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.2'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.6.9
63
+ specification_version: 4
64
+ summary: Governed AI SDK for Octoryn Router
65
+ test_files: []