llmemory 0.2.3 → 0.2.5
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 +4 -4
- data/README.md +37 -2
- data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +5 -0
- data/lib/llmemory/cli/commands/stats.rb +5 -0
- data/lib/llmemory/crypto/cipher.rb +12 -0
- data/lib/llmemory/crypto/field_helpers.rb +25 -0
- data/lib/llmemory/instrumentation.rb +4 -2
- data/lib/llmemory/llm/anthropic.rb +8 -3
- data/lib/llmemory/llm/base.rb +42 -0
- data/lib/llmemory/llm/openai.rb +27 -12
- data/lib/llmemory/llm/response.rb +18 -0
- data/lib/llmemory/llm/tracking_client.rb +61 -0
- data/lib/llmemory/llm/usage.rb +31 -0
- data/lib/llmemory/llm/usage_ledger.rb +118 -0
- data/lib/llmemory/llm/usage_recorder.rb +37 -0
- data/lib/llmemory/llm.rb +5 -0
- data/lib/llmemory/long_term/episodic/memory.rb +12 -1
- data/lib/llmemory/long_term/episodic/storages/active_record_storage.rb +25 -7
- data/lib/llmemory/long_term/episodic/storages/database_storage.rb +28 -11
- data/lib/llmemory/long_term/file_based/storages/active_record_storage.rb +28 -34
- data/lib/llmemory/long_term/file_based/storages/base.rb +24 -0
- data/lib/llmemory/long_term/file_based/storages/database_storage.rb +50 -34
- data/lib/llmemory/long_term/file_based/storages/file_storage.rb +10 -2
- data/lib/llmemory/long_term/file_based/storages/memory_storage.rb +2 -28
- data/lib/llmemory/long_term/graph_based/memory.rb +13 -0
- data/lib/llmemory/long_term/graph_based/storage.rb +3 -1
- data/lib/llmemory/long_term/procedural/memory.rb +12 -1
- data/lib/llmemory/long_term/procedural/storages/active_record_storage.rb +40 -9
- data/lib/llmemory/long_term/procedural/storages/database_storage.rb +57 -17
- data/lib/llmemory/maintenance/search_tokens_backfill.rb +339 -0
- data/lib/llmemory/maintenance.rb +1 -0
- data/lib/llmemory/mcp/tools/memory_retrieve.rb +2 -0
- data/lib/llmemory/mcp/tools/memory_stats.rb +13 -0
- data/lib/llmemory/mcp/tools/memory_timeline.rb +48 -14
- data/lib/llmemory/mcp/tools/memory_timeline_context.rb +15 -5
- data/lib/llmemory/memory.rb +34 -15
- data/lib/llmemory/short_term/checkpoint.rb +2 -0
- data/lib/llmemory/vector_store/active_record_store.rb +6 -0
- data/lib/llmemory/vector_store/memory_store.rb +13 -3
- data/lib/llmemory/vector_store/openai_embeddings.rb +23 -2
- data/lib/llmemory/version.rb +1 -1
- data/lib/tasks/llmemory.rake +37 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb36583aecef9a4a0d4a89e703d1edf7326112df260ceccd1b181cf1411a45d1
|
|
4
|
+
data.tar.gz: b91c1b67b6c88c12d0515a03304aa04b0f61bc310acbb6e1b02391fbe9944d08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 597d6cd707b07ebc3c754e2ac67269143c6bff221a22529e692b8a4cc4fc4415180b83f97d7336f95e6eef025c75a18f64819daa740588f032052fccca1a3632
|
|
7
|
+
data.tar.gz: 7b9ec61f4edb2cc053e86f1f478e7a23c82784732fbc918ca6bf35756583e568c92039c7a1cdf2939c6066df76e85ee99176f03b44c82ae708f2705093cb67e8
|
data/README.md
CHANGED
|
@@ -14,6 +14,8 @@ gem "llmemory"
|
|
|
14
14
|
|
|
15
15
|
Then run `bundle install`.
|
|
16
16
|
|
|
17
|
+
**Upgrading?** See the [Migration guide](docs/MIGRATION.md) for database columns, encryption backfill, and graph store configuration changes.
|
|
18
|
+
|
|
17
19
|
## Quick Start (Unified API)
|
|
18
20
|
|
|
19
21
|
The recommended way to use llmemory in a chat is the unified `Llmemory::Memory` API. It abstracts short-term (conversation history) and long-term (extracted facts) and combines retrieval from both:
|
|
@@ -51,6 +53,39 @@ memory.clear_session!
|
|
|
51
53
|
- **`prune!(mode: nil)`** — Prunes oversized tool results (soft-trim or hard-clear). Only when `prune_tool_results_enabled` is true.
|
|
52
54
|
- **`check_context_window!`** — Triggers consolidate and compact when context exceeds configured thresholds.
|
|
53
55
|
- **`clear_session!`** — Clears short-term only.
|
|
56
|
+
- **`llm_usage`** — Returns cumulative LLM token usage for this `user_id` (chat/completions + embeddings), persisted in the short-term store.
|
|
57
|
+
|
|
58
|
+
## LLM token usage
|
|
59
|
+
|
|
60
|
+
llmemory captures **real token counts** from OpenAI and Anthropic API responses (chat and embeddings), accumulates them per `user_id`, and exposes them for cost monitoring.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
memory = Llmemory::Memory.new(user_id: "user_123")
|
|
64
|
+
memory.consolidate!
|
|
65
|
+
memory.maintain!
|
|
66
|
+
|
|
67
|
+
usage = memory.llm_usage
|
|
68
|
+
# => {
|
|
69
|
+
# invoke: { input_tokens: 1200, output_tokens: 400, total_tokens: 1600, calls: 3 },
|
|
70
|
+
# embed: { total_tokens: 48, calls: 2 },
|
|
71
|
+
# updated_at: "2026-07-02T12:00:00Z"
|
|
72
|
+
# }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| What | Details |
|
|
76
|
+
|------|---------|
|
|
77
|
+
| **Counted** | `consolidate!`, reflection, skill mining, compaction summaries, iterative retrieval, graph/file extraction, OpenAI embeddings (index + search) |
|
|
78
|
+
| **Scope** | Cumulative per `user_id` (not per session); stored under pseudo-session `__llm_usage__` |
|
|
79
|
+
| **Not counted** | `context_tokens` (local byte estimate), retrieval context budget, MCP auth tokens |
|
|
80
|
+
| **Cache** | Embedding cache hits record zero tokens |
|
|
81
|
+
|
|
82
|
+
**Other surfaces:**
|
|
83
|
+
|
|
84
|
+
- **CLI:** `llmemory stats USER_ID` prints an `LLM TOKEN USAGE` section.
|
|
85
|
+
- **MCP:** `memory_stats` includes the same totals.
|
|
86
|
+
- **Rails metrics:** subscribe to `llm_invoke.llmemory` and `llm_embed.llmemory` (payload includes `input_tokens`, `output_tokens`, `total_tokens`, `response_chars`).
|
|
87
|
+
|
|
88
|
+
Dollar cost is not computed — multiply tokens by your model pricing externally. For lower-level access, `Llmemory::LLM::OpenAI#invoke` returns a `Response` with `#content` (via `#to_s`) and `#usage`.
|
|
54
89
|
|
|
55
90
|
## Configuration
|
|
56
91
|
|
|
@@ -138,7 +173,7 @@ memory = Llmemory::Memory.new(user_id: "agent-1", encryption_key: "tenant-specif
|
|
|
138
173
|
**What is encrypted:** conversation checkpoints (redis/postgres/active_record), file-based facts/resources/categories, episodic/procedural documents, graph node names/types/predicates (deterministic) and properties (random IV). **Vector embeddings are not encrypted** (required for pgvector search); associated `text_content` metadata is encrypted.
|
|
139
174
|
|
|
140
175
|
**Trade-offs:**
|
|
141
|
-
- Database keyword search
|
|
176
|
+
- Database keyword search on encrypted columns requires the `search_tokens` blind-index column (see [Migration guide](docs/MIGRATION.md)); file backends search in memory after decrypt without schema changes.
|
|
142
177
|
- `:memory` backends are in-process only and are **not** encrypted at rest.
|
|
143
178
|
- Existing plaintext data remains readable (markers `enc:v1:` / `encd:v1:`); new writes are encrypted when enabled.
|
|
144
179
|
- Deterministic encryption on graph identifiers leaks equality (same name ⇒ same ciphertext) but keeps graph traversal working.
|
|
@@ -685,7 +720,7 @@ MCP_TOKEN=your-secret-token llmemory mcp serve --http --port 443 \
|
|
|
685
720
|
| `memory_timeline_context` | Get N items before/after a specific memory |
|
|
686
721
|
| `memory_add_message` | Add message to short-term conversation (roles: user, assistant, system, tool, tool_result) |
|
|
687
722
|
| `memory_consolidate` | Extract facts from conversation to long-term |
|
|
688
|
-
| `memory_stats` | Get memory statistics for a user |
|
|
723
|
+
| `memory_stats` | Get memory statistics for a user (includes LLM token usage) |
|
|
689
724
|
| `memory_info` | Documentation on how to use the tools |
|
|
690
725
|
| `memory_episode_record` / `memory_episodes` | Record / list episodic trajectories |
|
|
691
726
|
| `memory_skill_register` / `memory_skill_report` / `memory_skills` | Register / outcome-track / list procedural skills |
|
|
@@ -6,6 +6,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
|
|
|
6
6
|
t.string :id, null: false, primary_key: true
|
|
7
7
|
t.string :user_id, null: false
|
|
8
8
|
t.text :text, null: false
|
|
9
|
+
t.text :search_tokens
|
|
9
10
|
t.timestamps
|
|
10
11
|
end
|
|
11
12
|
add_index :llmemory_resources, :user_id
|
|
@@ -18,6 +19,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
|
|
|
18
19
|
t.string :source_resource_id
|
|
19
20
|
t.float :importance, default: 0.7
|
|
20
21
|
t.jsonb :provenance
|
|
22
|
+
t.text :search_tokens
|
|
21
23
|
t.timestamps
|
|
22
24
|
end
|
|
23
25
|
add_index :llmemory_items, :user_id
|
|
@@ -36,6 +38,7 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
|
|
|
36
38
|
t.string :user_id, null: false
|
|
37
39
|
t.jsonb :data, null: false, default: {}
|
|
38
40
|
t.text :search_text
|
|
41
|
+
t.text :search_tokens
|
|
39
42
|
t.datetime :archived_at
|
|
40
43
|
t.timestamps
|
|
41
44
|
end
|
|
@@ -47,6 +50,8 @@ class CreateLlmemoryTables < ActiveRecord::Migration[7.0]
|
|
|
47
50
|
t.string :user_id, null: false
|
|
48
51
|
t.jsonb :data, null: false, default: {}
|
|
49
52
|
t.text :search_text
|
|
53
|
+
t.text :search_tokens
|
|
54
|
+
t.text :name_det
|
|
50
55
|
t.datetime :archived_at
|
|
51
56
|
t.timestamps
|
|
52
57
|
end
|
|
@@ -41,6 +41,11 @@ module Llmemory
|
|
|
41
41
|
puts "Long-term (file) categories: #{storage.list_categories(user_id).size}"
|
|
42
42
|
puts "Long-term (file) resources: #{storage.list_resources(user_id: user_id).size}"
|
|
43
43
|
end
|
|
44
|
+
|
|
45
|
+
puts "---"
|
|
46
|
+
puts Llmemory::LLM::UsageLedger.format_text(
|
|
47
|
+
Llmemory::LLM::UsageLedger.new(store: short_store).totals(user_id)
|
|
48
|
+
)
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def print_global_stats(short_store, long_type)
|
|
@@ -36,6 +36,10 @@ module Llmemory
|
|
|
36
36
|
def encrypted?(str)
|
|
37
37
|
false
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
def blind_index(token)
|
|
41
|
+
token.to_s.downcase
|
|
42
|
+
end
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
# AES-256-GCM encryption with separate content (random IV) and index
|
|
@@ -111,6 +115,14 @@ module Llmemory
|
|
|
111
115
|
s.start_with?(MARKER) || s.start_with?(DETERMINISTIC_MARKER)
|
|
112
116
|
end
|
|
113
117
|
|
|
118
|
+
# Deterministic HMAC digest for blind-index keyword search over encrypted fields.
|
|
119
|
+
def blind_index(token)
|
|
120
|
+
str = token.to_s.downcase
|
|
121
|
+
return str if str.empty?
|
|
122
|
+
|
|
123
|
+
OpenSSL::HMAC.hexdigest("SHA256", @index_key, str)[0, 32]
|
|
124
|
+
end
|
|
125
|
+
|
|
114
126
|
private
|
|
115
127
|
|
|
116
128
|
def encrypt_with_key(plaintext, key, iv:, marker:)
|
|
@@ -105,6 +105,31 @@ module Llmemory
|
|
|
105
105
|
rescue JSON::ParserError
|
|
106
106
|
nil
|
|
107
107
|
end
|
|
108
|
+
|
|
109
|
+
# Space-delimited blind-index digests for encrypted keyword search.
|
|
110
|
+
def search_tokens_for(text)
|
|
111
|
+
tokens = Llmemory::Tokenizer.tokenize(text).uniq
|
|
112
|
+
return nil if tokens.empty?
|
|
113
|
+
|
|
114
|
+
digests = tokens.map { |t| cipher.blind_index(t) }
|
|
115
|
+
" #{digests.join(' ')} "
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# OR-of-token SQL filter. Uses blind index on `search_tokens_column` when
|
|
119
|
+
# encryption is enabled; otherwise LIKE on the plaintext `column`.
|
|
120
|
+
def blind_token_filter(column, query, start_index, search_tokens_column: nil)
|
|
121
|
+
tokens = Llmemory::Tokenizer.tokenize(query)
|
|
122
|
+
return ["", []] if tokens.empty?
|
|
123
|
+
|
|
124
|
+
if cipher.enabled? && search_tokens_column
|
|
125
|
+
digests = tokens.map { |t| cipher.blind_index(t) }
|
|
126
|
+
likes = digests.each_index.map { |i| "#{search_tokens_column} LIKE $#{start_index + i}" }
|
|
127
|
+
[" AND (#{likes.join(' OR ')})", digests.map { |d| "% #{d} %" }]
|
|
128
|
+
else
|
|
129
|
+
likes = tokens.each_index.map { |i| "LOWER(#{column}) LIKE $#{start_index + i}" }
|
|
130
|
+
[" AND (#{likes.join(' OR ')})", tokens.map { |t| "%#{t}%" }]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
108
133
|
end
|
|
109
134
|
end
|
|
110
135
|
end
|
|
@@ -10,8 +10,10 @@ module Llmemory
|
|
|
10
10
|
# Events (payload keys are best-effort; subscribers should treat them as
|
|
11
11
|
# optional):
|
|
12
12
|
#
|
|
13
|
-
# llm_invoke.llmemory provider:, model:, prompt_chars:, response_chars
|
|
14
|
-
#
|
|
13
|
+
# llm_invoke.llmemory provider:, model:, prompt_chars:, response_chars:,
|
|
14
|
+
# input_tokens:, output_tokens:, total_tokens:
|
|
15
|
+
# llm_embed.llmemory provider:, model:, text_chars:, input_tokens:,
|
|
16
|
+
# output_tokens:, total_tokens:
|
|
15
17
|
# memory_write.llmemory memory_type:, user_id:
|
|
16
18
|
# memory_forget.llmemory memory_type:, user_id:, count:
|
|
17
19
|
# retrieve.llmemory query_chars:, candidates:, results:
|
|
@@ -11,6 +11,7 @@ module Llmemory
|
|
|
11
11
|
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
12
12
|
|
|
13
13
|
def initialize(api_key: nil, model: nil, base_url: nil)
|
|
14
|
+
super()
|
|
14
15
|
@api_key = api_key || config.llm_api_key || ENV["ANTHROPIC_API_KEY"]
|
|
15
16
|
@model = model || config.llm_model || DEFAULT_MODEL
|
|
16
17
|
@base_url = base_url || config.llm_base_url || DEFAULT_BASE_URL
|
|
@@ -18,7 +19,8 @@ module Llmemory
|
|
|
18
19
|
|
|
19
20
|
def invoke(prompt)
|
|
20
21
|
result = nil
|
|
21
|
-
|
|
22
|
+
payload = { provider: :anthropic, model: @model, prompt_chars: prompt.to_s.length }
|
|
23
|
+
Llmemory::Instrumentation.instrument(:llm_invoke, payload) do
|
|
22
24
|
response = connection.post("v1/messages") do |req|
|
|
23
25
|
req.body = {
|
|
24
26
|
model: @model,
|
|
@@ -33,8 +35,11 @@ module Llmemory
|
|
|
33
35
|
raise Llmemory::LLMError, "Anthropic API error: #{response.body}" unless response.success?
|
|
34
36
|
|
|
35
37
|
body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
|
|
36
|
-
content = body.dig("content", 0, "text")
|
|
37
|
-
|
|
38
|
+
content = body.dig("content", 0, "text")&.strip || ""
|
|
39
|
+
usage = parse_anthropic_usage(body["usage"])
|
|
40
|
+
record_usage(usage)
|
|
41
|
+
payload.merge!(instrumentation_payload(usage, content))
|
|
42
|
+
result = Response.new(content, usage: usage)
|
|
38
43
|
end
|
|
39
44
|
result
|
|
40
45
|
end
|
data/lib/llmemory/llm/base.rb
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "usage"
|
|
4
|
+
require_relative "response"
|
|
5
|
+
|
|
3
6
|
module Llmemory
|
|
4
7
|
module LLM
|
|
5
8
|
class Base
|
|
9
|
+
attr_reader :last_usage
|
|
10
|
+
|
|
11
|
+
def initialize(*)
|
|
12
|
+
@last_usage = Usage.zero
|
|
13
|
+
end
|
|
14
|
+
|
|
6
15
|
def invoke(prompt)
|
|
7
16
|
raise NotImplementedError, "#{self.class}#invoke must be implemented"
|
|
8
17
|
end
|
|
@@ -18,6 +27,39 @@ module Llmemory
|
|
|
18
27
|
def config
|
|
19
28
|
Llmemory.configuration
|
|
20
29
|
end
|
|
30
|
+
|
|
31
|
+
def parse_openai_chat_usage(raw)
|
|
32
|
+
return Usage.zero unless raw.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
Usage.new(
|
|
35
|
+
input_tokens: raw["prompt_tokens"] || raw[:prompt_tokens] || 0,
|
|
36
|
+
output_tokens: raw["completion_tokens"] || raw[:completion_tokens] || 0,
|
|
37
|
+
total_tokens: raw["total_tokens"] || raw[:total_tokens]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_anthropic_usage(raw)
|
|
42
|
+
return Usage.zero unless raw.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
input = raw["input_tokens"] || raw[:input_tokens] || 0
|
|
45
|
+
output = raw["output_tokens"] || raw[:output_tokens] || 0
|
|
46
|
+
Usage.new(input_tokens: input, output_tokens: output)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_openai_embed_usage(raw)
|
|
50
|
+
return Usage.zero unless raw.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
total = raw["total_tokens"] || raw[:total_tokens] || 0
|
|
53
|
+
Usage.new(input_tokens: 0, output_tokens: 0, total_tokens: total)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def record_usage(usage)
|
|
57
|
+
@last_usage = usage
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def instrumentation_payload(usage, content, extra = {})
|
|
61
|
+
usage.to_h.merge(response_chars: content.to_s.length).merge(extra)
|
|
62
|
+
end
|
|
21
63
|
end
|
|
22
64
|
end
|
|
23
65
|
end
|
data/lib/llmemory/llm/openai.rb
CHANGED
|
@@ -11,6 +11,7 @@ module Llmemory
|
|
|
11
11
|
DEFAULT_MODEL = "gpt-4"
|
|
12
12
|
|
|
13
13
|
def initialize(api_key: nil, model: nil, base_url: nil)
|
|
14
|
+
super()
|
|
14
15
|
@api_key = api_key || config.llm_api_key
|
|
15
16
|
@model = model || config.llm_model || DEFAULT_MODEL
|
|
16
17
|
@base_url = base_url || config.llm_base_url || DEFAULT_BASE_URL
|
|
@@ -18,7 +19,8 @@ module Llmemory
|
|
|
18
19
|
|
|
19
20
|
def invoke(prompt)
|
|
20
21
|
result = nil
|
|
21
|
-
|
|
22
|
+
payload = { provider: :openai, model: @model, prompt_chars: prompt.to_s.length }
|
|
23
|
+
Llmemory::Instrumentation.instrument(:llm_invoke, payload) do
|
|
22
24
|
response = connection.post("chat/completions") do |req|
|
|
23
25
|
req.body = {
|
|
24
26
|
model: @model,
|
|
@@ -32,7 +34,11 @@ module Llmemory
|
|
|
32
34
|
raise Llmemory::LLMError, "OpenAI API error: #{response.body}" unless response.success?
|
|
33
35
|
|
|
34
36
|
body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
|
|
35
|
-
|
|
37
|
+
content = body.dig("choices", 0, "message", "content")&.strip || ""
|
|
38
|
+
usage = parse_openai_chat_usage(body["usage"])
|
|
39
|
+
record_usage(usage)
|
|
40
|
+
payload.merge!(instrumentation_payload(usage, content))
|
|
41
|
+
result = Response.new(content, usage: usage)
|
|
36
42
|
end
|
|
37
43
|
result
|
|
38
44
|
end
|
|
@@ -54,18 +60,27 @@ module Llmemory
|
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
parsed = nil
|
|
64
|
+
instrument_payload = { provider: :openai, model: @model, prompt_chars: prompt.to_s.length }
|
|
65
|
+
Llmemory::Instrumentation.instrument(:llm_invoke, instrument_payload) do
|
|
66
|
+
response = connection.post("chat/completions") do |req|
|
|
67
|
+
req.body = payload.to_json
|
|
68
|
+
req.headers["Content-Type"] = "application/json"
|
|
69
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
70
|
+
end
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
raise Llmemory::LLMError, "OpenAI API error: #{response.body}" unless response.success?
|
|
73
|
+
|
|
74
|
+
body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
|
|
75
|
+
content = body.dig("choices", 0, "message", "content")&.strip
|
|
76
|
+
usage = parse_openai_chat_usage(body["usage"])
|
|
77
|
+
record_usage(usage)
|
|
78
|
+
instrument_payload.merge!(instrumentation_payload(usage, content.to_s))
|
|
79
|
+
return {} if content.nil? || content.empty?
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
JSON.parse(content)
|
|
81
|
+
parsed = JSON.parse(content)
|
|
82
|
+
end
|
|
83
|
+
parsed
|
|
69
84
|
rescue JSON::ParserError => e
|
|
70
85
|
raise Llmemory::LLMError, "Failed to parse JSON response: #{e.message}"
|
|
71
86
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module LLM
|
|
5
|
+
class Response
|
|
6
|
+
attr_reader :content, :usage
|
|
7
|
+
|
|
8
|
+
def initialize(content, usage: Usage.zero)
|
|
9
|
+
@content = content.to_s
|
|
10
|
+
@usage = usage
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_s
|
|
14
|
+
@content
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "usage_recorder"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LLM
|
|
7
|
+
# Transparent wrapper that records token usage to the per-user ledger.
|
|
8
|
+
class TrackingClient
|
|
9
|
+
def initialize(inner, user_id:, store: nil, api_key: nil)
|
|
10
|
+
@inner = inner
|
|
11
|
+
@user_id = user_id
|
|
12
|
+
@store = store
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def invoke(prompt)
|
|
17
|
+
response = inner_client.invoke(prompt)
|
|
18
|
+
usage = if response.respond_to?(:usage)
|
|
19
|
+
response.usage
|
|
20
|
+
elsif inner_client.respond_to?(:last_usage)
|
|
21
|
+
inner_client.last_usage
|
|
22
|
+
else
|
|
23
|
+
Usage.zero
|
|
24
|
+
end
|
|
25
|
+
UsageRecorder.record(user_id: @user_id, usage: usage, operation: :invoke, store: @store)
|
|
26
|
+
response
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def invoke_with_json_schema(prompt, json_schema)
|
|
30
|
+
result = inner_client.invoke_with_json_schema(prompt, json_schema)
|
|
31
|
+
usage = inner_client.respond_to?(:last_usage) ? inner_client.last_usage : Usage.zero
|
|
32
|
+
UsageRecorder.record(user_id: @user_id, usage: usage, operation: :invoke, store: @store)
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def last_usage
|
|
37
|
+
return inner_client.last_usage if inner_client.respond_to?(:last_usage)
|
|
38
|
+
|
|
39
|
+
Usage.zero
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def respond_to?(method, include_private = false)
|
|
43
|
+
inner_client.respond_to?(method, include_private) || super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def method_missing(method, *args, &block)
|
|
47
|
+
if inner_client.respond_to?(method)
|
|
48
|
+
inner_client.public_send(method, *args, &block)
|
|
49
|
+
else
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def inner_client
|
|
57
|
+
@inner_client ||= @inner || Llmemory::LLM.client(api_key: @api_key)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Llmemory
|
|
4
|
+
module LLM
|
|
5
|
+
class Usage
|
|
6
|
+
attr_reader :input_tokens, :output_tokens, :total_tokens
|
|
7
|
+
|
|
8
|
+
def initialize(input_tokens:, output_tokens:, total_tokens: nil)
|
|
9
|
+
@input_tokens = input_tokens.to_i
|
|
10
|
+
@output_tokens = output_tokens.to_i
|
|
11
|
+
@total_tokens = total_tokens.nil? ? (@input_tokens + @output_tokens) : total_tokens.to_i
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.zero
|
|
15
|
+
new(input_tokens: 0, output_tokens: 0, total_tokens: 0)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def +(other)
|
|
19
|
+
self.class.new(
|
|
20
|
+
input_tokens: @input_tokens + other.input_tokens,
|
|
21
|
+
output_tokens: @output_tokens + other.output_tokens,
|
|
22
|
+
total_tokens: @total_tokens + other.total_tokens
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{ input_tokens: @input_tokens, output_tokens: @output_tokens, total_tokens: @total_tokens }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../short_term/stores"
|
|
5
|
+
|
|
6
|
+
module Llmemory
|
|
7
|
+
module LLM
|
|
8
|
+
# Cumulative LLM token usage per user, persisted in the short-term store
|
|
9
|
+
# under a pseudo-session key (same pattern as ForgetLog).
|
|
10
|
+
class UsageLedger
|
|
11
|
+
SESSION_KEY = "__llm_usage__"
|
|
12
|
+
|
|
13
|
+
def initialize(store: nil)
|
|
14
|
+
@store = store || ShortTerm::Stores.build
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def record(user_id, usage, operation:)
|
|
18
|
+
state = load_raw(user_id)
|
|
19
|
+
case operation.to_sym
|
|
20
|
+
when :invoke
|
|
21
|
+
bucket = symbolize_bucket(state[:invoke] || state["invoke"])
|
|
22
|
+
state = state.merge(
|
|
23
|
+
invoke: {
|
|
24
|
+
input_tokens: bucket[:input_tokens] + usage.input_tokens,
|
|
25
|
+
output_tokens: bucket[:output_tokens] + usage.output_tokens,
|
|
26
|
+
total_tokens: bucket[:total_tokens] + usage.total_tokens,
|
|
27
|
+
calls: bucket[:calls] + 1
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
when :embed
|
|
31
|
+
bucket = symbolize_bucket(state[:embed] || state["embed"], embed: true)
|
|
32
|
+
state = state.merge(
|
|
33
|
+
embed: {
|
|
34
|
+
total_tokens: bucket[:total_tokens] + usage.total_tokens,
|
|
35
|
+
calls: bucket[:calls] + 1
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
else
|
|
39
|
+
return totals(user_id)
|
|
40
|
+
end
|
|
41
|
+
state[:updated_at] = Time.now.iso8601
|
|
42
|
+
@store.save(user_id, SESSION_KEY, stringify(state))
|
|
43
|
+
totals(user_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def totals(user_id)
|
|
47
|
+
normalize(load_raw(user_id))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reset!(user_id)
|
|
51
|
+
empty = default_state
|
|
52
|
+
@store.save(user_id, SESSION_KEY, stringify(empty))
|
|
53
|
+
empty
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.format_text(totals)
|
|
57
|
+
inv = totals[:invoke]
|
|
58
|
+
emb = totals[:embed]
|
|
59
|
+
lines = [
|
|
60
|
+
"LLM TOKEN USAGE:",
|
|
61
|
+
" Chat/completions: #{inv[:total_tokens]} total (#{inv[:input_tokens]} in, #{inv[:output_tokens]} out, #{inv[:calls]} calls)",
|
|
62
|
+
" Embeddings: #{emb[:total_tokens]} total (#{emb[:calls]} calls)"
|
|
63
|
+
]
|
|
64
|
+
lines << " Last updated: #{totals[:updated_at]}" if totals[:updated_at]
|
|
65
|
+
lines.join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def load_raw(user_id)
|
|
71
|
+
state = @store.load(user_id, SESSION_KEY)
|
|
72
|
+
return default_state unless state.is_a?(Hash)
|
|
73
|
+
normalize(state)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def default_state
|
|
77
|
+
{
|
|
78
|
+
invoke: { input_tokens: 0, output_tokens: 0, total_tokens: 0, calls: 0 },
|
|
79
|
+
embed: { total_tokens: 0, calls: 0 },
|
|
80
|
+
updated_at: nil
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def normalize(state)
|
|
85
|
+
invoke = symbolize_bucket(state[:invoke] || state["invoke"])
|
|
86
|
+
embed = symbolize_bucket(state[:embed] || state["embed"], embed: true)
|
|
87
|
+
{
|
|
88
|
+
invoke: invoke,
|
|
89
|
+
embed: embed,
|
|
90
|
+
updated_at: state[:updated_at] || state["updated_at"]
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def symbolize_bucket(bucket, embed: false)
|
|
95
|
+
bucket = {} unless bucket.is_a?(Hash)
|
|
96
|
+
if embed
|
|
97
|
+
{
|
|
98
|
+
total_tokens: (bucket[:total_tokens] || bucket["total_tokens"] || 0).to_i,
|
|
99
|
+
calls: (bucket[:calls] || bucket["calls"] || 0).to_i
|
|
100
|
+
}
|
|
101
|
+
else
|
|
102
|
+
{
|
|
103
|
+
input_tokens: (bucket[:input_tokens] || bucket["input_tokens"] || 0).to_i,
|
|
104
|
+
output_tokens: (bucket[:output_tokens] || bucket["output_tokens"] || 0).to_i,
|
|
105
|
+
total_tokens: (bucket[:total_tokens] || bucket["total_tokens"] || 0).to_i,
|
|
106
|
+
calls: (bucket[:calls] || bucket["calls"] || 0).to_i
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stringify(state)
|
|
112
|
+
state.transform_keys(&:to_s).transform_values do |v|
|
|
113
|
+
v.is_a?(Hash) ? v.transform_keys(&:to_s) : v
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "usage_ledger"
|
|
4
|
+
|
|
5
|
+
module Llmemory
|
|
6
|
+
module LLM
|
|
7
|
+
module UsageRecorder
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def record(user_id:, usage:, operation:, store: nil)
|
|
11
|
+
return if user_id.nil? || user_id.to_s.empty?
|
|
12
|
+
return if usage.nil?
|
|
13
|
+
|
|
14
|
+
UsageLedger.new(store: store).record(user_id, usage, operation: operation)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def record_embed_from_store(user_id:, vector_store:, store: nil)
|
|
18
|
+
usage = embed_usage_from(vector_store)
|
|
19
|
+
return unless usage
|
|
20
|
+
|
|
21
|
+
record(user_id: user_id, usage: usage, operation: :embed, store: store)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def embed_usage_from(vector_store)
|
|
25
|
+
return nil unless vector_store
|
|
26
|
+
|
|
27
|
+
if vector_store.respond_to?(:last_usage)
|
|
28
|
+
usage = vector_store.last_usage
|
|
29
|
+
return usage unless usage.nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
provider = vector_store.instance_variable_get(:@embedding_provider) if vector_store.instance_variable_defined?(:@embedding_provider)
|
|
33
|
+
provider&.last_usage if provider&.respond_to?(:last_usage)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/llmemory/llm.rb
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "llm/base"
|
|
4
|
+
require_relative "llm/usage"
|
|
5
|
+
require_relative "llm/response"
|
|
6
|
+
require_relative "llm/usage_ledger"
|
|
7
|
+
require_relative "llm/usage_recorder"
|
|
8
|
+
require_relative "llm/tracking_client"
|
|
4
9
|
require_relative "llm/openai"
|
|
5
10
|
require_relative "llm/anthropic"
|
|
6
11
|
|
|
@@ -135,6 +135,7 @@ module Llmemory
|
|
|
135
135
|
vs = vector_store
|
|
136
136
|
return if vs.nil? || text.to_s.strip.empty?
|
|
137
137
|
embedding = vs.embed(text)
|
|
138
|
+
record_embed_usage(vs)
|
|
138
139
|
return unless embedding
|
|
139
140
|
vs.store(id: id, embedding: embedding, metadata: { text: text, created_at: Time.now }, user_id: @user_id)
|
|
140
141
|
rescue StandardError
|
|
@@ -142,7 +143,9 @@ module Llmemory
|
|
|
142
143
|
end
|
|
143
144
|
|
|
144
145
|
def vector_candidates(query, top_k, vs)
|
|
145
|
-
vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id)
|
|
146
|
+
results = vs.search_by_text(query.to_s, top_k: top_k, user_id: @user_id)
|
|
147
|
+
record_embed_usage(vs)
|
|
148
|
+
results.filter_map do |r|
|
|
146
149
|
raw = @storage.get_episode(@user_id, r[:id] || r["id"])
|
|
147
150
|
raw && candidate_for(raw, (r[:score] || r["score"] || 1.0).to_f)
|
|
148
151
|
end
|
|
@@ -183,6 +186,14 @@ module Llmemory
|
|
|
183
186
|
return nil if actions.empty?
|
|
184
187
|
"Episode with #{normalized.size} step(s): #{actions.join(' -> ')}"
|
|
185
188
|
end
|
|
189
|
+
|
|
190
|
+
def record_embed_usage(vector_store)
|
|
191
|
+
Llmemory::LLM::UsageRecorder.record_embed_from_store(
|
|
192
|
+
user_id: @user_id,
|
|
193
|
+
vector_store: vector_store,
|
|
194
|
+
store: Llmemory::ShortTerm::Stores.build(cipher: @cipher)
|
|
195
|
+
)
|
|
196
|
+
end
|
|
186
197
|
end
|
|
187
198
|
end
|
|
188
199
|
end
|