AI providers¶
photo-tagger talks to a vision-language model through an OpenAI-compatible chat API. Four backends
are supported: Ollama, LM Studio, and
llama.cpp's llama-server for local servers, and openai
for any hosted OpenAI-compatible endpoint (the real OpenAI API or a drop-in gateway). The HTTP calls
and the structured-output decoding are handled by pydantic-ai, so the
rest of the pipeline only ever sees a validated result object.
The backend registry¶
Backends live in a registry in
providers.py.
Each one is a ProviderBackend (the Strategy pattern) that bundles the three things that actually
differ per backend: how to build the model-listing URL, how to parse model ids out of the listing,
and how to construct the pydantic-ai provider. The shared HTTP plumbing (URL validation, the listing
fetch, log-body truncation) is written once and reused. Adding a backend is a single entry in the
registry, so the agent setup, the --provider choices, and the model check never drift apart.
| Provider | --provider value |
Default base URL | Base-URL env var | Key required |
|---|---|---|---|---|
| LM Studio | lmstudio |
http://localhost:1234/v1 |
LM_STUDIO_BASE_URL |
no |
| Ollama | ollama |
http://localhost:11434/v1 |
OLLAMA_BASE_URL |
no |
| llama.cpp | llamacpp |
http://localhost:8080/v1 |
LLAMA_CPP_BASE_URL |
no |
| OpenAI | openai |
https://api.openai.com/v1 |
OPENAI_BASE_URL |
yes |
You point photo-tagger at a backend in three ways, in order of precedence (CLI flags win):
--provider ollama,--provider lmstudio,--provider llamacpp, or--provider openaiselects the backend. Omit it to get LM Studio.-u/--url URLoverrides the base URL. The matching env var isLM_STUDIO_BASE_URL,OLLAMA_BASE_URL,LLAMA_CPP_BASE_URL, orOPENAI_BASE_URL. When neither is set, the provider's default URL above is used.-k/--api-key KEYsets the API key. Prefer the env vars:OLLAMA_API_KEYfor Ollama,LM_STUDIO_API_KEY(withOPENAI_API_KEYas a fallback) for LM Studio,LLAMA_CPP_API_KEYfor llama.cpp (only needed whenllama-serverwas started with--api-key), andOPENAI_API_KEYfor theopenaiprovider. Local servers usually do not require a key; theopenaiprovider does and fails fast with a clear message when one is missing.
The model identifier comes from -m/--model (or the MODEL_NAME env var) and defaults to
qwen/qwen3-vl-30b.
For example, to run against a local Ollama server with all defaults:
Or set everything once through the environment so the flags can stay off the command line:
export OLLAMA_BASE_URL=http://localhost:11434/v1
export MODEL_NAME=qwen3-vl
photo-tagger -i ./photos --provider ollama
Choosing and pulling a model
Use a vision-language model, since photo-tagger sends the image as input. Qwen3-VL is a good
starting point. With Ollama, pull it once with ollama pull qwen3-vl; with LM Studio, download the
model from its UI and start the server. create_agent() checks the model is available before any
photos are processed, so a typo in -m/--model fails fast with a clear error instead of midway
through a batch.
How create_agent builds the agent¶
ai.py wires the
backend into a pydantic-ai Agent. The flow is the same for every backend:
flowchart LR
A[create_agent] --> B{validate model<br/>available?}
B -- no --> E[ProviderError]
B -- yes --> C[OpenAIChatModel<br/>over provider]
C --> D[Agent with<br/>GeneratedMetadata output]
create_agent() looks the backend up in the registry, resolves the base URL and API key, then
validates that the requested model is actually present on the provider before doing anything else.
The backend knows where to look: the OpenAI-compatible /v1/models listing (LM Studio, llama.cpp,
OpenAI) or Ollama's /api/tags. If the model name is not found, it raises a ProviderError so the
run stops before wasting time on images. The openai backend additionally short-circuits with a
ProviderError when no key is configured. Once validation passes it constructs an OpenAIChatModel
over the provider and wraps it in an Agent whose output_type is GeneratedMetadata.
You can run the same model check on its own with photo-tagger doctor, which also verifies ExifTool
is installed. See the CLI reference.
Structured output and retries¶
The agent does not return free text. pydantic-ai instructs the model to produce structured output
and decodes the response into GeneratedMetadata, defined in
models.py:
title: a short title (length-capped).description: a short description (length-capped).keywords: a list of flat keywords (count-capped).hierarchies: a separate list of taxonomy chains, written specific-to-general with<separators (for exampleGolden Eagle<Bird of Prey<Animal), also count-capped.
The taxonomy lives in its own field because, constrained to the schema, the model otherwise emits
only flat keywords and the hierarchy is lost. analyze_image_with_ai() folds each chain back into
the keyword list before returning, so the rest of the pipeline sees one keyword list (with <
chains) exactly as it did before. See Metadata and keywords for how those chains are
parsed and written.
If the model returns something that fails schema validation (a malformed payload, an over-long
field), pydantic-ai retries the call. The number of attempts is controlled by --retries (env var
RETRIES, default 5). The per-image timeout also feeds into this loop: on a timeout the retry
logic steps in. analyze_image_with_ai() runs the agent and returns an InferenceResult carrying
the validated metadata plus token usage and wall-clock seconds.
Keyword cap is silent, length caps trigger retries
GeneratedMetadata truncates an over-long keyword list silently rather than rejecting it, so a
model that overshoots the requested keyword count does not burn retries. Over-long title or
description fields, by contrast, fail validation and do cost a retry attempt.
Sampling settings¶
These flags shape how the model generates each response. All have env-var equivalents and config
file keys under [inference].
| Flag | Env var | Default | Effect |
|---|---|---|---|
--temperature |
TEMPERATURE |
0.2 |
Sampling temperature; lower is more focused. |
--max-tokens |
MAX_TOKENS |
1200 |
Maximum tokens to generate per image. |
--frequency-penalty |
FREQUENCY_PENALTY |
0.5 |
Penalizes repeated tokens to avoid loops. |
--timeout-seconds |
TIMEOUT_SECONDS |
60.0 |
Per-image inference timeout; feeds the retries. |
Keeping token use low¶
The model never sees the original file. Before each call,
image_io.py
loads the image (rawpy for RAW, Pillow otherwise), applies EXIF orientation, flattens transparency
onto white, downscales it, and JPEG-encodes the bytes that go to the model. A full-resolution RAW or
JPEG would inflate token counts and slow inference for no benefit, so two flags bound the payload:
--jpeg-dimensions(env varJPEG_DIMENSIONS, default1280) caps the longest side, in pixels, of the JPEG sent to the model.--jpeg-quality(env varJPEG_QUALITY, default80) sets the JPEG compression quality, from 1 to 100.
Because these inputs change the model's response, they are part of the cache key. See Caching and locking for how reruns reuse a previous result. For the full option list, see the CLI reference, and for the downstream keyword handling see Metadata and keywords.