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.
Files changed (78) hide show
  1. codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
  2. codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
  3. codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
  4. codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
  5. codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. receipts/__init__.py +0 -0
  7. receipts/ama/__init__.py +0 -0
  8. receipts/ama/interviewer.py +415 -0
  9. receipts/ats/__init__.py +1 -0
  10. receipts/ats/comparator.py +98 -0
  11. receipts/ats/scorer.py +550 -0
  12. receipts/ats/stats.py +164 -0
  13. receipts/cli.py +2062 -0
  14. receipts/config.py +106 -0
  15. receipts/errors.py +18 -0
  16. receipts/export.py +120 -0
  17. receipts/ingest/__init__.py +0 -0
  18. receipts/ingest/artifact_extractor.py +679 -0
  19. receipts/ingest/git_source.py +159 -0
  20. receipts/ingest/manifest.py +114 -0
  21. receipts/ingest/scanner.py +141 -0
  22. receipts/ingest/secrets_scanner.py +225 -0
  23. receipts/interactive/__init__.py +1 -0
  24. receipts/interactive/repl.py +755 -0
  25. receipts/ledger/__init__.py +0 -0
  26. receipts/ledger/pricing_table.py +54 -0
  27. receipts/ledger/token_ledger.py +226 -0
  28. receipts/llm/__init__.py +0 -0
  29. receipts/llm/anthropic_provider.py +95 -0
  30. receipts/llm/factory.py +124 -0
  31. receipts/llm/fake_provider.py +79 -0
  32. receipts/llm/gemini_provider.py +205 -0
  33. receipts/llm/json_utils.py +46 -0
  34. receipts/llm/metered.py +62 -0
  35. receipts/llm/ollama_provider.py +117 -0
  36. receipts/llm/openai_provider.py +118 -0
  37. receipts/llm/provider.py +42 -0
  38. receipts/llm/split.py +78 -0
  39. receipts/llm/token_estimate.py +35 -0
  40. receipts/mine/__init__.py +1 -0
  41. receipts/mine/code_metrics.py +246 -0
  42. receipts/mine/git_evidence.py +109 -0
  43. receipts/prep/__init__.py +1 -0
  44. receipts/prep/dossier.py +313 -0
  45. receipts/prep/readiness.py +227 -0
  46. receipts/resume/__init__.py +0 -0
  47. receipts/resume/claim_extractor.py +338 -0
  48. receipts/resume/loader.py +35 -0
  49. receipts/resume/pdf_parser.py +259 -0
  50. receipts/resume/tex_parser.py +394 -0
  51. receipts/rewrite/__init__.py +0 -0
  52. receipts/rewrite/compiler.py +180 -0
  53. receipts/rewrite/optimizer.py +140 -0
  54. receipts/rewrite/tex_rewriter.py +227 -0
  55. receipts/tui/__init__.py +0 -0
  56. receipts/tui/ama_app.py +454 -0
  57. receipts/tui/app.py +316 -0
  58. receipts/tui/dossier_app.py +170 -0
  59. receipts/tui/hub.py +367 -0
  60. receipts/tui/ingest_app.py +183 -0
  61. receipts/tui/rewrite_app.py +463 -0
  62. receipts/tui/score_app.py +237 -0
  63. receipts/tui/widgets/__init__.py +0 -0
  64. receipts/tui/widgets/claims_table.py +50 -0
  65. receipts/tui/widgets/diff_view.py +38 -0
  66. receipts/tui/widgets/status_bar.py +38 -0
  67. receipts/verify/__init__.py +0 -0
  68. receipts/verify/bm25.py +112 -0
  69. receipts/verify/enrich.py +128 -0
  70. receipts/verify/jd_fetch.py +101 -0
  71. receipts/verify/kb.py +379 -0
  72. receipts/verify/keyword_gap.py +127 -0
  73. receipts/verify/query_expand.py +88 -0
  74. receipts/verify/rerank.py +74 -0
  75. receipts/verify/rewriter.py +172 -0
  76. receipts/verify/router.py +211 -0
  77. receipts/verify/summaries.py +258 -0
  78. 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()
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)
@@ -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)]