# ADR-062 Live AI Pipeline Contract

## Status

Accepted, 2026-06-25 (amended 2026-07-28 — Dispatch address migrated off Ollama-only).

## Status History

```yaml
status_history:
  - date: 2026-06-25
    status: Accepted
    changed_by: hkl
    reason: >
      Audit of 11 live AI pipelines found no shared standard for auth chain,
      fallback order, human-review gate, or 503 handling. Each PWA re-implements
      these from scratch. ADR-056 covers the copy-paste hand-off pattern only —
      no ADR governed the live-API pipelines. Drafted alongside the campaign
      enrichment feature (counters PWA) to anchor all future AI features.
    changed_via: adr-kit (360lm)
  - date: 2026-07-28
    status: Accepted (amended)
    changed_by: hkl
    reason: |
      Migration item #3 from this ADR's own Consequences section executed:
      Dispatch (address) was Ollama-only and had gone off-contract in practice
      — /dispatch-ai/address was timing out 2/2 against a 60s gateway limit,
      leaving the DispatchWithAutomation intake with no working AI parse path
      at all. Fixed by porting counter-ai's proven call_claude_oauth chain
      (subprocess claude -p + mounted OAuth creds) into dispatch_ai.py: Tier 1
      claude -p OAuth -> Tier 2a OpenRouter -> Tier 2b Anthropic REST -> Tier 3
      Ollama gemma3:4b kept as the LAST-resort tier (was previously the ONLY
      tier — not removed, since dispatch-ai is shared with the PROD
      manual-dispatch PWA and Ollama staying available is the safety net for
      any future tier outage). Also required switching dispatch-ai's base
      image from python:3.11-alpine to python:3.11-slim: the claude CLI is a
      glibc-linked ELF binary (confirmed via `file`), incompatible with
      alpine's musl libc — every other working claude-oauth proxy already
      uses slim for this reason. Address-parse output schema extended
      (additively — legacy name/address/city/pincode/phone fields unchanged,
      new address_line1/address_line2/state/phones[] fields added alongside)
      to support DispatchWithAutomation's order-booking intake, which needs
      the raw address split per Shiprocket's field-length limits. Live-tested
      against a real order row: claude_oauth tier resolved in ~5s (vs. the
      prior 60s timeout), all 7 target fields present. Docket OCR
      (/dispatch-ai/ocr, llava vision) is UNCHANGED — ADR-062 §1 already
      permits Ollama llava as an acceptable Tier 1 for self-hosted vision.
    changed_via: adr-kit (360lm) — VCC build session, dev-only, additive to a shared prod container
```

## Context

**Canonical default tier order for text/LLM chains** (added 2026-07-28 for clarity — the
per-proxy table below remains the source of truth for what each proxy actually implements,
this is just the default new proxies should follow unless a row says otherwise):

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

Google/Gemini (ADR-114) is used by the **vision/OCR** chains (ocr-proxy, Vehicle odometer/fuel
pump) — NOT the text/address chains above. See ADR-114 for the Gemini CLI OAuth bridge and its
own tier order; do not conflate the two chains when reading the table below.

As of 2026-06-25 the 360lm platform has **11 live AI pipelines** across 7 PWAs:

| PWA | Proxy | Primary model | Fallback chain |
|---|---|---|---|
| Counters (import) | counter-ai:8779 | Claude OAuth | OpenRouter → Anthropic REST |
| Counters (geo) | counter-ai:8779 | Claude OAuth | OpenRouter → Anthropic REST |
| Tour Planner | tour-ai-proxy:8774 | Anthropic REST | None |
| Print Nest (extract) | print-ai-proxy:8780 | Claude OAuth | OpenRouter → Anthropic REST |
| Print Nest (chat) | print-ai-proxy:8780 | Claude OAuth | OpenRouter → Anthropic REST |
| Finance v1 (OCR) | ai-finance-proxy:8772 | Anthropic REST | None |
| Expense (bill OCR) | ocr-proxy:8766 | Ollama llava | None |
| Vehicle (odometer) | ocr-proxy:8766 | OpenRouter Gemini | Gemini-lite → Nemotron |
| Vehicle (fuel pump) | ocr-proxy:8766 | OpenRouter Gemini | Gemini-lite → Nemotron |
| Dispatch (docket OCR) | dispatch-ai:8767 | Ollama llava | None (vision, self-host acceptable per §1) |
| Dispatch (address) | dispatch-ai:8767 | **Claude OAuth** (2026-07-28) | OpenRouter → Anthropic REST → Ollama gemma3:4b (last resort) |

**ADR-056** (AI Handoff via Prompt, not Live API) covers a twelfth case — CC
Reconciliation — where the AI call is mediated by the user manually (copy-paste into
claude.ai). That pattern is governed separately and is out of scope here.

**Gaps found in the audit:**
1. No unified auth/fallback chain — tour-ai and ai-finance have no fallback; if their
   single endpoint fails, the user sees an error with no recovery path.
2. No shared 503 / `need_api_permission` handling — counter-ai and print-ai implement
   it; others don't.
3. No consistent human-review gate — most pipelines do surface a review step, but the
   button labels, status colours, and "confirm → commit" flow are inconsistent.
4. Spinner/loading UX varies per PWA — some show a CSS spin class, some just disable
   buttons, one shows nothing.
5. No standard for what "graceful degradation" means when the AI is unavailable.

## Decision

Every live AI feature in a 360lm PWA **must** follow this contract:

---

### 1. Auth / Fallback Chain (server-side, in proxy)

```
Tier 1 (free, no key):  Claude OAuth  — claude -p binary + /root/.claude credentials
Tier 2 (paid, shared):  OpenRouter    — OPENROUTER_API_KEY; try paid model first, then free
Tier 3 (approved only): Anthropic REST — ANTHROPIC_API_KEY; only if user explicitly approves
```

- **New proxies** must implement all three tiers in order.
- **Existing proxies** (tour-ai, ai-finance, ocr-proxy dispatch) should be migrated to
  the full chain on their next substantive update — not as emergency fixes.
- Vision tasks (image input): Ollama llava is acceptable as Tier 1 for self-hosted
  inference; fall through to OpenRouter vision models if Ollama is unavailable.
- The proxy **must** return `{"need_api_permission": true}` with HTTP 503 when Tiers 1+2
  fail and Tier 3 has not been approved. The PWA surfaces an "Allow API key" inline
  button; the user's click triggers a retry with `allow_api: true`.

---

### 2. Human-Review Gate (client-side, mandatory)

Every pipeline **must** show a review step before writing to the DB:

| Gate type | When to use | Pattern |
|---|---|---|
| **Form pre-fill** | Single record, user edits inline | Finance v1 OCR, Vehicle OCR, Dispatch address |
| **Preview table** | Multiple records, all-or-nothing | AI Import (counters), Print Nest extract |
| **Row-by-row card** | Multiple records, per-row accept/skip | Campaign enrichment (counters) |
| **Plan review** | Complex structured plan | Tour Planner route/replan |

Rules:
- The commit button (POST / PATCH to DB) must be **disabled** until the user has seen
  the review step. It may not fire automatically.
- The user must be able to **reject or override** any AI suggestion before committing.
- After apply, show a toast with counts: `N updated, M skipped, K failed`.

---

### 3. Loading UX (client-side, mandatory)

```
1. Disable the trigger button immediately on click.
2. Show a spinner via `.spin` class (already in all PWAs via shared CSS).
3. Show a status text: "⏳ Asking AI… this may take 10–30 seconds"
4. On success: update status text in accent colour, enable the review/commit button.
5. On error: update status text in error colour, re-enable the trigger button for retry.
```

Never leave the UI in a state where the user cannot tell if a call is in progress.

---

### 4. Graceful Degradation

If all AI tiers fail **and** the user has not approved Tier 3:
- Do **not** block the user from completing the task.
- Surface a manual fallback path: for enrichment, allow the user to fill fields manually;
  for OCR, allow the user to type values; for import, let the CSV upload proceed without
  enrichment.
- The fallback path label: "Skip AI — fill manually" or "Continue without AI".

---

### 5. Endpoint Contract

Every proxy endpoint used by a PWA must:
- Accept `Content-Type: application/json` POST.
- Require a `Bearer <SECRET>` header (secret stored as a `const` in the PWA file).
- Return `{"ok": true, "data": {...}, "mode": "<tier-used>"}` on success.
- Return `{"error": "<message>"}` with a 4xx/5xx status on failure.
- Return `{"need_api_permission": true, "message": "..."}` with HTTP 503 when
  Tiers 1+2 exhausted and Tier 3 not approved.
- Log `[proxy-name] mode: <tier>, <N> items` to stderr on every successful call.

---

### 6. When NOT to use a live AI pipeline (defer to ADR-056)

Use the copy-paste handoff (ADR-056) when:
- The computation requires the full Claude.ai context window (>100K tokens).
- The output is a long narrative that the user reads and selectively acts on.
- The feature is used less than once per week and latency is acceptable.
- No structured JSON output is needed.

Use a live pipeline when:
- Structured JSON output is required (field extraction, classification, matching).
- The user expects results within the same interaction (< 30 seconds).
- The output drives an immediate DB write after human review.

## Alternatives Considered

- **No standard — keep each PWA independent.** Rejected: already produces 3 different
  spinner patterns, 2 different 503 handlers, and 2 proxies with no fallback. Technical
  debt compounds with every new AI feature.
- **Shared JS helper file (`shared/ai-pipeline.js`).** Deferred: good idea once the
  contract is stable. Premature while proxies still differ in response shape. Add after
  two more PWAs adopt ADR-062 (see Consequences).
- **Single shared AI proxy for all PWAs.** Rejected: ADR-059 established per-PWA
  sidecar proxies for isolation and independent deployment. Shared proxy is a single
  point of failure and mixes domain concerns.

## Consequences

**Positive:**
- New AI features have a clear checklist — no rediscovery of auth patterns.
- 503 + `need_api_permission` becomes universal — users always have a retry path.
- Human-review gate is mandatory — no accidental auto-commit of AI output.
- Graceful degradation means AI unavailability never blocks core workflows.

**Negative / Debt:**
- tour-ai-proxy and ai-finance-proxy need OpenRouter fallback added on their next
  update (they currently have none — single point of failure).
- ~~ocr-proxy dispatch paths (Ollama-only) need a cloud fallback~~ — **DONE 2026-07-28**
  for dispatch-ai's address path (see amendment above); docket OCR (vision) stays
  Ollama llava per §1's self-host exemption.
- Existing 503 handling in non-counter PWAs is inconsistent — needs standardisation
  during next touch.

**Migration order (non-blocking, do on next substantive edit):**
1. tour-ai-proxy — add OpenRouter fallback tier
2. ai-finance-proxy — add OAuth + OpenRouter tiers
3. ~~ocr-proxy (dispatch paths) — add OpenRouter vision fallback~~ — DONE for
   dispatch-ai's address path 2026-07-28 (see 2026-07-28 amendment); docket OCR
   vision path intentionally left on Ollama llava.

## Related Decisions

- ADR-056 — AI Handoff via Prompt Copy-Paste (the non-live-pipeline pattern)
- ADR-059 — Tour Planner AI Uses a Dedicated Sidecar Proxy
- ADR-025 — Self-Hosted Ollama for AI Inference
- ADR-013 — Single HTML File, No Framework (constrains shared-JS option)

## References

- Audit source: `/root/.claude/projects/-root-360lm-web/` session 2026-06-25
- First feature governed by this ADR: Campaign Enrichment (counters PWA, 2026-06-25)
- Proxy files audited: counter_ai.py, tour_ai_proxy.py, print_ai_proxy.py,
  ai_finance_proxy.py, ocr_proxy.py, dispatch_ai.py
