# ADR-132 AI Provider Priority Is Universal — Claude OAuth → Gemini OAuth → OpenRouter → Anthropic REST → Ollama

## Status

Accepted, 2026-08-06.

## Status History

```yaml
status_history:
  - date: 2026-08-06
    status: Proposed
    changed_by: hkl
    reason: >
      Owner directive: every 360lm PWA must prefer the free-OAuth AI providers
      (Claude -p / claude-oauth sidecar, then Gemini CLI OAuth) before any paid
      provider, for BOTH text and vision — inserting Gemini OAuth as the universal
      Tier 2. Refines ADR-062 §1 (which set Claude -> OpenRouter -> Anthropic REST
      -> Ollama and deliberately scoped Gemini to vision-only). A 2026-08-06 audit
      of 13 live AI call sites found paid providers used where a free tier was
      available (ai-finance-proxy and tour-ai-proxy call api.anthropic.com with NO
      fallback at all), Gemini OAuth essentially unused as a fallback tier, and two
      Node services (health-ocr, excel-ai) calling Gemini via STATIC generativelanguage
      keys in violation of ADR-114. Companion rollout plan: docs/PLAN-ADR-132-ai-provider-priority-rollout.md
    changed_via: adr-kit (360lm) — DL session, dev-first, execution gated on hkl go-ahead
  - date: 2026-08-06
    status: Accepted
    changed_by: hkl
    reason: >
      Owner accepted the rule interactively (free-AI-first priority is now the standard).
      Scope confirmed DEV-ONLY, no prod cutover. Authorized Phase 0 build only (Gemini text
      endpoint, shared ai_chain helper, verify harness) — then stop and report before any app migration.
    changed_via: adr-kit (360lm) — DL session
```

## Context

**ADR-062 (Live AI Pipeline Contract) §1** established the canonical text chain as:

```
Claude -p OAuth  ->  OpenRouter  ->  Anthropic REST  ->  Ollama (last resort)
```

and explicitly scoped Google/Gemini (ADR-114) to the **vision/OCR** chains only, telling
readers "do not conflate the two chains." That was correct at the time, but two things have
changed:

1. **Gemini CLI OAuth is now a proven $0/token path.** `gemini-proxy` (`/opt/gemini-proxy/gemini.py::analyze_via_cli`) runs the `gemini` CLI (`/usr/bin/gemini`) against personal-OAuth creds at `/root/.gemini/oauth_creds.json`, with `GEMINI_API_KEY` stripped to force OAuth — an exact parallel to `claude -p`. It costs nothing per token, the same subscription/OAuth economics as Claude. Keeping it vision-only leaves a free tier unused for text.

2. **Paid providers are being hit where a free tier was available.** Audit of the 13 live AI call sites (2026-08-06):

| Call site | Current chain | Problem |
|---|---|---|
| tour-ai-proxy | Anthropic REST **only** | Paid + single point of failure (no fallback) |
| ai-finance-proxy | Anthropic REST **only** | Paid + single point of failure (no fallback) |
| ocr-proxy (odometer/fuel) | Claude /vision -> OpenRouter | Skips the free Gemini tier (fixed partial, 2026-08-06) |
| hub-media-proxy /suggest | Claude /vision **only** | No fallback below Claude |
| health-ocr (Node) | claude -p -> **generativelanguage** -> OpenRouter -> Anthropic | Gemini via STATIC key (ADR-114 violation) |
| excel-ai-sidecar (Node) | claude -p -> **generativelanguage** -> OpenRouter -> Anthropic | Gemini via STATIC key (ADR-114 violation) |
| counter-ai, print-ai, blogsmith, sitecap | Claude OAuth -> OpenRouter -> Anthropic REST | No Gemini tier (free tier skipped) |
| dispatch-ai (address) | Claude OAuth -> OpenRouter -> Anthropic REST -> Ollama | No Gemini tier (free tier skipped) |
| videosmith | Anthropic + OpenRouter | No Claude/Gemini OAuth tier |

