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 +7 -0
- data/LICENSE +13 -0
- data/README.md +26 -0
- data/lib/octoryn/client.rb +185 -0
- data/lib/octoryn/errors.rb +31 -0
- data/lib/octoryn/text_stream.rb +223 -0
- data/lib/octoryn/transport.rb +61 -0
- data/lib/octoryn/types.rb +45 -0
- data/lib/octoryn/version.rb +5 -0
- data/lib/octoryn.rb +8 -0
- metadata +65 -0
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
|
data/lib/octoryn.rb
ADDED
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: []
|