codebase-receipts-cli 1.0.0__py3-none-any.whl
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.
- codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
- codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
- codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
- codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
- codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- receipts/__init__.py +0 -0
- receipts/ama/__init__.py +0 -0
- receipts/ama/interviewer.py +415 -0
- receipts/ats/__init__.py +1 -0
- receipts/ats/comparator.py +98 -0
- receipts/ats/scorer.py +550 -0
- receipts/ats/stats.py +164 -0
- receipts/cli.py +2062 -0
- receipts/config.py +106 -0
- receipts/errors.py +18 -0
- receipts/export.py +120 -0
- receipts/ingest/__init__.py +0 -0
- receipts/ingest/artifact_extractor.py +679 -0
- receipts/ingest/git_source.py +159 -0
- receipts/ingest/manifest.py +114 -0
- receipts/ingest/scanner.py +141 -0
- receipts/ingest/secrets_scanner.py +225 -0
- receipts/interactive/__init__.py +1 -0
- receipts/interactive/repl.py +755 -0
- receipts/ledger/__init__.py +0 -0
- receipts/ledger/pricing_table.py +54 -0
- receipts/ledger/token_ledger.py +226 -0
- receipts/llm/__init__.py +0 -0
- receipts/llm/anthropic_provider.py +95 -0
- receipts/llm/factory.py +124 -0
- receipts/llm/fake_provider.py +79 -0
- receipts/llm/gemini_provider.py +205 -0
- receipts/llm/json_utils.py +46 -0
- receipts/llm/metered.py +62 -0
- receipts/llm/ollama_provider.py +117 -0
- receipts/llm/openai_provider.py +118 -0
- receipts/llm/provider.py +42 -0
- receipts/llm/split.py +78 -0
- receipts/llm/token_estimate.py +35 -0
- receipts/mine/__init__.py +1 -0
- receipts/mine/code_metrics.py +246 -0
- receipts/mine/git_evidence.py +109 -0
- receipts/prep/__init__.py +1 -0
- receipts/prep/dossier.py +313 -0
- receipts/prep/readiness.py +227 -0
- receipts/resume/__init__.py +0 -0
- receipts/resume/claim_extractor.py +338 -0
- receipts/resume/loader.py +35 -0
- receipts/resume/pdf_parser.py +259 -0
- receipts/resume/tex_parser.py +394 -0
- receipts/rewrite/__init__.py +0 -0
- receipts/rewrite/compiler.py +180 -0
- receipts/rewrite/optimizer.py +140 -0
- receipts/rewrite/tex_rewriter.py +227 -0
- receipts/tui/__init__.py +0 -0
- receipts/tui/ama_app.py +454 -0
- receipts/tui/app.py +316 -0
- receipts/tui/dossier_app.py +170 -0
- receipts/tui/hub.py +367 -0
- receipts/tui/ingest_app.py +183 -0
- receipts/tui/rewrite_app.py +463 -0
- receipts/tui/score_app.py +237 -0
- receipts/tui/widgets/__init__.py +0 -0
- receipts/tui/widgets/claims_table.py +50 -0
- receipts/tui/widgets/diff_view.py +38 -0
- receipts/tui/widgets/status_bar.py +38 -0
- receipts/verify/__init__.py +0 -0
- receipts/verify/bm25.py +112 -0
- receipts/verify/enrich.py +128 -0
- receipts/verify/jd_fetch.py +101 -0
- receipts/verify/kb.py +379 -0
- receipts/verify/keyword_gap.py +127 -0
- receipts/verify/query_expand.py +88 -0
- receipts/verify/rerank.py +74 -0
- receipts/verify/rewriter.py +172 -0
- receipts/verify/router.py +211 -0
- receipts/verify/summaries.py +258 -0
- receipts/verify/verifier.py +552 -0
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Per-provider, per-model pricing in dollars per 1K tokens.
|
|
2
|
+
|
|
3
|
+
Ollama is always $0 (local). For paid providers the prices here are
|
|
4
|
+
approximate list prices as of mid-2026 — the ledger uses them for cost
|
|
5
|
+
*estimates*, not billing. When a model isn't in the table the ledger
|
|
6
|
+
falls back to $0 and labels the cost as unknown.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ModelPricing:
|
|
16
|
+
input_per_1k: float
|
|
17
|
+
output_per_1k: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_TABLE: dict[str, dict[str, ModelPricing]] = {
|
|
21
|
+
"ollama": {},
|
|
22
|
+
"gemini": {
|
|
23
|
+
"gemini-3.5-flash": ModelPricing(0.0, 0.0),
|
|
24
|
+
"gemini-3.1-flash-lite": ModelPricing(0.0, 0.0),
|
|
25
|
+
"gemini-2.5-flash-lite": ModelPricing(0.0, 0.0),
|
|
26
|
+
"gemini-2.0-flash": ModelPricing(0.0, 0.0),
|
|
27
|
+
"gemini-embedding-2": ModelPricing(0.0, 0.0),
|
|
28
|
+
"gemini-embedding-001": ModelPricing(0.0, 0.0),
|
|
29
|
+
},
|
|
30
|
+
"anthropic": {
|
|
31
|
+
"claude-sonnet-4-20250514": ModelPricing(0.003, 0.015),
|
|
32
|
+
"claude-haiku-3-5-20241022": ModelPricing(0.0008, 0.004),
|
|
33
|
+
},
|
|
34
|
+
"openai": {
|
|
35
|
+
"gpt-4o-mini": ModelPricing(0.00015, 0.0006),
|
|
36
|
+
"gpt-4o": ModelPricing(0.0025, 0.01),
|
|
37
|
+
"text-embedding-3-small": ModelPricing(0.00002, 0.0),
|
|
38
|
+
},
|
|
39
|
+
"fake": {},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cost_estimate(
|
|
44
|
+
provider: str, model: str, input_tokens: int, output_tokens: int
|
|
45
|
+
) -> float:
|
|
46
|
+
"""Estimated cost in USD. Returns 0.0 for local/free providers."""
|
|
47
|
+
provider_table = _TABLE.get(provider, {})
|
|
48
|
+
pricing = provider_table.get(model)
|
|
49
|
+
if pricing is None:
|
|
50
|
+
return 0.0
|
|
51
|
+
return (
|
|
52
|
+
input_tokens / 1000 * pricing.input_per_1k
|
|
53
|
+
+ output_tokens / 1000 * pricing.output_per_1k
|
|
54
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""SQLite-backed token/cost ledger — per-session and lifetime.
|
|
2
|
+
|
|
3
|
+
Every LLM call is logged here, even for free providers (Ollama cost = $0).
|
|
4
|
+
The ledger is a local file under ``~/.receipts/ledger.db`` by default.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from receipts.ledger.pricing_table import cost_estimate
|
|
16
|
+
from receipts.llm.provider import CompletionResult
|
|
17
|
+
|
|
18
|
+
_DEFAULT_DB_PATH = Path.home() / ".receipts" / "ledger.db"
|
|
19
|
+
|
|
20
|
+
_CREATE_TABLE = """\
|
|
21
|
+
CREATE TABLE IF NOT EXISTS ledger (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
session_id TEXT NOT NULL,
|
|
24
|
+
timestamp REAL NOT NULL,
|
|
25
|
+
provider TEXT NOT NULL,
|
|
26
|
+
model TEXT NOT NULL,
|
|
27
|
+
input_tokens INTEGER NOT NULL,
|
|
28
|
+
output_tokens INTEGER NOT NULL,
|
|
29
|
+
tokens_estimated INTEGER NOT NULL,
|
|
30
|
+
cost_usd REAL NOT NULL
|
|
31
|
+
)"""
|
|
32
|
+
|
|
33
|
+
_CREATE_INDEX = """\
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_session
|
|
35
|
+
ON ledger (session_id)"""
|
|
36
|
+
|
|
37
|
+
_INSERT = """\
|
|
38
|
+
INSERT INTO ledger
|
|
39
|
+
(id, session_id, timestamp, provider, model,
|
|
40
|
+
input_tokens, output_tokens, tokens_estimated, cost_usd)
|
|
41
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class LedgerEntry:
|
|
46
|
+
id: str
|
|
47
|
+
session_id: str
|
|
48
|
+
timestamp: float
|
|
49
|
+
provider: str
|
|
50
|
+
model: str
|
|
51
|
+
input_tokens: int
|
|
52
|
+
output_tokens: int
|
|
53
|
+
tokens_estimated: bool
|
|
54
|
+
cost_usd: float
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class SessionSummary:
|
|
59
|
+
session_id: str
|
|
60
|
+
total_input: int
|
|
61
|
+
total_output: int
|
|
62
|
+
total_cost: float
|
|
63
|
+
call_count: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class LifetimeSummary:
|
|
68
|
+
total_input: int
|
|
69
|
+
total_output: int
|
|
70
|
+
total_cost: float
|
|
71
|
+
call_count: int
|
|
72
|
+
session_count: int
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ModelUsage:
|
|
77
|
+
provider: str
|
|
78
|
+
model: str
|
|
79
|
+
call_count: int
|
|
80
|
+
total_input: int
|
|
81
|
+
total_output: int
|
|
82
|
+
total_cost: float
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TokenLedger:
|
|
86
|
+
"""Append-only token/cost log backed by SQLite."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
db_path: Path | None = None,
|
|
91
|
+
session_id: str | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
self.db_path = db_path or _DEFAULT_DB_PATH
|
|
94
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
self.session_id = session_id or uuid.uuid4().hex[:12]
|
|
96
|
+
self._conn = sqlite3.connect(str(self.db_path))
|
|
97
|
+
self._conn.execute(_CREATE_TABLE)
|
|
98
|
+
self._conn.execute(_CREATE_INDEX)
|
|
99
|
+
self._conn.commit()
|
|
100
|
+
self._session_entries: list[LedgerEntry] = []
|
|
101
|
+
|
|
102
|
+
def log(self, result: CompletionResult) -> LedgerEntry:
|
|
103
|
+
"""Record a completed LLM call and return the entry."""
|
|
104
|
+
cost = cost_estimate(
|
|
105
|
+
result.provider,
|
|
106
|
+
result.model,
|
|
107
|
+
result.input_tokens,
|
|
108
|
+
result.output_tokens,
|
|
109
|
+
)
|
|
110
|
+
entry = LedgerEntry(
|
|
111
|
+
id=uuid.uuid4().hex[:16],
|
|
112
|
+
session_id=self.session_id,
|
|
113
|
+
timestamp=time.time(),
|
|
114
|
+
provider=result.provider,
|
|
115
|
+
model=result.model,
|
|
116
|
+
input_tokens=result.input_tokens,
|
|
117
|
+
output_tokens=result.output_tokens,
|
|
118
|
+
tokens_estimated=result.tokens_estimated,
|
|
119
|
+
cost_usd=cost,
|
|
120
|
+
)
|
|
121
|
+
self._conn.execute(
|
|
122
|
+
_INSERT,
|
|
123
|
+
(
|
|
124
|
+
entry.id,
|
|
125
|
+
entry.session_id,
|
|
126
|
+
entry.timestamp,
|
|
127
|
+
entry.provider,
|
|
128
|
+
entry.model,
|
|
129
|
+
entry.input_tokens,
|
|
130
|
+
entry.output_tokens,
|
|
131
|
+
int(entry.tokens_estimated),
|
|
132
|
+
entry.cost_usd,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
self._conn.commit()
|
|
136
|
+
self._session_entries.append(entry)
|
|
137
|
+
return entry
|
|
138
|
+
|
|
139
|
+
def session_summary(self) -> SessionSummary:
|
|
140
|
+
"""Summary of the current session from in-memory entries."""
|
|
141
|
+
total_in = sum(e.input_tokens for e in self._session_entries)
|
|
142
|
+
total_out = sum(e.output_tokens for e in self._session_entries)
|
|
143
|
+
total_cost = sum(e.cost_usd for e in self._session_entries)
|
|
144
|
+
return SessionSummary(
|
|
145
|
+
session_id=self.session_id,
|
|
146
|
+
total_input=total_in,
|
|
147
|
+
total_output=total_out,
|
|
148
|
+
total_cost=total_cost,
|
|
149
|
+
call_count=len(self._session_entries),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def model_breakdown(self) -> list[ModelUsage]:
|
|
153
|
+
"""Lifetime usage per (provider, model), heaviest first."""
|
|
154
|
+
rows = self._conn.execute(
|
|
155
|
+
"SELECT provider, model, COUNT(*),"
|
|
156
|
+
" COALESCE(SUM(input_tokens), 0),"
|
|
157
|
+
" COALESCE(SUM(output_tokens), 0),"
|
|
158
|
+
" COALESCE(SUM(cost_usd), 0)"
|
|
159
|
+
" FROM ledger GROUP BY provider, model"
|
|
160
|
+
" ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC"
|
|
161
|
+
).fetchall()
|
|
162
|
+
return [
|
|
163
|
+
ModelUsage(
|
|
164
|
+
provider=r[0],
|
|
165
|
+
model=r[1],
|
|
166
|
+
call_count=r[2],
|
|
167
|
+
total_input=r[3],
|
|
168
|
+
total_output=r[4],
|
|
169
|
+
total_cost=r[5],
|
|
170
|
+
)
|
|
171
|
+
for r in rows
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def last_session_summary(self) -> SessionSummary | None:
|
|
175
|
+
"""Summary of the most recent session recorded in the database.
|
|
176
|
+
|
|
177
|
+
Unlike :meth:`session_summary` (this process's in-memory entries),
|
|
178
|
+
this answers "what did my last run cost?" after the fact — each CLI
|
|
179
|
+
invocation is its own session, so a fresh ``TokenLedger`` always has
|
|
180
|
+
an empty in-memory session.
|
|
181
|
+
"""
|
|
182
|
+
# rowid breaks timestamp ties (two calls can share a clock tick):
|
|
183
|
+
# insertion order is the ground truth for "most recent".
|
|
184
|
+
row = self._conn.execute(
|
|
185
|
+
"SELECT session_id FROM ledger ORDER BY timestamp DESC, rowid DESC"
|
|
186
|
+
" LIMIT 1"
|
|
187
|
+
).fetchone()
|
|
188
|
+
if row is None:
|
|
189
|
+
return None
|
|
190
|
+
sid = row[0]
|
|
191
|
+
agg = self._conn.execute(
|
|
192
|
+
"SELECT COALESCE(SUM(input_tokens), 0),"
|
|
193
|
+
" COALESCE(SUM(output_tokens), 0),"
|
|
194
|
+
" COALESCE(SUM(cost_usd), 0),"
|
|
195
|
+
" COUNT(*)"
|
|
196
|
+
" FROM ledger WHERE session_id = ?",
|
|
197
|
+
(sid,),
|
|
198
|
+
).fetchone()
|
|
199
|
+
return SessionSummary(
|
|
200
|
+
session_id=sid,
|
|
201
|
+
total_input=agg[0],
|
|
202
|
+
total_output=agg[1],
|
|
203
|
+
total_cost=agg[2],
|
|
204
|
+
call_count=agg[3],
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def lifetime_summary(self) -> LifetimeSummary:
|
|
208
|
+
"""Aggregate across all sessions from the database."""
|
|
209
|
+
row = self._conn.execute(
|
|
210
|
+
"SELECT COALESCE(SUM(input_tokens), 0),"
|
|
211
|
+
" COALESCE(SUM(output_tokens), 0),"
|
|
212
|
+
" COALESCE(SUM(cost_usd), 0),"
|
|
213
|
+
" COUNT(*),"
|
|
214
|
+
" COUNT(DISTINCT session_id)"
|
|
215
|
+
" FROM ledger"
|
|
216
|
+
).fetchone()
|
|
217
|
+
return LifetimeSummary(
|
|
218
|
+
total_input=row[0],
|
|
219
|
+
total_output=row[1],
|
|
220
|
+
total_cost=row[2],
|
|
221
|
+
call_count=row[3],
|
|
222
|
+
session_count=row[4],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def close(self) -> None:
|
|
226
|
+
self._conn.close()
|
receipts/llm/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Anthropic provider — BYOK adapter for end users with their own API key.
|
|
2
|
+
|
|
3
|
+
Not used in the maintainer's own testing (no paid key). Exists so end users
|
|
4
|
+
who prefer Claude can ``pip install codebase-receipts-cli[anthropic]`` and
|
|
5
|
+
set ``RECEIPTS_PROVIDER=anthropic`` with their own key.
|
|
6
|
+
|
|
7
|
+
API facts verified against the installed anthropic 0.116.0 package:
|
|
8
|
+
``client.messages.create`` takes ``model``, ``max_tokens``, ``system``
|
|
9
|
+
(string), ``messages`` (list of dicts). Response has ``.content`` (list of
|
|
10
|
+
ContentBlock), ``.usage`` with ``input_tokens`` and ``output_tokens``.
|
|
11
|
+
Errors: ``AuthenticationError``, ``RateLimitError``, ``NotFoundError``.
|
|
12
|
+
Anthropic does not offer an embeddings API, so ``embed`` raises.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import anthropic
|
|
18
|
+
|
|
19
|
+
from receipts.errors import ProviderConfigError, ProviderUnavailableError
|
|
20
|
+
from receipts.llm.provider import CompletionResult, LLMProvider
|
|
21
|
+
|
|
22
|
+
_MISSING_KEY_HELP = """\
|
|
23
|
+
No Anthropic API key configured.
|
|
24
|
+
Set ANTHROPIC_API_KEY=<your key> in your environment or local .env file.
|
|
25
|
+
This is a paid API — see https://console.anthropic.com/ for pricing."""
|
|
26
|
+
|
|
27
|
+
_BAD_KEY_HELP = """\
|
|
28
|
+
Anthropic rejected your API key.
|
|
29
|
+
Check that ANTHROPIC_API_KEY is set to a valid key from
|
|
30
|
+
https://console.anthropic.com/ (no quotes, no extra spaces)."""
|
|
31
|
+
|
|
32
|
+
_NO_EMBED_HELP = """\
|
|
33
|
+
Anthropic does not offer an embeddings API.
|
|
34
|
+
Use a different provider for ingestion (e.g. RECEIPTS_PROVIDER=ollama),
|
|
35
|
+
then switch to anthropic for verification/rewriting only."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AnthropicProvider(LLMProvider):
|
|
39
|
+
name = "anthropic"
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self, *, api_key: str | None, chat_model: str, max_tokens: int = 4096
|
|
43
|
+
) -> None:
|
|
44
|
+
if not api_key:
|
|
45
|
+
raise ProviderConfigError(_MISSING_KEY_HELP)
|
|
46
|
+
self.chat_model = chat_model
|
|
47
|
+
self.max_tokens = max_tokens
|
|
48
|
+
self._client = anthropic.Anthropic(api_key=api_key)
|
|
49
|
+
|
|
50
|
+
def complete(
|
|
51
|
+
self, system: str, user: str, *, json_mode: bool = False
|
|
52
|
+
) -> CompletionResult:
|
|
53
|
+
messages = [{"role": "user", "content": user}]
|
|
54
|
+
try:
|
|
55
|
+
response = self._client.messages.create(
|
|
56
|
+
model=self.chat_model,
|
|
57
|
+
max_tokens=self.max_tokens,
|
|
58
|
+
system=system,
|
|
59
|
+
messages=messages,
|
|
60
|
+
)
|
|
61
|
+
except anthropic.AuthenticationError as exc:
|
|
62
|
+
raise ProviderConfigError(_BAD_KEY_HELP) from exc
|
|
63
|
+
except anthropic.RateLimitError as exc:
|
|
64
|
+
raise ProviderUnavailableError(
|
|
65
|
+
"Anthropic rate limit hit. Wait and retry, or switch to a"
|
|
66
|
+
" local provider: set RECEIPTS_PROVIDER=ollama."
|
|
67
|
+
) from exc
|
|
68
|
+
except anthropic.NotFoundError as exc:
|
|
69
|
+
raise ProviderConfigError(
|
|
70
|
+
f"Anthropic model '{self.chat_model}' was not found."
|
|
71
|
+
" Set ANTHROPIC_CHAT_MODEL to a valid model id"
|
|
72
|
+
" (see https://docs.anthropic.com/en/docs/about-claude/models)."
|
|
73
|
+
) from exc
|
|
74
|
+
except anthropic.APIError as exc:
|
|
75
|
+
raise ProviderUnavailableError(
|
|
76
|
+
f"Anthropic API error: {exc.message}"
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
text = ""
|
|
80
|
+
for block in response.content:
|
|
81
|
+
if block.type == "text":
|
|
82
|
+
text += block.text
|
|
83
|
+
|
|
84
|
+
usage = response.usage
|
|
85
|
+
return CompletionResult(
|
|
86
|
+
text=text,
|
|
87
|
+
input_tokens=usage.input_tokens,
|
|
88
|
+
output_tokens=usage.output_tokens,
|
|
89
|
+
provider=self.name,
|
|
90
|
+
model=self.chat_model,
|
|
91
|
+
tokens_estimated=False,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
95
|
+
raise ProviderConfigError(_NO_EMBED_HELP)
|
receipts/llm/factory.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Provider factory — the only place tool code gets a provider from.
|
|
2
|
+
|
|
3
|
+
Reads ``RECEIPTS_PROVIDER`` (via Settings), defaulting to ``ollama``. Never
|
|
4
|
+
silently falls back to a different provider than the one configured; when the
|
|
5
|
+
configured provider can't work, it fails with a message that says exactly
|
|
6
|
+
what to do next.
|
|
7
|
+
|
|
8
|
+
Concrete providers are imported lazily so that, e.g., an Ollama-only user
|
|
9
|
+
never imports the Gemini SDK, and the test suite can exercise factory logic
|
|
10
|
+
without any provider SDK side effects.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from receipts.config import Settings, load_settings
|
|
16
|
+
from receipts.errors import ProviderConfigError
|
|
17
|
+
from receipts.llm.provider import LLMProvider
|
|
18
|
+
|
|
19
|
+
_VALID_HINT = (
|
|
20
|
+
"Valid values: ollama (default), gemini, anthropic, openai,"
|
|
21
|
+
" fake (offline testing)."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_provider(
|
|
26
|
+
settings: Settings | None = None, *, verify: bool = True
|
|
27
|
+
) -> LLMProvider:
|
|
28
|
+
"""Return the configured provider, ready to use.
|
|
29
|
+
|
|
30
|
+
With ``verify=True`` (the default), provider reachability / model ids
|
|
31
|
+
are probed up front so the user gets a friendly actionable error
|
|
32
|
+
immediately instead of a failure on the first real call.
|
|
33
|
+
|
|
34
|
+
When ``RECEIPTS_EMBED_PROVIDER`` names a different provider, chat and
|
|
35
|
+
embeddings are split — this is how Claude (no embeddings API) works:
|
|
36
|
+
RECEIPTS_PROVIDER=anthropic + RECEIPTS_EMBED_PROVIDER=gemini.
|
|
37
|
+
"""
|
|
38
|
+
if settings is None:
|
|
39
|
+
settings = load_settings()
|
|
40
|
+
name = settings.provider
|
|
41
|
+
|
|
42
|
+
if name == "fake":
|
|
43
|
+
from receipts.llm.fake_provider import FakeProvider
|
|
44
|
+
|
|
45
|
+
# Deliberately unmetered: the fake provider exists for offline tests,
|
|
46
|
+
# which must not write to the user's real ledger database.
|
|
47
|
+
return FakeProvider()
|
|
48
|
+
|
|
49
|
+
provider = _build_provider(name, settings, verify=verify)
|
|
50
|
+
|
|
51
|
+
embed_name = settings.embed_provider
|
|
52
|
+
if embed_name and embed_name != name:
|
|
53
|
+
if embed_name == "anthropic":
|
|
54
|
+
raise ProviderConfigError(
|
|
55
|
+
"Anthropic has no embeddings API and can't be the embed"
|
|
56
|
+
" provider. Set RECEIPTS_EMBED_PROVIDER to ollama, gemini,"
|
|
57
|
+
" or openai."
|
|
58
|
+
)
|
|
59
|
+
from receipts.llm.split import SplitProvider
|
|
60
|
+
|
|
61
|
+
# The embed backend's chat model is never used — skip its probe.
|
|
62
|
+
embed = _build_provider(embed_name, settings, verify=False)
|
|
63
|
+
provider = SplitProvider(provider, embed)
|
|
64
|
+
|
|
65
|
+
return _metered(provider)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_provider(name: str, settings: Settings, *, verify: bool) -> LLMProvider:
|
|
69
|
+
"""Construct one concrete provider (unmetered, unsplit)."""
|
|
70
|
+
if name == "ollama":
|
|
71
|
+
from receipts.llm.ollama_provider import OllamaProvider
|
|
72
|
+
|
|
73
|
+
provider = OllamaProvider(
|
|
74
|
+
host=settings.ollama_host,
|
|
75
|
+
chat_model=settings.ollama_chat_model,
|
|
76
|
+
embed_model=settings.ollama_embed_model,
|
|
77
|
+
)
|
|
78
|
+
if verify:
|
|
79
|
+
provider.check_available()
|
|
80
|
+
return provider
|
|
81
|
+
|
|
82
|
+
if name == "gemini":
|
|
83
|
+
from receipts.llm.gemini_provider import GeminiProvider
|
|
84
|
+
|
|
85
|
+
provider = GeminiProvider(
|
|
86
|
+
api_key=settings.gemini_api_key,
|
|
87
|
+
chat_model=settings.gemini_chat_model,
|
|
88
|
+
embed_model=settings.gemini_embed_model,
|
|
89
|
+
)
|
|
90
|
+
if verify:
|
|
91
|
+
provider.check_available()
|
|
92
|
+
return provider
|
|
93
|
+
|
|
94
|
+
if name == "anthropic":
|
|
95
|
+
from receipts.llm.anthropic_provider import AnthropicProvider
|
|
96
|
+
|
|
97
|
+
return AnthropicProvider(
|
|
98
|
+
api_key=settings.anthropic_api_key,
|
|
99
|
+
chat_model=settings.anthropic_chat_model,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if name == "openai":
|
|
103
|
+
from receipts.llm.openai_provider import OpenAIProvider
|
|
104
|
+
|
|
105
|
+
return OpenAIProvider(
|
|
106
|
+
api_key=settings.openai_api_key,
|
|
107
|
+
chat_model=settings.openai_chat_model,
|
|
108
|
+
embed_model=settings.openai_embed_model,
|
|
109
|
+
base_url=settings.openai_base_url,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if name == "fake":
|
|
113
|
+
from receipts.llm.fake_provider import FakeProvider
|
|
114
|
+
|
|
115
|
+
return FakeProvider()
|
|
116
|
+
|
|
117
|
+
raise ProviderConfigError(f"Unknown provider '{name}'. {_VALID_HINT}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _metered(provider: LLMProvider) -> LLMProvider:
|
|
121
|
+
"""Wrap a real provider so every call lands in the token ledger."""
|
|
122
|
+
from receipts.llm.metered import MeteredProvider
|
|
123
|
+
|
|
124
|
+
return MeteredProvider(provider)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""In-memory fake provider for tests and offline demos.
|
|
2
|
+
|
|
3
|
+
No network, deterministic, and it records every call so tests can assert on
|
|
4
|
+
exactly what was sent. Token counts use a crude chars/4 heuristic on purpose:
|
|
5
|
+
real estimation (tiktoken) lives only in the Ollama provider, because tiktoken
|
|
6
|
+
downloads its encoding file on first use and the test suite must run with zero
|
|
7
|
+
network access.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from receipts.llm.provider import CompletionResult, LLMProvider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _rough_token_count(text: str) -> int:
|
|
19
|
+
return max(1, len(text) // 4)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RecordedCall:
|
|
24
|
+
kind: str # "complete" | "embed"
|
|
25
|
+
system: str | None = None
|
|
26
|
+
user: str | None = None
|
|
27
|
+
json_mode: bool = False
|
|
28
|
+
texts: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FakeProvider(LLMProvider):
|
|
32
|
+
"""Deterministic offline stand-in for a real LLM backend.
|
|
33
|
+
|
|
34
|
+
``responses`` are returned by ``complete`` in order; once exhausted (or if
|
|
35
|
+
none were given) a fixed canned response is returned. Embeddings are
|
|
36
|
+
derived from a hash of the input text, so the same text always maps to the
|
|
37
|
+
same vector.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name = "fake"
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
responses: list[str] | None = None,
|
|
45
|
+
*,
|
|
46
|
+
model: str = "fake-model",
|
|
47
|
+
embedding_dim: int = 8,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._responses = list(responses or [])
|
|
50
|
+
self.model = model
|
|
51
|
+
self.embedding_dim = embedding_dim
|
|
52
|
+
self.calls: list[RecordedCall] = []
|
|
53
|
+
|
|
54
|
+
def complete(
|
|
55
|
+
self, system: str, user: str, *, json_mode: bool = False
|
|
56
|
+
) -> CompletionResult:
|
|
57
|
+
self.calls.append(
|
|
58
|
+
RecordedCall("complete", system=system, user=user, json_mode=json_mode)
|
|
59
|
+
)
|
|
60
|
+
if self._responses:
|
|
61
|
+
text = self._responses.pop(0)
|
|
62
|
+
else:
|
|
63
|
+
text = '{"fake": true}' if json_mode else "fake response"
|
|
64
|
+
return CompletionResult(
|
|
65
|
+
text=text,
|
|
66
|
+
input_tokens=_rough_token_count(system) + _rough_token_count(user),
|
|
67
|
+
output_tokens=_rough_token_count(text),
|
|
68
|
+
provider=self.name,
|
|
69
|
+
model=self.model,
|
|
70
|
+
tokens_estimated=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
74
|
+
self.calls.append(RecordedCall("embed", texts=list(texts)))
|
|
75
|
+
return [self._vector_for(text) for text in texts]
|
|
76
|
+
|
|
77
|
+
def _vector_for(self, text: str) -> list[float]:
|
|
78
|
+
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
|
79
|
+
return [digest[i % len(digest)] / 255.0 for i in range(self.embedding_dim)]
|