Every paid token spent when a free OAuth tier could have served the same request is avoidable
cost. The fix is a single, uniform provider-priority order applied everywhere.

## Decision

**Decision Maker:** hkl

Every live AI call in a 360lm PWA — **text and vision alike** — must attempt providers in this
order, falling through only on failure/timeout/no-usable-output:

```
Tier 1  Claude OAuth   ($0)   claude -p  /  claude-proxy sidecar (:8788 /generate text, /vision images)
Tier 2  Gemini OAuth   ($0)   gemini CLI OAuth  /  gemini-proxy (:? /analyze vision, /generate text — to be added)
Tier 3  OpenRouter     (paid) OPENROUTER_API_KEY — free models first, then paid
Tier 4  Anthropic REST (paid, approval-gated) ANTHROPIC_API_KEY — only after need_api_permission approval (ADR-062 §1)
Tier 5  Ollama         (local, last resort) where already wired (dispatch, graphify); never the only tier
```

This **refines ADR-062 §1 only**. It:

- **Elevates Gemini from vision-only to a universal Tier 2** (both text and vision), reversing 062's
  "Gemini is vision-only, don't conflate" scoping.
- **Inserts Gemini OAuth ahead of OpenRouter**, pushing OpenRouter to Tier 3 and Anthropic REST to Tier 4.
- **Forbids static Gemini API keys** (reaffirms ADR-114): any Gemini call goes through the OAuth CLI /
  gemini-proxy, never `generativelanguage.googleapis.com` with a static key.

**All other sections of ADR-062 remain in force unchanged** — §2 human-review gate, §3 loading UX,
§4 graceful degradation, §5 endpoint contract (`{ok,data,mode}` / `need_api_permission` 503), §6
defer-to-ADR-056. This ADR changes *which providers, in what order*; it does not change the review
gate, the UX, or the wire contract.

### Cost-ordering caveat (accurate, not hand-waved)

Tiers 1 and 2 are $0 via subscription/personal OAuth. One honest exception: `gemini-proxy` has its
**own** internal fallback — if the free Gemini CLI OAuth path is exhausted/rate-limited, it falls to a
**billed Vertex service-account** call (`analyze_media`, `vertex/sa`) before returning failure. That
billed hop lives *inside* gemini-proxy and is its local safety net, but it means "reached Tier 2" is
not a hard $0 guarantee under Gemini-CLI quota exhaustion. Accepted: Vertex spend is expected to be
rare and small; revisit tier order if Vertex billing appears materially. This does not change the
directive — free-primary intent holds.

## Implementation Notes

- **Canonical chain lives in a shared helper, not re-copied per proxy.** ADR-062 deferred a shared
  helper "until the contract is stable"; ADR-132 stabilizes it. Build `ai_chain.py` (Python proxies)
  and `ai_chain.mjs` (Node proxies) implementing the 5-tier order with the ADR-062 §5 response shape,
  in two variants: `chain_text(system,user,...)` and `chain_vision(image_b64,mime,prompt,...)`.
  ADR-059's "no single shared *proxy*" still holds — this is a shared *library*, each sidecar keeps its
  own process/isolation.
- **Tier-1 Claude endpoints** (running container `claude-proxy`, internal `root_default` net):
  - Text: `POST http://claude-proxy:8788/generate  {system,user,model}` -> `{ok,result}`
  - Vision: `POST http://claude-proxy:8788/vision  {image_b64,ext,system,user,model}` -> `{ok,result}` (tokenless, internal-only)
- **Tier-2 Gemini endpoints** (container `gemini-proxy`):
  - Vision (exists): `POST /gemini-proxy/analyze` multipart `file`+`task`+`deep`, header `Authorization: Hub <b64 session>` -> `{ok,tier,model,analysis}`. NOTE: different wire contract than claude /vision (multipart + Hub session, not JSON+base64+tokenless) — the helper must adapt.
  - Text (**GAP — must be built**): add `POST /gemini-proxy/generate {system,user,model}` that runs `gemini -m <model> -p '<system+user>'` with `GEMINI_API_KEY` popped (mirror `analyze_via_cli` minus the `@file`).
