[object Object]

← back to Designer Wallcoverings

color cleaner v2: model-fallback chain (flash-lite→flash→pro) — each model has a SEPARATE per-day free quota, so 429-on-daily-cap fails over to next model = ~3x free throughput on one key. Verified red cleaned 8/10 via failover. + nightly-color-clean.sh runner (singleton lock, quota probe-gate, 2h cap, resumable)

3fd98379f66d7ac9c52054cc10cdcf3428de10fa · 2026-06-12 15:52:11 -0700 · Steve Abrams

Files touched

Diff

commit 3fd98379f66d7ac9c52054cc10cdcf3428de10fa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 12 15:52:11 2026 -0700

    color cleaner v2: model-fallback chain (flash-lite→flash→pro) — each model has a SEPARATE per-day free quota, so 429-on-daily-cap fails over to next model = ~3x free throughput on one key. Verified red cleaned 8/10 via failover. + nightly-color-clean.sh runner (singleton lock, quota probe-gate, 2h cap, resumable)
---
 DW-Programming/color-collection-cleaner-v2.js | 38 +++++++++++++----------
 DW-Programming/nightly-color-clean.sh         | 43 +++++++++++++++++++++++++++
 2 files changed, 66 insertions(+), 15 deletions(-)

diff --git a/DW-Programming/color-collection-cleaner-v2.js b/DW-Programming/color-collection-cleaner-v2.js
index 5e3a5c92..c93c1369 100644
--- a/DW-Programming/color-collection-cleaner-v2.js
+++ b/DW-Programming/color-collection-cleaner-v2.js
@@ -91,10 +91,15 @@ function downloadImage(url) {
     }).on('error', () => resolve(null)).on('timeout', function () { this.destroy(); resolve(null); });
   });
 }
