api: fix vision-request usage crash in stream finalizer; raise embeddings rate limit

The final usage chunk joined message contents with str.join, which raised
"expected str instance, list found" for multipart (vision) content and
surfaced as an error event at the end of otherwise-successful streams.
Flatten via _content_to_text instead (all three token-estimate sites).

Embeddings limit 120→1200 req/min per IP: bulk indexers legitimately
embed at several req/s; keep the limit an abuse guard, not a throttle.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014S8VtAvG499SsCbeESRK7V
parent 40635282
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.1.32" __version__ = "0.1.33"
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
# expandable_segments lets the allocator return freed pages to the driver even # expandable_segments lets the allocator return freed pages to the driver even
......
...@@ -110,7 +110,9 @@ _DEFAULT_LIMITS: Dict[str, Tuple[int, int]] = { ...@@ -110,7 +110,9 @@ _DEFAULT_LIMITS: Dict[str, Tuple[int, int]] = {
"/v1/images/": (30, 60), "/v1/images/": (30, 60),
"/v1/audio/": (60, 60), "/v1/audio/": (60, 60),
"/v1/video/": (10, 60), "/v1/video/": (10, 60),
"/v1/embeddings": (120, 60), # Embeddings are cheap and legitimately arrive in bulk (indexers embedding
# whole corpora at several req/s) — keep this an abuse guard, not a throttle.
"/v1/embeddings": (1200, 60),
} }
# API prefixes that count against the request queue # API prefixes that count against the request queue
......
...@@ -353,6 +353,17 @@ def _normalize_vision_content(content: list) -> list: ...@@ -353,6 +353,17 @@ def _normalize_vision_content(content: list) -> list:
return norm return norm
def _content_to_text(content) -> str:
"""Flatten a message content field to plain text for token estimates.
Vision requests carry content as a multipart LIST (text + image parts) —
joining messages with ``str.join`` would raise on those."""
if isinstance(content, list):
return "\n".join(
it.get("text", "") if isinstance(it, dict) else str(it)
for it in content)
return content if isinstance(content, str) else ("" if content is None else str(content))
def _normalize_tool_call_arguments(tool_calls): def _normalize_tool_call_arguments(tool_calls):
"""Return tool_calls with each ``function.arguments`` as a dict (mapping) """Return tool_calls with each ``function.arguments`` as a dict (mapping)
rather than a JSON string. OpenAI/Kilo send arguments as a JSON STRING, but rather than a JSON string. OpenAI/Kilo send arguments as a JSON STRING, but
...@@ -2875,7 +2886,7 @@ async def stream_chat_response( ...@@ -2875,7 +2886,7 @@ async def stream_chat_response(
yield f"data: {json.dumps(data)}\n\n" yield f"data: {json.dumps(data)}\n\n"
else: else:
# Calculate token counts for usage in final chunk # Calculate token counts for usage in final chunk
prompt_text = "\n".join([m.get("content", "") for m in messages]) prompt_text = "\n".join(_content_to_text(m.get("content")) for m in messages)
prompt_tokens = len(prompt_text.split()) prompt_tokens = len(prompt_text.split())
completion_tokens = len(generated_text.split()) if generated_text else 0 completion_tokens = len(generated_text.split()) if generated_text else 0
...@@ -2898,7 +2909,7 @@ async def stream_chat_response( ...@@ -2898,7 +2909,7 @@ async def stream_chat_response(
yield f"data: {json.dumps(final_chunk)}\n\n" yield f"data: {json.dumps(final_chunk)}\n\n"
else: else:
# Calculate token counts for usage in final chunk # Calculate token counts for usage in final chunk
prompt_text = "\n".join([m.get("content", "") for m in messages]) prompt_text = "\n".join(_content_to_text(m.get("content")) for m in messages)
prompt_tokens = len(prompt_text.split()) prompt_tokens = len(prompt_text.split())
completion_tokens = len(generated_text.split()) if generated_text else 0 completion_tokens = len(generated_text.split()) if generated_text else 0
...@@ -3129,7 +3140,7 @@ async def generate_chat_response( ...@@ -3129,7 +3140,7 @@ async def generate_chat_response(
_model_key_for_cache = getattr(current_manager, 'model_name', None) or model_name _model_key_for_cache = getattr(current_manager, 'model_name', None) or model_name
last_usage = (current_manager.get_last_usage() last_usage = (current_manager.get_last_usage()
if hasattr(current_manager, 'get_last_usage') else {}) if hasattr(current_manager, 'get_last_usage') else {})
prompt_text = "\n".join([m.get("content", "") for m in messages]) prompt_text = "\n".join(_content_to_text(m.get("content")) for m in messages)
prompt_tokens = last_usage.get('prompt_tokens') or len(prompt_text.split()) prompt_tokens = last_usage.get('prompt_tokens') or len(prompt_text.split())
completion_tokens = last_usage.get('completion_tokens') or ( completion_tokens = last_usage.get('completion_tokens') or (
len(generated_text.split()) if generated_text else 0) len(generated_text.split()) if generated_text else 0)
......
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