- **Deploy pattern for the bind-mounted Python sidecars:** edit the mounted `.py`, `docker restart <name>` (re-reads the file) — do **NOT** `docker compose up` (compose may reattach networks; and note `traefik-net` in `/root/360lm-web/docker-compose.yml` is an alias for the external `root_default` network, `external: true; name: root_default`). Back up before edit: `cp x.py x.py.bak-YYYYMMDD-adr132`.
- **Find all AI call sites:**
  `grep -rlniE 'openrouter\.ai|api\.anthropic\.com|claude-proxy:8788|generativelanguage|gemini-proxy|claude -p|extract_type|/vision' /opt /var/www/360lm /var/www/Others --include=*.py --include=*.mjs --include=*.js | grep -v node_modules`
- **Reference implementation already shipped:** `ocr-proxy` (2026-08-06) does Claude /vision -> OpenRouter with an env toggle (`OCR_USE_CLAUDE`) and a robust JSON extractor; it is the pattern to copy, and is itself listed in the rollout plan as still needing the Gemini Tier-2 inserted between Claude and OpenRouter.

### Phase 0 findings & corrections (2026-08-06)

Phase 0 (shared helper + verify harness + Gemini text endpoint) built in **dev**; the shared helper
passed an independent Opus review (**PASS, no must-fix**). Reality discovered on the VPS, correcting
the Tier-2 Gemini notes above:

- **Two services share the docker name `gemini-proxy`.** The RUNNING container is a **Node** sidecar
  (`/var/www/Others/Automation/gemini-proxy/proxy.js`, listens `:8789`, Bearer `GEMINI_PROXY_TOKEN`,
  internal-only / no Traefik, used by **n8n**). The **Python FastAPI** app at `/opt/gemini-proxy`
  (Traefik `/gemini-proxy`, Hub-gated `/analyze` vision, `:8080`) is defined in compose but **NOT
  running** (name collision) — `https://…/gemini-proxy/healthz` returns 404.
- **Gemini OAuth TEXT already exists and is LIVE** on the Node service
  (`http://gemini-proxy:8789/generate`, `{system,user,model}` → `{ok,result}`). It is *not* a gap;
  P1 staged a redundant Python `/generate` (backed up, **not deployed**).
- **Gemini OAuth VISION is NOT available live** (Python `/analyze` not running; Node has no vision
  route) — a real gap for the vision waves; until closed, vision chains run Claude → OpenRouter.
- **$0 confirmed** for Gemini CLI, but **only with `GOOGLE_CLOUD_PROJECT=gemini-pwa-360dlm`** set —
  without it the CLI errors `IneligibleTierError`. Latent bug: the Python `analyze_via_cli` never sets
  this, so if that service is ever deployed its free tier silently falls to **billed Vertex** — fix
  before any deploy.
- **Helper wiring TODO:** `ai_chain` currently targets gemini `:8080`; the live text endpoint is
  `:8789` + Bearer. Reconcile as part of the name-collision decision (do not pre-wire).
- **OPEN OWNER DECISION (name collision), gated with the wave rollout — pick one:** (a) point the
  helper at the live Node `:8789/generate` for TEXT and add a vision route to the Node service; (b)
  rename/retire the Node sidecar and run the Python service (Traefik + Hub-gated `/analyze`) under the
  `gemini-proxy` name; (c) run both under distinct names. **Never touch the running Node container
  while n8n depends on it.**

