igniter 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +2 -2
- data/docs/API_V2.md +58 -0
- data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- data/examples/README.md +3 -0
- data/examples/distributed_workflow.rb +52 -0
- data/examples/ringcentral_routing.rb +26 -35
- data/lib/igniter/compiler/compiled_graph.rb +20 -0
- data/lib/igniter/compiler/validation_pipeline.rb +3 -1
- data/lib/igniter/compiler/validators/await_validator.rb +53 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +43 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +75 -8
- data/lib/igniter/diagnostics/report.rb +102 -3
- data/lib/igniter/dsl/contract_builder.rb +109 -8
- data/lib/igniter/errors.rb +6 -1
- data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -0
- data/lib/igniter/integrations/llm/config.rb +69 -0
- data/lib/igniter/integrations/llm/context.rb +74 -0
- data/lib/igniter/integrations/llm/executor.rb +159 -0
- data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
- data/lib/igniter/integrations/llm/providers/base.rb +33 -0
- data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
- data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
- data/lib/igniter/integrations/llm.rb +59 -0
- data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
- data/lib/igniter/integrations/rails/contract_job.rb +76 -0
- data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
- data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
- data/lib/igniter/integrations/rails/railtie.rb +25 -0
- data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
- data/lib/igniter/integrations/rails.rb +12 -0
- data/lib/igniter/model/await_node.rb +21 -0
- data/lib/igniter/model/branch_node.rb +9 -3
- data/lib/igniter/model/collection_node.rb +9 -3
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +2 -0
- data/lib/igniter/runtime/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +91 -8
- data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
- data/lib/igniter/runtime/stores/file_store.rb +50 -2
- data/lib/igniter/runtime/stores/memory_store.rb +55 -2
- data/lib/igniter/runtime/stores/redis_store.rb +13 -1
- data/lib/igniter/server/client.rb +123 -0
- data/lib/igniter/server/config.rb +27 -0
- data/lib/igniter/server/handlers/base.rb +105 -0
- data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
- data/lib/igniter/server/handlers/event_handler.rb +28 -0
- data/lib/igniter/server/handlers/execute_handler.rb +37 -0
- data/lib/igniter/server/handlers/health_handler.rb +32 -0
- data/lib/igniter/server/handlers/status_handler.rb +27 -0
- data/lib/igniter/server/http_server.rb +109 -0
- data/lib/igniter/server/rack_app.rb +35 -0
- data/lib/igniter/server/registry.rb +56 -0
- data/lib/igniter/server/router.rb +75 -0
- data/lib/igniter/server.rb +67 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +4 -0
- metadata +36 -2
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module DSL
|
|
5
5
|
class ContractBuilder
|
|
6
|
-
def self.compile(name: "AnonymousContract", &block)
|
|
7
|
-
new(name: name).tap { |builder| builder.instance_eval(&block) }.compile
|
|
6
|
+
def self.compile(name: "AnonymousContract", correlation_keys: [], &block)
|
|
7
|
+
new(name: name, correlation_keys: correlation_keys).tap { |builder| builder.instance_eval(&block) }.compile
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def initialize(name:)
|
|
10
|
+
def initialize(name:, correlation_keys: [])
|
|
11
11
|
@name = name
|
|
12
|
+
@correlation_keys = correlation_keys
|
|
12
13
|
@nodes = []
|
|
13
14
|
@sequence = 0
|
|
14
15
|
@scope_stack = []
|
|
@@ -17,6 +18,7 @@ module Igniter
|
|
|
17
18
|
UNDEFINED_INPUT_DEFAULT = :__igniter_undefined__
|
|
18
19
|
UNDEFINED_CONST_VALUE = :__igniter_const_undefined__
|
|
19
20
|
UNDEFINED_GUARD_MATCHER = :__igniter_guard_matcher_undefined__
|
|
21
|
+
UNDEFINED_PROJECT_OPTION = :__igniter_project_undefined__
|
|
20
22
|
|
|
21
23
|
def input(name, type: nil, required: nil, default: UNDEFINED_INPUT_DEFAULT, **metadata)
|
|
22
24
|
input_metadata = with_source_location(metadata)
|
|
@@ -71,6 +73,33 @@ module Igniter
|
|
|
71
73
|
compute(name, with: from, call: call, executor: executor, **{ category: :map }.merge(metadata), &block)
|
|
72
74
|
end
|
|
73
75
|
|
|
76
|
+
def project(name, from:, key: UNDEFINED_PROJECT_OPTION, dig: UNDEFINED_PROJECT_OPTION, default: UNDEFINED_PROJECT_OPTION, **metadata)
|
|
77
|
+
if key != UNDEFINED_PROJECT_OPTION && dig != UNDEFINED_PROJECT_OPTION
|
|
78
|
+
raise CompileError, "project :#{name} cannot use both `key:` and `dig:`"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if key == UNDEFINED_PROJECT_OPTION && dig == UNDEFINED_PROJECT_OPTION
|
|
82
|
+
raise CompileError, "project :#{name} requires either `key:` or `dig:`"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
callable = proc do |**values|
|
|
86
|
+
source = values.fetch(from.to_sym)
|
|
87
|
+
extract_projected_value(
|
|
88
|
+
source,
|
|
89
|
+
key: key,
|
|
90
|
+
dig: dig,
|
|
91
|
+
default: default,
|
|
92
|
+
node_name: name
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
compute(name, with: from, call: callable, **{ category: :project }.merge(metadata))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def aggregate(name, depends_on: nil, with: nil, call: nil, executor: nil, **metadata, &block)
|
|
100
|
+
compute(name, depends_on: depends_on, with: with, call: call, executor: executor, **{ category: :aggregate }.merge(metadata), &block)
|
|
101
|
+
end
|
|
102
|
+
|
|
74
103
|
def guard(name, depends_on: nil, with: nil, call: nil, executor: nil, message: nil,
|
|
75
104
|
eq: UNDEFINED_GUARD_MATCHER, in: UNDEFINED_GUARD_MATCHER, matches: UNDEFINED_GUARD_MATCHER,
|
|
76
105
|
**metadata, &block)
|
|
@@ -156,9 +185,12 @@ module Igniter
|
|
|
156
185
|
)
|
|
157
186
|
end
|
|
158
187
|
|
|
159
|
-
def branch(name, with:, inputs
|
|
188
|
+
def branch(name, with:, inputs: nil, depends_on: nil, map_inputs: nil, using: nil, **metadata, &block)
|
|
160
189
|
raise CompileError, "branch :#{name} requires a block" unless block
|
|
161
|
-
raise CompileError, "branch :#{name} requires
|
|
190
|
+
raise CompileError, "branch :#{name} requires either `inputs:` or `map_inputs:`/`using:`" if inputs.nil? && map_inputs.nil? && using.nil?
|
|
191
|
+
raise CompileError, "branch :#{name} cannot combine `inputs:` with `map_inputs:` or `using:`" if inputs && (map_inputs || using)
|
|
192
|
+
raise CompileError, "branch :#{name} cannot use both `map_inputs:` and `using:`" if map_inputs && using
|
|
193
|
+
raise CompileError, "branch :#{name} requires an `inputs:` hash" if inputs && !inputs.is_a?(Hash)
|
|
162
194
|
|
|
163
195
|
definition = BranchBuilder.build(&block)
|
|
164
196
|
|
|
@@ -169,14 +201,18 @@ module Igniter
|
|
|
169
201
|
selector_dependency: with,
|
|
170
202
|
cases: definition[:cases],
|
|
171
203
|
default_contract: definition[:default_contract],
|
|
172
|
-
input_mapping: inputs,
|
|
204
|
+
input_mapping: inputs || {},
|
|
205
|
+
context_dependencies: normalize_dependencies(depends_on: depends_on, with: nil),
|
|
206
|
+
input_mapper: map_inputs || using,
|
|
173
207
|
path: scoped_path(name),
|
|
174
208
|
metadata: with_source_location(metadata)
|
|
175
209
|
)
|
|
176
210
|
)
|
|
177
211
|
end
|
|
178
212
|
|
|
179
|
-
def collection(name, with:, each:, key:, mode: :collect, **metadata)
|
|
213
|
+
def collection(name, with:, each:, key:, mode: :collect, depends_on: nil, map_inputs: nil, using: nil, **metadata)
|
|
214
|
+
raise CompileError, "collection :#{name} cannot use both `map_inputs:` and `using:`" if map_inputs && using
|
|
215
|
+
|
|
180
216
|
add_node(
|
|
181
217
|
Model::CollectionNode.new(
|
|
182
218
|
id: next_id,
|
|
@@ -185,14 +221,53 @@ module Igniter
|
|
|
185
221
|
contract_class: each,
|
|
186
222
|
key_name: key,
|
|
187
223
|
mode: mode,
|
|
224
|
+
context_dependencies: normalize_dependencies(depends_on: depends_on, with: nil),
|
|
225
|
+
input_mapper: map_inputs || using,
|
|
188
226
|
path: scoped_path(name),
|
|
189
227
|
metadata: with_source_location(metadata)
|
|
190
228
|
)
|
|
191
229
|
)
|
|
192
230
|
end
|
|
193
231
|
|
|
232
|
+
def remote(name, contract:, node:, inputs:, timeout: 30, **metadata) # rubocop:disable Metrics/MethodLength
|
|
233
|
+
raise CompileError, "remote :#{name} requires inputs: Hash" unless inputs.is_a?(Hash)
|
|
234
|
+
raise CompileError, "remote :#{name} requires a contract: name" if contract.nil? || contract.to_s.strip.empty?
|
|
235
|
+
raise CompileError, "remote :#{name} requires a node: URL" if node.nil? || node.to_s.strip.empty?
|
|
236
|
+
|
|
237
|
+
add_node(
|
|
238
|
+
Model::RemoteNode.new(
|
|
239
|
+
id: next_id,
|
|
240
|
+
name: name.to_sym,
|
|
241
|
+
contract_name: contract.to_s,
|
|
242
|
+
node_url: node.to_s,
|
|
243
|
+
input_mapping: inputs,
|
|
244
|
+
timeout: timeout,
|
|
245
|
+
path: scoped_path(name),
|
|
246
|
+
metadata: with_source_location(metadata)
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def await(name, event:, **metadata)
|
|
252
|
+
add_node(
|
|
253
|
+
Model::AwaitNode.new(
|
|
254
|
+
id: next_id,
|
|
255
|
+
name: name.to_sym,
|
|
256
|
+
path: scoped_path(name),
|
|
257
|
+
event_name: event,
|
|
258
|
+
metadata: with_source_location(metadata)
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
194
263
|
def compile
|
|
195
|
-
Compiler::GraphCompiler.call(
|
|
264
|
+
Compiler::GraphCompiler.call(
|
|
265
|
+
Model::Graph.new(
|
|
266
|
+
name: @name,
|
|
267
|
+
nodes: @nodes,
|
|
268
|
+
metadata: { correlation_keys: @correlation_keys || [] }
|
|
269
|
+
)
|
|
270
|
+
)
|
|
196
271
|
end
|
|
197
272
|
|
|
198
273
|
private
|
|
@@ -254,6 +329,32 @@ module Igniter
|
|
|
254
329
|
end
|
|
255
330
|
end
|
|
256
331
|
|
|
332
|
+
def extract_projected_value(source, key:, dig:, default:, node_name:)
|
|
333
|
+
if key != UNDEFINED_PROJECT_OPTION
|
|
334
|
+
return fetch_project_value(source, key, default, node_name)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
current = source
|
|
338
|
+
Array(dig).each do |part|
|
|
339
|
+
current = fetch_project_value(current, part, default, node_name)
|
|
340
|
+
end
|
|
341
|
+
current
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def fetch_project_value(source, part, default, node_name)
|
|
345
|
+
if source.is_a?(Hash)
|
|
346
|
+
return source.fetch(part) if source.key?(part)
|
|
347
|
+
return source.fetch(part.to_s) if source.key?(part.to_s)
|
|
348
|
+
return source.fetch(part.to_sym) if source.key?(part.to_sym)
|
|
349
|
+
elsif source.respond_to?(part)
|
|
350
|
+
return source.public_send(part)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
return default unless default == UNDEFINED_PROJECT_OPTION
|
|
354
|
+
|
|
355
|
+
raise ResolutionError, "project :#{node_name} could not extract #{part.inspect}"
|
|
356
|
+
end
|
|
357
|
+
|
|
257
358
|
def scoped_path(name)
|
|
258
359
|
return name.to_s if @scope_stack.empty?
|
|
259
360
|
|
data/lib/igniter/errors.rb
CHANGED
|
@@ -29,13 +29,18 @@ module Igniter
|
|
|
29
29
|
context[:source_location]
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def execution_id
|
|
33
|
+
context[:execution_id]
|
|
34
|
+
end
|
|
35
|
+
|
|
32
36
|
private
|
|
33
37
|
|
|
34
|
-
def format_message(message, context)
|
|
38
|
+
def format_message(message, context) # rubocop:disable Metrics/AbcSize
|
|
35
39
|
details = []
|
|
36
40
|
details << "graph=#{context[:graph]}" if context[:graph]
|
|
37
41
|
details << "node=#{context[:node_name]}" if context[:node_name]
|
|
38
42
|
details << "path=#{context[:node_path]}" if context[:node_path]
|
|
43
|
+
details << "execution=#{context[:execution_id]}" if context[:execution_id]
|
|
39
44
|
details << "location=#{context[:source_location]}" if context[:source_location]
|
|
40
45
|
|
|
41
46
|
return message if details.empty?
|
|
@@ -39,14 +39,18 @@ module Igniter
|
|
|
39
39
|
if node.kind == :branch
|
|
40
40
|
cases = node.cases.map { |entry| "#{entry[:match].inspect}:#{entry[:contract].name || 'AnonymousContract'}" }
|
|
41
41
|
line += " selector=#{node.selector_dependency}"
|
|
42
|
+
line += " depends_on=#{node.context_dependencies.join(',')}" if node.context_dependencies.any?
|
|
42
43
|
line += " cases=#{cases.join('|')}"
|
|
43
44
|
line += " default=#{node.default_contract.name || 'AnonymousContract'}"
|
|
45
|
+
line += " mapper=#{node.input_mapper}" if node.input_mapper?
|
|
44
46
|
end
|
|
45
47
|
if node.kind == :collection
|
|
46
48
|
line += " with=#{node.source_dependency}"
|
|
49
|
+
line += " depends_on=#{node.context_dependencies.join(',')}" if node.context_dependencies.any?
|
|
47
50
|
line += " each=#{node.contract_class.name || 'AnonymousContract'}"
|
|
48
51
|
line += " key=#{node.key_name}"
|
|
49
52
|
line += " mode=#{node.mode}"
|
|
53
|
+
line += " mapper=#{node.input_mapper}" if node.input_mapper?
|
|
50
54
|
end
|
|
51
55
|
lines << line
|
|
52
56
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
class Config
|
|
6
|
+
class OllamaConfig
|
|
7
|
+
attr_accessor :base_url, :default_model, :timeout
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@base_url = "http://localhost:11434"
|
|
11
|
+
@default_model = "llama3.2"
|
|
12
|
+
@timeout = 120
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class AnthropicConfig
|
|
17
|
+
attr_accessor :api_key, :base_url, :default_model, :timeout
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@api_key = ENV["ANTHROPIC_API_KEY"]
|
|
21
|
+
@base_url = "https://api.anthropic.com"
|
|
22
|
+
@default_model = "claude-sonnet-4-6"
|
|
23
|
+
@timeout = 120
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class OpenAIConfig
|
|
28
|
+
attr_accessor :api_key, :base_url, :default_model, :timeout
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@api_key = ENV["OPENAI_API_KEY"]
|
|
32
|
+
@base_url = "https://api.openai.com"
|
|
33
|
+
@default_model = "gpt-4o"
|
|
34
|
+
@timeout = 120
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
PROVIDERS = %i[ollama anthropic openai].freeze
|
|
39
|
+
|
|
40
|
+
attr_accessor :default_provider
|
|
41
|
+
attr_reader :providers
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@default_provider = :ollama
|
|
45
|
+
@providers = {
|
|
46
|
+
ollama: OllamaConfig.new,
|
|
47
|
+
anthropic: AnthropicConfig.new,
|
|
48
|
+
openai: OpenAIConfig.new
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ollama
|
|
53
|
+
@providers[:ollama]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def anthropic
|
|
57
|
+
@providers[:anthropic]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def openai
|
|
61
|
+
@providers[:openai]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def provider_config(name)
|
|
65
|
+
@providers.fetch(name.to_sym) { raise ArgumentError, "Unknown LLM provider: #{name}. Available: #{PROVIDERS.inspect}" }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
# Manages a conversation message history for multi-turn LLM interactions.
|
|
6
|
+
# Immutable — each operation returns a new Context.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# ctx = Igniter::LLM::Context.empty(system: "You are a helpful assistant.")
|
|
10
|
+
# ctx = ctx.append_user("What is Ruby?")
|
|
11
|
+
# ctx = ctx.append_assistant("Ruby is a dynamic language...")
|
|
12
|
+
# ctx.messages # => [{role: :system, ...}, {role: :user, ...}, {role: :assistant, ...}]
|
|
13
|
+
class Context
|
|
14
|
+
attr_reader :messages
|
|
15
|
+
|
|
16
|
+
def initialize(messages = [])
|
|
17
|
+
@messages = messages.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.empty(system: nil)
|
|
21
|
+
initial = system ? [{ role: :system, content: system }] : []
|
|
22
|
+
new(initial)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_h(data)
|
|
26
|
+
msgs = (data[:messages] || data["messages"] || []).map do |m|
|
|
27
|
+
{ role: (m[:role] || m["role"]).to_sym, content: (m[:content] || m["content"]).to_s }
|
|
28
|
+
end
|
|
29
|
+
new(msgs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def append_user(content)
|
|
33
|
+
append(role: :user, content: content)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def append_assistant(content)
|
|
37
|
+
append(role: :assistant, content: content)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def append_tool_result(tool_name, content)
|
|
41
|
+
append(role: :tool, content: content.to_s, name: tool_name.to_s)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def append(message)
|
|
45
|
+
self.class.new(@messages + [message.transform_keys(&:to_sym)])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def with_system(content)
|
|
49
|
+
existing = @messages.reject { |m| m[:role] == :system }
|
|
50
|
+
self.class.new([{ role: :system, content: content }] + existing)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def last_assistant_message
|
|
54
|
+
@messages.reverse.find { |m| m[:role] == :assistant }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def length
|
|
58
|
+
@messages.length
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def empty?
|
|
62
|
+
@messages.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_a
|
|
66
|
+
@messages.map { |m| { "role" => m[:role].to_s, "content" => m[:content].to_s } }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
{ messages: @messages }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
# Base class for LLM-powered compute nodes.
|
|
6
|
+
#
|
|
7
|
+
# Subclass and override #call(**inputs) to build prompts and get completions.
|
|
8
|
+
# Use the #complete and #chat helper methods inside #call.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# class DocumentSummarizer < Igniter::LLM::Executor
|
|
12
|
+
# provider :ollama
|
|
13
|
+
# model "llama3.2"
|
|
14
|
+
# system_prompt "You are a concise technical writer."
|
|
15
|
+
#
|
|
16
|
+
# def call(document:)
|
|
17
|
+
# complete("Summarize this in 3 bullet points:\n\n#{document}")
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# class OrderContract < Igniter::Contract
|
|
22
|
+
# define do
|
|
23
|
+
# input :document
|
|
24
|
+
# compute :summary, depends_on: :document, with: DocumentSummarizer
|
|
25
|
+
# output :summary
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
class Executor < Igniter::Executor
|
|
29
|
+
class << self
|
|
30
|
+
def provider(name = nil)
|
|
31
|
+
return @provider || Igniter::LLM.config.default_provider if name.nil?
|
|
32
|
+
|
|
33
|
+
@provider = name.to_sym
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model(name = nil)
|
|
37
|
+
return @model || provider_config.default_model if name.nil?
|
|
38
|
+
|
|
39
|
+
@model = name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def system_prompt(text = nil)
|
|
43
|
+
return @system_prompt if text.nil?
|
|
44
|
+
|
|
45
|
+
@system_prompt = text
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def temperature(val = nil)
|
|
49
|
+
return @temperature if val.nil?
|
|
50
|
+
|
|
51
|
+
@temperature = val
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tools(*tool_definitions)
|
|
55
|
+
return @tools || [] if tool_definitions.empty?
|
|
56
|
+
|
|
57
|
+
@tools = tool_definitions.flatten
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inherited(subclass)
|
|
61
|
+
super
|
|
62
|
+
subclass.instance_variable_set(:@provider, @provider)
|
|
63
|
+
subclass.instance_variable_set(:@model, @model)
|
|
64
|
+
subclass.instance_variable_set(:@system_prompt, @system_prompt)
|
|
65
|
+
subclass.instance_variable_set(:@temperature, @temperature)
|
|
66
|
+
subclass.instance_variable_set(:@tools, @tools&.dup)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def provider_config
|
|
72
|
+
Igniter::LLM.provider_instance(provider).instance_of?(Class) ? nil : Igniter::LLM.config.provider_config(provider)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
Igniter::LLM.config.ollama
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :last_usage, :last_context
|
|
79
|
+
|
|
80
|
+
# Subclasses override this method. Use #complete or #chat inside.
|
|
81
|
+
def call(**_inputs)
|
|
82
|
+
raise NotImplementedError, "#{self.class.name}#call must be implemented"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
protected
|
|
86
|
+
|
|
87
|
+
# Single-turn completion. Builds a simple user message from prompt.
|
|
88
|
+
def complete(prompt, context: nil)
|
|
89
|
+
messages = build_messages(prompt: prompt, context: context)
|
|
90
|
+
response = provider_instance.chat(
|
|
91
|
+
messages: messages,
|
|
92
|
+
model: self.class.model,
|
|
93
|
+
**completion_options
|
|
94
|
+
)
|
|
95
|
+
@last_usage = provider_instance.last_usage
|
|
96
|
+
@last_context = track_context(context, prompt, response[:content])
|
|
97
|
+
response[:content]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Multi-turn chat with a Context object or raw messages array.
|
|
101
|
+
def chat(context:)
|
|
102
|
+
messages = context.is_a?(Context) ? context.to_a : Array(context)
|
|
103
|
+
response = provider_instance.chat(
|
|
104
|
+
messages: messages,
|
|
105
|
+
model: self.class.model,
|
|
106
|
+
**completion_options
|
|
107
|
+
)
|
|
108
|
+
@last_usage = provider_instance.last_usage
|
|
109
|
+
response[:content]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Tool-use call. Returns DeferredResult if the LLM wants to call a tool.
|
|
113
|
+
def complete_with_tools(prompt, context: nil) # rubocop:disable Metrics/MethodLength
|
|
114
|
+
messages = build_messages(prompt: prompt, context: context)
|
|
115
|
+
response = provider_instance.chat(
|
|
116
|
+
messages: messages,
|
|
117
|
+
model: self.class.model,
|
|
118
|
+
tools: self.class.tools,
|
|
119
|
+
**completion_options
|
|
120
|
+
)
|
|
121
|
+
@last_usage = provider_instance.last_usage
|
|
122
|
+
|
|
123
|
+
if response[:tool_calls].any?
|
|
124
|
+
defer(payload: { tool_calls: response[:tool_calls], messages: messages })
|
|
125
|
+
else
|
|
126
|
+
response[:content]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def provider_instance
|
|
133
|
+
@provider_instance ||= Igniter::LLM.provider_instance(self.class.provider)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_messages(prompt:, context: nil)
|
|
137
|
+
base = []
|
|
138
|
+
base << { role: "system", content: self.class.system_prompt } if self.class.system_prompt
|
|
139
|
+
|
|
140
|
+
if context.is_a?(Context)
|
|
141
|
+
base + context.to_a + [{ role: "user", content: prompt }]
|
|
142
|
+
else
|
|
143
|
+
base + [{ role: "user", content: prompt }]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def completion_options
|
|
148
|
+
opts = {}
|
|
149
|
+
opts[:temperature] = self.class.temperature if self.class.temperature
|
|
150
|
+
opts
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def track_context(existing, user_prompt, assistant_reply)
|
|
154
|
+
ctx = existing.is_a?(Context) ? existing : Context.empty(system: self.class.system_prompt)
|
|
155
|
+
ctx.append_user(user_prompt).append_assistant(assistant_reply)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Igniter
|
|
8
|
+
module LLM
|
|
9
|
+
module Providers
|
|
10
|
+
# Anthropic Claude provider.
|
|
11
|
+
# Requires ANTHROPIC_API_KEY environment variable or explicit api_key:.
|
|
12
|
+
#
|
|
13
|
+
# API docs: https://docs.anthropic.com/en/api/messages
|
|
14
|
+
#
|
|
15
|
+
# Key differences from OpenAI-compatible providers:
|
|
16
|
+
# - system prompt is a top-level field, not a message
|
|
17
|
+
# - response content is an array of typed blocks (text, tool_use)
|
|
18
|
+
# - tool definitions use input_schema instead of parameters
|
|
19
|
+
class Anthropic < Base # rubocop:disable Metrics/ClassLength
|
|
20
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
21
|
+
API_BASE = "https://api.anthropic.com"
|
|
22
|
+
|
|
23
|
+
def initialize(api_key: ENV["ANTHROPIC_API_KEY"], base_url: API_BASE, timeout: 120)
|
|
24
|
+
super()
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@base_url = base_url.chomp("/")
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Send a chat completion request.
|
|
31
|
+
# Extracts any system message from the messages array automatically.
|
|
32
|
+
def chat(messages:, model:, tools: [], **options) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
33
|
+
validate_api_key!
|
|
34
|
+
|
|
35
|
+
system_content, chat_messages = extract_system(messages)
|
|
36
|
+
|
|
37
|
+
body = {
|
|
38
|
+
model: model,
|
|
39
|
+
max_tokens: options.delete(:max_tokens) || 4096,
|
|
40
|
+
messages: chat_messages
|
|
41
|
+
}
|
|
42
|
+
body[:system] = system_content if system_content
|
|
43
|
+
body[:tools] = normalize_tools(tools) if tools.any?
|
|
44
|
+
body[:temperature] = options[:temperature] if options[:temperature]
|
|
45
|
+
body[:top_p] = options[:top_p] if options[:top_p]
|
|
46
|
+
|
|
47
|
+
response = post("/v1/messages", body)
|
|
48
|
+
parse_response(response)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def extract_system(messages)
|
|
54
|
+
system_msg = messages.find { |m| (m[:role] || m["role"]).to_s == "system" }
|
|
55
|
+
other = messages.reject { |m| (m[:role] || m["role"]).to_s == "system" }
|
|
56
|
+
system_content = system_msg ? (system_msg[:content] || system_msg["content"]) : nil
|
|
57
|
+
[system_content, normalize_messages(other)]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_response(response) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
61
|
+
content_blocks = response.fetch("content", [])
|
|
62
|
+
|
|
63
|
+
text_content = content_blocks
|
|
64
|
+
.select { |b| b["type"] == "text" }
|
|
65
|
+
.map { |b| b["text"] }
|
|
66
|
+
.join
|
|
67
|
+
|
|
68
|
+
tool_calls = content_blocks
|
|
69
|
+
.select { |b| b["type"] == "tool_use" }
|
|
70
|
+
.map do |b|
|
|
71
|
+
{ name: b["name"].to_s, arguments: (b["input"] || {}).transform_keys(&:to_sym) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
usage = response.fetch("usage", {})
|
|
75
|
+
record_usage(
|
|
76
|
+
prompt_tokens: usage["input_tokens"] || 0,
|
|
77
|
+
completion_tokens: usage["output_tokens"] || 0
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
{ role: :assistant, content: text_content, tool_calls: tool_calls }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_messages(messages)
|
|
84
|
+
messages.map do |m|
|
|
85
|
+
{ "role" => (m[:role] || m["role"]).to_s, "content" => (m[:content] || m["content"]).to_s }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_tools(tools)
|
|
90
|
+
tools.map do |tool|
|
|
91
|
+
{
|
|
92
|
+
"name" => tool[:name].to_s,
|
|
93
|
+
"description" => tool[:description].to_s,
|
|
94
|
+
"input_schema" => tool.fetch(:parameters) { { "type" => "object", "properties" => {} } }
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def post(path, body) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
100
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
101
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
102
|
+
http.use_ssl = uri.scheme == "https"
|
|
103
|
+
http.read_timeout = @timeout
|
|
104
|
+
http.open_timeout = 10
|
|
105
|
+
|
|
106
|
+
request = Net::HTTP::Post.new(uri.path, headers)
|
|
107
|
+
request.body = JSON.generate(body)
|
|
108
|
+
|
|
109
|
+
response = http.request(request)
|
|
110
|
+
handle_response(response)
|
|
111
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
|
|
112
|
+
raise Igniter::LLM::ProviderError, "Cannot connect to Anthropic API: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def headers
|
|
116
|
+
{
|
|
117
|
+
"Content-Type" => "application/json",
|
|
118
|
+
"x-api-key" => @api_key.to_s,
|
|
119
|
+
"anthropic-version" => ANTHROPIC_VERSION
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def handle_response(response) # rubocop:disable Metrics/MethodLength
|
|
124
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
125
|
+
body = begin
|
|
126
|
+
JSON.parse(response.body)
|
|
127
|
+
rescue StandardError
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
error_msg = body.dig("error", "message") || response.body.to_s.slice(0, 200)
|
|
131
|
+
raise Igniter::LLM::ProviderError, "Anthropic API error #{response.code}: #{error_msg}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
JSON.parse(response.body)
|
|
135
|
+
rescue JSON::ParserError => e
|
|
136
|
+
raise Igniter::LLM::ProviderError, "Anthropic returned invalid JSON: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_api_key!
|
|
140
|
+
return if @api_key && !@api_key.empty?
|
|
141
|
+
|
|
142
|
+
raise Igniter::LLM::ConfigurationError,
|
|
143
|
+
"Anthropic API key not configured. Set ANTHROPIC_API_KEY or pass api_key: to the provider."
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|