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
@@ -0,0 +1,205 @@
1
+ """SECONDARY provider — Google Gemini via a free-tier Google AI Studio key.
2
+
3
+ API facts verified against the installed google-genai 2.10.0 package:
4
+ ``client.models.generate_content`` takes keyword-only params with the system
5
+ prompt in ``config.system_instruction``; ``response.text`` is Optional (None
6
+ on safety blocks); real token usage lives in ``response.usage_metadata``
7
+ (``prompt_token_count`` / ``candidates_token_count`` / ``thoughts_token_count``,
8
+ all Optional). We read GEMINI_API_KEY from our own config and pass it
9
+ explicitly, because the SDK would otherwise let a stray GOOGLE_API_KEY env var
10
+ silently take precedence.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ import time
17
+ from collections.abc import Callable
18
+ from typing import TypeVar
19
+
20
+ from google import genai
21
+ from google.genai import errors as genai_errors
22
+ from google.genai import types as genai_types
23
+
24
+ from receipts.errors import ProviderConfigError, ProviderUnavailableError
25
+ from receipts.llm.provider import CompletionResult, LLMProvider
26
+
27
+ _T = TypeVar("_T")
28
+
29
+ # Free-tier quotas reset on a one-minute window, so waiting out the window
30
+ # and retrying almost always succeeds. Waits are cumulative per call.
31
+ _RATE_LIMIT_BACKOFF_S = (20, 30, 45, 60, 60)
32
+
33
+ # 429 = quota window exhausted; 503 = model temporarily overloaded
34
+ # ("high demand"). Both are transient and clear on their own.
35
+ _TRANSIENT_CODES = {429, 503}
36
+
37
+ _MISSING_KEY_HELP = """\
38
+ No Gemini API key configured.
39
+ Get a free key at https://aistudio.google.com/apikey and set
40
+ GEMINI_API_KEY=<your key> in your environment or local .env file."""
41
+
42
+ _BAD_KEY_HELP = """\
43
+ Gemini rejected your API key (HTTP {code}).
44
+ Check that GEMINI_API_KEY is set to a valid key from
45
+ https://aistudio.google.com/apikey (no quotes, no extra spaces)."""
46
+
47
+ _RATE_LIMIT_HELP = """\
48
+ Gemini free-tier rate limit hit (HTTP 429) and did not clear after several
49
+ retries. Wait a few minutes and re-run, or switch back to local Ollama:
50
+ set RECEIPTS_PROVIDER=ollama (the default)."""
51
+
52
+ _DAILY_QUOTA_HELP = """\
53
+ Gemini free-tier DAILY quota exhausted for this model (HTTP 429, per-day
54
+ limit) — retrying won't help until the quota resets at midnight Pacific.
55
+ Options:
56
+ - switch to local Ollama for now: set RECEIPTS_PROVIDER=ollama
57
+ - try a model with a separate quota: set GEMINI_CHAT_MODEL=gemini-3.5-flash-lite
58
+ - wait for the daily reset."""
59
+
60
+
61
+ class GeminiProvider(LLMProvider):
62
+ name = "gemini"
63
+
64
+ def __init__(
65
+ self, *, api_key: str | None, chat_model: str, embed_model: str
66
+ ) -> None:
67
+ if not api_key:
68
+ raise ProviderConfigError(_MISSING_KEY_HELP)
69
+ self.chat_model = chat_model
70
+ self.embed_model = embed_model
71
+ self._client = genai.Client(api_key=api_key)
72
+ # Optional hook so TUIs can surface retry waits in their status bar;
73
+ # when unset, waits are announced on stderr (fine for plain CLI runs).
74
+ self.wait_callback: Callable[[str, int], None] | None = None
75
+
76
+ def check_available(self) -> None:
77
+ """Validate the API key and chat model id up front (no tokens).
78
+
79
+ A wrong model id otherwise fails on every one of dozens of calls
80
+ mid-run; ``models.get`` is a free metadata call that catches it
81
+ before any real work starts.
82
+ """
83
+ try:
84
+ self._client.models.get(model=self.chat_model)
85
+ except genai_errors.APIError as exc:
86
+ raise self._translate_api_error(exc, model=self.chat_model) from exc
87
+
88
+ def _notify_wait(self, reason: str, wait: int) -> None:
89
+ if self.wait_callback is not None:
90
+ try:
91
+ self.wait_callback(reason, wait)
92
+ return
93
+ except Exception:
94
+ pass
95
+ print(
96
+ f"[gemini] {reason} — waiting {wait}s before retrying...",
97
+ file=sys.stderr,
98
+ )
99
+
100
+ def _with_rate_limit_retry(self, fn: Callable[[], _T]) -> _T:
101
+ """Run an API call, waiting out transient 429/503s before giving up.
102
+
103
+ Free-tier quotas (requests + tokens) reset every minute and
104
+ "high demand" 503s clear on their own, so a paced retry converts
105
+ a hard failure into a slower-but-successful run.
106
+ """
107
+ for wait in _RATE_LIMIT_BACKOFF_S:
108
+ try:
109
+ return fn()
110
+ except genai_errors.APIError as exc:
111
+ code = exc.code or 0
112
+ if code not in _TRANSIENT_CODES:
113
+ raise
114
+ # A per-DAY quota 429 won't clear until midnight Pacific —
115
+ # waiting is pointless, so fail fast with a clear message.
116
+ if code == 429 and "perday" in str(exc).lower():
117
+ raise ProviderUnavailableError(_DAILY_QUOTA_HELP) from exc
118
+ reason = (
119
+ "free-tier rate limit hit"
120
+ if code == 429
121
+ else "model overloaded (503)"
122
+ )
123
+ self._notify_wait(reason, wait)
124
+ time.sleep(wait)
125
+ return fn()
126
+
127
+ def complete(
128
+ self, system: str, user: str, *, json_mode: bool = False
129
+ ) -> CompletionResult:
130
+ config: dict = {"system_instruction": system}
131
+ if json_mode:
132
+ config["response_mime_type"] = "application/json"
133
+ try:
134
+ response = self._with_rate_limit_retry(
135
+ lambda: self._client.models.generate_content(
136
+ model=self.chat_model, contents=user, config=config
137
+ )
138
+ )
139
+ except genai_errors.APIError as exc:
140
+ raise self._translate_api_error(exc, model=self.chat_model) from exc
141
+
142
+ # response.text is None when the prompt was blocked or no text part
143
+ # came back — surface an empty string, never invent content.
144
+ text = response.text or ""
145
+ usage = response.usage_metadata
146
+ input_tokens = (usage.prompt_token_count or 0) if usage else 0
147
+ output_tokens = 0
148
+ if usage:
149
+ # Thinking tokens are billed as output, so the ledger counts them.
150
+ output_tokens = (usage.candidates_token_count or 0) + (
151
+ usage.thoughts_token_count or 0
152
+ )
153
+ return CompletionResult(
154
+ text=text,
155
+ input_tokens=input_tokens,
156
+ output_tokens=output_tokens,
157
+ provider=self.name,
158
+ model=self.chat_model,
159
+ tokens_estimated=usage is None,
160
+ )
161
+
162
+ def embed(self, texts: list[str]) -> list[list[float]]:
163
+ if not texts:
164
+ return []
165
+ # A plain list[str] gets merged into ONE Content (each string becomes
166
+ # a part of a single input), so the API returns a single embedding for
167
+ # the whole batch. Wrapping each text in its own Content makes it a
168
+ # true batch — verified against google-genai 2.10.0 + gemini-embedding-2.
169
+ contents = [
170
+ genai_types.Content(parts=[genai_types.Part(text=t)]) for t in texts
171
+ ]
172
+ try:
173
+ response = self._with_rate_limit_retry(
174
+ lambda: self._client.models.embed_content(
175
+ model=self.embed_model, contents=contents
176
+ )
177
+ )
178
+ except genai_errors.APIError as exc:
179
+ raise self._translate_api_error(exc, model=self.embed_model) from exc
180
+ embeddings = [
181
+ list(embedding.values or []) for embedding in response.embeddings or []
182
+ ]
183
+ if len(embeddings) != len(texts):
184
+ raise ProviderUnavailableError(
185
+ f"Gemini returned {len(embeddings)} embedding(s) for"
186
+ f" {len(texts)} input(s). The embedding model"
187
+ f" '{self.embed_model}' may not support batching — try a"
188
+ " different GEMINI_EMBED_MODEL."
189
+ )
190
+ return embeddings
191
+
192
+ @staticmethod
193
+ def _translate_api_error(exc: genai_errors.APIError, *, model: str) -> Exception:
194
+ code = exc.code or 0
195
+ if code in (401, 403):
196
+ return ProviderConfigError(_BAD_KEY_HELP.format(code=code))
197
+ if code == 404:
198
+ return ProviderConfigError(
199
+ f"Gemini model '{model}' was not found. Set GEMINI_CHAT_MODEL /"
200
+ " GEMINI_EMBED_MODEL to a current model id"
201
+ " (see https://ai.google.dev/gemini-api/docs/models)."
202
+ )
203
+ if code == 429:
204
+ return ProviderUnavailableError(_RATE_LIMIT_HELP)
205
+ return ProviderUnavailableError(f"Gemini API error {code}: {exc.message}")
@@ -0,0 +1,46 @@
1
+ """Tolerant JSON parsing for LLM responses.
2
+
3
+ Small models (and rate-limited lite tiers) frequently wrap valid JSON in
4
+ markdown fences, prepend prose, or use slightly-off shapes. This module
5
+ recovers the JSON when it's genuinely there — and raises when it isn't.
6
+ It never invents content: if nothing parseable exists, the caller keeps
7
+ its honest fallback (e.g. "plausible — response could not be parsed").
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+
15
+ _FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```\s*$", re.DOTALL)
16
+
17
+
18
+ def parse_json_loose(text: str):
19
+ """``json.loads`` with markdown fences and surrounding prose stripped.
20
+
21
+ Raises ``ValueError`` (or ``json.JSONDecodeError``) when no JSON value
22
+ can be recovered.
23
+ """
24
+ if not text or not text.strip():
25
+ raise ValueError("empty response")
26
+
27
+ t = text.strip()
28
+
29
+ fenced = _FENCE_RE.match(t)
30
+ if fenced:
31
+ t = fenced.group(1).strip()
32
+
33
+ try:
34
+ return json.loads(t)
35
+ except json.JSONDecodeError:
36
+ pass
37
+
38
+ # Fall back to the outermost {...} or [...] block inside the text.
39
+ starts = [i for i in (t.find("{"), t.find("[")) if i != -1]
40
+ if not starts:
41
+ raise ValueError("no JSON object or array found")
42
+ start = min(starts)
43
+ end = max(t.rfind("}"), t.rfind("]"))
44
+ if end <= start:
45
+ raise ValueError("no JSON object or array found")
46
+ return json.loads(t[start : end + 1])
@@ -0,0 +1,62 @@
1
+ """Transparent metering wrapper — logs every LLM call to the token ledger.
2
+
3
+ Ground rule: every LLM call is metered and logged, even when the provider is
4
+ free. The factory wraps real providers in this class so no pipeline code has
5
+ to remember to log anything. Attribute access (``name``, ``wait_callback``,
6
+ ``embed_model``, ...) is forwarded to the wrapped provider, so callers can't
7
+ tell the difference — and ledger failures never break an actual LLM call.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from receipts.ledger.token_ledger import TokenLedger
13
+ from receipts.llm.provider import CompletionResult, LLMProvider
14
+
15
+
16
+ class MeteredProvider(LLMProvider):
17
+ """Wraps any provider and records its calls in the SQLite token ledger."""
18
+
19
+ def __init__(self, inner: LLMProvider, ledger: TokenLedger | None = None):
20
+ object.__setattr__(self, "_inner", inner)
21
+ object.__setattr__(self, "_ledger", ledger or TokenLedger())
22
+
23
+ @property
24
+ def name(self) -> str:
25
+ return self._inner.name
26
+
27
+ def complete(
28
+ self, system: str, user: str, *, json_mode: bool = False
29
+ ) -> CompletionResult:
30
+ result = self._inner.complete(system, user, json_mode=json_mode)
31
+ try:
32
+ self._ledger.log(result)
33
+ except Exception:
34
+ pass # metering must never break the actual call
35
+ return result
36
+
37
+ def embed(self, texts: list[str]) -> list[list[float]]:
38
+ vectors = self._inner.embed(texts)
39
+ try:
40
+ # Embedding APIs rarely report usage; estimate with chars/4 and
41
+ # label it estimated so the ledger stays honest.
42
+ est_tokens = max(1, sum(len(t) for t in texts) // 4)
43
+ self._ledger.log(
44
+ CompletionResult(
45
+ text="",
46
+ input_tokens=est_tokens,
47
+ output_tokens=0,
48
+ provider=self._inner.name,
49
+ model=getattr(self._inner, "embed_model", "embedding"),
50
+ tokens_estimated=True,
51
+ )
52
+ )
53
+ except Exception:
54
+ pass
55
+ return vectors
56
+
57
+ def __getattr__(self, item):
58
+ return getattr(object.__getattribute__(self, "_inner"), item)
59
+
60
+ def __setattr__(self, key, value):
61
+ # Forward attribute writes (e.g. wait_callback) to the real provider.
62
+ setattr(object.__getattribute__(self, "_inner"), key, value)
@@ -0,0 +1,117 @@
1
+ """PRIMARY provider — local Ollama. Free, private, no API key needed.
2
+
3
+ API facts verified against the installed ollama 0.6.2 package:
4
+ ``chat()`` returns a ``ChatResponse`` whose ``prompt_eval_count`` /
5
+ ``eval_count`` are Optional and can be omitted (e.g. fully cached prompts),
6
+ an unreachable daemon raises the built-in ``ConnectionError`` on non-streaming
7
+ calls, and a missing model raises ``ollama.ResponseError`` with
8
+ ``status_code == 404``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import httpx
14
+ import ollama
15
+
16
+ from receipts.errors import ProviderConfigError, ProviderUnavailableError
17
+ from receipts.llm.provider import CompletionResult, LLMProvider
18
+ from receipts.llm.token_estimate import estimate_tokens
19
+
20
+ _UNREACHABLE_HELP = """\
21
+ Could not reach Ollama at {host}.
22
+ - If Ollama isn't running, start it (open the Ollama app or run `ollama serve`).
23
+ - If it runs on a different address, set OLLAMA_HOST to the right URL.
24
+ - Or switch provider: set RECEIPTS_PROVIDER=gemini and
25
+ GEMINI_API_KEY=<your free key>."""
26
+
27
+ _MISSING_MODEL_HELP = """\
28
+ Ollama model '{model}' is not available locally.
29
+ Pull it first: ollama pull {model}
30
+ Or point {env_var} at a model you already have (see `ollama list`)."""
31
+
32
+
33
+ class OllamaProvider(LLMProvider):
34
+ name = "ollama"
35
+
36
+ def __init__(self, *, host: str, chat_model: str, embed_model: str) -> None:
37
+ self.host = host
38
+ self.chat_model = chat_model
39
+ self.embed_model = embed_model
40
+ self._client = ollama.Client(host=host)
41
+
42
+ def check_available(self) -> None:
43
+ """Cheap reachability probe; raises a friendly error if the daemon is down."""
44
+ try:
45
+ self._client.list()
46
+ except (ConnectionError, httpx.ConnectError) as exc:
47
+ raise ProviderUnavailableError(
48
+ _UNREACHABLE_HELP.format(host=self.host)
49
+ ) from exc
50
+
51
+ def complete(
52
+ self, system: str, user: str, *, json_mode: bool = False
53
+ ) -> CompletionResult:
54
+ messages = [
55
+ {"role": "system", "content": system},
56
+ {"role": "user", "content": user},
57
+ ]
58
+ try:
59
+ response = self._client.chat(
60
+ model=self.chat_model,
61
+ messages=messages,
62
+ format="json" if json_mode else None,
63
+ )
64
+ except (ConnectionError, httpx.ConnectError) as exc:
65
+ raise ProviderUnavailableError(
66
+ _UNREACHABLE_HELP.format(host=self.host)
67
+ ) from exc
68
+ except ollama.ResponseError as exc:
69
+ raise self._translate_response_error(
70
+ exc, model=self.chat_model, env_var="OLLAMA_CHAT_MODEL"
71
+ ) from exc
72
+
73
+ text = response.message.content or ""
74
+ input_tokens = response.prompt_eval_count
75
+ output_tokens = response.eval_count
76
+ # Ollama doesn't reliably report token counts; when either side is
77
+ # missing we estimate locally and label the result as an estimate.
78
+ estimated = input_tokens is None or output_tokens is None
79
+ if input_tokens is None:
80
+ input_tokens = estimate_tokens(system) + estimate_tokens(user)
81
+ if output_tokens is None:
82
+ output_tokens = estimate_tokens(text)
83
+ return CompletionResult(
84
+ text=text,
85
+ input_tokens=input_tokens,
86
+ output_tokens=output_tokens,
87
+ provider=self.name,
88
+ model=self.chat_model,
89
+ tokens_estimated=estimated,
90
+ )
91
+
92
+ def embed(self, texts: list[str]) -> list[list[float]]:
93
+ if not texts:
94
+ return []
95
+ try:
96
+ response = self._client.embed(model=self.embed_model, input=list(texts))
97
+ except (ConnectionError, httpx.ConnectError) as exc:
98
+ raise ProviderUnavailableError(
99
+ _UNREACHABLE_HELP.format(host=self.host)
100
+ ) from exc
101
+ except ollama.ResponseError as exc:
102
+ raise self._translate_response_error(
103
+ exc, model=self.embed_model, env_var="OLLAMA_EMBED_MODEL"
104
+ ) from exc
105
+ return [list(vector) for vector in response.embeddings]
106
+
107
+ @staticmethod
108
+ def _translate_response_error(
109
+ exc: ollama.ResponseError, *, model: str, env_var: str
110
+ ) -> Exception:
111
+ if exc.status_code == 404:
112
+ return ProviderConfigError(
113
+ _MISSING_MODEL_HELP.format(model=model, env_var=env_var)
114
+ )
115
+ return ProviderUnavailableError(
116
+ f"Ollama returned an error (status {exc.status_code}): {exc.error}"
117
+ )
@@ -0,0 +1,118 @@
1
+ """OpenAI 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 GPT can ``pip install codebase-receipts-cli[openai]`` and set
5
+ ``RECEIPTS_PROVIDER=openai`` with their own key.
6
+
7
+ API facts verified against the installed openai 2.44.0 package:
8
+ ``client.chat.completions.create`` takes ``model``, ``messages`` (list of
9
+ dicts with role/content), ``response_format``. Response has ``.choices[0]
10
+ .message.content``, ``.usage`` with ``prompt_tokens`` / ``completion_tokens``.
11
+ ``client.embeddings.create`` takes ``model``, ``input`` (list of strings).
12
+ Response has ``.data[i].embedding``.
13
+ Errors: ``AuthenticationError``, ``RateLimitError``, ``NotFoundError``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import openai
19
+
20
+ from receipts.errors import ProviderConfigError, ProviderUnavailableError
21
+ from receipts.llm.provider import CompletionResult, LLMProvider
22
+
23
+ _MISSING_KEY_HELP = """\
24
+ No OpenAI API key configured.
25
+ Set OPENAI_API_KEY=<your key> in your environment or local .env file.
26
+ This is a paid API — see https://platform.openai.com/ for pricing."""
27
+
28
+ _BAD_KEY_HELP = """\
29
+ OpenAI rejected your API key.
30
+ Check that OPENAI_API_KEY is set to a valid key from
31
+ https://platform.openai.com/ (no quotes, no extra spaces)."""
32
+
33
+ _DEFAULT_CHAT_MODEL = "gpt-4o-mini"
34
+ _DEFAULT_EMBED_MODEL = "text-embedding-3-small"
35
+
36
+
37
+ class OpenAIProvider(LLMProvider):
38
+ name = "openai"
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ api_key: str | None,
44
+ chat_model: str = _DEFAULT_CHAT_MODEL,
45
+ embed_model: str = _DEFAULT_EMBED_MODEL,
46
+ base_url: str | None = None,
47
+ ) -> None:
48
+ if not api_key:
49
+ raise ProviderConfigError(_MISSING_KEY_HELP)
50
+ self.chat_model = chat_model
51
+ self.embed_model = embed_model
52
+ self.base_url = base_url
53
+ # base_url points at any OpenAI-compatible endpoint (Groq,
54
+ # OpenRouter, a local server). None = the official OpenAI API.
55
+ if base_url:
56
+ self._client = openai.OpenAI(api_key=api_key, base_url=base_url)
57
+ else:
58
+ self._client = openai.OpenAI(api_key=api_key)
59
+
60
+ def complete(
61
+ self, system: str, user: str, *, json_mode: bool = False
62
+ ) -> CompletionResult:
63
+ messages = [
64
+ {"role": "system", "content": system},
65
+ {"role": "user", "content": user},
66
+ ]
67
+ kwargs: dict = {"model": self.chat_model, "messages": messages}
68
+ if json_mode:
69
+ kwargs["response_format"] = {"type": "json_object"}
70
+
71
+ try:
72
+ response = self._client.chat.completions.create(**kwargs)
73
+ except openai.AuthenticationError as exc:
74
+ raise ProviderConfigError(_BAD_KEY_HELP) from exc
75
+ except openai.RateLimitError as exc:
76
+ raise ProviderUnavailableError(
77
+ "OpenAI rate limit hit. Wait and retry, or switch to a"
78
+ " local provider: set RECEIPTS_PROVIDER=ollama."
79
+ ) from exc
80
+ except openai.NotFoundError as exc:
81
+ raise ProviderConfigError(
82
+ f"OpenAI model '{self.chat_model}' was not found."
83
+ " Set OPENAI_CHAT_MODEL to a valid model id"
84
+ " (see https://platform.openai.com/docs/models)."
85
+ ) from exc
86
+ except openai.APIError as exc:
87
+ raise ProviderUnavailableError(f"OpenAI API error: {exc.message}") from exc
88
+
89
+ choice = response.choices[0] if response.choices else None
90
+ text = (choice.message.content or "") if choice else ""
91
+
92
+ usage = response.usage
93
+ input_tokens = usage.prompt_tokens if usage else 0
94
+ output_tokens = usage.completion_tokens if usage else 0
95
+
96
+ return CompletionResult(
97
+ text=text,
98
+ input_tokens=input_tokens,
99
+ output_tokens=output_tokens,
100
+ provider=self.name,
101
+ model=self.chat_model,
102
+ tokens_estimated=usage is None,
103
+ )
104
+
105
+ def embed(self, texts: list[str]) -> list[list[float]]:
106
+ if not texts:
107
+ return []
108
+ try:
109
+ response = self._client.embeddings.create(
110
+ model=self.embed_model, input=list(texts)
111
+ )
112
+ except openai.AuthenticationError as exc:
113
+ raise ProviderConfigError(_BAD_KEY_HELP) from exc
114
+ except openai.APIError as exc:
115
+ raise ProviderUnavailableError(f"OpenAI API error: {exc.message}") from exc
116
+
117
+ sorted_data = sorted(response.data, key=lambda e: e.index)
118
+ return [list(e.embedding) for e in sorted_data]
@@ -0,0 +1,42 @@
1
+ """Abstract LLM provider interface.
2
+
3
+ Every concrete provider (Ollama, Gemini, Anthropic, OpenAI) implements this
4
+ interface. Tool code only ever talks to ``LLMProvider`` and gets instances
5
+ from :mod:`receipts.llm.factory` — never by importing a concrete provider
6
+ directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CompletionResult:
17
+ """A completed LLM call plus the usage numbers the ledger needs."""
18
+
19
+ text: str
20
+ input_tokens: int
21
+ output_tokens: int
22
+ provider: str
23
+ model: str
24
+ tokens_estimated: bool = False
25
+ """True when token counts were estimated locally (e.g. via tiktoken for
26
+ Ollama) rather than reported by the provider."""
27
+
28
+
29
+ class LLMProvider(ABC):
30
+ """Interface every LLM backend implements."""
31
+
32
+ name: str
33
+
34
+ @abstractmethod
35
+ def complete(
36
+ self, system: str, user: str, *, json_mode: bool = False
37
+ ) -> CompletionResult:
38
+ """Return response text plus token usage (input/output) for the ledger."""
39
+
40
+ @abstractmethod
41
+ def embed(self, texts: list[str]) -> list[list[float]]:
42
+ """Return one embedding vector per input text, in order."""
receipts/llm/split.py ADDED
@@ -0,0 +1,78 @@
1
+ """Split provider — chat via one provider, embeddings via another.
2
+
3
+ Exists for one big reason: Anthropic has no embeddings API, so Claude
4
+ alone cannot run the verification pipeline (KB search embeds the query
5
+ at verify time). With a split, chat goes to the configured provider and
6
+ embeddings go to ``RECEIPTS_EMBED_PROVIDER``:
7
+
8
+ RECEIPTS_PROVIDER=anthropic
9
+ RECEIPTS_EMBED_PROVIDER=gemini
10
+
11
+ The embed provider MUST match whatever embedded the knowledge base —
12
+ vectors from different models are not comparable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from receipts.llm.provider import CompletionResult, LLMProvider
18
+
19
+ _OWN_ATTRS = {"_chat", "_embed"}
20
+
21
+
22
+ class SplitProvider(LLMProvider):
23
+ """Composite provider: ``complete`` and ``embed`` go to different backends."""
24
+
25
+ def __init__(self, chat: LLMProvider, embed: LLMProvider) -> None:
26
+ object.__setattr__(self, "_chat", chat)
27
+ object.__setattr__(self, "_embed", embed)
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ # The chat provider's name drives downstream behavior (e.g. the
32
+ # batching heuristic cares about the model answering prompts).
33
+ return self._chat.name
34
+
35
+ @property
36
+ def embed_provider_name(self) -> str:
37
+ return self._embed.name
38
+
39
+ @property
40
+ def chat_model(self) -> str | None:
41
+ return getattr(self._chat, "chat_model", None)
42
+
43
+ @property
44
+ def embed_model(self) -> str | None:
45
+ return getattr(self._embed, "embed_model", None)
46
+
47
+ def complete(
48
+ self, system: str, user: str, *, json_mode: bool = False
49
+ ) -> CompletionResult:
50
+ return self._chat.complete(system, user, json_mode=json_mode)
51
+
52
+ def embed(self, texts: list[str]) -> list[list[float]]:
53
+ return self._embed.embed(texts)
54
+
55
+ def check_available(self) -> None:
56
+ """Validate the chat backend up front when it supports probing."""
57
+ check = getattr(self._chat, "check_available", None)
58
+ if callable(check):
59
+ check()
60
+
61
+ def __getattr__(self, item):
62
+ chat = object.__getattribute__(self, "_chat")
63
+ try:
64
+ return getattr(chat, item)
65
+ except AttributeError:
66
+ return getattr(object.__getattribute__(self, "_embed"), item)
67
+
68
+ def __setattr__(self, key, value):
69
+ # Forward attribute writes (e.g. wait_callback) to whichever inner
70
+ # providers understand them, so retry-wait notifications still work.
71
+ forwarded = False
72
+ for inner_name in ("_chat", "_embed"):
73
+ inner = object.__getattribute__(self, inner_name)
74
+ if hasattr(inner, key):
75
+ setattr(inner, key, value)
76
+ forwarded = True
77
+ if not forwarded:
78
+ object.__setattr__(self, key, value)