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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +2 -2
  4. data/docs/API_V2.md +58 -0
  5. data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
  6. data/examples/README.md +3 -0
  7. data/examples/distributed_workflow.rb +52 -0
  8. data/examples/ringcentral_routing.rb +26 -35
  9. data/lib/igniter/compiler/compiled_graph.rb +20 -0
  10. data/lib/igniter/compiler/validation_pipeline.rb +3 -1
  11. data/lib/igniter/compiler/validators/await_validator.rb +53 -0
  12. data/lib/igniter/compiler/validators/dependencies_validator.rb +43 -1
  13. data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
  14. data/lib/igniter/compiler.rb +2 -0
  15. data/lib/igniter/contract.rb +75 -8
  16. data/lib/igniter/diagnostics/report.rb +102 -3
  17. data/lib/igniter/dsl/contract_builder.rb +109 -8
  18. data/lib/igniter/errors.rb +6 -1
  19. data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -0
  20. data/lib/igniter/integrations/llm/config.rb +69 -0
  21. data/lib/igniter/integrations/llm/context.rb +74 -0
  22. data/lib/igniter/integrations/llm/executor.rb +159 -0
  23. data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
  24. data/lib/igniter/integrations/llm/providers/base.rb +33 -0
  25. data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
  26. data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
  27. data/lib/igniter/integrations/llm.rb +59 -0
  28. data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
  29. data/lib/igniter/integrations/rails/contract_job.rb +76 -0
  30. data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
  31. data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
  32. data/lib/igniter/integrations/rails/railtie.rb +25 -0
  33. data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
  34. data/lib/igniter/integrations/rails.rb +12 -0
  35. data/lib/igniter/model/await_node.rb +21 -0
  36. data/lib/igniter/model/branch_node.rb +9 -3
  37. data/lib/igniter/model/collection_node.rb +9 -3
  38. data/lib/igniter/model/remote_node.rb +26 -0
  39. data/lib/igniter/model.rb +2 -0
  40. data/lib/igniter/runtime/execution.rb +2 -2
  41. data/lib/igniter/runtime/input_validator.rb +5 -3
  42. data/lib/igniter/runtime/resolver.rb +91 -8
  43. data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
  44. data/lib/igniter/runtime/stores/file_store.rb +50 -2
  45. data/lib/igniter/runtime/stores/memory_store.rb +55 -2
  46. data/lib/igniter/runtime/stores/redis_store.rb +13 -1
  47. data/lib/igniter/server/client.rb +123 -0
  48. data/lib/igniter/server/config.rb +27 -0
  49. data/lib/igniter/server/handlers/base.rb +105 -0
  50. data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
  51. data/lib/igniter/server/handlers/event_handler.rb +28 -0
  52. data/lib/igniter/server/handlers/execute_handler.rb +37 -0
  53. data/lib/igniter/server/handlers/health_handler.rb +32 -0
  54. data/lib/igniter/server/handlers/status_handler.rb +27 -0
  55. data/lib/igniter/server/http_server.rb +109 -0
  56. data/lib/igniter/server/rack_app.rb +35 -0
  57. data/lib/igniter/server/registry.rb +56 -0
  58. data/lib/igniter/server/router.rb +75 -0
  59. data/lib/igniter/server.rb +67 -0
  60. data/lib/igniter/version.rb +1 -1
  61. data/lib/igniter.rb +4 -0
  62. 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:, **metadata, &block)
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 an `inputs:` hash" unless inputs.is_a?(Hash)
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(Model::Graph.new(name: @name, nodes: @nodes))
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
 
@@ -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