marginfuse 0.2.0 → 0.3.1

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: ed4b052cde767f31778131e10df10f850d206c4db7f2215040de98faec393af0
4
- data.tar.gz: 428cb94a4937c73876d998db48026aea9b2f8b69730b1149bd5e3947051a19c9
3
+ metadata.gz: cae1d3d92e08b1a32116bacfdb72d19db66577325fb861231642792c57269499
4
+ data.tar.gz: 06f74aaad11706184f3bb865b4138e1a960ecc21db9ea88f94a805aabc8fed2f
5
5
  SHA512:
6
- metadata.gz: e7d968bfd7aa9d82acaa132b72402396cca466b6940d825da506326b7a508376dcbacf203cc16529a7f8d98c0a52f9e3b6d04ba3341820ca1eea8541b0443bb5
7
- data.tar.gz: 2cd18e81de9e717d56607177da46c46c758bb46a443c9bebe2298e16ad68873156bd1e5123d3b09c24460f68823d53ec7064d942be43da0938d52e07fa2b1a5e
6
+ metadata.gz: ea248283d17a1d94b6344cb75f2369622b98dd58c8f972c206100bf92fb29b7735ce6eb7eae33cfdc39bff4485f94a1dae7a7f768e2a438c452e391ea0b00494
7
+ data.tar.gz: 3394d7d8f8376c3721686bf11a2cf83687434d369a5e13856d95785b34da97ac720601063ca065915721cc446cad3c2241611300fbc04e36ba7eecd87363b2a5
data/CHANGELOG.md CHANGED
@@ -1,9 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ - Validate successful decision responses before using routing fields. Malformed
6
+ responses fail open to the original provider and model, mark the decision
7
+ degraded, and invoke the error hook. Valid blocks without an id still block.
8
+ - Correct OpenAI examples to separate cached input tokens from fresh input,
9
+ and clarify provider dispatch for cross-provider downgrade policies.
10
+
11
+
3
12
  All notable changes to this project are documented here. The format follows
4
13
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
14
  follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
15
 
16
+ ## [0.3.0]
17
+
18
+ ### Fixed
19
+
20
+ - A downgrade that crosses vendors is reported against the vendor that actually
21
+ ran it. `guard()` already ran the model the server chose, but the usage event
22
+ still named the requested provider, so the call was priced from the wrong
23
+ catalog and the saving the downgrade exists to prove was computed against the
24
+ wrong basis. An `allow` is unchanged, because the decision already defaults
25
+ its provider to the requested one.
26
+ - A downgrade whose provider call then fails is acknowledged as
27
+ `used_downgrade_model` rather than `proceeded_as_requested`. The cheaper model
28
+ did run; what failed came after. Reporting otherwise told reconciliation the
29
+ policy never applied, which skewed realized-savings attribution on the error
30
+ path.
31
+
32
+ ### Changed
33
+
34
+ - Pinned contract v2, whose new scenarios cover both corrections above and add
35
+ a privacy check that hands the SDK content-bearing fields and scans the bytes
36
+ that actually leave the process.
37
+
7
38
  ## [0.2.0]
8
39
 
9
40
  ### Added
data/README.md CHANGED
@@ -26,7 +26,9 @@ bundle add marginfuse
26
26
 
27
27
  ## Track an AI call
28
28
 
29
- Monitoring. One call after each AI request, metadata only.
29
+ Monitoring. One call after each AI request, metadata only. OpenAI prompt tokens
30
+ include cached reads: subtract them from input and report them separately to
31
+ avoid pricing cached tokens at the full input rate.
30
32
 
31
33
  ```ruby
32
34
  require "marginfuse"
@@ -34,6 +36,7 @@ require "marginfuse"
34
36
  mf = MarginFuse.new(api_key: ENV.fetch("MARGINFUSE_KEY"))
35
37
 
36
38
  response = client.chat(model: "gpt-4.1", messages: messages)
39
+ cached = response.usage.prompt_tokens_details&.cached_tokens || 0
37
40
 
38
41
  mf.track(
39
42
  customer_id: "cus_8x2m91", # your Stripe customer id, or your own
@@ -41,7 +44,8 @@ mf.track(
41
44
  provider: "openai",
42
45
  model: "gpt-4.1",
43
46
  usage: {
44
- input_tokens: response.usage.prompt_tokens,
47
+ input_tokens: [0, response.usage.prompt_tokens - cached].max,
48
+ cached_input_tokens: cached,
45
49
  output_tokens: response.usage.completion_tokens
46
50
  }
47
51
  )
@@ -53,7 +57,9 @@ or the last events go with it.
53
57
 
54
58
  ## Guard a call
55
59
 
56
- Protection. Ask before the call runs, and act on the answer.
60
+ Protection. Ask before the call runs, and act on the answer. This example assumes
61
+ same-provider downgrades; cross-provider policies require dispatching to the
62
+ client named by the decision provider.
57
63
 
58
64
  ```ruby