**RESOLVED 2026-08-06 (option a, implemented + tested in dev):** added a Bearer-gated `POST /vision`
route to the LIVE Node `gemini-proxy` (`:8789`, gemini-cli `@file` attach, `toolCalls:0` stays
contained) so ONE service now serves both TEXT (`/generate`) and VISION (`/vision`) at $0 OAuth — no
Python service, no name collision, n8n untouched. Shared helper rewired: Gemini to `http://gemini-proxy:8789`
(text+vision) with `GEMINI_PROXY_TOKEN` Bearer. Verified: helper `chain_text` -> `mode:gemini-oauth`
`{ping:pong}`, `chain_vision` -> `mode:gemini-oauth` odometer 116265 km (matches Claude & OpenRouter reads).
Backups: `proxy.js.bak-20260806-adr132`, `ai_chain.py.bak-20260806-gemini8789`. TODO: mirror the same
Gemini rewire into `ai_chain.mjs` (only needed when the Node-based proxies health/excel/sitecap are migrated).

## Alternatives Considered

- **Keep Gemini vision-only (status quo ADR-062 §1).** Rejected: wastes the free Gemini text quota and
  leaves several text proxies with only *paid* fallbacks (or none — tour-ai, ai-finance).
- **Single shared AI proxy for the whole fleet.** Rejected again (as in ADR-062/ADR-059): single point
  of failure, mixes domain concerns. Shared *library* + per-PWA sidecars instead.
- **Put OpenRouter free models ahead of Gemini OAuth.** Rejected: Gemini CLI OAuth is $0 and generally
  higher quality than OpenRouter's free models; the only cost risk is gemini-proxy's internal billed
  Vertex fallback under quota exhaustion (see caveat), judged rare enough not to invert the order.
- **Route everything through the existing OC AI Proxy (`:4999` + `intercept.js`).** Partially adopted
  for Anthropic-format Node callers, but most sidecars carry bespoke inline chains and speak their own
  wire shapes; a shared helper covers both styles without forcing a single hop.

## Consequences

**Positive:**
- Maximum free-tier utilization: two $0 OAuth tiers are exhausted before any paid token is spent.
- Eliminates the two paid-only single-points-of-failure (tour-ai, ai-finance).
- Closes two ADR-114 violations (static Gemini keys in health-ocr, excel-ai).
- One uniform order + shared helper — new AI features get it for free.

**Negative / Debt:**
- Per-proxy migration required (see rollout plan) — ~11 proxies, done on the schedule, dev-first.
- **New shared dependency / correlated failure:** routing all PWAs through the same Claude + Gemini
  OAuth sessions means a throttle on either session degrades the whole fleet at once (each proxy's
  lower tiers still cover hard failures, but not slow-throttle). Accept and monitor.
- **Latency:** OAuth CLI tiers (claude -p ~15-18s vision; gemini CLI similar) are much slower than a
  paid vision API (~1-3s). Interactive flows (odometer snap-and-wait) pay this. Every proxy keeps an
  env toggle so a specific latency-sensitive path can be flipped back without code change.
- gemini-proxy needs a **text** endpoint built (Tier-2 text gap).
- ocr-proxy needs the Gemini Tier-2 inserted (currently Claude -> OpenRouter, skips it).

## Related Decisions

- **ADR-062** — Live AI Pipeline Contract (this ADR refines its §1 only; §2–§6 stay in force)
- **ADR-114** — Google AI via OAuth/ADC, never static keys (reaffirmed; Tier 2 obeys it)
- **ADR-025** — Self-Hosted Ollama (stays as Tier 5 last resort)
- **ADR-056** — AI Handoff via Prompt (the non-live-pipeline pattern; out of scope)
- **ADR-059** — Per-PWA sidecar proxies (constrains us to a shared *library*, not a shared proxy)

## References

- Audit source: DL session 2026-08-06, `grep` of `/opt` + `/var/www` AI call sites (13 sites, 11 proxies)
- Trigger: RentVeh meter-reading outage -> ocr-proxy re-pointed to Claude OAuth -> owner directive to make free-OAuth universal
- Companion rollout plan: `docs/PLAN-ADR-132-ai-provider-priority-rollout.md`
