@@ -47,6 +47,29 @@ export async function handleImageDescription(
4747 runtime : IAgentRuntime ,
4848 params : ImageDescriptionParams | string
4949) : Promise < { title : string ; description : string } > {
50+ // Honour `DISABLE_IMAGE_DESCRIPTION` (set by the runtime when
51+ // `features.vision === false`). The runtime exposes it via getSetting; some
52+ // hosts only set it in process.env. Check both before burning a quota slot.
53+ // The docs (`docs/runtime/core.md`) already promise this behaviour, but
54+ // historically only `plugin-discord` honoured it at the call site, leaving
55+ // every other caller (agent-orchestrator's task validator, vision, lifeops,
56+ // farcaster, telegram) free to spend the rate-limit budget.
57+ const disableSetting = getSetting ( runtime , "DISABLE_IMAGE_DESCRIPTION" , "" ) ;
58+ const disabled =
59+ disableSetting === "true" ||
60+ disableSetting === "1" ||
61+ process . env . DISABLE_IMAGE_DESCRIPTION === "true" ||
62+ process . env . DISABLE_IMAGE_DESCRIPTION === "1" ;
63+ if ( disabled ) {
64+ logger . debug (
65+ "[ELIZAOS_CLOUD] IMAGE_DESCRIPTION skipped — DISABLE_IMAGE_DESCRIPTION is set"
66+ ) ;
67+ return {
68+ title : "Image description disabled" ,
69+ description : "Image description is disabled by configuration." ,
70+ } ;
71+ }
72+
5073 let imageUrl : string ;
5174 let promptText : string | undefined ;
5275 const modelName = getImageDescriptionModel ( runtime ) ;
@@ -84,21 +107,56 @@ export async function handleImageDescription(
84107 max_tokens : maxTokens ,
85108 } ;
86109
87- // Retry with exponential backoff for transient errors (429 rate limit)
110+ // On 429, honour the upstream's `retryAfter` instead of retrying on a
111+ // hardcoded backoff. Hardcoded retries inside the rate-limit window add
112+ // wasted requests to the same bucket and make the problem worse — see
113+ // #7374's billing render-loop fix and S33's dashboard 429-aware UX.
114+ // Strategy: only retry once, only if the upstream signals a short wait
115+ // (≤5s, i.e. transient burst). Anything longer, bail immediately and let
116+ // the caller fail fast.
88117 let response : Response | null = null ;
89- for ( let attempt = 0 ; attempt < 3 ; attempt ++ ) {
118+ let attemptedRetry = false ;
119+ for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
90120 response = await client . routes . postApiV1ChatCompletionsRaw ( {
91121 json : requestBody ,
92122 } ) ;
123+ if ( response . status !== 429 || attemptedRetry ) break ;
124+
125+ // `Number(null) === 0`, so guard against a missing header before
126+ // calling `Number(...)` — otherwise the header path always wins with a
127+ // bogus `0` and the body fallback becomes unreachable.
128+ const headerValue = response . headers . get ( "retry-after" ) ;
129+ const headerRetryAfter =
130+ headerValue !== null && Number . isFinite ( Number ( headerValue ) )
131+ ? Number ( headerValue )
132+ : undefined ;
133+ let bodyRetryAfter : number | undefined ;
134+ try {
135+ const peek = ( await response . clone ( ) . json ( ) ) as {
136+ retryAfter ?: unknown ;
137+ } ;
138+ bodyRetryAfter =
139+ typeof peek ?. retryAfter === "number" &&
140+ Number . isFinite ( peek . retryAfter )
141+ ? peek . retryAfter
142+ : undefined ;
143+ } catch {
144+ // Body wasn't JSON — fall through to header value.
145+ }
146+ const retryAfter = headerRetryAfter ?? bodyRetryAfter ?? 0 ;
93147
94- if ( response . status === 429 && attempt < 2 ) {
95- const wait = ( attempt + 1 ) * 2000 ; // 2s, 4s
148+ if ( retryAfter > 0 && retryAfter <= 5 ) {
96149 logger . warn (
97- `[ELIZAOS_CLOUD] Image analysis rate-limited (429), retrying in ${ wait / 1000 } s...`
150+ `[ELIZAOS_CLOUD] Image analysis rate-limited (429), retrying once after ${ retryAfter } s...`
98151 ) ;
99- await new Promise ( ( r ) => setTimeout ( r , wait ) ) ;
152+ await new Promise ( ( r ) => setTimeout ( r , retryAfter * 1000 ) ) ;
153+ attemptedRetry = true ;
100154 continue ;
101155 }
156+ // Long rate-limit window: don't burn another bucket slot retrying inside it.
157+ logger . warn (
158+ `[ELIZAOS_CLOUD] Image analysis rate-limited (429); upstream retryAfter=${ retryAfter || "unknown" } s — failing fast`
159+ ) ;
102160 break ;
103161 }
104162
@@ -109,6 +167,11 @@ export async function handleImageDescription(
109167 "Eliza Cloud credits exhausted — top up at https://www.elizacloud.ai/dashboard/settings?tab=billing"
110168 ) ;
111169 }
170+ if ( status === 429 ) {
171+ throw new Error (
172+ "Eliza Cloud rate limit exceeded for image description — try again in a minute"
173+ ) ;
174+ }
112175 throw new Error ( `ElizaOS Cloud API error: ${ status } ` ) ;
113176 }
114177
0 commit comments