ruby_llm_mesh 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: 0f335121a2c16a9ca428f4d5110b69f6caf999d4534358cd6c68d668382c9130
4
+ data.tar.gz: ec867f6d775e2ba68d5922136ebc7e6a3af52dbf213fad8016c0f613e92a25ca
5
+ SHA512:
6
+ metadata.gz: 4c094b33bdaa1f55997d3f3719d25d4591b0daeaf23930e6868dab33b65a745560fc6bc43aadd711303d3b8cbb12ce4856f149825104d8d96401b2818c340ec7
7
+ data.tar.gz: 1b99d7efe252cacc5074257ac8c950d5bd6e84d68beb5a15edd3bc05c64c0499283a98befdfa74a09a2494182e6d68690a1ab69710a799a08bef9b924fd087c1
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-08-07
9
+
10
+ ### Added
11
+
12
+ - Unified multi-provider routing via `RubyLlmMesh.complete` / `AiAgentRouter.complete`
13
+ - Providers: OpenAI, Anthropic, and OpenAI-compatible local nodes (Ollama, LM Studio, vLLM, etc.)
14
+ - Per-provider circuit breaker with configurable failure threshold and reset timeout
15
+ - Automatic fallback across a provider ladder
16
+ - RAG helpers: text chunker, bag-of-words embeddings, tool schema adapters
17
+ - Optional ActiveRecord `acts_as_ai_agent` for memory, semantic cache, and audit logging
18
+ - Rails railtie that loads only when Rails is present
19
+
20
+ [0.1.0]: https://github.com/theworker02/ruby_llm_mesh/releases/tag/v0.1.0
@@ -0,0 +1,63 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies within all community spaces, and also applies when
49
+ an individual is officially representing the community in public spaces.
50
+
51
+ ## Enforcement
52
+
53
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
54
+ reported by opening a GitHub issue or contacting the maintainers via the
55
+ repository. All complaints will be reviewed and investigated promptly and fairly.
56
+
57
+ ## Attribution
58
+
59
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
60
+ version 2.1, available at
61
+ https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
62
+
63
+ [homepage]: https://www.contributor-covenant.org
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,39 @@
1
+ # Contributing to ruby_llm_mesh
2
+
3
+ Thanks for your interest in improving this gem.
4
+
5
+ ## Development setup
6
+
7
+ ```bash
8
+ git clone https://github.com/theworker02/ruby_llm_mesh.git
9
+ cd ruby_llm_mesh
10
+ bundle install
11
+ bundle exec rake test
12
+ ```
13
+
14
+ ## Guidelines
15
+
16
+ 1. Open an issue before large API changes.
17
+ 2. Prefer focused pull requests with tests for new behavior.
18
+ 3. Keep the public DSL (`AiAgentRouter` / `RubyLlmMesh`) stable unless a major version bump is intentional.
19
+ 4. Do not commit API keys, credentials, or local `.env` files.
20
+ 5. Follow the existing code style (frozen string literals, clear error types).
21
+
22
+ ## Testing
23
+
24
+ Unit tests must not require live provider credentials. Stub HTTP with WebMock or inject stub providers.
25
+
26
+ ```bash
27
+ bundle exec rake test
28
+ ```
29
+
30
+ ## Releases
31
+
32
+ Maintainers cut releases by tagging `vX.Y.Z` on `main`, which triggers the
33
+ RubyGems Trusted Publisher workflow (`.github/workflows/push_gem.yml`).
34
+
35
+ See the README for Trusted Publisher setup details.
36
+
37
+ ## Code of Conduct
38
+
39
+ By participating, you agree to uphold our [Code of Conduct](CODE_OF_CONDUCT.md).
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "minitest", "~> 5.25"
8
+ gem "rake", "~> 13.2"
9
+ gem "webmock", "~> 3.24"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 theworker02
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,177 @@
1
+ <p align="center">
2
+ <img src="assets/logo.png" alt="ruby_llm_mesh logo" width="160" />
3
+ </p>
4
+
5
+ <h1 align="center">ruby_llm_mesh</h1>
6
+
7
+ <p align="center">
8
+ Unified multi-provider AI routing for Ruby &amp; Rails<br/>
9
+ <code>AiAgentRouter</code> · circuit breaking · fallback · RAG helpers
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://rubygems.org/gems/ruby_llm_mesh"><img src="https://img.shields.io/gem/v/ruby_llm_mesh?color=9B1B30" alt="Gem Version" /></a>
14
+ <a href="https://github.com/theworker02/ruby_llm_mesh/actions/workflows/ci.yml"><img src="https://github.com/theworker02/ruby_llm_mesh/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
15
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-E85D4C" alt="License: MIT" /></a>
16
+ <a href="https://theworker02.github.io/ruby_llm_mesh/"><img src="https://img.shields.io/badge/docs-GitHub%20Pages-1A1A1A" alt="GitHub Pages" /></a>
17
+ </p>
18
+
19
+ **Official RubyGems page:** [https://rubygems.org/gems/ruby_llm_mesh](https://rubygems.org/gems/ruby_llm_mesh)
20
+
21
+ ## What it does
22
+
23
+ `ruby_llm_mesh` routes prompts across OpenAI, Anthropic, and local OpenAI-compatible nodes with a single DSL. When a provider fails or its circuit is open, the router can fall through the rest of the ladder. Lightweight RAG helpers and an optional ActiveRecord mixin are included for Rails apps.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ gem install ruby_llm_mesh
29
+ ```
30
+
31
+ Or in a Gemfile:
32
+
33
+ ```ruby
34
+ gem "ruby_llm_mesh"
35
+ ```
36
+
37
+ Then `bundle install`.
38
+
39
+ ## Quickstart
40
+
41
+ ```ruby
42
+ require "ruby_llm_mesh"
43
+
44
+ AiAgentRouter.configure do |c|
45
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
46
+ c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
47
+ c.local_node_base_url = "http://127.0.0.1:11434"
48
+ c.default_providers = %i[anthropic openai local_node]
49
+ c.fallback = true
50
+ end
51
+
52
+ response = AiAgentRouter.complete(
53
+ prompt: "Summarize circuit breakers in one sentence.",
54
+ providers: %i[anthropic openai local_node],
55
+ fallback: true
56
+ )
57
+
58
+ puts response.content
59
+ puts response.provider # => :anthropic (or next healthy provider)
60
+ puts response.fallback_used # => true if a later provider was used
61
+ ```
62
+
63
+ `RubyLlmMesh.complete` is the same entry point; `AiAgentRouter` is the public product alias.
64
+
65
+ ## Configuration
66
+
67
+ | Option | Default | Description |
68
+ |--------|---------|-------------|
69
+ | `default_providers` | `%i[openai anthropic local_node]` | Provider ladder order |
70
+ | `fallback` | `true` | Continue to next provider on failure |
71
+ | `timeout` | `30` | HTTP open/read timeout (seconds) |
72
+ | `openai_api_key` / `openai_base_url` / `openai_model` | env / `https://api.openai.com/v1` / `gpt-4o-mini` | OpenAI settings |
73
+ | `anthropic_api_key` / `anthropic_base_url` / `anthropic_model` | env / `https://api.anthropic.com` / `claude-sonnet-4-5` | Anthropic settings |
74
+ | `local_node_base_url` / `local_node_model` | `http://127.0.0.1:11434` / `llama3.2` | Local node settings |
75
+ | `circuit_failure_threshold` | `3` | Failures before opening a circuit |
76
+ | `circuit_reset_timeout` | `60` | Seconds before half-open retry |
77
+ | `logger` | `nil` | Object responding to `#info` / `#warn` / `#error` or `#call` |
78
+
79
+ Environment variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LOCAL_NODE_BASE_URL`, etc.) are read automatically when present.
80
+
81
+ ## Providers
82
+
83
+ - **`:openai`** — Chat Completions API
84
+ - **`:anthropic`** — Messages API
85
+ - **`:local_node`** — OpenAI-compatible `/v1/chat/completions`, with a fallback to Ollama’s `/api/chat` on 404
86
+
87
+ Unknown providers raise `RubyLlmMesh::ProviderError` and are skipped in the ladder when fallback is enabled.
88
+
89
+ ## Circuit breaker & fallback
90
+
91
+ Each provider has an independent circuit. After `circuit_failure_threshold` consecutive failures (or a failure while half-open), the circuit opens for `circuit_reset_timeout` seconds. Open circuits are skipped; the router continues down the ladder when `fallback: true`.
92
+
93
+ If every provider fails, `RubyLlmMesh::AllProvidersFailedError` is raised with a per-provider error map.
94
+
95
+ ```ruby
96
+ breaker = RubyLlmMesh::Router.circuit_breaker
97
+ breaker.state_for(:openai) # => :closed | :open | :half_open
98
+ RubyLlmMesh::Router.reset_circuit_breaker!
99
+ ```
100
+
101
+ ## RAG helpers
102
+
103
+ ```ruby
104
+ chunks = RubyLlmMesh::Rag::Chunker.new(size: 500, overlap: 50).chunk(long_text)
105
+
106
+ embedder = RubyLlmMesh::Rag::Embeddings.new(dimensions: 256)
107
+ hits = embedder.top_k("billing refunds", documents, k: 3)
108
+
109
+ tool = RubyLlmMesh::Rag::Tools.define(
110
+ name: "lookup_order",
111
+ description: "Fetch an order by id",
112
+ parameters: { order_id: { type: "string" } },
113
+ required: [:order_id]
114
+ )
115
+ openai_tools = RubyLlmMesh::Rag::Tools.for_openai([tool])
116
+ ```
117
+
118
+ The built-in embedder is a local bag-of-words helper for prototyping. Swap in a production embedding API when you need higher quality vectors.
119
+
120
+ ## Rails: `acts_as_ai_agent`
121
+
122
+ The railtie loads only when Rails is present. On ActiveRecord models:
123
+
124
+ ```ruby
125
+ class Conversation < ApplicationRecord
126
+ acts_as_ai_agent
127
+ # expects JSON/text columns: messages, semantic_cache, ai_audits (configurable)
128
+ end
129
+
130
+ conversation = Conversation.create!
131
+ response = conversation.ai_complete("What did we discuss last?", providers: %i[openai])
132
+ ```
133
+
134
+ Hooks provide conversational memory, a simple prompt→response cache, and an audit trail. Attribute names are configurable via `acts_as_ai_agent(memory_attribute:, cache_attribute:, audit_attribute:)`.
135
+
136
+ ## Branding
137
+
138
+ Primary colors from the project logo:
139
+
140
+ | Token | Hex |
141
+ |-------|-----|
142
+ | Ruby | `#9B1B30` |
143
+ | Coral | `#E85D4C` |
144
+ | Charcoal | `#1A1A1A` |
145
+
146
+ Docs site: [https://theworker02.github.io/ruby_llm_mesh/](https://theworker02.github.io/ruby_llm_mesh/)
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ bundle install
152
+ bundle exec rake test
153
+ ```
154
+
155
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
156
+
157
+ ## Trusted publishing (RubyGems)
158
+
159
+ Releases are intended to publish via [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) using `.github/workflows/push_gem.yml` on tags matching `v*`.
160
+
161
+ Configure a **pending trusted publisher** (first release) or trusted publisher at:
162
+
163
+ [https://rubygems.org/profile/oidc_pending_trusted_publishers](https://rubygems.org/profile/oidc_pending_trusted_publishers)
164
+
165
+ | Field | Value |
166
+ |-------|-------|
167
+ | Gem name | `ruby_llm_mesh` |
168
+ | GitHub repository owner | `theworker02` |
169
+ | GitHub repository name | `ruby_llm_mesh` |
170
+ | Workflow filename | `push_gem.yml` |
171
+ | Environment name | `release` |
172
+
173
+ Also create a GitHub Environment named `release` on the repository (Settings → Environments). Pushing tag `v0.1.0` (or later) runs the workflow with `id-token: write` and publishes via `rubygems/release-gem`.
174
+
175
+ ## License
176
+
177
+ MIT — see [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.warning = true
11
+ end
12
+
13
+ task default: :test
data/assets/logo.png ADDED
Binary file
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlmMesh
4
+ module ActiveRecord
5
+ # Optional Rails hooks for conversational memory, semantic caching, and audit logs.
6
+ #
7
+ # class Conversation < ApplicationRecord
8
+ # acts_as_ai_agent
9
+ # end
10
+ module ActsAsAiAgent
11
+ def acts_as_ai_agent(memory_attribute: :messages, cache_attribute: :semantic_cache, audit_attribute: :ai_audits)
12
+ class_attribute :ai_agent_memory_attribute, instance_writer: false
13
+ class_attribute :ai_agent_cache_attribute, instance_writer: false
14
+ class_attribute :ai_agent_audit_attribute, instance_writer: false
15
+
16
+ self.ai_agent_memory_attribute = memory_attribute
17
+ self.ai_agent_cache_attribute = cache_attribute
18
+ self.ai_agent_audit_attribute = audit_attribute
19
+
20
+ include InstanceMethods
21
+ end
22
+
23
+ module InstanceMethods
24
+ def ai_complete(prompt, **options)
25
+ cached = ai_semantic_lookup(prompt)
26
+ return cached if cached
27
+
28
+ memory = ai_memory
29
+ system = options.delete(:system)
30
+ system = [system, "Conversation memory:\n#{memory.join("\n")}"].compact.join("\n\n") unless memory.empty?
31
+
32
+ response = RubyLlmMesh.complete(prompt: prompt, system: system, **options)
33
+ ai_remember!(role: "user", content: prompt)
34
+ ai_remember!(role: "assistant", content: response.content, provider: response.provider)
35
+ ai_cache!(prompt, response)
36
+ ai_audit!(prompt, response)
37
+ response
38
+ end
39
+
40
+ def ai_memory
41
+ raw = read_ai_json(self.class.ai_agent_memory_attribute)
42
+ Array(raw)
43
+ end
44
+
45
+ def ai_remember!(entry)
46
+ memory = ai_memory
47
+ memory << entry.transform_keys(&:to_s)
48
+ write_ai_json(self.class.ai_agent_memory_attribute, memory)
49
+ save if respond_to?(:save)
50
+ end
51
+
52
+ def ai_clear_memory!
53
+ write_ai_json(self.class.ai_agent_memory_attribute, [])
54
+ save if respond_to?(:save)
55
+ end
56
+
57
+ def ai_semantic_lookup(prompt)
58
+ cache = read_ai_json(self.class.ai_agent_cache_attribute)
59
+ return nil unless cache.is_a?(Hash)
60
+
61
+ entry = cache[prompt.to_s]
62
+ return nil unless entry
63
+
64
+ RubyLlmMesh::Response.new(
65
+ content: entry["content"],
66
+ provider: (entry["provider"] || :cache).to_sym,
67
+ model: entry["model"],
68
+ usage: entry["usage"] || {},
69
+ latency_ms: 0,
70
+ fallback_used: false
71
+ )
72
+ end
73
+
74
+ def ai_cache!(prompt, response)
75
+ cache = read_ai_json(self.class.ai_agent_cache_attribute)
76
+ cache = {} unless cache.is_a?(Hash)
77
+ cache[prompt.to_s] = {
78
+ "content" => response.content,
79
+ "provider" => response.provider.to_s,
80
+ "model" => response.model,
81
+ "usage" => response.usage
82
+ }
83
+ write_ai_json(self.class.ai_agent_cache_attribute, cache)
84
+ end
85
+
86
+ def ai_audit!(prompt, response)
87
+ audits = read_ai_json(self.class.ai_agent_audit_attribute)
88
+ audits = [] unless audits.is_a?(Array)
89
+ audits << {
90
+ "at" => Time.now.utc.iso8601,
91
+ "prompt" => prompt.to_s,
92
+ "provider" => response.provider.to_s,
93
+ "model" => response.model,
94
+ "latency_ms" => response.latency_ms,
95
+ "fallback_used" => response.fallback_used
96
+ }
97
+ write_ai_json(self.class.ai_agent_audit_attribute, audits)
98
+ end
99
+
100
+ private
101
+
102
+ def read_ai_json(attr)
103
+ return [] unless respond_to?(attr)
104
+
105
+ value = public_send(attr)
106
+ return value if value.is_a?(Array) || value.is_a?(Hash)
107
+ return JSON.parse(value) if value.is_a?(String) && !value.empty?
108
+
109
+ value.nil? ? nil : value
110
+ rescue JSON::ParserError
111
+ nil
112
+ end
113
+
114
+ def write_ai_json(attr, value)
115
+ return unless respond_to?("#{attr}=")
116
+
117
+ public_send("#{attr}=", value)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ if defined?(::ActiveRecord::Base)
125
+ ::ActiveRecord::Base.extend(RubyLlmMesh::ActiveRecord::ActsAsAiAgent)
126
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlmMesh
4
+ # Simple per-provider circuit breaker shared across the process.
5
+ class CircuitBreaker
6
+ OPEN = :open
7
+ CLOSED = :closed
8
+ HALF_OPEN = :half_open
9
+
10
+ def initialize(failure_threshold:, reset_timeout:)
11
+ @failure_threshold = failure_threshold
12
+ @reset_timeout = reset_timeout
13
+ @states = Hash.new { |h, k| h[k] = { failures: 0, opened_at: nil, state: CLOSED } }
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def allow?(provider)
18
+ @mutex.synchronize do
19
+ entry = @states[provider]
20
+ case entry[:state]
21
+ when CLOSED
22
+ true
23
+ when OPEN
24
+ if Time.now - entry[:opened_at] >= @reset_timeout
25
+ entry[:state] = HALF_OPEN
26
+ true
27
+ else
28
+ false
29
+ end
30
+ when HALF_OPEN
31
+ true
32
+ end
33
+ end
34
+ end
35
+
36
+ def record_success(provider)
37
+ @mutex.synchronize do
38
+ @states[provider] = { failures: 0, opened_at: nil, state: CLOSED }
39
+ end
40
+ end
41
+
42
+ def record_failure(provider)
43
+ @mutex.synchronize do
44
+ entry = @states[provider]
45
+ entry[:failures] += 1
46
+ if entry[:failures] >= @failure_threshold || entry[:state] == HALF_OPEN
47
+ entry[:state] = OPEN
48
+ entry[:opened_at] = Time.now
49
+ end
50
+ end
51
+ end
52
+
53
+ def state_for(provider)
54
+ @mutex.synchronize { @states[provider][:state] }
55
+ end
56
+
57
+ def reset!(provider = nil)
58
+ @mutex.synchronize do
59
+ if provider
60
+ @states.delete(provider)
61
+ else
62
+ @states.clear
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlmMesh
4
+ class Configuration
5
+ attr_accessor :default_providers, :fallback, :timeout, :max_retries,
6
+ :openai_api_key, :openai_base_url, :openai_model,
7
+ :anthropic_api_key, :anthropic_base_url, :anthropic_model,
8
+ :local_node_base_url, :local_node_model,
9
+ :circuit_failure_threshold, :circuit_reset_timeout,
10
+ :logger
11
+
12
+ def initialize
13
+ @default_providers = %i[openai anthropic local_node]
14
+ @fallback = true
15
+ @timeout = 30
16
+ @max_retries = 1
17
+
18
+ @openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
19
+ @openai_base_url = ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1")
20
+ @openai_model = ENV.fetch("OPENAI_MODEL", "gpt-4o-mini")
21
+
22
+ @anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil)
23
+ @anthropic_base_url = ENV.fetch("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
24
+ @anthropic_model = ENV.fetch("ANTHROPIC_MODEL", "claude-sonnet-4-5")
25
+
26
+ @local_node_base_url = ENV.fetch("LOCAL_NODE_BASE_URL", "http://127.0.0.1:11434")
27
+ @local_node_model = ENV.fetch("LOCAL_NODE_MODEL", "llama3.2")
28
+
29
+ @circuit_failure_threshold = 3
30
+ @circuit_reset_timeout = 60
31
+ @logger = nil
32
+ end
33
+ end
34
+
35
+ class << self
36
+ def configuration
37
+ @configuration ||= Configuration.new
38
+ end
39
+
40
+ def configure
41
+ yield configuration
42
+ end
43
+
44
+ def reset_configuration!
45
+ @configuration = Configuration.new
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlmMesh
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class ProviderError < Error
9
+ attr_reader :provider, :status, :body
10
+
11
+ def initialize(message, provider: nil, status: nil, body: nil)
12
+ @provider = provider
13
+ @status = status
14
+ @body = body
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ class RateLimitError < ProviderError; end
20
+ class TimeoutError < ProviderError; end
21
+ class AuthenticationError < ProviderError; end
22
+ class CircuitOpenError < ProviderError; end
23
+
24
+ class AllProvidersFailedError < Error
25
+ attr_reader :errors
26
+
27
+ def initialize(errors)
28
+ @errors = errors
29
+ summary = errors.map { |provider, err| "#{provider}: #{err.message}" }.join("; ")
30
+ super("All providers failed — #{summary}")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlmMesh
4
+ module Providers
5
+ class Anthropic < Base
6
+ def name
7
+ :anthropic
8
+ end
9
+
10
+ def complete(prompt:, system: nil, model: nil, **options)
11
+ api_key = config.anthropic_api_key
12
+ raise AuthenticationError.new("ANTHROPIC_API_KEY is not configured", provider: name) if api_key.to_s.empty?
13
+
14
+ model ||= config.anthropic_model
15
+ body = {
16
+ model: model,
17
+ max_tokens: options.fetch(:max_tokens, 1024),
18
+ messages: [{ role: "user", content: prompt }]
19
+ }
20
+ body[:system] = system if system
21
+ body[:temperature] = options[:temperature] if options.key?(:temperature)
22
+ body[:tools] = options[:tools] if options[:tools]
23
+
24
+ response, latency_ms = http_post(
25
+ "#{config.anthropic_base_url.chomp('/')}/v1/messages",
26
+ headers: {
27
+ "x-api-key" => api_key,
28
+ "anthropic-version" => "2023-06-01",
29
+ "Content-Type" => "application/json"
30
+ },
31
+ body: body
32
+ )
33
+
34
+ raise_for_status!(response)
35
+ data = parse_json(response)
36
+ content = Array(data["content"]).map { |block| block["text"] }.compact.join
37
+
38
+ Response.new(
39
+ content: content,
40
+ provider: name,
41
+ model: data["model"] || model,
42
+ usage: data["usage"] || {},
43
+ raw: data,
44
+ latency_ms: latency_ms
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end