marginfuse 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6594a128c2a5670e8eef2c8da7e21a96c305c3e13f66d4262889169c95df5a8a
4
- data.tar.gz: 96e931bd8cf686b799f306483043c965f81f683eba6d377ce3d5d4bd18d2297e
3
+ metadata.gz: c0b1ac060efeb37a1cff83a21bfec10f5e5972c67622a637100b00746a9defbe
4
+ data.tar.gz: '0058b2089e6d46488e0db908ad66e675f83e1b4a8116abdb2edef4a557e7445f'
5
5
  SHA512:
6
- metadata.gz: 139be111ee7a9d54f9c93fd4b2a00712c27c7c2a8ddf0407a7806cd05a43c230dd1714be49654b053cdf3b4db756503f1516ccb42a9f54b859b63acf6924b9c2
7
- data.tar.gz: 45f3c6d52858f692f327c7be010b7a082d7ee457f027c028c7d1cec7d8128eea825600d3d40b0e1fe0f5ef3065a8c9bdea2e648f2fc8f7f7ce4730e9fb0700c4
6
+ metadata.gz: 79059326eec63bb75f28c62b93d144d236d14450d477100be60423b7a6a655dfba9556277f9452ae55f204b8c90e60bb43fcc9008fe3bfedc5b4dbf8f0c334a8
7
+ data.tar.gz: 0f8bea6add4ee66ac6badb04073b362b08f1ab22524479407b9cd1b475b7d2f152e69d020a41c712eadef3dcf5fd667bc82ce005edb8e39b3682bc4f7734ceb9
data/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
5
  follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.0]
8
+
9
+ ### Fixed
10
+
11
+ - A downgrade that crosses vendors is reported against the vendor that actually
12
+ ran it. `guard()` already ran the model the server chose, but the usage event
13
+ still named the requested provider, so the call was priced from the wrong
14
+ catalog and the saving the downgrade exists to prove was computed against the
15
+ wrong basis. An `allow` is unchanged, because the decision already defaults
16
+ its provider to the requested one.
17
+ - A downgrade whose provider call then fails is acknowledged as
18
+ `used_downgrade_model` rather than `proceeded_as_requested`. The cheaper model
19
+ did run; what failed came after. Reporting otherwise told reconciliation the
20
+ policy never applied, which skewed realized-savings attribution on the error
21
+ path.
22
+
23
+ ### Changed
24
+
25
+ - Pinned contract v2, whose new scenarios cover both corrections above and add
26
+ a privacy check that hands the SDK content-bearing fields and scans the bytes
27
+ that actually leave the process.
28
+
29
+ ## [0.2.0]
30
+
31
+ ### Added
32
+
33
+ - `identify`: tell MarginFuse who a customer is and which plan they are on.
34
+
35
+ MarginFuse can now compute margin without a revenue source connected, from
36
+ plans you declare in Settings and a plan assigned per customer. This call is
37
+ how your application assigns that plan itself.
38
+
39
+ ```ruby
40
+ mf.identify(customer_id: "user_8x2m91", plan: "pro", name: "Acme Studio")
41
+ ```
42
+
43
+ `plan` is the key of a plan declared in MarginFuse, not a Stripe price id.
44
+ Safe to call on every sign-in: sending the plan the customer is already on
45
+ changes nothing. `period_start` backdates the cycle, `clear_plan` ends it.
46
+
47
+ Unlike `track`, this one reports failure instead of failing quietly. A wrong
48
+ plan is a wrong margin, and there is no safe default for "I could not record
49
+ what this customer pays". Check `result.ok?`; `on_error` is called too. It
50
+ still never raises into your code.
51
+
52
+ - `plan` on `track`, `guard` and `decide`, so a plan can ride along with usage
53
+ rather than needing its own call. There it is a hint: a key that does not
54
+ resolve is ignored rather than failing your event, because usage must never
55
+ be lost to a plan note.
56
+
57
+ Both are additive. Existing code keeps working unchanged.
58
+
7
59
  ## [0.1.0]
8
60
 
9
61
  First release. Ruby 3.2+, zero dependencies, standard library only.
data/README.md CHANGED
@@ -96,6 +96,37 @@ There is no failure a caller should branch on. A decision that times out or
96
96
  errors is an *allow* with `degraded?` true, because MarginFuse being unreachable
97
97
  must never become your outage. Transport failures go to `on_error`.
98
98
 
99
+ ## Tell MarginFuse what a customer pays
100
+
101
+ Margin needs a revenue side. With Stripe connected it comes from there. Without
102
+ one, you declare your plans in MarginFuse and say which plan each customer is
103
+ on:
104
+
105
+ ```ruby
106
+ result = mf.identify(
107
+ customer_id: "user_8x2m91",
108
+ plan: "pro", # the key of a plan you declared in Settings
109
+ name: "Acme Studio",
110
+ metadata: { "tier" => "legacy" } # labels segment policies can match on
111
+ )
112
+
113
+ warn "MarginFuse identify: #{result.error}" unless result.ok?
114
+ ```
115
+
116
+ Safe to call on every sign-in: sending the plan the customer is already on
117
+ changes nothing. Sending a different one ends the current cycle and prorates
118
+ what accrued. `period_start` backdates the cycle for a customer who has been
119
+ paying since an earlier date; `clear_plan: true` takes them off plans.
120
+
121
+ This is the one call that does not fail open. `track` retries later and
122
+ `decide` allows, because both have a safe default; "I could not record what
123
+ this customer pays" has none, and a wrong plan is a wrong margin. So it reports
124
+ the failure to you instead of swallowing it. It still never raises.
125
+
126
+ `track`, `guard` and `decide` also accept a `plan`, so it can ride along with
127
+ usage rather than needing its own call. There it is a hint: a key that does not
128
+ resolve is ignored rather than failing your event.
129
+
99
130
  ## OpenRouter and other gateways
