apipurse-sdk 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: a0fdd0668219be947c676430083275ccfd4e9007ed855e2f03e6cb9677fefcd2
4
+ data.tar.gz: 9993092dd2bb386e08dfba2a98fd8014fe194b28609081dcd1ecc891daa8bddb
5
+ SHA512:
6
+ metadata.gz: 2a2ab7d9d4152dea0402cd355f3a67cf59985d8f7053dca26be0eb8451e11b502a793f554ab0a868061cb757f1a0d8794ebe16b89ca865745ee21f9027346a75
7
+ data.tar.gz: c7812964ac4cbb85719e19afc8b40faa68576882d823b80688eea41ec7c9b07ced8587b2443bdca2768ba426124ba9f8fa243bc3c406be4137a78139c4c64a2f
@@ -0,0 +1,97 @@
1
+ # Apipurse::WrapAnthropic.call(client, apipurse) — automatic, zero-call-site
2
+ # cost tracking for the official `anthropic` Ruby gem. Same design as
3
+ # Apipurse::WrapOpenAI — see wrap_openai.rb for the full "this is not a
4
+ # proxy" explanation, which applies identically here.
5
+ #
6
+ # Usage:
7
+ #
8
+ # client = Anthropic::Client.new
9
+ # apipurse = Apipurse::Client.new(
10
+ # ingest_token: ENV["APIPURSE_INGEST_TOKEN"],
11
+ # provider_id: ENV["APIPURSE_PROVIDER_ID"],
12
+ # base_url: "https://your-apipurse-domain.com"
13
+ # )
14
+ # Apipurse::WrapAnthropic.call(client, apipurse)
15
+ #
16
+ # message = client.messages.create(
17
+ # model: "claude-sonnet-4-20250514",
18
+ # max_tokens: 1024,
19
+ # messages: [{ role: "user", content: "Hello" }]
20
+ # )
21
+ #
22
+ # Streaming: unlike OpenAI, Anthropic's stream always carries token counts in
23
+ # its `message_start` and `message_delta` events — no extra request flag
24
+ # needed.
25
+ module Apipurse
26
+ module WrapAnthropic
27
+ def self.call(client, apipurse, on_track: nil)
28
+ messages = client.messages
29
+ original = messages.method(:create)
30
+
31
+ messages.define_singleton_method(:create) do |*args, **kwargs, &block|
32
+ result = original.call(*args, **kwargs, &block)
33
+
34
+ if result.respond_to?(:usage)
35
+ Apipurse::WrapAnthropic.report(result, apipurse, on_track)
36
+ result
37
+ elsif result.respond_to?(:each)
38
+ Apipurse::WrapAnthropic.wrap_stream(result, apipurse, on_track)
39
+ else
40
+ result
41
+ end
42
+ end
43
+
44
+ client
45
+ end
46
+
47
+ def self.report(message, apipurse, on_track)
48
+ usage = message.respond_to?(:usage) ? message.usage : nil
49
+ return unless usage
50
+
51
+ result = apipurse.track_completion(
52
+ model: message.model,
53
+ input_tokens: (usage.respond_to?(:input_tokens) ? usage.input_tokens : nil) || 0,
54
+ output_tokens: (usage.respond_to?(:output_tokens) ? usage.output_tokens : nil) || 0
55
+ )
56
+ on_track&.call(result)
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def self.wrap_stream(stream, apipurse, on_track)
62
+ Enumerator.new do |yielder|
63
+ model = nil
64
+ input_tokens = 0
65
+ output_tokens = 0
66
+
67
+ stream.each do |event|
68
+ event_type = event.respond_to?(:type) ? event.type : nil
69
+
70
+ if event_type == "message_start" && event.respond_to?(:message) && event.message
71
+ model = event.message.model
72
+ usage = event.message.respond_to?(:usage) ? event.message.usage : nil
73
+ if usage
74
+ input_tokens = usage.respond_to?(:input_tokens) ? (usage.input_tokens || input_tokens) : input_tokens
75
+ output_tokens = usage.respond_to?(:output_tokens) ? (usage.output_tokens || output_tokens) : output_tokens
76
+ end
77
+ end
78
+
79
+ if event_type == "message_delta" && event.respond_to?(:usage) && event.usage
80
+ output_tokens = event.usage.respond_to?(:output_tokens) ? (event.usage.output_tokens || output_tokens) : output_tokens
81
+ end
82
+
83
+ yielder << event
84
+ end
85
+
86
+ next unless model
87
+
88
+ begin
89
+ result = apipurse.track_completion(model: model, input_tokens: input_tokens, output_tokens: output_tokens)
90
+ on_track&.call(result)
91
+ rescue StandardError
92
+ nil
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,90 @@
1
+ # Apipurse::WrapOpenAI.call(client, apipurse) — automatic, zero-call-site
2
+ # cost tracking for the official `openai` Ruby gem.
3
+ #
4
+ # This is NOT a network proxy. The wrapper runs entirely inside your own
5
+ # process: it calls the real OpenAI client exactly as before, inspects the
6
+ # response your own code already received, and reports only the model ID +
7
+ # token counts to APIPurse's /api/track/completion endpoint.
8
+ #
9
+ # Usage:
10
+ #
11
+ # client = OpenAI::Client.new
12
+ # apipurse = Apipurse::Client.new(
13
+ # ingest_token: ENV["APIPURSE_INGEST_TOKEN"],
14
+ # provider_id: ENV["APIPURSE_PROVIDER_ID"],
15
+ # base_url: "https://your-apipurse-domain.com"
16
+ # )
17
+ # Apipurse::WrapOpenAI.call(client, apipurse)
18
+ #
19
+ # # Tracked automatically, no extra code here:
20
+ # completion = client.chat.completions.create(
21
+ # model: "gpt-4o",
22
+ # messages: [{ role: "user", content: "Hello" }]
23
+ # )
24
+ #
25
+ # Reporting failures (e.g. a gem version whose response shape doesn't match
26
+ # what this wrapper expects) are swallowed here rather than raised, so a
27
+ # tracking hiccup can never break your real completion call.
28
+ module Apipurse
29
+ module WrapOpenAI
30
+ def self.call(client, apipurse, on_track: nil)
31
+ completions = client.chat.completions
32
+ original = completions.method(:create)
33
+
34
+ completions.define_singleton_method(:create) do |*args, **kwargs, &block|
35
+ result = original.call(*args, **kwargs, &block)
36
+
37
+ if result.respond_to?(:usage)
38
+ Apipurse::WrapOpenAI.report(result, apipurse, on_track)
39
+ result
40
+ elsif result.respond_to?(:each)
41
+ Apipurse::WrapOpenAI.wrap_stream(result, apipurse, on_track)
42
+ else
43
+ result
44
+ end
45
+ end
46
+
47
+ client
48
+ end
49
+
50
+ def self.report(completion, apipurse, on_track)
51
+ usage = completion.respond_to?(:usage) ? completion.usage : nil
52
+ return unless usage
53
+
54
+ result = apipurse.track_completion(
55
+ model: completion.model,
56
+ input_tokens: (usage.respond_to?(:prompt_tokens) ? usage.prompt_tokens : nil) || 0,
57
+ output_tokens: (usage.respond_to?(:completion_tokens) ? usage.completion_tokens : nil) || 0
58
+ )
59
+ on_track&.call(result)
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
64
+ def self.wrap_stream(stream, apipurse, on_track)
65
+ Enumerator.new do |yielder|
66
+ last_model = nil
67
+ last_usage = nil
68
+
69
+ stream.each do |chunk|
70
+ last_model = chunk.model if chunk.respond_to?(:model) && chunk.model
71
+ last_usage = chunk.usage if chunk.respond_to?(:usage) && chunk.usage
72
+ yielder << chunk
73
+ end
74
+
75
+ next unless last_model && last_usage
76
+
77
+ begin
78
+ result = apipurse.track_completion(
79
+ model: last_model,
80
+ input_tokens: (last_usage.respond_to?(:prompt_tokens) ? last_usage.prompt_tokens : nil) || 0,
81
+ output_tokens: (last_usage.respond_to?(:completion_tokens) ? last_usage.completion_tokens : nil) || 0
82
+ )
83
+ on_track&.call(result)
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
data/lib/apipurse.rb ADDED
@@ -0,0 +1,84 @@
1
+ # APIPurse SDK (Ruby reference implementation) — real-time cost tracking.
2
+ #
3
+ # Unlike the OpenAI/Anthropic/Stripe connectors (which poll the provider's
4
+ # own cost report every few hours), this reports usage the instant your own
5
+ # code finishes a call, so it shows up on the dashboard immediately and can
6
+ # trigger a budget alert right away instead of hours later.
7
+ #
8
+ # Not a network proxy — nothing routes through APIPurse's servers. Only the
9
+ # model ID and token counts are ever sent to /api/track/completion.
10
+ #
11
+ # Dependency-free: uses only Net::HTTP and JSON from the standard library.
12
+ #
13
+ # Two integration styles, both call the same endpoint under the hood:
14
+ #
15
+ # 1. Automatic (recommended) — wrap your existing OpenAI/Anthropic client
16
+ # once at setup with Apipurse::WrapOpenAI/Apipurse::WrapAnthropic (see
17
+ # wrap_openai.rb / wrap_anthropic.rb) and every call is tracked with zero
18
+ # changes at each call site.
19
+ # 2. Manual — call track_completion() yourself right after a completion.
20
+
21
+ require "json"
22
+ require "net/http"
23
+ require "uri"
24
+
25
+ module Apipurse
26
+ TrackCompletionResult = Struct.new(:ok, :cost_usd, :alert_triggered, :error, keyword_init: true)
27
+
28
+ class Client
29
+ def initialize(ingest_token:, provider_id:, base_url: "http://localhost:3000", silent: true)
30
+ @ingest_token = ingest_token
31
+ @provider_id = provider_id
32
+ @base_url = base_url.chomp("/")
33
+ @silent = silent
34
+ end
35
+
36
+ # Reports one completion's token usage. Computes and stores the exact
37
+ # dollar cost immediately (via APIPurse's model price table) and checks
38
+ # your budget right away — no waiting for a scheduled sync.
39
+ def track_completion(model:, input_tokens:, output_tokens:, request_count: 1)
40
+ uri = URI("#{@base_url}/api/track/completion")
41
+ body = {
42
+ provider_id: @provider_id,
43
+ model: model,
44
+ input_tokens: input_tokens,
45
+ output_tokens: output_tokens,
46
+ request_count: request_count,
47
+ }.to_json
48
+
49
+ response = Net::HTTP.start(
50
+ uri.host, uri.port,
51
+ use_ssl: uri.scheme == "https",
52
+ open_timeout: 10, read_timeout: 10
53
+ ) do |http|
54
+ request = Net::HTTP::Post.new(uri)
55
+ request["Content-Type"] = "application/json"
56
+ request["Authorization"] = "Bearer #{@ingest_token}"
57
+ request.body = body
58
+ http.request(request)
59
+ end
60
+
61
+ payload = JSON.parse(response.body)
62
+
63
+ unless response.is_a?(Net::HTTPSuccess)
64
+ message = payload["error"] || "HTTP #{response.code}"
65
+ return TrackCompletionResult.new(ok: false, error: message) if @silent
66
+
67
+ raise "APIPurse tracking failed: #{message}"
68
+ end
69
+
70
+ TrackCompletionResult.new(
71
+ ok: payload["ok"] || false,
72
+ cost_usd: payload["cost_usd"],
73
+ alert_triggered: payload["alert_triggered"]
74
+ )
75
+ rescue StandardError => e
76
+ raise if !@silent
77
+
78
+ TrackCompletionResult.new(ok: false, error: e.message)
79
+ end
80
+ end
81
+ end
82
+
83
+ require_relative "apipurse/wrap_openai"
84
+ require_relative "apipurse/wrap_anthropic"
metadata ADDED
@@ -0,0 +1,47 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: apipurse-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - APIPurse
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-26 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Automatic OpenAI/Anthropic client wrappers plus a manual track_completion()
14
+ client. Not a network proxy: nothing routes through APIPurse''s servers.'
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/apipurse.rb
21
+ - lib/apipurse/wrap_anthropic.rb
22
+ - lib/apipurse/wrap_openai.rb
23
+ homepage: https://github.com/yelmorab/Apipurse
24
+ licenses:
25
+ - MIT
26
+ metadata:
27
+ source_code_uri: https://github.com/yelmorab/Apipurse
28
+ post_install_message:
29
+ rdoc_options: []
30
+ require_paths:
31
+ - lib
32
+ required_ruby_version: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: '3.0'
37
+ required_rubygems_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ requirements: []
43
+ rubygems_version: 3.0.3.1
44
+ signing_key:
45
+ specification_version: 4
46
+ summary: APIPurse real-time cost tracking SDK for Ruby
47
+ test_files: []