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
|
@@ -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])
|
receipts/llm/metered.py
ADDED
|
@@ -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]
|
receipts/llm/provider.py
ADDED
|
@@ -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)
|