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 +7 -0
- data/lib/terret/openrouter/accumulator.rb +72 -0
- data/lib/terret/openrouter/adapter.rb +97 -0
- data/lib/terret/openrouter/async_transport.rb +45 -0
- data/lib/terret/openrouter/sse.rb +33 -0
- data/lib/terret/openrouter/translate.rb +52 -0
- data/lib/terret/openrouter.rb +51 -0
- metadata +81 -0
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: []
|