59
65
  outcome = mf.guard(
@@ -64,10 +70,12 @@ outcome = mf.guard(
64
70
  ) do |decision|
65
71
  # decision.model is the one to call: a downgrade verdict changes it.
66
72
  response = client.chat(model: decision.model, messages: messages)
73
+ cached = response.usage.prompt_tokens_details&.cached_tokens || 0
67
74
  {
68
75
  result: response,
69
76
  usage: {
70
- input_tokens: response.usage.prompt_tokens,
77
+ input_tokens: [0, response.usage.prompt_tokens - cached].max,
78
+ cached_input_tokens: cached,
71
79
  output_tokens: response.usage.completion_tokens
72
80
  }
73
81
  }
@@ -98,9 +106,11 @@ must never become your outage. Transport failures go to `on_error`.
98
106
 
99
107
  ## Tell MarginFuse what a customer pays
100
108
 
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:
109
+ Margin needs a revenue side: Stripe for web billing, RevenueCat for App Store
110
+ and Google Play proceeds, or declared plan prices. RevenueCat joins by App User
111
+ ID; use that same ID in your events. Without a billing connection, declare your
112
+ plans in MarginFuse and say which plan each customer is on. Declared revenue
113
+ is unverified and does not confirm payment:
104
114
 
105
115
  ```ruby
106
116
  result = mf.identify(
@@ -182,7 +192,7 @@ MARGINFUSE = MarginFuse.new(
182
192
  Everything, and nothing else:
183
193
 
184
194
  ```
185
- event_id customer_id feature provider model requested_model
195
+ event_id customer_id feature provider model requested_model plan
186
196
  usage(input_tokens, output_tokens, cached_input_tokens,
187
197
  cache_creation_tokens, images, audio_seconds)
188
198
  cost_usd occurred_at outcome decision_id retry_of_event_id corrects_event_id
@@ -81,6 +81,10 @@ module MarginFuse
81
81
  end
82
82
 
83
83
  parsed = JSON.parse(response.body)
84
+ unless valid_wire_decision?(parsed)
85
+ report(RuntimeError.new("decide: invalid response payload"), "decide")
86
+ return fail_open(provider, model, "invalid response")
87
+ end
84
88
  Decision.new(
85
89
  id: parsed["id"],
86
90
  action: Decision.action_from_wire(parsed["action"]),
@@ -208,7 +212,8 @@ module MarginFuse
208
212
  #
209
213
  # Yields the decision to the block, which must return a hash with +:usage+
210
214
  # and optionally +:result+, +:cost_usd+ and +:outcome+. Use
211
- # +decision.model+: a downgrade verdict changes it.
215
+ # +decision.model+ and +decision.provider+: a downgrade verdict changes
216
+ # them, and it can send you to another vendor.
212
217
  #
213
218
  # It yields rather than returning a decision for you to act on, because
214
219
  # enforcement must not depend on the caller remembering to check anything.
@@ -234,27 +239,34 @@ module MarginFuse
234
239
  return GuardOutcome.new(kind: :topup_required, decision: decision)
235
240
  end
236
241
 
242
+ # A downgrade can cross vendors, so the vendor that ran the call comes
243
+ # from the decision exactly as the model does. Reporting the caller's
244
+ # would price the call from the wrong catalog and credit the saving to the
245
+ # wrong vendor. The decision defaults both to what was asked for, so
246
+ # anything but a downgrade reports what it always did.
237
247
  model_used = decision.action == :downgrade ? decision.model : model
248
+ provider_used = decision.action == :downgrade ? decision.provider : provider
249
+ # A downgrade whose provider call then fails is still a downgrade: the
250
+ # cheaper model is the one that ran, so both paths acknowledge the same.
251
+ acknowledgment =
252
+ decision.action == :downgrade ? :used_downgrade_model : :proceeded_as_requested
238
253
 
239
254
  begin
240
255
  call = yield(decision)
241
256
  rescue Exception => e # rubocop:disable Lint/RescueException
242
- track(customer_id: customer_id, plan: plan, feature: feature, provider: provider,
257
+ track(customer_id: customer_id, plan: plan, feature: feature, provider: provider_used,
243
258
  model: model_used, requested_model: model, usage: {},
244
259
  outcome: :provider_error, decision_id: decision.id)
245
- acknowledge(decision.id, :proceeded_as_requested) if decision.id
260
+ acknowledge(decision.id, acknowledgment) if decision.id
246
261
  raise e
247
262
  end
248
263
 
249
264
  call ||= {}
250
- track(customer_id: customer_id, plan: plan, feature: feature, provider: provider,
265
+ track(customer_id: customer_id, plan: plan, feature: feature, provider: provider_used,
251
266
  model: model_used, requested_model: model, usage: call[:usage],
252
267
  cost_usd: call[:cost_usd], outcome: call[:outcome] || :success,
253
268
  decision_id: decision.id)
254
- if decision.id
255
- acknowledge(decision.id,
256
- decision.action == :downgrade ? :used_downgrade_model : :proceeded_as_requested)
257
- end
269
+ acknowledge(decision.id, acknowledgment) if decision.id
258
270
 
259
271
  GuardOutcome.new(kind: :completed, decision: decision, result: call[:result])
260
272
  end
@@ -274,6 +286,23 @@ module MarginFuse
274
286
 
275
287
  private
276
288
 
289
+ def valid_wire_decision?(value)
290
+ return false unless value.is_a?(Hash)
291
+ return false unless %w[allow block topup_required downgrade].include?(value["action"])
292
+
293
+ %w[id model provider topupContext degradedReason].each do |key|
294
+ return false if value.key?(key) && !value[key].is_a?(String)
295
+ end
296
+ if value.key?("degraded") && value["degraded"] != true && value["degraded"] != false
297
+ return false
298
+ end
299
+
300
+ %w[model provider].each do |key|
301
+ return false if value.key?(key) && value[key].strip.empty?
302
+ end
303
+ value["action"] != "downgrade" || value.key?("model")
304
+ end
305
+
277
306
  def fail_open(provider, model, reason)
278
307
  Decision.new(id: nil, action: :allow, model: model, provider: provider,
279
308
  topup_context: nil, degraded: true, degraded_reason: reason)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MarginFuse
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
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.2.0
4
+ version: 0.3.1
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-03 00:00:00.000000000 Z
11
+ date: 2026-09-06 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