100
131
 
101
132
  Gateways report the real cost of every call. Forward it and your figures are
@@ -63,9 +63,10 @@ module MarginFuse
63
63
  # outage.
64
64
  #
65
65
  # @return [Decision]
66
- def decide(customer_id:, provider:, model:, feature: nil, expected_usage: nil)
66
+ def decide(customer_id:, provider:, model:, plan: nil, feature: nil, expected_usage: nil)
67
67
  body = {
68
68
  "customerId" => customer_id,
69
+ "plan" => plan,
69
70
  "feature" => feature,
70
71
  "provider" => provider,
71
72
  "model" => model,
@@ -106,12 +107,14 @@ module MarginFuse
106
107
  # Call {#flush} before the process exits, or the last events go with it.
107
108
  #
108
109
  # @return [void]
109
- def track(customer_id:, provider:, model:, usage: nil, feature: nil, requested_model: nil,
110
- cost_usd: nil, event_id: nil, occurred_at: nil, outcome: :success,
111
- decision_id: nil, retry_of_event_id: nil, corrects_event_id: nil)
110
+ def track(customer_id:, provider:, model:, usage: nil, plan: nil, feature: nil,
111
+ requested_model: nil, cost_usd: nil, event_id: nil, occurred_at: nil,
112
+ outcome: :success, decision_id: nil, retry_of_event_id: nil,
113
+ corrects_event_id: nil)
112
114
  event = {
113
115
  "eventId" => event_id || "evt_#{SecureRandom.uuid}",
114
116
  "customerId" => customer_id,
117
+ "plan" => plan,
115
118
  "feature" => feature,
116
119
  "provider" => provider,
117
120
  "model" => model,
@@ -128,6 +131,58 @@ module MarginFuse
128
131
  background { send_event(event) }
129
132
  end
130
133
 
134
+ # Tells MarginFuse who a customer is and what plan they are on.
135
+ #
136
+ # +plan+ is the key of a plan you declared in MarginFuse Settings, not a
137
+ # Stripe price id. MarginFuse derives that customer's revenue from the
138
+ # plan's price for every cycle, which is what makes margin per customer and
139
+ # margin policies work with no revenue source connected. Those figures are
140
+ # labeled as a declared price wherever they appear, because nobody
141
+ # confirmed collection.
142
+ #
143
+ # Safe to call on every sign-in: sending the plan the customer is already
144
+ # on changes nothing. Sending a different one ends the current cycle at
145
+ # that moment and prorates what accrued. +period_start+ backdates the cycle;
146
+ # +clear_plan+ takes the customer off plans entirely.
147
+ #
148
+ # Unlike {#track}, this waits and reports failure. track has a safe default
149
+ # - retry later - and "I could not record what this customer pays" has
150
+ # none, because a wrong plan is a wrong margin. It still never raises:
151
+ # check +result.ok?+, and +on_error+ is called too.
152
+ #
153
+ # @return [IdentifyResult]
154
+ def identify(customer_id:, plan: nil, clear_plan: false, period_start: nil,
155
+ name: nil, email: nil, metadata: nil)
156
+ body = {
157
+ "customerId" => customer_id,
158
+ "plan" => plan,
159
+ "clearPlan" => (true if clear_plan),
160
+ "periodStart" => period_start&.utc&.iso8601(6),
161
+ "name" => name,
162
+ "email" => email,
163
+ "metadata" => metadata
164
+ }.compact
165
+
166
+ response = post("/v1/identify", body, 5.0)
167
+ unless (200..299).cover?(response.code.to_i)
168
+ error = RuntimeError.new("identify: HTTP #{response.code}")
169
+ report(error, "identify")
170
+ return IdentifyResult.new(ok: false, error: error.message)
171
+ end
172
+
173
+ parsed = JSON.parse(response.body)
174
+ IdentifyResult.new(
175
+ ok: true,
176
+ customer_id: parsed["customerId"],
177
+ plan: parsed["plan"],
178
+ period_start: parsed["periodStart"],
179
+ period_end: parsed["periodEnd"]
180
+ )
181
+ rescue StandardError => e
182
+ report(e, "identify")
183
+ IdentifyResult.new(ok: false, error: e.message)
184
+ end
185
+
131
186
  # {#track} for jobs and scripts that must not exit early.
132
187
  # @return [void]
133
188
  def track_and_wait(**params)
@@ -153,7 +208,8 @@ module MarginFuse
153
208
  #
154
209
  # Yields the decision to the block, which must return a hash with +:usage+
155
210
  # and optionally +:result+, +:cost_usd+ and +:outcome+. Use
156
- # +decision.model+: a downgrade verdict changes it.
211
+ # +decision.model+ and +decision.provider+: a downgrade verdict changes
212
+ # them, and it can send you to another vendor.
157
213
  #
158
214
  # It yields rather than returning a decision for you to act on, because
159
215
  # enforcement must not depend on the caller remembering to check anything.
@@ -164,9 +220,9 @@ module MarginFuse
164
220
  # provider may still have charged for it.
165
221
  #
166
222
  # @return [GuardOutcome]
167
- def guard(customer_id:, provider:, model:, feature: nil, expected_usage: nil)
223
+ def guard(customer_id:, provider:, model:, plan: nil, feature: nil, expected_usage: nil)
168
224
  decision = decide(customer_id: customer_id, provider: provider, model: model,
169
- feature: feature, expected_usage: expected_usage)
225
+ plan: plan, feature: feature, expected_usage: expected_usage)
170
226
 
171
227
  # Enforcement depends on the ACTION alone. A missing id costs an
172
228
  # acknowledgment; it must never turn a block into a provider call.
@@ -179,27 +235,34 @@ module MarginFuse
179
235
  return GuardOutcome.new(kind: :topup_required, decision: decision)
180
236
  end
181
237
 
238
+ # A downgrade can cross vendors, so the vendor that ran the call comes
239
+ # from the decision exactly as the model does. Reporting the caller's
240
+ # would price the call from the wrong catalog and credit the saving to the
241
+ # wrong vendor. The decision defaults both to what was asked for, so
242
+ # anything but a downgrade reports what it always did.
182
243
  model_used = decision.action == :downgrade ? decision.model : model
244
+ provider_used = decision.action == :downgrade ? decision.provider : provider
245
+ # A downgrade whose provider call then fails is still a downgrade: the
246
+ # cheaper model is the one that ran, so both paths acknowledge the same.
247
+ acknowledgment =
248
+ decision.action == :downgrade ? :used_downgrade_model : :proceeded_as_requested
183
249
 
184
250
  begin
185
251
  call = yield(decision)
186
252
  rescue Exception => e # rubocop:disable Lint/RescueException
187
- track(customer_id: customer_id, feature: feature, provider: provider,
253
+ track(customer_id: customer_id, plan: plan, feature: feature, provider: provider_used,
188
254
  model: model_used, requested_model: model, usage: {},
189
255
  outcome: :provider_error, decision_id: decision.id)
190
- acknowledge(decision.id, :proceeded_as_requested) if decision.id
256
+ acknowledge(decision.id, acknowledgment) if decision.id
191
257
  raise e
192
258
  end
193
259
 
194
260
  call ||= {}
195
- track(customer_id: customer_id, feature: feature, provider: provider,
261
+ track(customer_id: customer_id, plan: plan, feature: feature, provider: provider_used,
196
262
  model: model_used, requested_model: model, usage: call[:usage],
197
263
  cost_usd: call[:cost_usd], outcome: call[:outcome] || :success,
198
264
  decision_id: decision.id)
199
- if decision.id
200
- acknowledge(decision.id,
201
- decision.action == :downgrade ? :used_downgrade_model : :proceeded_as_requested)
202
- end
265
+ acknowledge(decision.id, acknowledgment) if decision.id
203
266
 
204
267
  GuardOutcome.new(kind: :completed, decision: decision, result: call[:result])
205
268
  end
@@ -29,6 +29,23 @@ module MarginFuse
29
29
  end
30
30
  end
31
31
 
32
+ # What {Client#identify} recorded, or why it could not.
33
+ #
34
+ # +ok+ is the only field to branch on. When it is false the call changed
35
+ # nothing and +error+ says what happened; the SDK still did not raise.
36
+ #
37
+ # Unlike {Client#track}, identify reports its failures. track has a safe
38
+ # default - retry later - and "I could not record what this customer pays"
39
+ # has none: a wrong plan is a wrong margin.
40
+ IdentifyResult = Struct.new(
41
+ :ok, :customer_id, :plan, :period_start, :period_end, :error,
42
+ keyword_init: true
43
+ ) do
44
+ def ok?
45
+ !!ok
46
+ end
47
+ end
48
+
32
49
  # The result of the whole guard loop. +kind+ is :completed, :blocked or
33
50
  # :topup_required. +result+ is your block's own return value.
34
51
  GuardOutcome = Struct.new(:kind, :decision, :result, keyword_init: true) do
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MarginFuse
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
 
6
6
  # The version of the shared SDK contract this build was verified against.
7
7
  #
@@ -12,5 +12,5 @@ module MarginFuse
12
12
  # same vectors.
13
13
  #
14
14
  # See github.com/marginfuse/sdk-contract
15
- CONTRACT_VERSION = 1
15
+ CONTRACT_VERSION = 2
16
16
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: marginfuse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pemira Labs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-04 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |-
14
14
  MarginFuse server-side SDK. Connect revenue to per-request AI cost, see gross