-function callGemini(body) {
+// Each Gemini model has a SEPARATE per-day free-tier request quota. When one
+// model 429s on its daily cap, fail over to the next — ~3x free daily throughput
+// on a single key. (mirrors the enrich-ai-tags MODEL_CHAIN pattern, 2026-06-12)
+const MODEL_CHAIN = (process.env.GEMINI_MODEL_CHAIN || process.env.GEMINI_MODEL ||
+  'gemini-2.5-flash-lite,gemini-2.5-flash,gemini-2.5-pro').split(',').map(s => s.trim()).filter(Boolean);
+function callGemini(body, model) {
   return new Promise((resolve, reject) => {
     const req = https.request({ hostname: 'generativelanguage.googleapis.com',
-      path: `/v1beta/models/${process.env.GEMINI_MODEL || 'gemini-2.5-flash'}:generateContent?key=${GEMINI_KEY}`, method: 'POST',
+      path: `/v1beta/models/${model}:generateContent?key=${GEMINI_KEY}`, method: 'POST',
       headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 30000 }, (res) => {
       let d = ''; res.on('data', c => d += c); res.on('end', () => {
         if (res.statusCode === 429) return reject({ code: 429, retryAfter: parseFloat(res.headers['retry-after'] || '15') });
@@ -120,21 +125,24 @@ Answer on ONE line EXACTLY: "COLOR=YES|NO (dominant) BANNER=YES|NO"` },
     // output budget on internal reasoning and returns finishReason=MAX_TOKENS with an
     // EMPTY text part, which the parser reads as "not this color" → false-removes. (2026-06-12)
     generationConfig: { temperature: 0.1, maxOutputTokens: 60, thinkingConfig: { thinkingBudget: 0 } } });
-  for (let a = 1; a <= 5; a++) {
-    try { const v = await callGemini(body);
-      // Tri-state: only an EXPLICIT "COLOR=NO" removes. A "COLOR=YES" keeps. Anything
-      // else (empty/garbled/unexpected) is null=uncertain → caller keeps + retries,
-      // never removes on ambiguity. This is the belt-and-suspenders guard against the
-      // thinking-budget class of bug that caused mass false-removes. (2026-06-12)
-      const yes = /COLOR\s*[=:]\s*YES/i.test(v), no = /COLOR\s*[=:]\s*NO/i.test(v);
-      return { color: yes ? true : (no ? false : null), banner: /BANNER\s*[=:]\s*YES/i.test(v), raw: v };
-    } catch (e) {
-      if (e.code === 429) { await new Promise(r => setTimeout(r, Math.min(e.retryAfter || 15, 60) * 1000)); continue; }
-      if (e.code >= 500 && a < 3) { await new Promise(r => setTimeout(r, 4000)); continue; }
-      return { err: e.code || 'unknown' };
+  // Walk the model chain; a 429 (daily quota on THAT model) fails over to the next.
+  for (const model of MODEL_CHAIN) {
+    for (let a = 1; a <= 3; a++) {
+      try { const v = await callGemini(body, model);
+        // Tri-state: only an EXPLICIT "COLOR=NO" removes. A "COLOR=YES" keeps. Anything
+        // else (empty/garbled/unexpected) is null=uncertain → caller keeps + retries,
+        // never removes on ambiguity. Belt-and-suspenders against the thinking-budget
+        // class of bug that caused mass false-removes. (2026-06-12)
+        const yes = /COLOR\s*[=:]\s*YES/i.test(v), no = /COLOR\s*[=:]\s*NO/i.test(v);
+        return { color: yes ? true : (no ? false : null), banner: /BANNER\s*[=:]\s*YES/i.test(v), raw: v, model };
+      } catch (e) {
+        if (e.code === 429) break;                 // daily quota on this model → next model
+        if (e.code >= 500 && a < 3) { await new Promise(r => setTimeout(r, 4000)); continue; }
+        return { err: e.code || 'unknown' };
+      }
     }
   }
-  return { err: 'max-retries' };
+  return { err: 'all-quota' };                      // every model's daily quota is spent
 }
 async function fetchCollectionProducts(collectionId) {
   const all = []; let sinceId = 0;
diff --git a/DW-Programming/nightly-color-clean.sh b/DW-Programming/nightly-color-clean.sh
new file mode 100755
index 00000000..7dc22ece
--- /dev/null
+++ b/DW-Programming/nightly-color-clean.sh
@@ -0,0 +1,43 @@
+#!/bin/zsh
+# Nightly resumable color-collection cleaner. Runs after the Gemini free-tier
+# daily request quota resets (~midnight PT), processes ~one quota's worth (~10k
+# calls) of the remaining color collections, resumes via .done state, and is
+# capped at 2h so it can't grind on failed retries once the quota re-exhausts.
+# Self-idles once every collection is fully processed ("to process: 0").
+# Remove when done:  launchctl bootout gui/$(id -u)/com.steve.color-cleaner-nightly
+set -u
+DIR="$HOME/Projects/Designer-Wallcoverings/DW-Programming"
+PROJ_ENV="$HOME/Projects/Designer-Wallcoverings/.env"
+SECRETS="$HOME/Projects/secrets-manager/.env"
+LOCK="$DIR/data/nightly.lock"
+LOG="$DIR/data/nightly-$(date +%Y%m%d).log"
+cd "$DIR" || exit 1
+
+# singleton — bail if a prior night's run is somehow still alive
+if [ -f "$LOCK" ] && kill -0 "$(cat "$LOCK" 2>/dev/null)" 2>/dev/null; then
+  echo "$(date): previous run still active (pid $(cat "$LOCK")), skipping" >> "$LOG"; exit 0
+fi
+echo $$ > "$LOCK"
+trap 'rm -f "$LOCK"' EXIT
+
+# load env (sandbox token from project .env, gemini key from secrets master)
+set -a; source "$PROJ_ENV" 2>/dev/null
+GEMINI_API_KEY="$(grep -E '^GEMINI_API_KEY=' "$SECRETS" | head -1 | cut -d= -f2-)"; set +a
+
+echo "=== $(date) nightly color-clean start ===" >> "$LOG"
+
+# quota probe — one cheap call. If it 429s, the daily quota hasn't reset / is
+# already spent; skip tonight rather than grind on failures.
+PROBE=$(curl -s "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}" \
+  -H 'Content-Type: application/json' \
+  -d '{"contents":[{"parts":[{"text":"ok"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0},"maxOutputTokens":10}}')
+if echo "$PROBE" | grep -q '"error"'; then
+  echo "$(date): gemini quota unavailable — $(echo "$PROBE" | grep -oE '\"code\": *[0-9]+' | head -1). Skipping." >> "$LOG"
+  exit 0
+fi
+
+# resumable sweep, gentle concurrency, hard 2h cap (kills post-quota grind)
+/usr/bin/timeout 7200 node color-collection-cleaner-v2.js --all --conc 3 >> "$LOG" 2>&1
+echo "--- report ---" >> "$LOG"
+node color-clean-report.js >> "$LOG" 2>&1
+echo "=== $(date) nightly color-clean end ===" >> "$LOG"

← df60b0ab color cleaner v2: (1) tri-state safety — removal now require  ·  back to Designer Wallcoverings  ·  nightly runner: chain-aware quota probe (proceed if ANY chai 6f9d054f →