terret-core 0.1.0 → 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.
data/lib/terret/llm.rb CHANGED
@@ -16,16 +16,89 @@ module Terret
16
16
  # Stream events yielded by adapters.
17
17
  TextDelta = Data.define(:text)
18
18
  ToolCallEnd = Data.define(:tool_call)
19
+ Usage = Data.define(:prompt_tokens, :completion_tokens, :cost)
20
+ StreamError = Data.define(:message, :code) # surfaced mid-stream, then raised
19
21
  MessageStop = Data.define(:stop_reason) # :end_turn | :tool_use
20
22
 
23
+ # Storage codec for message parts. Durable logs hold only these primitive,
24
+ # storage-named hashes ("text", never Terret::LLM::Text), so a class
25
+ # rename can never invalidate stored sessions. decode(encode(p)) == p.
26
+ module_function
27
+
28
+ def encode_part(part)
29
+ case part
30
+ when Text then { type: "text", text: part.text }
31
+ when ToolCall then { type: "tool_call", id: part.id, name: part.name, args: part.args }
32
+ when ToolResult then { type: "tool_result", id: part.id, content: part.content, error: part.error }
33
+ else raise ArgumentError, "cannot encode #{part.class} as a message part"
34
+ end
35
+ end
36
+
37
+ def decode_part(hash)
38
+ case hash[:type]
39
+ when "text" then Text.new(text: hash[:text])
40
+ when "tool_call" then ToolCall.new(id: hash[:id], name: hash[:name], args: hash[:args])
41
+ when "tool_result" then ToolResult.new(id: hash[:id], content: hash[:content], error: hash[:error])
42
+ else raise ArgumentError, "unknown part tag #{hash[:type].inspect}"
43
+ end
44
+ end
45
+
46
+ # Adapter failures. Retryable covers 429/overload/5xx and transport drops
47
+ # before any bytes streamed; everything else raises through immediately.
48
+ class AdapterError < StandardError
49
+ attr_reader :status, :body
50
+
51
+ def initialize(message, status: nil, body: nil)
52
+ super(message)
53
+ @status = status
54
+ @body = body
55
+ end
56
+ end
57
+
58
+ class RetryableError < AdapterError; end
59
+
60
+ # Shared behaviour for real adapters: jittered exponential backoff around
61
+ # anything raising RetryableError. Sleeper is injectable for tests.
62
+ class AdapterBase
63
+ def initialize(max_attempts: 4, base_delay: 0.5, sleeper: ->(s) { sleep(s) })
64
+ @max_attempts = max_attempts
65
+ @base_delay = base_delay
66
+ @sleeper = sleeper
67
+ end
68
+
69
+ def with_retries
70
+ attempt = 0
71
+ begin
72
+ yield
73
+ rescue RetryableError
74
+ attempt += 1
75
+ raise if attempt >= @max_attempts
76
+
77
+ # equal jitter: uniform over (half, full] of base * 2^n
78
+ cap = @base_delay * (2**(attempt - 1))
79
+ @sleeper.call(cap * (0.5 + rand * 0.5))
80
+ retry
81
+ end
82
+ end
83
+ end
84
+
21
85
  # ctx.llm — the adapter seam. `llm/stream` is a waterfall wrapping every
22
86
  # request: middleware may rewrite the request or replace the stream.
23
87
  class Service < Hames::Service
24
88
  service_key :llm
89
+ config_schema roles: { type: Hash, default: {},
90
+ doc: "role => \"provider/model\" map; an open set of roles" }
25
91
 
26
92
  def start(_ctx)
27
93
  @adapters = {}
28
- @roles = config[:roles] || {} # :main => "fake/scripted"
94
+ @roles = (config[:roles] || {}).dup # :main => "fake/scripted"
95
+ end
96
+
97
+ # The role map is the whole of this service's config, and layering
98
+ # replaces a row's config wholesale — so does this. Registered adapters
99
+ # are runtime state, not config, and survive untouched.
100
+ def reconfigure(config)
101
+ @roles = (config[:roles] || {}).dup
29
102
  end
30
103
 
31
104
  def register_adapter(name, adapter)
@@ -39,6 +112,20 @@ module Terret
39
112
  [@adapters.fetch(provider), model]
40
113
  end
41
114
 
115
+ # §9.2 set_model lands here: repoint a role at a "provider/model" spec
116
+ # on the live service. Takes effect at the next step's resolve.
117
+ def set_role(role, spec)
118
+ raise ArgumentError, "role must be a name, got #{role.inspect}" unless role.respond_to?(:to_sym)
119
+
120
+ provider, model = spec.to_s.split("/", 2)
121
+ if provider.nil? || provider.empty? || model.nil? || model.empty?
122
+ raise ArgumentError, "model spec must be provider/model, got #{spec.inspect}"
123
+ end
124
+
125
+ @adapters.fetch(provider) { raise ArgumentError, "unknown provider #{provider.inspect}" }
126
+ @roles[role.to_sym] = spec.to_s
127
+ end
128
+
42
129
  # Streams the request through the `llm/stream` waterfall; the base of
43
130
  # the waterfall invokes the resolved adapter. Yields StreamEvents,
44
131
  # returns the final assistant Message.
@@ -52,7 +139,7 @@ module Terret
52
139
  end
53
140
 
54
141
  # Deterministic scripted adapter for tests, demos, and record/replay.
55
- # Script: array of hashes { text:, tool_calls: [ToolCall, ...] }.
142
+ # Script: array of hashes { text:, tool_calls: [ToolCall, ...], usage: }.
56
143
  class FakeAdapter
57
144
  def initialize(script)
58
145
  @script = script.dup
@@ -70,6 +157,7 @@ module Terret
70
157
  yield ToolCallEnd.new(tool_call: tc)
71
158
  parts << tc
72
159
  end
160
+ yield Usage.new(**step[:usage]) if step[:usage]
73
161
  yield MessageStop.new(stop_reason: calls.empty? ? :end_turn : :tool_use)
74
162
  Message.new(role: :assistant, parts: parts)
75
163
  end