lumen-llm 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: 68399d42a44e4e0cfbdce9d294278dbe2a0aaa17d91795c0c8dc5faf0e3be502
4
+ data.tar.gz: 31d3eb2f3b8280fc97c35775587534f4cc6716cc7ddc788bfbc38d0c59e0f972
5
+ SHA512:
6
+ metadata.gz: 38ae5b9ee3f02ff592a74df1e88ceef5815ac5ecdbf3243e756dc24623311343abc499accea0d0d6625d9b048b3dec077b166a7efe5c653701b46516b18cff77
7
+ data.tar.gz: 530d17a9d585f593f2369074900b994239c64eb204b86d34936bfb5625a6dc0fe0f988bba45edafceff4d054a4fcf671b2dc198edaf085628446e3ff0d596ab5
data/AGENTS.md ADDED
@@ -0,0 +1,45 @@
1
+ # AGENTS.md
2
+
3
+ This repo contains the `lumen-llm` Ruby gem.
4
+
5
+ ## Ground Rules
6
+
7
+ - Keep runtime dependencies at zero.
8
+ - Keep Ruby syntax compatible with Ruby 2.3.
9
+ - Keep Rails support optional and compatible with Rails 4+.
10
+ - Use `LumenLLM::` as the canonical public namespace.
11
+ - Keep `Lumen::` as a compatibility alias.
12
+ - Do not add agent loops, tool calls, streaming, persistence, provider SDKs, or schema validation in v1.
13
+
14
+ ## Development Commands
15
+
16
+ ```sh
17
+ bin/setup
18
+ bin/test
19
+ ruby -Ilib:test test/runner_test.rb
20
+ bin/debug-template examples/templates/translator.yml examples/inputs/translator.json
21
+ gem build lumen-llm.gemspec
22
+ ```
23
+
24
+ ## Testing Expectations
25
+
26
+ - Use Minitest.
27
+ - Keep the Minitest development dependency below `5.16` while Ruby 2.3 is supported.
28
+ - Do not make real network calls in tests.
29
+ - Prefer fake providers, fake transports, and in-memory stores.
30
+ - Test public behavior instead of private implementation.
31
+ - Run `bin/test` before handing off changes.
32
+
33
+ ## Compatibility Checklist
34
+
35
+ Before changing code, check that the change:
36
+
37
+ - avoids Ruby 2.4+ only syntax unless the support floor changes;
38
+ - does not assume Rails constants exist;
39
+ - does not assume ActiveSupport helpers exist;
40
+ - does not assume Redis is installed;
41
+ - does not require OpenRouter credentials in tests.
42
+
43
+ ## Skills
44
+
45
+ Agent-facing skills live in `skills/`. Each skill must be a self-contained directory with a `SKILL.md` file containing YAML frontmatter with `name` and `description`.
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial public gem extraction.
6
+ - Provides YAML templates, single-call OpenRouter chat, JSON/text parsing, optional stores, and Rails integration.
7
+
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,50 @@
1
+ # Contributing
2
+
3
+ Thanks for helping improve `lumen-llm`.
4
+
5
+ ## Development
6
+
7
+ Set up the project:
8
+
9
+ ```sh
10
+ bin/setup
11
+ ```
12
+
13
+ Run the test suite:
14
+
15
+ ```sh
16
+ bin/test
17
+ bundle exec rake test
18
+ ```
19
+
20
+ Check template rendering:
21
+
22
+ ```sh
23
+ bin/debug-template examples/templates/translator.yml examples/inputs/translator.json
24
+ ```
25
+
26
+ Check gem packaging:
27
+
28
+ ```sh
29
+ gem build --strict --output /tmp/lumen-llm-0.1.0.gem lumen-llm.gemspec
30
+ ```
31
+
32
+ ## Compatibility Rules
33
+
34
+ - Keep runtime dependencies at zero.
35
+ - Keep Ruby syntax compatible with Ruby 2.3.
36
+ - Keep Rails support optional and compatible with Rails 4+.
37
+ - Use `LumenLLM::` as the canonical public namespace.
38
+ - Keep `Lumen::` as a compatibility alias.
39
+ - Do not add agent loops, tool calls, streaming, persistence, provider SDKs, or schema validation in v1.
40
+
41
+ ## Tests
42
+
43
+ - Use Minitest.
44
+ - Do not make real network calls in tests.
45
+ - Prefer fake providers, fake transports, and in-memory stores.
46
+ - Test public behavior instead of private implementation.
47
+
48
+ ## Releases
49
+
50
+ Only maintainers publish releases. Normal pushes and pull requests run CI only. RubyGems releases are triggered by `v*` tags through GitHub Actions and RubyGems Trusted Publishing.
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dong Xu
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.
22
+
data/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # lumen-llm
2
+
3
+ [![CI](https://github.com/uxgnod/lumen-llm/actions/workflows/ci.yml/badge.svg)](https://github.com/uxgnod/lumen-llm/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/lumen-llm.svg)](https://rubygems.org/gems/lumen-llm)
5
+
6
+ `lumen-llm` is a tiny Ruby LLM prompt runner for old Ruby and Rails apps.
7
+
8
+ It provides the small set of features extracted from Lumen:
9
+
10
+ - YAML prompt templates
11
+ - simple `{{variable}}` interpolation
12
+ - single-call OpenRouter chat completions
13
+ - JSON or text response parsing
14
+ - optional cache and usage stores
15
+ - optional Rails 4+ defaults
16
+
17
+ It intentionally does not provide agents, tool calls, streaming, vector search, provider SDKs, persistence, or schema validation.
18
+
19
+ ## Requirements
20
+
21
+ - Ruby `>= 2.3`
22
+ - Rails `>= 4` is optional
23
+ - No runtime gem dependencies
24
+
25
+ HTTP uses Ruby stdlib `net/http`. JSON, YAML, logger, URI, digest, and date are also stdlib.
26
+
27
+ Ruby 2.3 and 2.4 are end-of-life runtimes. This gem supports them for legacy Rails apps, but new applications should use a maintained Ruby when possible.
28
+
29
+ ## Installation
30
+
31
+ Install directly:
32
+
33
+ ```sh
34
+ gem install lumen-llm
35
+ ```
36
+
37
+ Or add it to your Gemfile:
38
+
39
+ ```ruby
40
+ gem "lumen-llm"
41
+ ```
42
+
43
+ Then:
44
+
45
+ ```ruby
46
+ require "lumen_llm"
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ For a plain Ruby app:
52
+
53
+ ```ruby
54
+ LumenLLM.configure do |config|
55
+ config.template_path = File.expand_path("lumen_templates", __dir__)
56
+ config.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
57
+ config.openrouter_referer = "https://example.com"
58
+ config.openrouter_title = "MyApp"
59
+ end
60
+ ```
61
+
62
+ For Rails 4+:
63
+
64
+ ```ruby
65
+ # config/initializers/lumen_llm.rb
66
+ LumenLLM.configure do |config|
67
+ config.template_path = Rails.root.join("lumen_templates").to_s
68
+ config.logger = Rails.logger
69
+ config.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
70
+ config.openrouter_referer = "https://www.example.com"
71
+ config.openrouter_title = "MyRailsApp"
72
+ end
73
+ ```
74
+
75
+ The Railtie sets `template_path` and `logger` defaults when Rails is present, but explicit configuration is recommended.
76
+
77
+ ## Templates
78
+
79
+ Create `lumen_templates/translator.yml`:
80
+
81
+ ```yaml
82
+ key: translator
83
+ model: openai/gpt-5-mini
84
+ provider: openrouter
85
+ output_type: json
86
+
87
+ system_prompt: |
88
+ You are a professional localization assistant.
89
+ Return only valid JSON. Do not use markdown.
90
+
91
+ user_prompt: |
92
+ Translate "{{source_text}}" into these target languages:
93
+ {{target_languages_json}}
94
+
95
+ Return a JSON object using the same language codes as keys.
96
+ ```
97
+
98
+ Run it:
99
+
100
+ ```ruby
101
+ result = LumenLLM.run(
102
+ "translator",
103
+ input: {
104
+ source_text: "Save changes",
105
+ target_languages_json: { "de" => "German", "fr" => "French" }.to_json
106
+ }
107
+ )
108
+
109
+ puts result["de"]
110
+ ```
111
+
112
+ ## Compatibility Alias
113
+
114
+ The canonical namespace is `LumenLLM`.
115
+
116
+ For migrations from the original Rails-internal Lumen library, this gem also exposes:
117
+
118
+ ```ruby
119
+ Lumen.run("translator", input: { ... })
120
+ Lumen::TemplateLoader.load("translator")
121
+ Lumen::Runner.new(template: template, input: input)
122
+ ```
123
+
124
+ ## Stores
125
+
126
+ By default, `lumen-llm` uses `LumenLLM::Stores::NullStore`, which does not cache or record stats.
127
+
128
+ Use memory store in tests or simple scripts:
129
+
130
+ ```ruby
131
+ LumenLLM.configure do |config|
132
+ config.store = LumenLLM::Stores::MemoryStore.new
133
+ end
134
+ ```
135
+
136
+ Use Redis by passing an existing Redis-like client:
137
+
138
+ ```ruby
139
+ redis = Redis.new(url: ENV["REDIS_URL"])
140
+
141
+ LumenLLM.configure do |config|
142
+ config.store = LumenLLM::Stores::RedisStore.new(redis)
143
+ end
144
+ ```
145
+
146
+ `lumen-llm` does not depend on the `redis` gem. Your app owns that dependency.
147
+
148
+ ## Force Refresh
149
+
150
+ ```ruby
151
+ LumenLLM.run("translator", input: input, force: true)
152
+ ```
153
+
154
+ `force: true` bypasses the configured cache for that call.
155
+
156
+ ## Development Workflow
157
+
158
+ ```sh
159
+ bin/setup
160
+ bin/test
161
+ bundle exec rake test
162
+ bin/debug-template examples/templates/translator.yml examples/inputs/translator.json
163
+ gem build --strict --output /tmp/lumen-llm-0.1.0.gem lumen-llm.gemspec
164
+ ```
165
+
166
+ The test suite uses Minitest and no real network calls.
167
+ Minitest is capped below `5.16` because newer Minitest releases require Ruby 2.6+.
168
+
169
+ ## Contributing
170
+
171
+ Bug reports and small pull requests are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing changes, especially the Ruby 2.3 compatibility rules and the no-runtime-dependency constraint.
172
+
173
+ ## Security
174
+
175
+ Please do not report security vulnerabilities in public issues. See [SECURITY.md](SECURITY.md) for the private reporting process.
176
+
177
+ ## Agent Skills
178
+
179
+ The `skills/` directory contains Agent Skills compatible with the open `SKILL.md` format:
180
+
181
+ - `skills/lumen-llm-setup`
182
+ - `skills/lumen-llm-template-authoring`
183
+ - `skills/lumen-llm-debugging`
184
+
185
+ They help coding agents install, configure, test, and debug this gem without guessing project conventions.
186
+
187
+ ## Release
188
+
189
+ Maintainers release through GitHub Actions and RubyGems Trusted Publishing.
190
+ Normal pushes and pull requests only run checks; only `v*` tags trigger the release workflow.
191
+
192
+ Build locally before tagging:
193
+
194
+ ```sh
195
+ gem build --strict --output /tmp/lumen-llm-0.1.0.gem lumen-llm.gemspec
196
+ ```
197
+
198
+ After CI passes and RubyGems trusted publishing is configured, publish by pushing a version tag:
199
+
200
+ ```sh
201
+ git tag v0.1.0
202
+ git push origin v0.1.0
203
+ ```
data/SECURITY.md ADDED
@@ -0,0 +1,22 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Security fixes are provided for the latest released version of `lumen-llm`.
6
+
7
+ ## Reporting a Vulnerability
8
+
9
+ Please do not open a public issue for a security vulnerability.
10
+
11
+ Email `uxgnod@gmail.com` with:
12
+
13
+ - a description of the issue;
14
+ - affected versions, if known;
15
+ - reproduction steps or proof-of-concept details;
16
+ - any suggested mitigation.
17
+
18
+ We will acknowledge the report, investigate privately, and publish a fixed release when needed.
19
+
20
+ ## Security Notes
21
+
22
+ `lumen-llm` intentionally keeps runtime dependencies at zero and does not include provider SDKs, persistence, agent loops, tool calls, or streaming in v1. Tests must not make real network calls or require OpenRouter credentials.
@@ -0,0 +1,5 @@
1
+ {
2
+ "source_text": "Save changes",
3
+ "target_languages_json": "{\"de\":\"German\",\"fr\":\"French\",\"es\":\"Spanish\"}"
4
+ }
5
+
@@ -0,0 +1,19 @@
1
+ key: translator
2
+ model: openai/gpt-5-mini
3
+ provider: openrouter
4
+ output_type: json
5
+
6
+ system_prompt: |
7
+ You are a professional localization assistant for software products.
8
+ Translate short UI copy naturally and concisely.
9
+ Preserve placeholders, numbers, HTML tags, and product names.
10
+ Return only valid JSON. Do not use markdown.
11
+
12
+ user_prompt: |
13
+ Translate the following source text into the target languages.
14
+
15
+ Source text: "{{source_text}}"
16
+ Target languages: {{target_languages_json}}
17
+
18
+ Return a JSON object using the exact same language codes from target languages as keys.
19
+
data/lib/lumen.rb ADDED
@@ -0,0 +1,2 @@
1
+ require "lumen_llm"
2
+
@@ -0,0 +1,39 @@
1
+ require "logger"
2
+
3
+ module LumenLLM
4
+ class Configuration
5
+ attr_accessor :template_path,
6
+ :openrouter_api_key,
7
+ :openrouter_referer,
8
+ :openrouter_title,
9
+ :store,
10
+ :provider_registry,
11
+ :cache_ttl,
12
+ :http_open_timeout,
13
+ :http_read_timeout
14
+
15
+ attr_writer :logger
16
+
17
+ def initialize
18
+ @template_path = nil
19
+ @logger = nil
20
+ @openrouter_api_key = ENV["OPENROUTER_API_KEY"]
21
+ @openrouter_referer = ENV["OPENROUTER_REFERER"] || "https://example.com"
22
+ @openrouter_title = ENV["OPENROUTER_TITLE"] || "lumen-llm"
23
+ @store = nil
24
+ @provider_registry = nil
25
+ @cache_ttl = 3600
26
+ @http_open_timeout = 10
27
+ @http_read_timeout = 60
28
+ end
29
+
30
+ def logger
31
+ @logger ||= Logger.new($stderr)
32
+ end
33
+
34
+ def logger_configured?
35
+ !@logger.nil?
36
+ end
37
+ end
38
+ end
39
+
@@ -0,0 +1,8 @@
1
+ module LumenLLM
2
+ class Error < StandardError; end
3
+ class ConfigurationError < Error; end
4
+ class TemplateNotFoundError < Error; end
5
+ class ParserError < Error; end
6
+ class ProviderError < Error; end
7
+ end
8
+
@@ -0,0 +1,13 @@
1
+ module LumenLLM
2
+ module Parser
3
+ def self.extract(provider:, raw:, output_type:)
4
+ case provider.to_s
5
+ when "openrouter"
6
+ Providers::OpenRouter::Parser.extract(raw, output_type)
7
+ else
8
+ raise ParserError, "No parser defined for provider: #{provider}"
9
+ end
10
+ end
11
+ end
12
+ end
13
+
@@ -0,0 +1,19 @@
1
+ module LumenLLM
2
+ class ProviderRegistry
3
+ def initialize
4
+ @providers = {}
5
+ end
6
+
7
+ def register(name, provider = nil, &block)
8
+ @providers[name.to_s] = block || provider
9
+ end
10
+
11
+ def fetch(name)
12
+ provider = @providers[name.to_s]
13
+ raise ConfigurationError, "Unknown LLM provider: #{name}" unless provider
14
+
15
+ provider.respond_to?(:call) ? provider.call : provider
16
+ end
17
+ end
18
+ end
19
+
@@ -0,0 +1,79 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module LumenLLM
6
+ module Providers
7
+ module OpenRouter
8
+ class Client
9
+ ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
10
+
11
+ def initialize(api_key: nil, referer: nil, title: nil, transport: nil, open_timeout: nil, read_timeout: nil)
12
+ config = LumenLLM.configuration
13
+ @api_key = api_key || config.openrouter_api_key || ENV["OPENROUTER_API_KEY"]
14
+ @referer = referer || config.openrouter_referer
15
+ @title = title || config.openrouter_title
16
+ @transport = transport || NetHTTPTransport.new
17
+ @open_timeout = open_timeout || config.http_open_timeout
18
+ @read_timeout = read_timeout || config.http_read_timeout
19
+ end
20
+
21
+ def chat(messages, model:)
22
+ raise ConfigurationError, "OpenRouter API key is not configured" if blank?(@api_key)
23
+
24
+ body = {
25
+ :model => model,
26
+ :messages => messages
27
+ }
28
+
29
+ response = @transport.request(
30
+ URI.parse(ENDPOINT),
31
+ headers,
32
+ body.to_json,
33
+ @open_timeout,
34
+ @read_timeout
35
+ )
36
+
37
+ unless success?(response)
38
+ raise ProviderError, "OpenRouter API Error: #{response.code} - #{response.body}"
39
+ end
40
+
41
+ JSON.parse(response.body)
42
+ end
43
+
44
+ private
45
+
46
+ def headers
47
+ {
48
+ "Authorization" => "Bearer #{@api_key}",
49
+ "Content-Type" => "application/json",
50
+ "HTTP-Referer" => @referer,
51
+ "X-Title" => @title
52
+ }
53
+ end
54
+
55
+ def success?(response)
56
+ response.code.to_i >= 200 && response.code.to_i < 300
57
+ end
58
+
59
+ def blank?(value)
60
+ value.nil? || value.to_s.strip == ""
61
+ end
62
+ end
63
+
64
+ class NetHTTPTransport
65
+ def request(uri, headers, body, open_timeout, read_timeout)
66
+ http = Net::HTTP.new(uri.host, uri.port)
67
+ http.use_ssl = uri.scheme == "https"
68
+ http.open_timeout = open_timeout
69
+ http.read_timeout = read_timeout
70
+
71
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
72
+ request.body = body
73
+ http.request(request)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+
@@ -0,0 +1,28 @@
1
+ require "json"
2
+
3
+ module LumenLLM
4
+ module Providers
5
+ module OpenRouter
6
+ class Parser
7
+ def self.extract(raw, output_type = "text")
8
+ content = raw.dig("choices", 0, "message", "content")
9
+ raise ParserError, "Missing content in response." unless content
10
+
11
+ clean = content.gsub(/```json|```/, "").strip
12
+
13
+ case output_type.to_s
14
+ when "json"
15
+ begin
16
+ JSON.parse(clean)
17
+ rescue JSON::ParserError
18
+ raise ParserError, "Invalid JSON output:\n#{clean}"
19
+ end
20
+ else
21
+ clean
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
@@ -0,0 +1,15 @@
1
+ require "lumen_llm"
2
+
3
+ if defined?(Rails::Railtie)
4
+ module LumenLLM
5
+ class Railtie < Rails::Railtie
6
+ initializer "lumen_llm.configure" do |app|
7
+ LumenLLM.configure do |config|
8
+ config.template_path ||= app.root.join("lumen_templates").to_s
9
+ config.logger = Rails.logger unless config.logger_configured?
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+
@@ -0,0 +1,50 @@
1
+ require "digest/sha1"
2
+ require "json"
3
+
4
+ module LumenLLM
5
+ class Runner
6
+ def initialize(template:, input:, store: nil, provider_registry: nil)
7
+ @template = template
8
+ @input = input
9
+ @store = store || LumenLLM.configuration.store || Stores::NullStore.new
10
+ @provider_registry = provider_registry || LumenLLM.configuration.provider_registry || LumenLLM.provider_registry
11
+ end
12
+
13
+ def run(force: false)
14
+ cache_key = "lumen:#{@template.key}:#{Digest::SHA1.hexdigest(@input.to_json)}"
15
+
16
+ raw = if force
17
+ log("[LumenLLM::Runner] FORCE REFRESH: #{cache_key}")
18
+ generate
19
+ else
20
+ @store.cache(cache_key, :ttl => LumenLLM.configuration.cache_ttl) { generate }
21
+ end
22
+
23
+ Parser.extract(
24
+ provider: @template.provider,
25
+ raw: raw,
26
+ output_type: @template.output_type
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def generate
33
+ payload = @template.render(@input)
34
+ response = @provider_registry.fetch(payload[:provider]).chat(payload[:messages], model: payload[:model])
35
+ track_stats(@template.key, response)
36
+ response
37
+ end
38
+
39
+ def track_stats(key, response)
40
+ usage = response["usage"] || {}
41
+ @store.incr_stat("prompt_tokens:#{key}", usage["prompt_tokens"]) if usage["prompt_tokens"]
42
+ @store.incr_stat("completion_tokens:#{key}", usage["completion_tokens"]) if usage["completion_tokens"]
43
+ @store.track_usage(key, response["model"], usage)
44
+ end
45
+
46
+ def log(message)
47
+ LumenLLM.configuration.logger.info(message)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,52 @@
1
+ require "date"
2
+
3
+ module LumenLLM
4
+ module Stores
5
+ class MemoryStore
6
+ def initialize
7
+ @values = {}
8
+ @stats = Hash.new(0)
9
+ end
10
+
11
+ def cache(key, _options = {})
12
+ return @values[key] if @values.key?(key)
13
+
14
+ @values[key] = yield
15
+ end
16
+
17
+ def incr_stat(metric, amount = 1)
18
+ @stats["stats:#{metric}"] += amount.to_i
19
+ end
20
+
21
+ def stat(key)
22
+ @stats["stats:#{key}"].to_i
23
+ end
24
+
25
+ def all_stats(pattern = "stats:*")
26
+ prefix = pattern.sub(/\*$/, "")
27
+ result = {}
28
+ @stats.each do |key, value|
29
+ result[key] = value if key.index(prefix) == 0
30
+ end
31
+ result
32
+ end
33
+
34
+ def track_usage(template_key, model, usage)
35
+ date = Date.today.strftime("%Y-%m-%d")
36
+ [
37
+ "usage:total",
38
+ "usage:#{template_key}",
39
+ "usage:model:#{model}",
40
+ "usage:#{template_key}:model:#{model}",
41
+ "usage:date:#{date}",
42
+ "usage:#{template_key}:date:#{date}"
43
+ ].each do |prefix|
44
+ incr_stat("#{prefix}:prompt_tokens", usage["prompt_tokens"])
45
+ incr_stat("#{prefix}:completion_tokens", usage["completion_tokens"])
46
+ incr_stat("#{prefix}:total_tokens", usage["total_tokens"])
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
@@ -0,0 +1,24 @@
1
+ module LumenLLM
2
+ module Stores
3
+ class NullStore
4
+ def cache(_key, _options = {})
5
+ yield
6
+ end
7
+
8
+ def incr_stat(_metric, _amount = 1)
9
+ end
10
+
11
+ def stat(_key)
12
+ 0
13
+ end
14
+
15
+ def all_stats(_pattern = "stats:*")
16
+ {}
17
+ end
18
+
19
+ def track_usage(_template_key, _model, _usage)
20
+ end
21
+ end
22
+ end
23
+ end
24
+
@@ -0,0 +1,55 @@
1
+ require "json"
2
+ require "date"
3
+
4
+ module LumenLLM
5
+ module Stores
6
+ class RedisStore
7
+ def initialize(redis)
8
+ @redis = redis
9
+ end
10
+
11
+ def cache(key, options = {})
12
+ ttl = options[:ttl] || 3600
13
+ value = @redis.get(key)
14
+ return JSON.parse(value) if value
15
+
16
+ result = yield
17
+ @redis.setex(key, ttl, result.to_json)
18
+ result
19
+ end
20
+
21
+ def incr_stat(metric, amount = 1)
22
+ @redis.incrby("stats:#{metric}", amount.to_i)
23
+ end
24
+
25
+ def stat(key)
26
+ @redis.get("stats:#{key}").to_i
27
+ end
28
+
29
+ def all_stats(pattern = "stats:*")
30
+ result = {}
31
+ @redis.keys(pattern).each do |key|
32
+ result[key] = @redis.get(key).to_i
33
+ end
34
+ result
35
+ end
36
+
37
+ def track_usage(template_key, model, usage)
38
+ date = Date.today.strftime("%Y-%m-%d")
39
+ [
40
+ "usage:total",
41
+ "usage:#{template_key}",
42
+ "usage:model:#{model}",
43
+ "usage:#{template_key}:model:#{model}",
44
+ "usage:date:#{date}",
45
+ "usage:#{template_key}:date:#{date}"
46
+ ].each do |prefix|
47
+ incr_stat("#{prefix}:prompt_tokens", usage["prompt_tokens"])
48
+ incr_stat("#{prefix}:completion_tokens", usage["completion_tokens"])
49
+ incr_stat("#{prefix}:total_tokens", usage["total_tokens"])
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
@@ -0,0 +1,36 @@
1
+ module LumenLLM
2
+ class Template
3
+ attr_reader :key, :system_prompt, :user_prompt, :model, :provider, :output_type
4
+
5
+ def initialize(key:, system_prompt:, user_prompt:, model:, provider: :openrouter, output_type: :json)
6
+ @key = key.to_s
7
+ @system_prompt = system_prompt.to_s
8
+ @user_prompt = user_prompt.to_s
9
+ @model = model.to_s
10
+ @provider = provider.to_s
11
+ @output_type = output_type.to_s
12
+ end
13
+
14
+ def render(input)
15
+ {
16
+ :provider => provider,
17
+ :model => model,
18
+ :messages => [
19
+ { :role => "system", :content => interpolate(system_prompt, input) },
20
+ { :role => "user", :content => interpolate(user_prompt, input) }
21
+ ]
22
+ }
23
+ end
24
+
25
+ private
26
+
27
+ def interpolate(str, input)
28
+ rendered = str.dup
29
+ input.each do |key, value|
30
+ rendered = rendered.gsub("{{#{key}}}", value.to_s)
31
+ end
32
+ rendered
33
+ end
34
+ end
35
+ end
36
+
@@ -0,0 +1,36 @@
1
+ require "yaml"
2
+
3
+ module LumenLLM
4
+ class TemplateLoader
5
+ def self.load(key, path: nil)
6
+ new(path || LumenLLM.configuration.template_path).load(key)
7
+ end
8
+
9
+ def initialize(path)
10
+ @path = path
11
+ end
12
+
13
+ def load(key)
14
+ raise ConfigurationError, "template_path is not configured" if blank?(@path)
15
+
16
+ template_file = File.join(@path.to_s, "#{key}.yml")
17
+ raise TemplateNotFoundError, "Template not found: #{key}" unless File.exist?(template_file)
18
+
19
+ config = YAML.load_file(template_file)
20
+ Template.new(
21
+ key: config["key"] || key,
22
+ system_prompt: config["system_prompt"],
23
+ user_prompt: config["user_prompt"],
24
+ model: config["model"],
25
+ provider: config["provider"] || "openrouter",
26
+ output_type: config["output_type"] || "text"
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def blank?(value)
33
+ value.nil? || value.to_s.strip == ""
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,4 @@
1
+ module LumenLLM
2
+ VERSION = "0.1.0"
3
+ end
4
+
data/lib/lumen_llm.rb ADDED
@@ -0,0 +1,57 @@
1
+ require "lumen_llm/version"
2
+ require "lumen_llm/errors"
3
+ require "lumen_llm/configuration"
4
+ require "lumen_llm/template"
5
+ require "lumen_llm/template_loader"
6
+ require "lumen_llm/provider_registry"
7
+ require "lumen_llm/stores/null_store"
8
+ require "lumen_llm/stores/memory_store"
9
+ require "lumen_llm/stores/redis_store"
10
+ require "lumen_llm/providers/open_router/client"
11
+ require "lumen_llm/providers/open_router/parser"
12
+ require "lumen_llm/parser"
13
+ require "lumen_llm/runner"
14
+
15
+ module LumenLLM
16
+ class << self
17
+ def configuration
18
+ @configuration ||= Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield(configuration)
23
+ configuration
24
+ end
25
+
26
+ def reset_configuration!
27
+ @configuration = Configuration.new
28
+ @provider_registry = nil
29
+ end
30
+
31
+ def provider_registry
32
+ @provider_registry ||= default_provider_registry
33
+ end
34
+
35
+ def run(template_key, input: {}, force: false, store: nil, provider_registry: nil)
36
+ template = TemplateLoader.load(template_key)
37
+ Runner.new(
38
+ template: template,
39
+ input: input,
40
+ store: store,
41
+ provider_registry: provider_registry
42
+ ).run(force: force)
43
+ end
44
+
45
+ private
46
+
47
+ def default_provider_registry
48
+ registry = ProviderRegistry.new
49
+ registry.register("openrouter") { Providers::OpenRouter::Client.new }
50
+ registry
51
+ end
52
+ end
53
+ end
54
+
55
+ Lumen = LumenLLM unless defined?(::Lumen)
56
+
57
+ require "lumen_llm/railtie" if defined?(Rails)
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: lumen-llm-debugging
3
+ description: Use when debugging lumen-llm failures involving template loading, OpenRouter requests, JSON parsing, cache behavior, or Rails integration.
4
+ ---
5
+
6
+ # lumen-llm Debugging
7
+
8
+ Use this skill when a user reports a failing `LumenLLM.run` call.
9
+
10
+ ## Checklist
11
+
12
+ 1. Confirm the template path and template key.
13
+ 2. Render the template locally with `bin/debug-template`.
14
+ 3. Check `OPENROUTER_API_KEY` or explicit `config.openrouter_api_key`.
15
+ 4. Confirm the model name is available through OpenRouter.
16
+ 5. If JSON parsing fails, inspect the raw response content for markdown fences or explanations.
17
+ 6. If cache seems stale, retry with `force: true`.
18
+ 7. If Rails integration fails, require `lumen_llm` and inspect the initializer.
19
+
20
+ ## Useful Commands
21
+
22
+ ```sh
23
+ bin/test
24
+ bin/debug-template examples/templates/translator.yml examples/inputs/translator.json
25
+ ruby -Ilib -e 'require "lumen_llm"; p LumenLLM.configuration'
26
+ ```
27
+
28
+ ## Common Causes
29
+
30
+ - Missing template path.
31
+ - Template filename does not match the key passed to `LumenLLM.run`.
32
+ - The model returned non-JSON while `output_type: json` was set.
33
+ - Redis was assumed to exist but no store was configured.
34
+ - Rails app used `Settings.openrouter`; migrate to explicit `LumenLLM.configure`.
35
+
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: lumen-llm-setup
3
+ description: Use when installing, configuring, or migrating the lumen-llm Ruby gem in a Ruby or Rails app, especially Rails 4/5 apps that need a lightweight OpenRouter LLM runner.
4
+ ---
5
+
6
+ # lumen-llm Setup
7
+
8
+ Use this skill when a user asks to add `lumen-llm`, migrate from an internal `Lumen` module, or configure OpenRouter credentials.
9
+
10
+ ## Workflow
11
+
12
+ 1. Inspect the app Ruby and Rails versions before editing.
13
+ 2. Add `gem "lumen-llm"` to the Gemfile.
14
+ 3. Add `require "lumen_llm"` only when the app does not use Bundler autorequire.
15
+ 4. For Rails, create `config/initializers/lumen_llm.rb`.
16
+ 5. Configure `template_path`, `logger`, `openrouter_api_key`, `openrouter_referer`, and `openrouter_title`.
17
+ 6. Choose a store:
18
+ - no store for default no-cache behavior;
19
+ - `MemoryStore` for local scripts and tests;
20
+ - `RedisStore` with an app-owned Redis client for production cache/stats.
21
+ 7. Add a template under `lumen_templates/`.
22
+ 8. Verify with a fake provider or a single manual OpenRouter call.
23
+
24
+ ## Rails Initializer Pattern
25
+
26
+ ```ruby
27
+ LumenLLM.configure do |config|
28
+ config.template_path = Rails.root.join("lumen_templates").to_s
29
+ config.logger = Rails.logger
30
+ config.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
31
+ config.openrouter_referer = "https://www.example.com"
32
+ config.openrouter_title = "MyRailsApp"
33
+ end
34
+ ```
35
+
36
+ ## Migration Notes
37
+
38
+ - Prefer new calls as `LumenLLM.run(...)`.
39
+ - Existing `Lumen.run(...)`, `Lumen::TemplateLoader`, and `Lumen::Runner` are supported through the compatibility alias.
40
+ - Do not depend on app-specific `Settings`; pass values through `LumenLLM.configure`.
41
+
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: lumen-llm-template-authoring
3
+ description: Use when creating or reviewing lumen-llm YAML prompt templates, especially templates that require JSON output and Ruby 2.3/Rails 4 compatibility.
4
+ ---
5
+
6
+ # lumen-llm Template Authoring
7
+
8
+ Use this skill when writing templates under `lumen_templates/` or `examples/templates/`.
9
+
10
+ ## Template Shape
11
+
12
+ ```yaml
13
+ key: translator
14
+ model: openai/gpt-5-mini
15
+ provider: openrouter
16
+ output_type: json
17
+
18
+ system_prompt: |
19
+ Return only valid JSON.
20
+
21
+ user_prompt: |
22
+ Input: {{input_json}}
23
+ ```
24
+
25
+ ## Rules
26
+
27
+ - Use `{{variable_name}}` placeholders.
28
+ - Pass structured data as JSON strings from Ruby.
29
+ - Set `output_type: json` only when the model is instructed to return raw JSON.
30
+ - Tell the model not to wrap JSON in markdown.
31
+ - Preserve placeholders and product names when translating UI copy.
32
+ - Keep templates app-specific; the gem should only ship generic examples.
33
+
34
+ ## Test Pattern
35
+
36
+ Use `LumenLLM::TemplateLoader.load` to load the template and `template.render(input)` to verify the rendered messages. Use a fake provider for full runner tests.
37
+
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lumen-llm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dong Xu
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: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.10'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.16'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '5.10'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.16'
32
+ - !ruby/object:Gem::Dependency
33
+ name: mutex_m
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '0.2'
42
+ type: :development
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '0.2'
52
+ - !ruby/object:Gem::Dependency
53
+ name: rake
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '12.3'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '14'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '12.3'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '14'
72
+ description: lumen-llm provides YAML prompt templates, single-call OpenRouter chat
73
+ completion, JSON/text parsing, optional cache stores, and Rails 4+ integration without
74
+ runtime gem dependencies.
75
+ email:
76
+ - uxgnod@gmail.com
77
+ executables: []
78
+ extensions: []
79
+ extra_rdoc_files: []
80
+ files:
81
+ - AGENTS.md
82
+ - CHANGELOG.md
83
+ - CONTRIBUTING.md
84
+ - LICENSE.txt
85
+ - README.md
86
+ - SECURITY.md
87
+ - examples/inputs/translator.json
88
+ - examples/templates/translator.yml
89
+ - lib/lumen.rb
90
+ - lib/lumen_llm.rb
91
+ - lib/lumen_llm/configuration.rb
92
+ - lib/lumen_llm/errors.rb
93
+ - lib/lumen_llm/parser.rb
94
+ - lib/lumen_llm/provider_registry.rb
95
+ - lib/lumen_llm/providers/open_router/client.rb
96
+ - lib/lumen_llm/providers/open_router/parser.rb
97
+ - lib/lumen_llm/railtie.rb
98
+ - lib/lumen_llm/runner.rb
99
+ - lib/lumen_llm/stores/memory_store.rb
100
+ - lib/lumen_llm/stores/null_store.rb
101
+ - lib/lumen_llm/stores/redis_store.rb
102
+ - lib/lumen_llm/template.rb
103
+ - lib/lumen_llm/template_loader.rb
104
+ - lib/lumen_llm/version.rb
105
+ - skills/lumen-llm-debugging/SKILL.md
106
+ - skills/lumen-llm-setup/SKILL.md
107
+ - skills/lumen-llm-template-authoring/SKILL.md
108
+ homepage: https://github.com/uxgnod/lumen-llm
109
+ licenses:
110
+ - MIT
111
+ metadata:
112
+ source_code_uri: https://github.com/uxgnod/lumen-llm
113
+ changelog_uri: https://github.com/uxgnod/lumen-llm/blob/main/CHANGELOG.md
114
+ bug_tracker_uri: https://github.com/uxgnod/lumen-llm/issues
115
+ allowed_push_host: https://rubygems.org
116
+ rubygems_mfa_required: 'true'
117
+ rdoc_options: []
118
+ require_paths:
119
+ - lib
120
+ required_ruby_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '2.3'
125
+ required_rubygems_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ requirements: []
131
+ rubygems_version: 3.6.9
132
+ specification_version: 4
133
+ summary: A tiny Ruby/Rails-friendly LLM prompt runner for OpenRouter.
134
+ test_files: []