Manual disable is rotation-only; include media in response cache key

Two independent fixes surfaced while diagnosing kilo-spora vision requests
returning 503 and (once enabled) risking stale image answers.

handlers.py: _raise_if_provider_unavailable no longer rejects a direct,
provider-named request just because the provider is manually disabled. The
dashboard disable toggle is meant to remove a provider from rotation/autoselect
(those scans use is_provider_disabled_cheap) — it must not take the provider
offline across the whole API. Direct calls still 503 on genuine unavailability
(failure cooldown or usage-limit cooldown).

cache.py: _generate_cache_key hashed only the text parts of multimodal
messages, dropping image_url/video_url/audio_url. A caller that sends a fixed
prompt with a varying image (e.g. the real-estate OUTDOOR/INDOOR classifier)
collapsed every image to one cache key, so the first image's answer was served
for all subsequent images. Fold each media reference into the message hash so
different attachments produce different keys.
Co-Authored-By: 's avatarClaude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 9f9ec1a0
......@@ -1382,8 +1382,30 @@ class ResponseCache:
if isinstance(temperature, (int, float)):
temperature = round(temperature * 4) / 4 # Round to nearest 0.25
# Create message content hash for semantic deduplication
# Include only the text content of messages, ignore metadata
# Create message content hash for semantic deduplication.
# Include the text AND any attached media (images/video/audio) so two
# requests that share a prompt but carry different attachments never
# collapse to the same key. Vision/multimodal callers commonly send a
# fixed instruction with a varying image; hashing text alone would make
# every image reuse the first image's cached answer.
def _media_ref(part: dict) -> str:
"""Stable string identity for a non-text multimodal part."""
for media_key in ('image_url', 'video_url', 'audio_url'):
media = part.get(media_key)
if isinstance(media, dict):
url = media.get('url')
if url:
return f"{media_key}:{url}"
elif isinstance(media, str) and media:
return f"{media_key}:{media}"
# input_audio / file style parts: fold in the whole part deterministically.
if part.get('type') in ('input_audio', 'file', 'input_image'):
try:
return f"{part.get('type')}:{json.dumps(part, sort_keys=True)}"
except Exception:
return f"{part.get('type')}:{part}"
return ''
message_texts = []
for msg in messages:
if isinstance(msg, dict):
......@@ -1391,11 +1413,16 @@ class ResponseCache:
content = msg.get('content', '')
# Handle both string and list content (for multimodal)
if isinstance(content, list):
# For multimodal content, extract text parts
# For multimodal content, extract text parts and media identities
text_parts = []
for part in content:
if isinstance(part, dict) and part.get('type') == 'text':
text_parts.append(part.get('text', ''))
if isinstance(part, dict):
if part.get('type') == 'text':
text_parts.append(part.get('text', ''))
else:
ref = _media_ref(part)
if ref:
text_parts.append(ref)
elif isinstance(part, str):
text_parts.append(part)
content = ' '.join(text_parts)
......
......@@ -103,30 +103,40 @@ def _should_record_failure(error) -> bool:
def _raise_if_provider_unavailable(handler, provider_id: str) -> None:
"""Reject the request with 503 when the provider is disabled, saying why.
"""Reject a direct request with 503 only when the provider is genuinely
unavailable, saying why.
is_rate_limited() collapses three very different states — the dashboard
disable toggle, the failure cooldown and the usage-limit cooldown — into one
boolean. Returning a bare "Provider temporarily unavailable" with no log line
made a provider that had simply been toggled off in the dashboard look
identical to an upstream outage, with nothing in debug.log to tell them
apart. Log the reason and put it in the response detail.
boolean. Those are NOT equivalent for a direct call:
* The dashboard "disable" toggle only removes a provider from *rotation* and
*autoselect* (those scans call is_provider_disabled_cheap, which honours the
manual flag). A direct request that names the provider/model explicitly must
still go through — manually disabling a provider must never take it offline
across the whole API.
* A failure cooldown or a usage-limit cooldown is a real availability problem:
calling upstream would just fail again, so a direct call is still rejected,
with the reason logged and echoed in the response detail.
"""
if not handler.is_rate_limited():
return
if handler.is_manually_disabled():
reason = "manually disabled from the dashboard (re-enable it there)"
now = time.time()
cooldown_until = handler.error_tracking.get('disabled_until') if getattr(handler, 'error_tracking', None) else None
usage_until = getattr(handler, '_usage_disabled_until', None)
in_failure_cooldown = bool(cooldown_until and cooldown_until > now)
in_usage_cooldown = bool(usage_until and usage_until > now)
# The only remaining reason is_rate_limited() can be true is the manual
# dashboard toggle, which is rotation-only — let the direct call proceed.
if not in_failure_cooldown and not in_usage_cooldown:
return
if in_failure_cooldown:
reason = f"in failure cooldown for another {int(cooldown_until - now)}s"
else:
now = time.time()
cooldown_until = handler.error_tracking.get('disabled_until') if getattr(handler, 'error_tracking', None) else None
usage_until = getattr(handler, '_usage_disabled_until', None)
if cooldown_until and cooldown_until > now:
reason = f"in failure cooldown for another {int(cooldown_until - now)}s"
elif usage_until and usage_until > now:
reason = f"disabled by a usage limit for another {int(usage_until - now)}s"
else:
reason = "temporarily unavailable"
reason = f"disabled by a usage limit for another {int(usage_until - now)}s"
logging.getLogger(__name__).warning(
f"[{provider_id}] Rejecting request with 503: provider is {reason}"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment