terret-openrouter 0.1.0

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: 7d65c1ea74581205c219735230596ea49ae2a70d2e091f0be3350cbb642b88b0
4
+ data.tar.gz: c547f2ba2ad7f207f0fb84cd1eb5b05eaefd894fc0ceaa567bc24d27dcc82469
5
+ SHA512:
6
+ metadata.gz: 24d3c8eee134e36363a3434724c22b9683de36800f76fb13aac18da01b7f0a40a0dcf042f9123b380fc33e445744ff6c04883adc80a2e992c39bb4ec8e9299fd
7
+ data.tar.gz: 6bfd78533bbcdb6c6fe9caae98718f58065ef166209d67cd0812fc1549020deb9386d49a9af92782914cbc48505e37e5362db3acf91660e0fa8e7a3db3da56cc
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Terret
6
+ module OpenRouter
7
+ # Accumulates parsed streaming chunks into vocabulary StreamEvents and the
8
+ # final assistant Message. Tool-call argument fragments are gathered per
9
+ # index; the calls close on finalize. Pure, no I/O.
10
+ class Accumulator
11
+ STOP_REASONS = {
12
+ "stop" => :end_turn, "tool_calls" => :tool_use,
13
+ "length" => :length, "error" => :error
14
+ }.freeze
15
+
16
+ attr_reader :error
17
+
18
+ def initialize
19
+ @text = +""
20
+ @calls = {} # index => { id:, name:, args: +"" }
21
+ @stop = nil
22
+ @error = nil
23
+ end
24
+
25
+ def feed(chunk)
26
+ if (err = chunk[:error])
27
+ @error = LLM::StreamError.new(message: err[:message], code: err[:code])
28
+ yield @error
29
+ end
30
+ if (usage = chunk[:usage])
31
+ yield LLM::Usage.new(prompt_tokens: usage[:prompt_tokens],
32
+ completion_tokens: usage[:completion_tokens],
33
+ cost: usage[:cost])
34
+ end
35
+ choice = chunk[:choices]&.first or return
36
+ @stop = choice[:finish_reason] if choice[:finish_reason]
37
+ delta = choice[:delta] or return
38
+ if (text = delta[:content]) && !text.empty?
39
+ @text << text
40
+ yield LLM::TextDelta.new(text: text)
41
+ end
42
+ Array(delta[:tool_calls]).each { |tc| accumulate_call(tc) }
43
+ nil
44
+ end
45
+
46
+ def finalize
47
+ parts = []
48
+ parts << LLM::Text.new(text: @text) unless @text.empty?
49
+ @calls.keys.sort.each do |index|
50
+ slot = @calls[index]
51
+ args = slot[:args].empty? ? {} : JSON.parse(slot[:args], symbolize_names: true)
52
+ call = LLM::ToolCall.new(id: slot[:id], name: slot[:name], args: args)
53
+ yield LLM::ToolCallEnd.new(tool_call: call)
54
+ parts << call
55
+ end
56
+ yield LLM::MessageStop.new(stop_reason: STOP_REASONS.fetch(@stop, :end_turn))
57
+ LLM::Message.new(role: :assistant, parts: parts)
58
+ end
59
+
60
+ private
61
+
62
+ def accumulate_call(tc)
63
+ slot = @calls[tc[:index]] ||= { id: nil, name: nil, args: +"" }
64
+ slot[:id] = tc[:id] if tc[:id]
65
+ if (fn = tc[:function])
66
+ slot[:name] = fn[:name] if fn[:name]
67
+ slot[:args] << fn[:arguments] if fn[:arguments]
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Terret
6
+ module OpenRouter
7
+ # The ctx.llm adapter. Transport is injectable: tests drive it with canned
8
+ # SSE bodies; the default (lazily required) streams over async-http.
9
+ # Retries apply only before any bytes have streamed — once the consumer
10
+ # has seen deltas, a failure surfaces as StreamError and raises.
11
+ class Adapter < LLM::AdapterBase
12
+ DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
13
+
14
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, referer: nil,
15
+ title: nil, transport: nil, credentials: nil, **retry_opts)
16
+ super(**retry_opts)
17
+ @api_key = api_key
18
+ @base_url = base_url.chomp("/")
19
+ @referer = referer
20
+ @title = title
21
+ @transport = transport
22
+ # An optional resolver callable (the row wires it to ctx[:credentials]),
23
+ # consulted lazily at request time so mount order never matters.
24
+ @credentials = credentials
25
+ end
26
+
27
+ def stream(request, &on_event)
28
+ body = JSON.generate(Translate.request_body(request))
29
+ headers = request_headers
30
+ with_retries do
31
+ transport.call(url: "#{@base_url}/chat/completions",
32
+ headers: headers, body: body) do |status, chunks|
33
+ check_status!(status, chunks)
34
+ consume(chunks, &on_event)
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def consume(chunks, &on_event)
42
+ parser = SSE::Parser.new
43
+ acc = Accumulator.new
44
+ chunks.each do |chunk|
45
+ parser.feed(chunk) do |data|
46
+ next if data == "[DONE]"
47
+
48
+ acc.feed(JSON.parse(data, symbolize_names: true), &on_event)
49
+ end
50
+ end
51
+ if (err = acc.error)
52
+ raise LLM::AdapterError.new("mid-stream error: #{err.message}", status: err.code)
53
+ end
54
+
55
+ acc.finalize(&on_event)
56
+ end
57
+
58
+ def check_status!(status, chunks)
59
+ return if status == 200
60
+
61
+ body = chunks.to_a.join
62
+ message = begin
63
+ JSON.parse(body).dig("error", "message") || body
64
+ rescue JSON::ParserError
65
+ body
66
+ end
67
+ klass = status == 429 || status >= 500 ? LLM::RetryableError : LLM::AdapterError
68
+ raise klass.new("OpenRouter #{status}: #{message}", status: status, body: body)
69
+ end
70
+
71
+ def request_headers
72
+ # Precedence: an explicit config key, then the credentials resolver
73
+ # (itself ENV-first, then an encrypted store), then the adapter's own
74
+ # last-resort ENV read so the gem stays usable with no credentials
75
+ # service mounted.
76
+ key = @api_key
77
+ key ||= @credentials.call if @credentials
78
+ key ||= ENV["OPENROUTER_API_KEY"]
79
+ unless key
80
+ raise LLM::AdapterError, "no OpenRouter API key (set OPENROUTER_API_KEY or pass api_key:)"
81
+ end
82
+
83
+ headers = { "Authorization" => "Bearer #{key}", "Content-Type" => "application/json" }
84
+ headers["HTTP-Referer"] = @referer if @referer
85
+ headers["X-OpenRouter-Title"] = @title if @title # renamed upstream from X-Title
86
+ headers
87
+ end
88
+
89
+ def transport
90
+ @transport ||= begin
91
+ require_relative "async_transport"
92
+ AsyncTransport.new
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/http/client"
5
+ require "async/http/endpoint"
6
+
7
+ module Terret
8
+ module OpenRouter
9
+ # Default transport: streams over async-http on the Fiber scheduler.
10
+ # Sync reuses a running reactor when called from inside one and spins a
11
+ # temporary reactor otherwise, so the adapter blocks correctly in both
12
+ # plain Ruby and Async callers. The connection stays open only for the
13
+ # duration of the block, which is why the contract yields rather than
14
+ # returns: an SSE body must be consumed before the response closes.
15
+ class AsyncTransport
16
+ CONNECT_ERRORS = [SystemCallError, SocketError, IOError, Async::TimeoutError].freeze
17
+
18
+ def initialize(timeout: 120)
19
+ @timeout = timeout
20
+ end
21
+
22
+ def call(url:, headers:, body:)
23
+ Sync do
24
+ endpoint = Async::HTTP::Endpoint.parse(url, timeout: @timeout)
25
+ client = Async::HTTP::Client.new(endpoint)
26
+ begin
27
+ response = begin
28
+ client.post(endpoint.path, headers, body)
29
+ rescue *CONNECT_ERRORS => e
30
+ raise LLM::RetryableError, "connection failed: #{e.class}: #{e.message}"
31
+ end
32
+ begin
33
+ chunks = Enumerator.new { |y| response.body&.each { |c| y << c } }
34
+ yield response.status, chunks
35
+ ensure
36
+ response.close
37
+ end
38
+ ensure
39
+ client.close
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module OpenRouter
5
+ module SSE
6
+ # Incremental server-sent-events parser. Fed raw transport chunks with
7
+ # boundaries wherever the network put them; yields one data payload per
8
+ # event (multi-line data joined per the SSE spec). Comment lines and
9
+ # non-data fields are ignored — OpenRouter uses comments as keep-alives.
10
+ class Parser
11
+ def initialize
12
+ @buffer = +""
13
+ @data = []
14
+ end
15
+
16
+ def feed(chunk)
17
+ @buffer << chunk
18
+ while (line, rest = @buffer.split("\n", 2)) && rest
19
+ @buffer = rest
20
+ line.chomp!("\r")
21
+ if line.empty?
22
+ yield @data.join("\n") unless @data.empty?
23
+ @data.clear
24
+ elsif line.start_with?("data:")
25
+ @data << line.delete_prefix("data:").sub(/\A /, "")
26
+ end
27
+ end
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Terret
6
+ module OpenRouter
7
+ # Pure translation between the provider-neutral vocabulary and
8
+ # OpenRouter's OpenAI-compatible wire format. No I/O.
9
+ module Translate
10
+ module_function
11
+
12
+ def request_body(request)
13
+ body = { model: request.model, messages: wire_messages(request), stream: true }
14
+ tools = Array(request.tools)
15
+ body[:tools] = tools.map { |s| { type: "function", function: s } } unless tools.empty?
16
+ body
17
+ end
18
+
19
+ def wire_messages(request)
20
+ out = []
21
+ system = request.system.to_s
22
+ out << { role: "system", content: system } unless system.empty?
23
+ request.messages.each { |m| out.concat(wire_message(m)) }
24
+ out
25
+ end
26
+
27
+ def wire_message(message)
28
+ case message.role
29
+ when :user
30
+ [{ role: "user", content: message.text }]
31
+ when :assistant
32
+ msg = { role: "assistant", content: message.text.empty? ? nil : message.text }
33
+ calls = message.tool_calls
34
+ unless calls.empty?
35
+ msg[:tool_calls] = calls.map do |tc|
36
+ { id: tc.id, type: "function",
37
+ function: { name: tc.name, arguments: JSON.generate(tc.args) } }
38
+ end
39
+ end
40
+ [msg]
41
+ when :tool
42
+ message.parts.grep(LLM::ToolResult).map do |tr|
43
+ content = tr.error ? "Error: #{tr.error}" : tr.content.to_s
44
+ { role: "tool", tool_call_id: tr.id, content: content }
45
+ end
46
+ else
47
+ raise ArgumentError, "cannot translate message role #{message.role.inspect}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "terret"
5
+ rescue LoadError
6
+ require_relative "../../../terret-core/lib/terret" # monorepo path source
7
+ end
8
+
9
+ module Terret
10
+ # The one v1 adapter (plan §6.5): OpenRouter is OpenAI-compatible, so a
11
+ # single implementation reaches the whole model space behind ctx.llm.
12
+ module OpenRouter
13
+ end
14
+ end
15
+
16
+ require_relative "openrouter/sse"
17
+ require_relative "openrouter/translate"
18
+ require_relative "openrouter/accumulator"
19
+ require_relative "openrouter/adapter"
20
+
21
+ module Terret
22
+ module OpenRouter
23
+ # Config-row form: mounts the adapter into ctx.llm under the "openrouter"
24
+ # provider name. Roles then point at it: { main: "openrouter/<model>" }.
25
+ class Plugin < Hames::Service
26
+ inject :llm
27
+ # transport: and sleeper: are injectable seams (tests pass callables), not
28
+ # YAML config, so they are deliberately absent from the schema.
29
+ config_schema api_key: { type: String,
30
+ doc: "OpenRouter key; falls back to ENV OPENROUTER_API_KEY when unset" },
31
+ base_url: { type: String, default: "https://openrouter.ai/api/v1",
32
+ doc: "OpenAI-compatible API base URL" },
33
+ referer: { type: String, doc: "HTTP-Referer header sent with each request" },
34
+ title: { type: String, doc: "X-Title header sent with each request" },
35
+ max_attempts: { type: Integer, default: 4, doc: "retry attempts on a retryable error" },
36
+ base_delay: { type: Numeric, default: 0.5, doc: "seconds of the first retry backoff" }
37
+
38
+ def start(ctx)
39
+ # A resolver evaluated at REQUEST time, not now: it checks for the
40
+ # credentials service at call time, so the row need not inject it (the
41
+ # gem stays usable without terret-core's credentials mounted) and mount
42
+ # order between the two rows never matters. resolve(:openrouter) is
43
+ # ENV-first, so ENV-direct keeps working; when it resolves anything, the
44
+ # value is registered as a scrub pattern (plan §6.9).
45
+ credentials = -> { ctx[:credentials].resolve(:openrouter) if ctx.service?(:credentials) }
46
+ adapter = Adapter.new(**config, credentials: credentials)
47
+ ctx.effect { ctx[:llm].register_adapter("openrouter", adapter) }
48
+ end
49
+ end
50
+ end
51
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terret-openrouter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Obie Fernandez
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: terret-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: async-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.94'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.94'
40
+ description: 'The one v1 model adapter for Terret: OpenRouter is OpenAI-compatible,
41
+ so a single streaming implementation reaches the whole model space behind the provider-neutral
42
+ ctx.llm seam. SSE streaming with tool calling, usage accounting, and retry with
43
+ jittered backoff.'
44
+ email:
45
+ - obiefernandez@gmail.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - lib/terret/openrouter.rb
51
+ - lib/terret/openrouter/accumulator.rb
52
+ - lib/terret/openrouter/adapter.rb
53
+ - lib/terret/openrouter/async_transport.rb
54
+ - lib/terret/openrouter/sse.rb
55
+ - lib/terret/openrouter/translate.rb
56
+ homepage: https://terret.org
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://terret.org
61
+ source_code_uri: https://github.com/terret-org/terret
62
+ bug_tracker_uri: https://github.com/terret-org/terret/issues
63
+ rubygems_mfa_required: 'true'
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '4.0'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 4.0.16
79
+ specification_version: 4
80
+ summary: OpenRouter adapter for the Terret agent harness
81
+ test_files: []