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 +7 -0
- data/lib/apipurse/wrap_anthropic.rb +97 -0
- data/lib/apipurse/wrap_openai.rb +90 -0
- data/lib/apipurse.rb +84 -0
- metadata +47 -0
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: []
|