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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +77 -0
- data/LICENSE +21 -0
- data/README.md +70 -0
- data/lib/generators/myrr/install/install_generator.rb +20 -0
- data/lib/generators/myrr/install/templates/initializer.rb +12 -0
- data/lib/myrr/bridge.rb +97 -0
- data/lib/myrr/budget.rb +67 -0
- data/lib/myrr/card.rb +54 -0
- data/lib/myrr/client.rb +158 -0
- data/lib/myrr/configuration.rb +58 -0
- data/lib/myrr/pay/client.rb +154 -0
- data/lib/myrr/pay/middleware.rb +186 -0
- data/lib/myrr/pay.rb +13 -0
- data/lib/myrr/port/metadata.rb +79 -0
- data/lib/myrr/port/middleware.rb +113 -0
- data/lib/myrr/rack/middleware.rb +93 -0
- data/lib/myrr/railtie.rb +25 -0
- data/lib/myrr/tokenizer.rb +28 -0
- data/lib/myrr/transaction.rb +57 -0
- data/lib/myrr/version.rb +3 -0
- data/lib/myrr/wallet.rb +131 -0
- data/lib/myrr-rb.rb +3 -0
- data/lib/myrr.rb +76 -0
- metadata +150 -0
|
@@ -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
|
data/lib/myrr/railtie.rb
ADDED
|
@@ -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
|