33Some images — especially the timestamped ``screen-shot-…`` files —
44don't have meaningful filenames or alt text, so we can't tell from
55metadata alone whether they're charts, chats, or game UIs. This
6- module sends each one through Claude's vision API to get:
6+ module sends each one through an OpenAI-compatible LiteLLM vision endpoint to get:
77
88* a content category (``chart``, ``map``, ``table``, ``chart-screenshot``,
99 ``chat``, ``social-media``, ``ui-screenshot``, ``photo``, ``other``)
1010* a one-sentence description (becomes alt text on the IA item)
1111* a concise suggested title
12+ * any visible text extracted from the image
1213
1314Results are written to ``data/image_captions.csv`` keyed by identifier
1415so :mod:`ia_image_upload` can join them in and override the
1516filename-derived category for items in scope.
1617
17- Auth: ``ANTHROPIC_API_KEY `` env var . Resumable: identifiers already in
18- the captions CSV are skipped.
18+ Auth: ``LITELLM_API_KEY `` + ``LITELLM_BASE_URL`` env vars . Resumable:
19+ identifiers already in the captions CSV are skipped.
1920"""
2021
2122from __future__ import annotations
2728import os
2829import re
2930import threading
31+ from collections .abc import Mapping
3032from dataclasses import dataclass
3133from pathlib import Path
34+ from typing import cast
35+ from urllib .parse import urljoin
3236
33- import anthropic
34- from anthropic import APIStatusError
37+ import httpx
3538from tenacity import (
3639 retry ,
3740 retry_if_exception_type ,
4043)
4144from tqdm .contrib .concurrent import thread_map
4245
43- from fakethirtyeight .images import IMAGE_LOG , IMAGE_REFS_FILE
46+ from fakethirtyeight .http import DEFAULT_TIMEOUT , make_ssl_context
47+ from fakethirtyeight .images import IMAGE_LOG , IMAGE_REFS_FILE , _is_image_body
4448from fakethirtyeight .paths import DATA_DIR , ensure_dirs
4549
4650log = logging .getLogger (__name__ )
5256 "ai_category" ,
5357 "ai_description" ,
5458 "ai_title" ,
59+ "ai_text" ,
5560 "model" ,
5661 "status" ,
5762 "error" ,
5863)
5964
60- #: Default model — Sonnet is the sweet spot for visual classification.
61- #: Override with ``--model`` on the CLI.
62- DEFAULT_MODEL = "claude-sonnet-4-5 "
65+ #: Default model. Prefer ``LITELLM_MODEL`` so local config can choose
66+ #: whatever the gateway exposes. Override with ``--model`` on the CLI.
67+ DEFAULT_MODEL = os . environ . get ( "LITELLM_MODEL" ) or "gpt-4o-mini "
6368
6469#: Categories the model is allowed to return. Constrained list keeps
6570#: the downstream filter logic deterministic.
95100 "title": a concise display title for an archive.org item
96101 (≤80 chars; if the image is a chart, prefer the
97102 chart's own title text)
103+ "text": all legible text visible in the image, preserving the
104+ rough reading order. Use an empty string if there is
105+ no legible text. Do not summarize or invent text.
98106
99107Return ONLY the JSON. No prose, no markdown fences.\
100108 """ .format (categories = ", " .join (ALLOWED_CATEGORIES ))
@@ -106,13 +114,14 @@ class CaptionResult:
106114 ai_category : str = ""
107115 ai_description : str = ""
108116 ai_title : str = ""
117+ ai_text : str = ""
109118 model : str = ""
110119 status : str = "ok"
111120 error : str = ""
112121
113122
114123def _detect_media_type (path : Path ) -> str :
115- """Pick the IANA media type Claude expects for the image source ."""
124+ """Pick the IANA media type the vision endpoint expects ."""
116125 ext = path .suffix .lower ()
117126 by_ext = {
118127 ".jpg" : "image/jpeg" ,
@@ -124,6 +133,32 @@ def _detect_media_type(path: Path) -> str:
124133 return by_ext .get (ext , "image/jpeg" )
125134
126135
136+ def _litellm_url () -> str :
137+ base = (os .environ .get ("LITELLM_BASE_URL" ) or "" ).strip ()
138+ if not base :
139+ msg = "Set LITELLM_BASE_URL first."
140+ raise RuntimeError (msg )
141+ if base .rstrip ("/" ).endswith ("/chat/completions" ):
142+ return base
143+ return urljoin (base .rstrip ("/" ) + "/" , "chat/completions" )
144+
145+
146+ def _litellm_headers () -> dict [str , str ]:
147+ key = (os .environ .get ("LITELLM_API_KEY" ) or "" ).strip ()
148+ if not key :
149+ msg = "Set LITELLM_API_KEY first."
150+ raise RuntimeError (msg )
151+ user_agent = (os .environ .get ("LITELLM_USER_AGENT" ) or "" ).strip ()
152+ if not user_agent :
153+ msg = "Set LITELLM_USER_AGENT first."
154+ raise RuntimeError (msg )
155+ return {
156+ "Authorization" : f"Bearer { key } " ,
157+ "Content-Type" : "application/json" ,
158+ "User-Agent" : user_agent ,
159+ }
160+
161+
127162_JSON_RX = re .compile (r"\{.*\}" , re .DOTALL )
128163
129164
@@ -137,38 +172,73 @@ def _parse_response(text: str) -> dict[str, str]:
137172 return json .loads (text )
138173
139174
175+ def _content_from_response (data : dict [str , object ]) -> str :
176+ """Extract assistant text from an OpenAI-compatible response."""
177+ if data .get ("error" ):
178+ error = data ["error" ]
179+ if isinstance (error , Mapping ):
180+ error_map = cast (Mapping [str , object ], error )
181+ message_obj = error_map .get ("message" )
182+ message = str (message_obj or error_map )
183+ else :
184+ message = str (error )
185+ msg = f"LiteLLM error: { message } "
186+ raise RuntimeError (msg [:500 ])
187+
188+ choices = data .get ("choices" )
189+ if not isinstance (choices , list ) or not choices :
190+ msg = f"LiteLLM response missing choices: keys={ sorted (data )} "
191+ raise RuntimeError (msg )
192+ first = choices [0 ]
193+ if not isinstance (first , dict ):
194+ msg = "LiteLLM response choice is not an object"
195+ raise RuntimeError (msg )
196+ message = first .get ("message" )
197+ if isinstance (message , dict ) and isinstance (message .get ("content" ), str ):
198+ return message ["content" ]
199+ text = first .get ("text" )
200+ if isinstance (text , str ):
201+ return text
202+ msg = "LiteLLM response choice missing message content"
203+ raise RuntimeError (msg )
204+
205+
140206@retry (
141- retry = retry_if_exception_type (APIStatusError ),
207+ retry = retry_if_exception_type (httpx . HTTPError ),
142208 stop = stop_after_attempt (4 ),
143209 wait = wait_exponential (multiplier = 2 , min = 4 , max = 60 ),
144210 reraise = True ,
145211)
146212def _classify_one (
147- client : anthropic . Anthropic , image_path : Path , * , model : str
213+ client : httpx . Client , image_path : Path , * , model : str
148214) -> dict [str , str ]:
149- """Send one image to Claude. Returns the parsed JSON response ."""
215+ """Send one image to a LiteLLM vision endpoint and parse JSON output ."""
150216 data = base64 .standard_b64encode (image_path .read_bytes ()).decode ("ascii" )
151- msg = client . messages . create (
152- model = model ,
153- max_tokens = 400 ,
154- messages = [
217+ payload = {
218+ " model" : model ,
219+ " max_tokens" : 400 ,
220+ " messages" : [
155221 {
156222 "role" : "user" ,
157223 "content" : [
224+ {"type" : "text" , "text" : _PROMPT },
158225 {
159- "type" : "image " ,
160- "source " : {
161- "type " : "base64" ,
162- "media_type" : _detect_media_type (image_path ),
163- "data" : data ,
226+ "type" : "image_url " ,
227+ "image_url " : {
228+ "url " : (
229+ f"data: { _detect_media_type (image_path )} ;base64, { data } "
230+ )
164231 },
165232 },
166- {"type" : "text" , "text" : _PROMPT },
167233 ],
168234 }
169235 ],
170- )
171- raw = "" .join (block .text for block in msg .content if block .type == "text" )
236+ }
237+ resp = client .post (_litellm_url (), json = payload )
238+ if resp .status_code in {429 , 500 , 502 , 503 , 504 }:
239+ resp .raise_for_status ()
240+ resp .raise_for_status ()
241+ raw = _content_from_response (resp .json ())
172242 return _parse_response (raw )
173243
174244
@@ -191,6 +261,22 @@ def _load_done(path: Path) -> set[str]:
191261 return out
192262
193263
264+ def _ensure_caption_header (path : Path ) -> None :
265+ """Upgrade older caption CSVs before appending rows with new fields."""
266+ if not path .exists () or path .stat ().st_size == 0 :
267+ return
268+ with path .open (newline = "" , encoding = "utf-8" ) as fh :
269+ reader = csv .DictReader (fh )
270+ if reader .fieldnames == list (CAPTION_FIELDS ):
271+ return
272+ rows = list (reader )
273+ with path .open ("w" , newline = "" , encoding = "utf-8" ) as fh :
274+ writer = csv .DictWriter (fh , fieldnames = CAPTION_FIELDS )
275+ writer .writeheader ()
276+ for row in rows :
277+ writer .writerow ({field : row .get (field , "" ) for field in CAPTION_FIELDS })
278+
279+
194280def _select_targets (
195281 refs_path : Path ,
196282 image_log_path : Path ,
@@ -222,8 +308,11 @@ def _select_targets(
222308 if only_screenshots and cat_by_id .get (ident ) != "screenshot" :
223309 continue
224310 fp = DATA_DIR .parent / fp_rel
225- if fp .exists ():
226- targets .append ((ident , fp ))
311+ if not fp .exists ():
312+ continue
313+ with fp .open ("rb" ) as image_fh :
314+ if _is_image_body (image_fh .read (2048 )):
315+ targets .append ((ident , fp ))
227316 return targets
228317
229318
@@ -237,16 +326,17 @@ def caption_images(
237326 image_log_path : Path = IMAGE_LOG ,
238327 out_path : Path = CAPTIONS_FILE ,
239328) -> tuple [int , int ]:
240- """Classify a subset of downloaded images using Claude vision.
329+ """Classify a subset of downloaded images using LiteLLM vision.
241330
242331 Returns ``(captioned, failed)``. Resumable via ``out_path``.
243332 """
244- if not os . environ . get ( "ANTHROPIC_API_KEY" ):
245- msg = "Set ANTHROPIC_API_KEY first."
246- raise RuntimeError ( msg )
333+ # Validate config before doing any target scan or log setup.
334+ headers = _litellm_headers ()
335+ _litellm_url ( )
247336
248337 ensure_dirs ()
249338 out_path .parent .mkdir (parents = True , exist_ok = True )
339+ _ensure_caption_header (out_path )
250340
251341 targets = _select_targets (
252342 refs_path , image_log_path , only_screenshots = only_screenshots
@@ -268,9 +358,14 @@ def caption_images(
268358
269359 write_header = not out_path .exists ()
270360 write_lock = threading .Lock ()
271- client = anthropic .Anthropic ()
272-
273- with out_path .open ("a" , newline = "" , encoding = "utf-8" ) as fh :
361+ with (
362+ httpx .Client (
363+ timeout = DEFAULT_TIMEOUT ,
364+ headers = headers ,
365+ verify = make_ssl_context (),
366+ ) as client ,
367+ out_path .open ("a" , newline = "" , encoding = "utf-8" ) as fh ,
368+ ):
274369 writer = csv .DictWriter (fh , fieldnames = CAPTION_FIELDS )
275370 if write_header :
276371 writer .writeheader ()
@@ -285,6 +380,7 @@ def _process(item: tuple[str, Path]) -> int:
285380 ai_category = _normalize_category (parsed .get ("category" , "" )),
286381 ai_description = str (parsed .get ("description" , "" )).strip ()[:300 ],
287382 ai_title = str (parsed .get ("title" , "" )).strip ()[:120 ],
383+ ai_text = str (parsed .get ("text" , "" )).strip ()[:5000 ],
288384 model = model ,
289385 status = "ok" ,
290386 )
@@ -303,6 +399,7 @@ def _process(item: tuple[str, Path]) -> int:
303399 "ai_category" : result .ai_category ,
304400 "ai_description" : result .ai_description ,
305401 "ai_title" : result .ai_title ,
402+ "ai_text" : result .ai_text ,
306403 "model" : result .model ,
307404 "status" : result .status ,
308405 "error" : result .error ,
0 commit comments