myrr-rb 0.3.3

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.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Myrr
6
+ module Pay
7
+ # Client for a seller's Myrr Pay API.
8
+ #
9
+ # Wraps the HTTP endpoints that sellers expose for agents to
10
+ # create customers, check balances, top-up, and manage subscriptions.
11
+ #
12
+ # @example
13
+ # client = Myrr::Pay::Client.new(base_url: "https://seller.example.com")
14
+ # client.create_customer(agent_did: "did:myrr:abc123")
15
+ # client.get_balance(agent_did: "did:myrr:abc123")
16
+ class Client
17
+ # @param base_url [String] The seller's Myrr protocol server URL
18
+ # @param api_key [String, nil] Optional API key for internal endpoints
19
+ # @param open_timeout [Integer] HTTP open timeout (seconds)
20
+ # @param read_timeout [Integer] HTTP read timeout (seconds)
21
+ def initialize(base_url:, api_key: nil, open_timeout: 5, read_timeout: 10)
22
+ require "net/http"
23
+ require "uri"
24
+
25
+ @base_url = base_url
26
+ @api_key = api_key
27
+ @open_timeout = open_timeout
28
+ @read_timeout = read_timeout
29
+ end
30
+
31
+ # Create a Stripe Customer for the agent on this seller's account.
32
+ #
33
+ # @param agent_did [String] The did:myrr: identifier
34
+ # @return [Hash] Response with stripe_customer_id, balance, currency
35
+ def create_customer(agent_did:)
36
+ post("/v1/pay/customers", { agent_did: agent_did })
37
+ end
38
+
39
+ # Get the agent's current balance on this seller.
40
+ #
41
+ # @param agent_did [String] The did:myrr: identifier
42
+ # @return [Hash] Response with balance_cents, currency
43
+ def get_balance(agent_did:)
44
+ get("/v1/pay/balance", { agent_did: agent_did })
45
+ end
46
+
47
+ # Top up the agent's balance via off_session PaymentIntent.
48
+ #
49
+ # @param agent_did [String] The did:myrr: identifier
50
+ # @param amount_cents [Integer] Amount in cents
51
+ # @param payment_method_id [String] Stripe PaymentMethod ID
52
+ # @return [Hash] Response with payment_intent_id, status, amount_cents
53
+ def topup(agent_did:, amount_cents:, payment_method_id:)
54
+ post("/v1/pay/topup", {
55
+ agent_did: agent_did,
56
+ amount_cents: amount_cents,
57
+ payment_method_id: payment_method_id
58
+ })
59
+ end
60
+
61
+ # Debit the agent's balance (internal — requires API key).
62
+ #
63
+ # @param agent_did [String] The did:myrr: identifier
64
+ # @param amount_cents [Integer] Amount in cents
65
+ # @param endpoint_path [String] The endpoint being accessed
66
+ # @param token_count [Integer] Number of tokens consumed
67
+ # @param description [String, nil] Optional description
68
+ # @return [Hash] Response with status, amount_cents, balance_cents
69
+ def debit(agent_did:, amount_cents:, endpoint_path: "", token_count: 0, description: nil)
70
+ body = {
71
+ agent_did: agent_did,
72
+ amount_cents: amount_cents,
73
+ endpoint_path: endpoint_path,
74
+ token_count: token_count
75
+ }
76
+ body[:description] = description if description
77
+ post("/v1/pay/debit", body)
78
+ end
79
+
80
+ # Create a subscription for the agent.
81
+ #
82
+ # @param agent_did [String] The did:myrr: identifier
83
+ # @param payment_method_id [String] Stripe PaymentMethod ID
84
+ # @param plan_name [String] Name of the plan (matches pricing config)
85
+ # @return [Hash] Response with subscription details
86
+ def create_subscription(agent_did:, payment_method_id:, plan_name:)
87
+ post("/v1/pay/subscriptions", {
88
+ agent_did: agent_did,
89
+ payment_method_id: payment_method_id,
90
+ plan_name: plan_name
91
+ })
92
+ end
93
+
94
+ # Get the agent's active subscription.
95
+ #
96
+ # @param agent_did [String] The did:myrr: identifier
97
+ # @return [Hash] Response with subscription details or nil
98
+ def get_subscription(agent_did:)
99
+ get("/v1/pay/subscriptions", { agent_did: agent_did })
100
+ end
101
+
102
+ # Public method for direct API calls (used by middleware)
103
+ def get(path, params = {})
104
+ uri = URI.join(@base_url, path)
105
+ uri.query = URI.encode_www_form(params) unless params.empty?
106
+
107
+ http = Net::HTTP.new(uri.host, uri.port)
108
+ http.open_timeout = @open_timeout
109
+ http.read_timeout = @read_timeout
110
+ http.use_ssl = (uri.scheme == "https")
111
+
112
+ request = Net::HTTP::Get.new(uri)
113
+ request["Content-Type"] = "application/json"
114
+ request["Authorization"] = "Bearer #{@api_key}" if @api_key
115
+
116
+ response = http.request(request)
117
+ parse_response(response)
118
+ end
119
+
120
+ def post(path, body = {})
121
+ uri = URI.join(@base_url, path)
122
+
123
+ http = Net::HTTP.new(uri.host, uri.port)
124
+ http.open_timeout = @open_timeout
125
+ http.read_timeout = @read_timeout
126
+ http.use_ssl = (uri.scheme == "https")
127
+
128
+ request = Net::HTTP::Post.new(uri)
129
+ request["Content-Type"] = "application/json"
130
+ request["Authorization"] = "Bearer #{@api_key}" if @api_key
131
+ request.body = body.to_json
132
+
133
+ response = http.request(request)
134
+ parse_response(response)
135
+ end
136
+
137
+ def parse_response(response)
138
+ data = JSON.parse(response.body)
139
+ case response.code.to_i
140
+ when 200..299
141
+ data
142
+ when 402
143
+ raise Myrr::Pay::InsufficientFunds, data["error"]
144
+ when 404
145
+ raise Myrr::Pay::NotFound, data["error"]
146
+ else
147
+ raise Myrr::Pay::Error, data["error"] || "HTTP #{response.code}"
148
+ end
149
+ rescue JSON::ParserError
150
+ raise Myrr::Pay::Error, "Invalid JSON response (HTTP #{response.code})"
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../client"
4
+
5
+ module Myrr
6
+ module Pay
7
+ # Rack middleware that enforces payment on the sell-side.
8
+ #
9
+ # Runs after identity middleware. For agent requests to paid endpoints,
10
+ # it checks the pricing from the Go server, then checks subscription
11
+ # or balance, debits if possible, or returns 402.
12
+ #
13
+ # @example In a Rails app
14
+ # # config/application.rb
15
+ # config.middleware.use Myrr::Pay::Middleware
16
+ class Middleware
17
+ # @param app [Object] The Rack application
18
+ # @param options [Hash] Optional overrides
19
+ # :client [Myrr::Pay::Client] Pre-configured pay client
20
+ def initialize(app, options = {})
21
+ @app = app
22
+ @client = options[:client]
23
+ end
24
+
25
+ def call(env)
26
+ @env = env
27
+ request = Rack::Request.new(env)
28
+
29
+ # Skip for non-agent requests
30
+ agent_did = env["myrr.agent_did"]
31
+ unless agent_did
32
+ return @app.call(env)
33
+ end
34
+
35
+ # Get pricing from Go server
36
+ pricing = fetch_pricing
37
+ unless pricing && pricing_requires_payment?(pricing)
38
+ # Free endpoint — pass through
39
+ return @app.call(env)
40
+ end
41
+
42
+ payg_cents = pricing.dig("payg", "per_1m_tokens_cents") || 0
43
+
44
+ # Check subscription first
45
+ sub = check_subscription(agent_did)
46
+ if sub && sub["status"] == "active"
47
+ tokens_remaining = (sub["tokens_included"] || 0) - (sub["tokens_used"] || 0)
48
+
49
+ if tokens_remaining > 0
50
+ # Serve content — billing happens after render
51
+ status, headers, body = @app.call(env)
52
+
53
+ content_type = headers["content-type"] || headers["Content-Type"] || ""
54
+ if content_type.include?("text/markdown")
55
+ body_text = ""
56
+ body.each { |chunk| body_text << chunk.to_s }
57
+ body.rewind if body.respond_to?(:rewind)
58
+
59
+ token_count = count_tokens(body_text)
60
+ if token_count <= tokens_remaining
61
+ headers["X-Myrr-Billing"] = "subscription"
62
+ end
63
+ end
64
+
65
+ return [status, headers, body]
66
+ end
67
+ end
68
+
69
+ # Subscription exhausted or none — check PAYG balance
70
+ status, headers, body = @app.call(env)
71
+
72
+ content_type = headers["content-type"] || headers["Content-Type"] || ""
73
+ unless content_type.include?("text/markdown")
74
+ return [status, headers, body]
75
+ end
76
+
77
+ body_text = ""
78
+ body.each { |chunk| body_text << chunk.to_s }
79
+ body.rewind if body.respond_to?(:rewind)
80
+
81
+ token_count = count_tokens(body_text)
82
+ amount_cents = pay_cents(payg_cents, token_count)
83
+ return [status, headers, body] if amount_cents <= 0
84
+
85
+ # Check balance
86
+ balance = check_balance(agent_did) || 0
87
+
88
+ if balance >= amount_cents
89
+ # Debit
90
+ debit_agent!(agent_did, amount_cents, request.path_info, token_count)
91
+ headers["X-Myrr-Billing"] = "payg"
92
+ [status, headers, body]
93
+ else
94
+ # Return 402
95
+ [402, { "content-type" => "application/json" }, [render_402(request, token_count, amount_cents, payg_cents)]]
96
+ end
97
+ rescue => e
98
+ warn "[myrr] Pay middleware error: #{e.message}"
99
+ @app.call(env)
100
+ end
101
+
102
+ private
103
+
104
+ def client
105
+ @client ||= Myrr::Pay::Client.new(
106
+ base_url: Myrr.config.protocol_server_url,
107
+ api_key: Myrr.config.site_api_key
108
+ )
109
+ end
110
+
111
+ def fetch_pricing
112
+ client.get("/v1/pricing")
113
+ rescue => e
114
+ warn "[myrr] Failed to fetch pricing: #{e.message}"
115
+ nil
116
+ end
117
+
118
+ def pricing_requires_payment?(pricing)
119
+ payg = pricing.dig("payg", "per_1m_tokens_cents")
120
+ sub = pricing["subscription"]
121
+ (payg && payg > 0) || (sub && sub["monthly_cents"] && sub["monthly_cents"] > 0)
122
+ rescue
123
+ false
124
+ end
125
+
126
+ def check_subscription(agent_did)
127
+ client.get_subscription(agent_did: agent_did)
128
+ rescue Myrr::Pay::NotFound
129
+ nil
130
+ rescue => e
131
+ warn "[myrr] Subscription check failed: #{e.message}"
132
+ nil
133
+ end
134
+
135
+ def check_balance(agent_did)
136
+ resp = client.get_balance(agent_did: agent_did)
137
+ resp["balance_cents"].to_i
138
+ rescue => e
139
+ warn "[myrr] Balance check failed: #{e.message}"
140
+ 0
141
+ end
142
+
143
+ def debit_agent!(agent_did, amount_cents, endpoint_path, token_count)
144
+ client.debit(
145
+ agent_did: agent_did,
146
+ amount_cents: amount_cents,
147
+ endpoint_path: endpoint_path,
148
+ token_count: token_count
149
+ )
150
+ rescue => e
151
+ warn "[myrr] Debit failed: #{e.message}"
152
+ end
153
+
154
+ def count_tokens(text)
155
+ if defined?(Myrr::Tokenizer)
156
+ Myrr::Tokenizer.count(text)
157
+ else
158
+ (text.length / 4.0).ceil
159
+ end
160
+ end
161
+
162
+ def pay_cents(cents_per_million, token_count)
163
+ return 0 if cents_per_million.nil? || cents_per_million <= 0 || token_count.nil? || token_count <= 0
164
+ charge = (cents_per_million.to_f * token_count.to_f / 1_000_000.0).ceil
165
+ [charge, 1].max
166
+ end
167
+
168
+ def render_402(request, token_count, amount_cents, payg_cents)
169
+ JSON.generate({
170
+ error: "payment_required",
171
+ payment: {
172
+ type: "stripe_customer_balance",
173
+ endpoint: request.path_info,
174
+ token_count: token_count,
175
+ amount_cents: amount_cents,
176
+ pricing: { per_1m_tokens_cents: payg_cents },
177
+ actions: {
178
+ topup_url: "/v1/pay/topup",
179
+ check_balance_url: "/v1/pay/balance"
180
+ }
181
+ }
182
+ })
183
+ end
184
+ end
185
+ end
186
+ end
data/lib/myrr/pay.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pay/client"
4
+ require_relative "pay/middleware"
5
+
6
+ module Myrr
7
+ module Pay
8
+ Error = Class.new(StandardError)
9
+ InsufficientFunds = Class.new(Error)
10
+ NotFound = Class.new(Error)
11
+ SubscriptionExhausted = Class.new(Error)
12
+ end
13
+ end
@@ -0,0 +1,79 @@
1
+ require "yaml"
2
+
3
+ module Myrr
4
+ module Port
5
+ # Controller concern that extracts page metadata (title, description)
6
+ # from common Rails conventions and inline front-matter in .md.erb views.
7
+ #
8
+ # Detection order (highest priority first):
9
+ # 1. Inline YAML front-matter in the .md.erb template — view wins
10
+ # 2. +@myrr_title+ / +@myrr_description+ — explicit override
11
+ # 3. +@page_title+ / +@page_description+ — Jumpstart convention
12
+ # 4. +@title+ / +@meta_description+ — standard Rails pattern
13
+ # 5. +content_for(:title)+ — popular in view-level title setting
14
+ # 6. +Current.meta_tags&.title+ / +Current.meta_tags&.description+
15
+ #
16
+ # Inline front-matter values override auto-detected values for
17
+ # overlapping keys. Auto-detected fills in any missing fields.
18
+ # +token_count+ is never set here — the middleware always computes it.
19
+ module Metadata
20
+ extend ActiveSupport::Concern
21
+
22
+ included do
23
+ before_action :set_myrr_metadata
24
+ end
25
+
26
+ private
27
+
28
+ def set_myrr_metadata
29
+ return if request.env.key?("myrr.metadata")
30
+
31
+ inline = inline_frontmatter_from_template
32
+ auto = { title: detect_title, description: detect_description }.compact
33
+
34
+ if inline
35
+ request.env["myrr.metadata"] = auto.merge(inline)
36
+ else
37
+ request.env["myrr.metadata"] = auto
38
+ end
39
+ end
40
+
41
+ # Reads the .md.erb template source for the current action and checks
42
+ # for YAML front-matter (delimited by ---). Returns parsed Hash or nil.
43
+ def inline_frontmatter_from_template
44
+ tmpl = lookup_context.find(
45
+ action_name,
46
+ lookup_context.prefixes,
47
+ false,
48
+ [],
49
+ { formats: [:md] }
50
+ )
51
+ return nil unless tmpl
52
+
53
+ source = tmpl.source
54
+ return nil unless source.start_with?("---\n") || source.start_with?("---\r\n")
55
+
56
+ nl = source.start_with?("---\r\n") ? "\r\n" : "\n"
57
+ rest = source[4..]
58
+ end_idx = rest.index(nl + "---" + nl)
59
+ return nil unless end_idx
60
+
61
+ yaml_str = rest[0...end_idx]
62
+ YAML.safe_load(yaml_str, permitted_classes: [Symbol])&.transform_keys(&:to_sym)
63
+ rescue
64
+ nil
65
+ end
66
+
67
+ def detect_title
68
+ @myrr_title || @page_title || @title ||
69
+ view_context&.content_for(:title).presence ||
70
+ (defined?(Current) && Current.meta_tags&.title)
71
+ end
72
+
73
+ def detect_description
74
+ @myrr_description || @page_description || @meta_description ||
75
+ (defined?(Current) && Current.meta_tags&.description)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,113 @@
1
+ require "yaml"
2
+
3
+ module Myrr
4
+ module Port
5
+ class Middleware
6
+ HEADER_NAME = "X-Token-Count"
7
+
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ status, headers, body_out = @app.call(env)
14
+
15
+ if content_type_is_markdown?(headers)
16
+ raw_body = extract_body(body_out)
17
+ resolved_body, meta = resolve_metadata(raw_body, env["myrr.metadata"] || {})
18
+
19
+ if meta[:title] || meta[:description]
20
+ resolved_body = build_frontmatter_body(resolved_body, meta)
21
+ end
22
+
23
+ token_count = Myrr::Tokenizer.count(resolved_body)
24
+ headers[HEADER_NAME] = token_count.to_s
25
+ body_out = [resolved_body]
26
+ end
27
+
28
+ [status, headers, body_out]
29
+ end
30
+
31
+ private
32
+
33
+ def content_type_is_markdown?(headers)
34
+ headers["content-type"].to_s.include?("text/markdown")
35
+ end
36
+
37
+ def extract_body(body)
38
+ text = +""
39
+ body.each { |chunk| text << chunk.to_s }
40
+ text
41
+ end
42
+
43
+ # --- Metadata resolution ---
44
+
45
+ def resolve_metadata(raw_body, auto_meta)
46
+ if auto_meta[:title] || auto_meta[:description]
47
+ [strip_inline_frontmatter(raw_body), auto_meta]
48
+ else
49
+ inline = parse_inline_frontmatter(raw_body)
50
+ inline ? [inline[:body], inline[:data]] : [raw_body, {}]
51
+ end
52
+ end
53
+
54
+ def strip_inline_frontmatter(body)
55
+ return body unless body.start_with?("---\n") || body.start_with?("---\r\n")
56
+ nl = body.start_with?("---\r\n") ? "\r\n" : "\n"
57
+ rest = body[4..]
58
+ end_idx = rest.index(nl + "---" + nl)
59
+ return body unless end_idx
60
+ rest[(end_idx + 5 + nl.length)..] || ""
61
+ end
62
+
63
+ def parse_inline_frontmatter(body)
64
+ return nil unless body.start_with?("---\n") || body.start_with?("---\r\n")
65
+ nl = body.start_with?("---\r\n") ? "\r\n" : "\n"
66
+ rest = body[4..]
67
+ end_idx = rest.index(nl + "---" + nl)
68
+ return nil unless end_idx
69
+ yaml_str = rest[0...end_idx]
70
+ data = (YAML.safe_load(yaml_str, permitted_classes: [Symbol]) || {}).transform_keys(&:to_sym)
71
+ content = rest[(end_idx + 5 + nl.length)..] || ""
72
+ { data: data, body: content }
73
+ rescue
74
+ nil
75
+ end
76
+
77
+ # --- Front-matter builder (self-consistent token_count) ---
78
+ #
79
+ # The token_count value is part of the body it counts, so we
80
+ # converge iteratively: measure → insert → remeasure. 2-3 passes
81
+ # are sufficient for typical cases.
82
+
83
+ def build_frontmatter_body(body_text, metadata)
84
+ lines = ["---"]
85
+ lines << yaml_value("title", metadata[:title])
86
+ lines << yaml_value("description", metadata[:description])
87
+ lines << "---"
88
+
89
+ # Pass 1: measure without token_count.
90
+ pass1 = lines.compact.join("\n") + "\n\n" + body_text
91
+ count = Myrr::Tokenizer.count(pass1)
92
+
93
+ # Pass 2: insert and measure.
94
+ lines.insert(-2, "token_count: #{count}")
95
+ pass2 = lines.compact.join("\n") + "\n\n" + body_text
96
+ true_count = Myrr::Tokenizer.count(pass2)
97
+
98
+ # If still off, rebuild one more time.
99
+ if true_count != count
100
+ lines[-2] = "token_count: #{true_count}"
101
+ lines.compact.join("\n") + "\n\n" + body_text
102
+ else
103
+ pass2
104
+ end
105
+ end
106
+
107
+ def yaml_value(key, value)
108
+ return nil if value.nil?
109
+ "#{key}: \"#{value.to_s.gsub('"', '\\"')}\""
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,93 @@
1
+ module Myrr
2
+ module Rack
3
+ # Rack middleware that detects X-Myrr-Agent headers on incoming requests,
4
+ # performs challenge-response verification against the protocol server
5
+ # (or uses a configured identity_adapter), and sets
6
+ # +request.env["myrr.current_agent"]+.
7
+ #
8
+ # After the inner app runs, if the response is text/markdown, it counts
9
+ # the tokens and adds +env["myrr.token_count"]+ and the
10
+ # +X-Myrr-Tokens+ response header.
11
+ class Middleware
12
+ # @param app [Rack app] The downstream Rack application
13
+ # @param myrr [Module] The Myrr module (defaults to Myrr)
14
+ def initialize(app, myrr_module = nil)
15
+ @app = app
16
+ @myrr = myrr_module || Myrr
17
+ end
18
+
19
+ # @param env [Hash] Rack environment
20
+ def call(env)
21
+ agent_did = env["HTTP_X_MYRR_AGENT"]
22
+
23
+ if agent_did && !agent_did.empty?
24
+ begin
25
+ agent_info = @myrr.verify_agent(agent_did)
26
+ if agent_info
27
+ env["myrr.current_agent"] = agent_info
28
+ override_format_to_markdown(env) if should_override_format?(env)
29
+ end
30
+ rescue => e
31
+ if @myrr.config&.fail_open
32
+ warn "[myrr] Agent verification failed (fail_open=true): #{e.message}"
33
+ else
34
+ return fail_response(env, e.message)
35
+ end
36
+ end
37
+ end
38
+
39
+ status, headers, body = @app.call(env)
40
+
41
+ # Token counting for markdown responses
42
+ if env["myrr.current_agent"] && content_type_is_markdown?(headers)
43
+ body_text = extract_body(body)
44
+ token_count = @myrr::Tokenizer.count(body_text)
45
+ env["myrr.token_count"] = token_count
46
+ headers["X-Myrr-Tokens"] = token_count.to_s
47
+ end
48
+
49
+ [status, headers, body]
50
+ end
51
+
52
+ private
53
+
54
+ def content_type_is_markdown?(headers)
55
+ content_type = headers["content-type"].to_s
56
+ content_type.include?("text/markdown")
57
+ end
58
+
59
+ def extract_body(body)
60
+ body_text = +""
61
+ body.each { |chunk| body_text << chunk.to_s }
62
+ body_text
63
+ end
64
+
65
+ # Determine whether to override the request format to markdown.
66
+ def should_override_format?(env)
67
+ return false unless @myrr.config&.auto_format_md != false
68
+
69
+ path = env["PATH_INFO"].to_s
70
+ return false if path.match?(/\.[a-z0-9]+\z/)
71
+
72
+ accept = env["HTTP_ACCEPT"].to_s.strip
73
+ return false unless accept.empty? || accept == "*/*"
74
+
75
+ true
76
+ end
77
+
78
+ def override_format_to_markdown(env)
79
+ env["myrr.original_accept"] = env["HTTP_ACCEPT"]
80
+ env["HTTP_ACCEPT"] = "text/markdown"
81
+ end
82
+
83
+ def fail_response(env, message)
84
+ body = JSON.generate({
85
+ error: "identity_verification_failed",
86
+ message: message
87
+ })
88
+
89
+ [502, { "content-type" => "application/json" }, [body]]
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,25 @@
1
+ require_relative "port/metadata"
2
+
3
+ module Myrr
4
+ # Rails integration for myrr-rb.
5
+ #
6
+ # Automatically:
7
+ # 1. Registers the +text/markdown+ MIME type
8
+ # 2. Mounts the port middleware for token counting and front-matter
9
+ # 3. Injects the metadata concern into all controllers
10
+ class Railtie < Rails::Railtie
11
+ initializer "myrr.register_mime_type" do
12
+ Mime::Type.register "text/markdown", :md
13
+ end
14
+
15
+ initializer "myrr.port_middleware" do |app|
16
+ app.config.middleware.use Myrr::Port::Middleware
17
+ end
18
+
19
+ initializer "myrr.port_metadata" do
20
+ ActiveSupport.on_load(:action_controller_base) do
21
+ include Myrr::Port::Metadata
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ require "tiktoken_ruby"
2
+
3
+ module Myrr
4
+ # Token counting for agent responses.
5
+ #
6
+ # Uses OpenAI's cl100k_base encoding via tiktoken_ruby to count tokens
7
+ # in rendered markdown responses.
8
+ #
9
+ # @example
10
+ # Myrr::Tokenizer.count("Hello, world!") # => 4
11
+ module Tokenizer
12
+ ENCODING_MODEL = "gpt-4" # Uses cl100k_base encoding
13
+
14
+ class << self
15
+ # Count the number of tokens in the given text.
16
+ #
17
+ # Uses OpenAI's cl100k_base encoding via tiktoken_ruby.
18
+ #
19
+ # @param text [String] The text to count tokens for
20
+ # @return [Integer] Number of tokens
21
+ def count(text)
22
+ return 0 if text.nil? || text.strip.empty?
23
+ enc = Tiktoken.encoding_for_model(ENCODING_MODEL)
24
+ enc.encode(text).length
25
+ end
26
+ end
27
+ end
28
+ end