Spaces:
Running
Running
| import os | |
| import base64 | |
| import random | |
| import httpx | |
| from urllib.parse import quote | |
| from fastapi import APIRouter, Request, HTTPException, Header | |
| from fastapi.responses import Response, JSONResponse, StreamingResponse | |
| import re | |
| from typing import Optional, Any | |
| import json | |
| from helper.assets import ( | |
| save_base64_image, | |
| cleanup_image, | |
| is_base64_image, | |
| ) | |
| import asyncio | |
| from helper.ratelimit import ( | |
| enforce_rate_limit, | |
| resolve_rate_limit_identity, | |
| check_audio_rate_limit, | |
| check_video_rate_limit, | |
| check_image_rate_limit, | |
| MAX_CHAT_PROMPT_BYTES, | |
| MAX_CHAT_PROMPT_CHARS, | |
| MAX_GROQ_PROMPT_BYTES, | |
| MAX_GROQ_PROMPT_CHARS, | |
| MAX_MEDIA_PROMPT_BYTES, | |
| MAX_MEDIA_PROMPT_CHARS, | |
| extract_user_text, | |
| calculate_messages_size, | |
| normalize_prompt_value, | |
| enforce_prompt_size, | |
| resolve_bound_subject, | |
| get_usage_snapshot_for_subject, | |
| ) | |
| from helper.keywords import * | |
| from uuid import uuid4 | |
| from time import time | |
| from typing import Dict, List, Optional, Tuple | |
| router = APIRouter(prefix="/gen") | |
| PKEY = os.getenv("POLLINATIONS_KEY", "") | |
| PKEY2 = os.getenv("POLLINATIONS2_KEY", "") | |
| PKEY3 = os.getenv("POLLINATIONS3_KEY", "") | |
| AIRFORCE_KEY = os.getenv("AIRFORCE") | |
| AIRFORCE_VIDEO_MODEL = "grok-imagine-video" | |
| AIRFORCE_API_URL = "https://api.airforce/v1/images/generations" | |
| valid_ratios = {"3:2", "2:3", "1:1", "", None} | |
| ratios = {"3:2", "2:3", "1:1"} | |
| valid_modes = {"normal", "fun", "", None} | |
| modes = {"normal", "fun"} | |
| MODEL_MAP = { | |
| "llama-3.1-8b-instant": "Meta Llama 3.1 8B Instant", | |
| "gpt-4o-mini": "OpenAI GPT 4o Mini", | |
| "nemotron-3-super": "NVIDIA Nemotron 3 Super", | |
| "openai/gpt-oss-120b": "OpenAI GPT-OSS 120B", | |
| "openai/gpt-oss-20b": "OpenAI GPT-OSS 20B", | |
| "qwen-3-235b-a22b-instruct-2507": "Qwen3 Instruct", | |
| "llama-3.3-70b-versatile": "Meta Llama 3.3 70B Versatile", | |
| "meta-llama/llama-4-scout-17b-16e-instruct": "Meta Llama 4 Scout", | |
| } | |
| FALLBACK_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct" | |
| FALLBACK_PROVIDER = "groq" | |
| # Header that API-key authenticated clients send so we know to stream | |
| # thinking tokens back to them. | |
| API_KEY_HEADER = "x-api-key" | |
| # ────────────────────────────────────────────── | |
| # CENTRAL ROUTING LOGIC | |
| # ────────────────────────────────────────────── | |
| def route_chat( | |
| messages: List[Dict[str, Any]], | |
| uses_tools: bool = False, | |
| ) -> Tuple[str, str]: | |
| """ | |
| Inspect messages and return (chosen_model, provider). | |
| This is the single source of truth for model selection. | |
| No API calls, no side-effects — pure routing logic. | |
| """ | |
| total_chars, total_bytes = calculate_messages_size(messages) | |
| prompt_text = extract_user_text(messages) | |
| long_context = is_long_context(messages) | |
| code_present = contains_code(prompt_text) | |
| math_heavy = is_math_heavy(prompt_text) | |
| structured_task = is_structured_task(prompt_text) | |
| multi_q = multiple_questions(prompt_text) | |
| code_heavy = is_code_heavy(prompt_text, code_present, long_context) | |
| has_images = contains_images(messages) | |
| score = 0 | |
| if long_context: score += 3 | |
| if math_heavy: score += 3 | |
| if structured_task: score += 2 | |
| if code_present: score += 2 | |
| if multi_q: score += 1 | |
| for kw in REASONING_KEYWORDS: | |
| if kw in prompt_text: | |
| score += 1 | |
| score = min(score, 10) | |
| # ── multimodal fast-path ────────────────── | |
| if has_images: | |
| return "gpt-4o-mini", "navy vision" | |
| # ── tool-use branch ────────────────────── | |
| if uses_tools: | |
| if long_context: | |
| return "nemotron-3-super", "navy" | |
| if score >= 6: | |
| return "nemotron-3-super", "navy" | |
| if score >= 4: | |
| return "openai/gpt-oss-120b", "groq" | |
| return "openai/gpt-oss-20b", "groq" | |
| # ── code branch ────────────────────────── | |
| if code_present: | |
| if code_heavy and score >= 6: | |
| return "o3-mini", "navy" | |
| if score >= 4: | |
| return "llama-3.3-70b-versatile", "groq" | |
| # ── general reasoning branch ───────────── | |
| if score >= 6: | |
| return "sonar", "navy" | |
| if score >= 4: | |
| return "meta-llama/llama-4-scout-17b-16e-instruct", "groq" | |
| # ── default ────────────────────────────── | |
| chosen_model, provider = "llama-3.1-8b-instant", "groq" | |
| # Groq context-size guard — promote to navy if too large | |
| if provider == "groq" and ( | |
| total_chars > MAX_GROQ_PROMPT_CHARS or total_bytes > MAX_GROQ_PROMPT_BYTES | |
| ): | |
| return "gpt-4o-mini", "navy" | |
| return chosen_model, provider | |
| def _log_routing( | |
| chosen_model: str, | |
| provider: str, | |
| messages: List[Dict[str, Any]], | |
| uses_tools: bool, | |
| ) -> None: | |
| prompt_text = extract_user_text(messages) | |
| long_context = is_long_context(messages) | |
| code_present = contains_code(prompt_text) | |
| math_heavy = is_math_heavy(prompt_text) | |
| structured_task = is_structured_task(prompt_text) | |
| multi_q = multiple_questions(prompt_text) | |
| has_images = contains_images(messages) | |
| print( | |
| f"\n[ADVANCED ROUTER]\n" | |
| f" Uses tools: {uses_tools}\n" | |
| f" Long context: {long_context}\n" | |
| f" Code present: {code_present}\n" | |
| f" Math heavy: {math_heavy}\n" | |
| f" Structured: {structured_task}\n" | |
| f" Multi-question:{multi_q}\n" | |
| f" Has images: {has_images}\n" | |
| f" → Selected: {chosen_model} ({provider})\n" | |
| ) | |
| # ────────────────────────────────────────────── | |
| # CENTRAL HTTP CALL | |
| # ────────────────────────────────────────────── | |
| def _get_provider_url_and_key(provider: str) -> Tuple[str, str]: | |
| """Return (url, api_key) for the given provider, raising on misconfiguration.""" | |
| if provider == "groq": | |
| keys = [k.strip() for k in os.getenv("GROQ_KEY", "").split(",") if k.strip()] | |
| if not keys: | |
| raise HTTPException(500, "Missing GROQ_KEY(s)") | |
| return "https://api.groq.com/openai/v1/chat/completions", random.choice(keys) | |
| if provider == "cerebras": | |
| keys = [k.strip() for k in os.getenv("CER_KEY", "").split(",") if k.strip()] | |
| if not keys: | |
| raise HTTPException(500, "Missing CER_KEY(s)") | |
| return "https://api.cerebras.ai/v1/chat/completions", random.choice(keys) | |
| if provider == "navy vision": | |
| keys = [k.strip() for k in os.getenv("NAVY_KEY", "").split(",") if k.strip()] | |
| if not keys: | |
| raise HTTPException(500, "Missing NAVY_KEY(s)") | |
| return "https://api.navy/v1/chat/completions", random.choice(keys) | |
| if provider == "navy": | |
| keys = [k.strip() for k in os.getenv("NAVY_TEXT_ONLY", "").split(",") if k.strip()] | |
| if not keys: | |
| raise HTTPException(500, "Missing NAVY_TEXT_ONLY key(s)") | |
| return "https://api.navy/v1/chat/completions", random.choice(keys) | |
| raise HTTPException(500, f"Unknown provider: {provider!r}") | |
| async def call_chat_completions( | |
| messages: List[Dict[str, Any]], | |
| model: str, | |
| provider: str, | |
| extra_body: Optional[Dict[str, Any]] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Resilient chat-completions call designed to survive Cloudflare 524 timeouts. | |
| Strategy: | |
| 1. Ask the upstream for a *streaming* response so bytes arrive before | |
| Cloudflare's ~100 s idle timeout fires. | |
| 2. Accumulate the stream into a single synthetic non-streaming payload | |
| so callers don't need to change. | |
| 3. Retry up to 2 times (with a short back-off) on 502/503/524. | |
| 4. On exhausted retries fall through to the Groq fallback. | |
| """ | |
| url, api_key = _get_provider_url_and_key(provider) | |
| headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} | |
| # Always request streaming upstream — we reassemble below. | |
| body: Dict[str, Any] = {"model": model, "messages": messages, "stream": True} | |
| if extra_body: | |
| body.update(extra_body) | |
| body["stream"] = True # force streaming even if caller passed stream=False | |
| TRANSIENT = {502, 503, 524, 429} | |
| MAX_ATTEMPTS = 3 | |
| last_exc: Optional[Exception] = None | |
| for attempt in range(MAX_ATTEMPTS): | |
| if attempt: | |
| await asyncio.sleep(2 ** attempt) # 2 s, 4 s | |
| try: | |
| async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, read=300.0)) as client: | |
| async with client.stream("POST", url, json=body, headers=headers) as r: | |
| # Transient upstream error — retry. | |
| if r.status_code in TRANSIENT: | |
| body_bytes = await r.aread() | |
| last_exc = HTTPException( | |
| status_code=r.status_code, | |
| detail=body_bytes.decode("utf-8", errors="replace")[:500], | |
| ) | |
| print(f"[call_chat_completions] attempt {attempt+1} got {r.status_code}, retrying…") | |
| continue | |
| if r.status_code != 200: | |
| body_bytes = await r.aread() | |
| raise HTTPException( | |
| status_code=r.status_code, | |
| detail=body_bytes.decode("utf-8", errors="replace")[:1000], | |
| ) | |
| # ── Reassemble streaming SSE into a single response object ── | |
| accumulated_content = "" | |
| accumulated_reasoning = "" | |
| tool_calls_map: Dict[int, Dict[str, Any]] = {} | |
| usage: Dict[str, Any] = {} | |
| finish_reason: Optional[str] = None | |
| resp_id = "" | |
| resp_model = model | |
| async for line in r.aiter_lines(): | |
| if not line or not line.startswith("data:"): | |
| continue | |
| raw = line[5:].strip() | |
| if raw == "[DONE]": | |
| break | |
| try: | |
| obj = json.loads(raw) | |
| except Exception: | |
| continue | |
| if not isinstance(obj, dict): | |
| continue | |
| resp_id = resp_id or obj.get("id", "") | |
| resp_model = obj.get("model", resp_model) | |
| if "usage" in obj and obj["usage"]: | |
| usage = obj["usage"] | |
| choices = obj.get("choices") or [] | |
| if not choices: | |
| continue | |
| choice = choices[0] | |
| finish_reason = choice.get("finish_reason") or finish_reason | |
| delta = choice.get("delta") or {} | |
| # Accumulate text content. | |
| dc = delta.get("content") | |
| if dc: | |
| accumulated_content += dc | |
| # Accumulate reasoning / thinking tokens. | |
| dr = delta.get("reasoning_content") or delta.get("reasoning") | |
| if dr: | |
| accumulated_reasoning += dr | |
| # Accumulate tool-call argument chunks (streamed as fragments). | |
| for tc_delta in (delta.get("tool_calls") or []): | |
| idx = tc_delta.get("index", 0) | |
| if idx not in tool_calls_map: | |
| tool_calls_map[idx] = { | |
| "id": tc_delta.get("id", ""), | |
| "type": tc_delta.get("type", "function"), | |
| "function": {"name": "", "arguments": ""}, | |
| } | |
| existing = tool_calls_map[idx] | |
| if tc_delta.get("id"): | |
| existing["id"] = tc_delta["id"] | |
| fn_delta = tc_delta.get("function") or {} | |
| if fn_delta.get("name"): | |
| existing["function"]["name"] += fn_delta["name"] | |
| if fn_delta.get("arguments"): | |
| existing["function"]["arguments"] += fn_delta["arguments"] | |
| # Reassemble into a standard non-streaming response shape. | |
| tool_calls_list = [tool_calls_map[i] for i in sorted(tool_calls_map)] | |
| message: Dict[str, Any] = {"role": "assistant", "content": accumulated_content} | |
| if accumulated_reasoning: | |
| message["reasoning_content"] = accumulated_reasoning | |
| if tool_calls_list: | |
| message["tool_calls"] = tool_calls_list | |
| return { | |
| "id": resp_id, | |
| "object": "chat.completion", | |
| "model": resp_model, | |
| "choices": [ | |
| { | |
| "index": 0, | |
| "message": message, | |
| "finish_reason": finish_reason or "stop", | |
| } | |
| ], | |
| "usage": usage, | |
| } | |
| except HTTPException: | |
| raise | |
| except (httpx.RemoteProtocolError, httpx.ReadError, httpx.ConnectError) as exc: | |
| last_exc = exc | |
| print(f"[call_chat_completions] attempt {attempt+1} network error: {exc}, retrying…") | |
| continue | |
| # All attempts exhausted — fall back to Groq. | |
| print(f"[call_chat_completions] all attempts failed ({last_exc}), falling back to Groq") | |
| fb_url, fb_key = _get_provider_url_and_key(FALLBACK_PROVIDER) | |
| fb_headers = {"Authorization": f"Bearer {fb_key}", "Content-Type": "application/json"} | |
| fallback_body = { | |
| "model": FALLBACK_MODEL, | |
| "messages": messages, | |
| "stream": False, | |
| } | |
| if extra_body: | |
| # Forward tools/tool_choice but not stream override. | |
| for k in ("tools", "tool_choice"): | |
| if k in extra_body: | |
| fallback_body[k] = extra_body[k] | |
| async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client: | |
| fb_r = await client.post(fb_url, json=fallback_body, headers=fb_headers) | |
| if fb_r.status_code != 200: | |
| raise HTTPException( | |
| status_code=fb_r.status_code, | |
| detail=f"Primary and fallback both failed. Fallback: {fb_r.text[:500]}", | |
| ) | |
| return fb_r.json() | |
| def _extract_text_from_response(data: Dict[str, Any]) -> str: | |
| try: | |
| return data["choices"][0]["message"]["content"] or "" | |
| except Exception: | |
| return "" | |
| def _extract_usage(data: Dict[str, Any]) -> Tuple[int, int]: | |
| usage = data.get("usage", {}) | |
| input_tok = usage.get("prompt_tokens") or usage.get("input_tokens", 0) | |
| output_tok = usage.get("completion_tokens") or usage.get("output_tokens", 0) | |
| return input_tok, output_tok | |
| # ────────────────────────────────────────────── | |
| # HELPER: image generation | |
| # ────────────────────────────────────────────── | |
| def is_cinematic_image_prompt(prompt: str) -> bool: | |
| for kw in CREATIVE_KEYWORDS: | |
| if kw in prompt.lower(): | |
| return True | |
| return False | |
| def _is_api_key_request(request: Request) -> bool: | |
| """ | |
| Return True when the caller authenticated with an API key rather than a | |
| session cookie / browser auth. We use this to decide whether to forward | |
| think-tag / reasoning_content tokens to the client. | |
| """ | |
| return bool( | |
| request.headers.get(API_KEY_HEADER) | |
| or request.headers.get("authorization", "").lower().startswith("bearer ") | |
| ) | |
| def _inject_reasoning_into_chunk(obj: Dict[str, Any]) -> Dict[str, Any]: | |
| """ | |
| Some navy models return thinking tokens in a non-standard | |
| ``reasoning_content`` field inside each delta. When that field is | |
| present we wrap it in <think>…</think> and prepend it to the regular | |
| ``content`` delta so that every SSE-speaking client sees a single, | |
| unified text stream. | |
| The original ``reasoning_content`` field is preserved so clients that | |
| know about it can still use it directly. | |
| """ | |
| try: | |
| delta = obj["choices"][0]["delta"] | |
| except (KeyError, IndexError, TypeError): | |
| return obj | |
| reasoning = delta.get("reasoning_content") or delta.get("reasoning") or "" | |
| content = delta.get("content") or "" | |
| if reasoning and isinstance(reasoning, str): | |
| # Wrap in <think> tags and prepend to the visible content delta. | |
| wrapped = f"<think>{reasoning}</think>" | |
| delta["content"] = wrapped + content | |
| # Keep the raw field so native clients can parse it too. | |
| delta["reasoning_content"] = reasoning | |
| obj["choices"][0]["delta"] = delta | |
| return obj | |
| def _normalize_usage_block(obj: Dict[str, Any]) -> Dict[str, Any]: | |
| """Rewrite the usage block to a canonical shape (in-place, returns obj).""" | |
| if "usage" not in obj or not isinstance(obj.get("usage"), dict): | |
| return obj | |
| u = obj["usage"] | |
| input_tok = u.get("prompt_tokens") or u.get("input_tokens", 0) | |
| output_tok = u.get("completion_tokens") or u.get("output_tokens", 0) | |
| obj["usage"] = { | |
| "prompt_tokens": input_tok, | |
| "completion_tokens": output_tok, | |
| "total_tokens": input_tok + output_tok, | |
| "input_tokens": input_tok, | |
| "output_tokens": output_tok, | |
| } | |
| return obj | |
| # ────────────────────────────────────────────── | |
| # IMAGE GENERATION | |
| # ────────────────────────────────────────────── | |
| async def generate_image( | |
| request: Request, | |
| prompt: str = None, | |
| authorization: str = Header(None), | |
| x_client_id: str = Header(None), | |
| ): | |
| timeout = httpx.Timeout(300.0, read=300.0) | |
| if prompt is None: | |
| payload = await request.json() | |
| prompt = payload.get("prompt") | |
| mode = payload.get("mode") | |
| image_urls = payload.get("image_urls") | |
| else: | |
| mode = request.query_params.get("mode") | |
| image_urls = request.query_params.getlist("image_urls") | |
| prompt = normalize_prompt_value(prompt, "prompt") | |
| enforce_prompt_size(prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Image prompt") | |
| await check_image_rate_limit(request, authorization, x_client_id) | |
| chosen_model = "zimage" | |
| if is_cinematic_image_prompt(prompt): | |
| chosen_model = "flux" | |
| if isinstance(mode, str): | |
| m = mode.strip().lower() | |
| if m == "fantasy": | |
| chosen_model = "flux" | |
| elif m == "realistic": | |
| chosen_model = "zimage" | |
| has_input_image = bool(image_urls) | |
| temp_assets = [] | |
| if has_input_image: | |
| chosen_model = "klein" | |
| params = {"model": chosen_model, "key": PKEY2} | |
| if has_input_image: | |
| processed = [] | |
| for img in image_urls[:2]: | |
| if is_base64_image(img): | |
| image_id = save_base64_image(img) | |
| temp_assets.append(image_id) | |
| served = f"{request.base_url}asset-cdn/assets/{image_id}" | |
| processed.append(served) | |
| else: | |
| processed.append(img) | |
| params["image"] = "|".join(processed) | |
| encoded_prompt = quote(prompt, safe="") | |
| query = "&".join(f"{k}={quote(str(v), safe='')}" for k, v in params.items()) | |
| url = f"https://gen.pollinations.ai/image/{encoded_prompt}?{query}" | |
| try: | |
| async with httpx.AsyncClient(timeout=timeout) as client: | |
| resp = await client.get(url) | |
| finally: | |
| for aid in temp_assets: | |
| cleanup_image(aid) | |
| if resp.status_code != 200: | |
| raise HTTPException(500, f"Pollinations error: {resp.status_code}") | |
| return Response(content=resp.content, media_type="image/jpeg") | |
| # ────────────────────────────────────────────── | |
| # SFX GENERATION | |
| # ────────────────────────────────────────────── | |
| async def gensfx( | |
| request: Request, | |
| prompt: str = None, | |
| authorization: str = Header(None), | |
| x_client_id: str = Header(None), | |
| ): | |
| if prompt is None: | |
| payload = await request.json() | |
| prompt = payload.get("prompt") | |
| prompt = normalize_prompt_value(prompt, "prompt") | |
| enforce_prompt_size(prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Audio prompt") | |
| await check_audio_rate_limit(request, authorization, x_client_id) | |
| url = f"https://gen.pollinations.ai/audio/{prompt}?model=acestep&key={PKEY}" | |
| async with httpx.AsyncClient(timeout=None) as client: | |
| resp = await client.get(url) | |
| if resp.status_code != 200: | |
| return JSONResponse( | |
| status_code=resp.status_code, | |
| content={"success": False, "error": "Upstream music/sfx generation failed"}, | |
| ) | |
| return Response(resp.content, media_type="audio/mpeg") | |
| # ────────────────────────────────────────────── | |
| # TTS GENERATION | |
| # ────────────────────────────────────────────── | |
| async def gentts( | |
| request: Request, | |
| prompt: str = None, | |
| authorization: str = Header(None), | |
| x_client_id: str = Header(None), | |
| ): | |
| if prompt is None: | |
| payload = await request.json() | |
| prompt = payload.get("prompt") | |
| prompt = normalize_prompt_value(prompt, "prompt") | |
| enforce_prompt_size(prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Audio prompt") | |
| await check_audio_rate_limit(request, authorization, x_client_id) | |
| url = f"https://gen.pollinations.ai/audio/{prompt}?key={PKEY3}" | |
| async with httpx.AsyncClient(timeout=None) as client: | |
| resp = await client.get(url) | |
| if resp.status_code != 200: | |
| return JSONResponse( | |
| status_code=resp.status_code, | |
| content={"success": False, "error": "Upstream audio generation failed"}, | |
| ) | |
| return Response(resp.content, media_type="audio/mpeg") | |
| # ────────────────────────────────────────────── | |
| # VIDEO GENERATION (Pollinations) | |
| # ────────────────────────────────────────────── | |
| async def genvideo( | |
| request: Request, | |
| prompt: str = None, | |
| authorization: str = Header(None), | |
| x_client_id: str = Header(None), | |
| ): | |
| if request.method == "HEAD": | |
| return Response( | |
| status_code=200, | |
| headers={ | |
| "Y-prompt": "string — required. The text prompt used to generate the video.", | |
| "Y-ratio": "string — optional. Aspect ratio of the output video.", | |
| "Y-ratio-values": "3:2,2:3,1:1", | |
| "Y-ratio-default": "3:2", | |
| "Y-mode": "string — optional. Controls generation style.", | |
| "Y-mode-values": "normal,fun", | |
| "Y-mode-default": "normal", | |
| "Y-duration": "integer — optional. Duration in seconds (1–10).", | |
| "Y-duration-default": "5", | |
| "Y-image_urls": "array<string> — optional. Up to 2 image URLs for conditioning.", | |
| "Y-image_urls-max": "2", | |
| "Y-response_format": "video/mp4", | |
| "Y-model": "grok-video", | |
| }, | |
| ) | |
| aspectRatio = "3:2" | |
| inputMode = "normal" | |
| duration = 5 | |
| image_urls = None | |
| if prompt is None: | |
| user_body = await request.json() | |
| prompt = user_body.get("prompt") | |
| ratio = user_body.get("ratio") | |
| mode = user_body.get("mode") | |
| image_urls = user_body.get("image_urls") | |
| duration = user_body.get("duration", 5) | |
| if ratio not in valid_ratios: | |
| raise HTTPException(400, f"Invalid aspect ratio '{ratio}'. Must be one of 3:2, 2:3, or 1:1.") | |
| if ratio in ratios: | |
| aspectRatio = ratio | |
| if mode not in valid_modes: | |
| raise HTTPException(400, f"Invalid mode '{mode}'. Must be 'normal' or 'fun'.") | |
| if mode in modes: | |
| inputMode = mode | |
| if image_urls: | |
| if not isinstance(image_urls, list): | |
| raise HTTPException(400, "image_urls must be a list") | |
| if len(image_urls) > 2: | |
| raise HTTPException(400, "You may provide at most two image URLs") | |
| try: | |
| duration = max(1, min(10, int(duration))) | |
| except (TypeError, ValueError): | |
| duration = 5 | |
| prompt = normalize_prompt_value(prompt, "prompt") | |
| enforce_prompt_size(prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Video prompt") | |
| await check_video_rate_limit(request, authorization, x_client_id) | |
| RATIO_MAP = {"3:2": "16:9", "2:3": "9:16", "1:1": "9:16"} | |
| pollinations_ratio = RATIO_MAP.get(aspectRatio, "16:9") | |
| encoded_prompt = quote(prompt, safe="") | |
| params = { | |
| "model": "ltx-2", | |
| "duration": duration, | |
| "aspectRatio": pollinations_ratio, | |
| "seed": -1, | |
| } | |
| temp_assets = [] | |
| if image_urls: | |
| processed_urls = [] | |
| for img in image_urls[:2]: | |
| if is_base64_image(img): | |
| image_id = save_base64_image(img) | |
| temp_assets.append(image_id) | |
| served_url = f"{request.base_url}asset-cdn/assets/{image_id}" | |
| processed_urls.append(served_url) | |
| else: | |
| processed_urls.append(img) | |
| params["image"] = "|".join(processed_urls) | |
| if inputMode == "fun": | |
| params["enhance"] = "true" | |
| query_string = "&".join(f"{k}={quote(str(v), safe='')}" for k, v in params.items()) | |
| url = f"https://gen.pollinations.ai/image/{encoded_prompt}?{query_string}&key={PKEY}" | |
| print(f"[VIDEO GEN] Pollinations URL: {url}") | |
| resp = None | |
| try: | |
| async with httpx.AsyncClient(timeout=600) as client: | |
| resp = await client.get(url) | |
| finally: | |
| for aid in temp_assets: | |
| cleanup_image(aid) | |
| if resp is None: | |
| raise HTTPException(502, "Video generation request failed") | |
| if resp.status_code != 200: | |
| body_text = "" | |
| try: | |
| body_text = resp.text | |
| except Exception: | |
| pass | |
| return JSONResponse( | |
| status_code=resp.status_code, | |
| content={ | |
| "success": False, | |
| "error": "Upstream video generation failed", | |
| "status_code": resp.status_code, | |
| "message": body_text[:1000], | |
| }, | |
| ) | |
| if not resp.content: | |
| raise HTTPException(502, "Pollinations returned empty response") | |
| return Response( | |
| content=resp.content, | |
| media_type="video/mp4", | |
| headers={ | |
| "Content-Length": str(len(resp.content)), | |
| "Accept-Ranges": "bytes", | |
| }, | |
| ) | |
| # ────────────────────────────────────────────── | |
| # VIDEO GENERATION (Airforce) | |
| # ────────────────────────────────────────────── | |
| async def genvideo_airforce( | |
| request: Request, | |
| prompt: str = None, | |
| authorization: str = Header(None), | |
| x_client_id: str = Header(None), | |
| ): | |
| if request.method == "HEAD": | |
| return Response( | |
| status_code=200, | |
| headers={ | |
| "Y-prompt": "string — required. The text prompt used to generate the video.", | |
| "Y-ratio": "string — optional. Aspect ratio of the output video.", | |
| "Y-ratio-values": "3:2,2:3,1:1", | |
| "Y-ratio-default": "3:2", | |
| "Y-mode": "string — optional. Controls generation style.", | |
| "Y-mode-values": "normal,fun", | |
| "Y-mode-default": "normal", | |
| "Y-duration": "integer — optional. Duration in seconds.", | |
| "Y-duration-default": "5", | |
| "Y-image_urls": "array<string> — optional. Up to 2 image URLs for conditioning.", | |
| "Y-image_urls-max": "2", | |
| "Y-response_format": "video/mp4", | |
| "Y-model": "grok-imagine-video", | |
| }, | |
| ) | |
| aspectRatio = "3:2" | |
| inputMode = "normal" | |
| image_urls = None | |
| if prompt is None: | |
| user_body = await request.json() | |
| prompt = user_body.get("prompt") | |
| ratio = user_body.get("ratio") | |
| mode = user_body.get("mode") | |
| image_urls = user_body.get("image_urls") | |
| if ratio not in valid_ratios: | |
| raise HTTPException(400, f"Invalid aspect ratio {ratio}. Must be one of 3:2, 2:3, or 1:1. Default is 3:2") | |
| if ratio in ratios: | |
| aspectRatio = ratio | |
| if mode not in valid_modes: | |
| raise HTTPException(400, f"Invalid mode {mode}. Must be 'normal' or 'fun'. Default is normal") | |
| if mode in modes: | |
| inputMode = mode | |
| if image_urls: | |
| if not isinstance(image_urls, list): | |
| raise HTTPException(400, "image_urls must be a list") | |
| if len(image_urls) > 2: | |
| raise HTTPException(400, "You may provide at most two image URLs") | |
| prompt = normalize_prompt_value(prompt, "prompt") | |
| enforce_prompt_size(prompt, MAX_MEDIA_PROMPT_CHARS, MAX_MEDIA_PROMPT_BYTES, "Video prompt") | |
| await check_video_rate_limit(request, authorization, x_client_id) | |
| payload = { | |
| "model": AIRFORCE_VIDEO_MODEL, | |
| "prompt": prompt, | |
| "n": 1, | |
| "size": "1024x1024", | |
| "response_format": "b64_json", | |
| "sse": False, | |
| "mode": inputMode, | |
| "aspectRatio": aspectRatio, | |
| } | |
| if image_urls: | |
| payload["image_urls"] = image_urls | |
| async with httpx.AsyncClient(timeout=600) as client: | |
| resp = await client.post( | |
| AIRFORCE_API_URL, | |
| headers={"Authorization": f"Bearer {AIRFORCE_KEY}", "Content-Type": "application/json"}, | |
| json=payload, | |
| ) | |
| if resp.status_code != 200: | |
| return JSONResponse(status_code=resp.status_code, content=resp.json()) | |
| if not resp.content: | |
| raise HTTPException(502, "api.airforce returned empty response") | |
| try: | |
| result = resp.json() | |
| b64_video = result["data"][0]["b64_json"] | |
| except Exception: | |
| raise HTTPException(502, f"Invalid api.airforce response: {resp.text[:500]}") | |
| if not b64_video: | |
| raise HTTPException(502, "Airforce returned empty b64_json") | |
| video_bytes = base64.b64decode(b64_video) | |
| return Response( | |
| content=video_bytes, | |
| media_type="video/mp4", | |
| headers={ | |
| "Content-Length": str(len(video_bytes)), | |
| "Accept-Ranges": "bytes", | |
| }, | |
| ) | |
| # ────────────────────────────────────────────── | |
| # CHAT COMPLETIONS (/gen/chat/completions) | |
| # ────────────────────────────────────────────── | |
| async def _check_chat_rate_limit( | |
| request: Request, | |
| authorization: Optional[str], | |
| client_id: Optional[str] = None, | |
| ): | |
| return await enforce_rate_limit(request, authorization, "cloudChatDaily", client_id) | |
| async def generate_text( | |
| request: Request, | |
| authorization: Optional[str] = Header(None), | |
| x_client_id: Optional[str] = Header(None), | |
| ): | |
| body = await request.json() | |
| messages = body.get("messages", []) | |
| if not isinstance(messages, list) or len(messages) == 0: | |
| raise HTTPException(400, "messages[] is required") | |
| uses_tools = ( | |
| "tools" in body and isinstance(body["tools"], list) and len(body["tools"]) > 0 | |
| ) or ("tool_choice" in body and body["tool_choice"] not in [None, "none"]) | |
| chosen_model, provider = route_chat(messages, uses_tools=uses_tools) | |
| _log_routing(chosen_model, provider, messages, uses_tools) | |
| await _check_chat_rate_limit(request, authorization, x_client_id) | |
| # Determine whether the caller is an API-key client that should receive | |
| # raw thinking tokens. | |
| forward_thinking = _is_api_key_request(request) | |
| body["model"] = chosen_model | |
| stream = body.get("stream", False) | |
| url, api_key = _get_provider_url_and_key(provider) | |
| headers = {"Authorization": f"Bearer {api_key}"} | |
| if stream: | |
| body["stream"] = True | |
| async def stream_fallback(client: httpx.AsyncClient): | |
| fallback_body = { | |
| "model": FALLBACK_MODEL, | |
| "messages": body["messages"], | |
| "stream": True, | |
| } | |
| fb_url, fb_key = _get_provider_url_and_key(FALLBACK_PROVIDER) | |
| fb_headers = {"Authorization": f"Bearer {fb_key}"} | |
| print("[FALLBACK] Starting Groq fallback stream") | |
| async with client.stream("POST", fb_url, json=fallback_body, headers=fb_headers) as r: | |
| if r.status_code >= 400: | |
| err = (await r.aread()).decode("utf-8", errors="replace") | |
| yield f'data: {{"error": "Fallback provider failed: {err[:500]}"}}\n\n' | |
| return | |
| async for line in r.aiter_lines(): | |
| if not line: | |
| yield "\n" | |
| continue | |
| yield (line if line.startswith("data:") else f"data: {line}\n\n") + "\n" | |
| async def stream_primary(client: httpx.AsyncClient): | |
| try: | |
| async with client.stream("POST", url, json=body, headers=headers) as r: | |
| if r.status_code >= 400: | |
| print("[STREAM FALLBACK] Primary provider failed → switching to fallback") | |
| async for chunk in stream_fallback(client): | |
| yield chunk | |
| return | |
| async for line in r.aiter_lines(): | |
| if not line: | |
| yield "\n" | |
| continue | |
| if line.startswith("data:"): | |
| try: | |
| obj = json.loads(line[5:].strip()) | |
| if isinstance(obj, dict) and isinstance(obj.get("error"), dict): | |
| async for chunk in stream_fallback(client): | |
| yield chunk | |
| return | |
| except Exception: | |
| pass | |
| yield line + "\n" | |
| except Exception as e: | |
| print(f"[STREAM ERROR] {e}") | |
| async for chunk in stream_fallback(client): | |
| yield chunk | |
| async def event_generator(): | |
| sent_metadata = False | |
| async with httpx.AsyncClient(timeout=None) as client: | |
| async for chunk in stream_primary(client): | |
| # ── emit router metadata once as the very first SSE frame ── | |
| if not sent_metadata: | |
| meta = { | |
| "router_metadata": { | |
| "model_name": MODEL_MAP.get(chosen_model, chosen_model) | |
| } | |
| } | |
| yield f"data: {json.dumps(meta)}\n\n" | |
| sent_metadata = True | |
| # ── pass [DONE] straight through ────────────────────────── | |
| if "data: [DONE]" in chunk: | |
| yield chunk | |
| continue | |
| # ── process data: … lines ───────────────────────────────── | |
| if chunk.startswith("data:"): | |
| raw = chunk[5:].strip() | |
| try: | |
| obj = json.loads(raw) | |
| except Exception: | |
| # Not valid JSON — forward verbatim (keeps partial | |
| # chunks from blocking the stream). | |
| yield chunk | |
| continue | |
| if not isinstance(obj, dict): | |
| yield chunk | |
| continue | |
| # Normalize usage block whenever it appears. | |
| _normalize_usage_block(obj) | |
| # ── thinking / reasoning tokens ─────────────────────── | |
| # Navy models may embed thinking in two ways: | |
| # | |
| # 1. As delta.reasoning_content (separate field) | |
| # 2. Inline inside delta.content wrapped in <think>…</think> | |
| # | |
| # For API-key callers we always surface both forms. | |
| # For browser/session callers we strip reasoning_content | |
| # so it doesn't confuse UI clients that don't expect it, | |
| # but <think> tags already present in content are left | |
| # alone (they arrived that way from upstream). | |
| if forward_thinking: | |
| # Merge reasoning_content into content as | |
| # <think>…</think> and keep the raw field. | |
| obj = _inject_reasoning_into_chunk(obj) | |
| else: | |
| # Strip the non-standard field so browser clients | |
| # don't see unexpected keys. | |
| try: | |
| delta = obj["choices"][0]["delta"] | |
| delta.pop("reasoning_content", None) | |
| delta.pop("reasoning", None) | |
| obj["choices"][0]["delta"] = delta | |
| except (KeyError, IndexError, TypeError): | |
| pass | |
| yield f"data: {json.dumps(obj)}\n\n" | |
| continue | |
| # ── any other line (comments, keep-alives, …) ───────────── | |
| yield chunk | |
| return StreamingResponse( | |
| event_generator(), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "Connection": "keep-alive", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) | |
| # ── non-streaming ───────────────────────── | |
| async with httpx.AsyncClient(timeout=None) as client: | |
| r = await client.post(url, json=body, headers=headers) | |
| # navy-vision fallback | |
| if provider == "navy vision" and r.status_code >= 400: | |
| print("[FALLBACK] Navy vision failed — switching to fallback") | |
| fb_url, fb_key = _get_provider_url_and_key(FALLBACK_PROVIDER) | |
| fallback_body = dict(body) | |
| fallback_body["model"] = FALLBACK_MODEL | |
| r = await client.post( | |
| fb_url, | |
| json=fallback_body, | |
| headers={"Authorization": f"Bearer {fb_key}"}, | |
| ) | |
| content_type = (r.headers.get("content-type") or "").lower() | |
| if "application/json" in content_type: | |
| try: | |
| payload = r.json() | |
| except Exception: | |
| payload = {"error": "Upstream returned invalid JSON"} | |
| else: | |
| # Normalize usage fields. | |
| _normalize_usage_block(payload) | |
| # ── thinking tokens in non-streaming responses ──────────────────── | |
| # Some navy models put thinking content in | |
| # message.reasoning_content. For API-key callers we prepend it to | |
| # message.content wrapped in <think>…</think>; for others we drop | |
| # the non-standard field. | |
| try: | |
| message = payload["choices"][0]["message"] | |
| reasoning = ( | |
| message.pop("reasoning_content", None) | |
| or message.pop("reasoning", None) | |
| or "" | |
| ) | |
| if reasoning and isinstance(reasoning, str): | |
| if forward_thinking: | |
| existing = message.get("content") or "" | |
| message["content"] = f"<think>{reasoning}</think>{existing}" | |
| # Restore the raw field for clients that want it. | |
| message["reasoning_content"] = reasoning | |
| # else: already popped — nothing to do. | |
| payload["choices"][0]["message"] = message | |
| except (KeyError, IndexError, TypeError): | |
| pass | |
| payload.setdefault("router_metadata", {})["model_name"] = MODEL_MAP.get( | |
| chosen_model, chosen_model | |
| ) | |
| else: | |
| payload = { | |
| "error": "Upstream returned non-JSON response", | |
| "status_code": r.status_code, | |
| "message": r.text[:1000], | |
| } | |
| return JSONResponse(status_code=r.status_code, content=payload) | |
| # ────────────────────────────────────────────── | |
| # PROMPT ANALYZE (/gen/prompt_analyze) | |
| # ────────────────────────────────────────────── | |
| async def analyze_prompt(request: Request): | |
| body = await request.json() | |
| messages = body.get("prompt", []) | |
| if not isinstance(messages, list) or len(messages) == 0: | |
| raise HTTPException(400, "messages[] is required") | |
| uses_tools = ( | |
| "tools" in body and isinstance(body["tools"], list) and len(body["tools"]) > 0 | |
| ) or ("tool_choice" in body and body["tool_choice"] not in [None, "none"]) | |
| chosen_model, _ = route_chat(messages, uses_tools=uses_tools) | |
| return {MODEL_MAP.get(chosen_model, chosen_model)} | |
| # ────────────────────────────────────────────── | |
| # MODELS LIST | |
| # ────────────────────────────────────────────── | |
| def return_models_openai(): | |
| return { | |
| "object": "list", | |
| "data": [ | |
| { | |
| "id": "lightning", | |
| "object": "model", | |
| "created": 1767225600, | |
| "owned_by": "inferenceport-ai", | |
| } | |
| ], | |
| } | |
| # ────────────────────────────────────────────── | |
| # RESPONSES API (/gen/responses) | |
| # ────────────────────────────────────────────── | |
| def _resp_id(prefix: str) -> str: | |
| return f"{prefix}_{uuid4().hex}" | |
| def _resp_ts() -> int: | |
| return int(time()) | |
| def _content_to_text(content: Any) -> str: | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| parts = [] | |
| for item in content: | |
| if isinstance(item, dict) and item.get("type") in ("input_text", "output_text", "text"): | |
| txt = item.get("text") | |
| if isinstance(txt, str): | |
| parts.append(txt) | |
| return "".join(parts) | |
| return "" | |
| def _responses_input_to_messages( | |
| input_data: Any, | |
| instructions: Optional[str] = None, | |
| ) -> List[Dict[str, Any]]: | |
| messages: List[Dict[str, Any]] = [] | |
| if instructions: | |
| messages.append({"role": "developer", "content": instructions}) | |
| if isinstance(input_data, str): | |
| messages.append({"role": "user", "content": input_data}) | |
| return messages | |
| if isinstance(input_data, list): | |
| for item in input_data: | |
| if isinstance(item, str): | |
| messages.append({"role": "user", "content": item}) | |
| continue | |
| if not isinstance(item, dict): | |
| continue | |
| role = item.get("role", "user") | |
| text = _content_to_text(item.get("content", "")) | |
| if text: | |
| messages.append({"role": role, "content": text}) | |
| return messages | |
| def _build_responses_payload( | |
| model: str, | |
| text: str, | |
| response_id: str, | |
| input_tokens: int = 0, | |
| output_tokens: int = 0, | |
| tool_calls: Optional[List[Dict[str, Any]]] = None, | |
| ) -> Dict[str, Any]: | |
| # Build content: text part first, then one function_call part per tool call | |
| content: List[Dict[str, Any]] = [] | |
| if text: | |
| content.append({"type": "output_text", "text": text, "annotations": []}) | |
| for tc in (tool_calls or []): | |
| fn = tc.get("function", {}) | |
| content.append({ | |
| "type": "tool_use", | |
| "id": tc.get("id", _resp_id("tool")), | |
| "name": fn.get("name", ""), | |
| "input": json.loads(fn["arguments"]) if fn.get("arguments") else {}, | |
| }) | |
| # Top-level output items: one message item (text) + one per tool call | |
| output_items: List[Dict[str, Any]] = [] | |
| if text or not tool_calls: | |
| output_items.append({ | |
| "id": _resp_id("msg"), | |
| "type": "message", | |
| "role": "assistant", | |
| "status": "completed", | |
| "content": [c for c in content if c["type"] == "output_text"], | |
| }) | |
| for tc in (tool_calls or []): | |
| fn = tc.get("function", {}) | |
| output_items.append({ | |
| "id": tc.get("id", _resp_id("tool")), | |
| "type": "function_call", | |
| "call_id": tc.get("id", ""), | |
| "name": fn.get("name", ""), | |
| "arguments": fn.get("arguments", "{}"), | |
| "status": "completed", | |
| }) | |
| return { | |
| "id": response_id, | |
| "object": "response", | |
| "created_at": _resp_ts(), | |
| "status": "completed", | |
| "completed_at": _resp_ts(), | |
| "error": None, | |
| "incomplete_details": None, | |
| "instructions": None, | |
| "max_output_tokens": None, | |
| "model": model, | |
| "output": output_items, | |
| "output_text": text, | |
| "usage": { | |
| "input_tokens": input_tokens, | |
| "output_tokens": output_tokens, | |
| "total_tokens": input_tokens + output_tokens, | |
| }, | |
| } | |
| async def create_responses( | |
| request: Request, | |
| authorization: Optional[str] = Header(None), | |
| x_client_id: Optional[str] = Header(None), | |
| ): | |
| body = await request.json() | |
| model = body.get("model") | |
| input_data = body.get("input") | |
| instructions = body.get("instructions") | |
| stream = body.get("stream", False) | |
| tools = body.get("tools") | |
| tool_choice = body.get("tool_choice") | |
| if not model: | |
| raise HTTPException(400, "model is required") | |
| if input_data is None: | |
| raise HTTPException(400, "input is required") | |
| messages = _responses_input_to_messages(input_data, instructions=instructions) | |
| if not messages: | |
| raise HTTPException(400, "input could not be parsed") | |
| uses_tools = bool(tools) or (tool_choice not in [None, "none"]) | |
| # Build extra fields to forward upstream | |
| extra_body: Dict[str, Any] = {} | |
| if tools: | |
| extra_body["tools"] = tools | |
| if tool_choice is not None: | |
| extra_body["tool_choice"] = tool_choice | |
| chosen_model, provider = route_chat(messages, uses_tools=uses_tools) | |
| _log_routing(chosen_model, provider, messages, uses_tools=uses_tools) | |
| await _check_chat_rate_limit(request, authorization, x_client_id) | |
| async def _generate() -> Tuple[str, List[Dict[str, Any]], int, int]: | |
| data = await call_chat_completions( | |
| messages, chosen_model, provider, extra_body=extra_body or None | |
| ) | |
| input_tokens, output_tokens = _extract_usage(data) | |
| message = data.get("choices", [{}])[0].get("message", {}) | |
| text = message.get("content") or "" | |
| tool_calls = message.get("tool_calls") or [] | |
| return text, tool_calls, input_tokens, output_tokens | |
| # ── non-streaming ───────────────────────── | |
| if stream is False: | |
| text, tool_calls, input_tokens, output_tokens = await _generate() | |
| response_id = _resp_id("resp") | |
| return JSONResponse( | |
| content=_build_responses_payload( | |
| chosen_model, text, response_id, input_tokens, output_tokens, tool_calls | |
| ) | |
| ) | |
| # ── streaming ───────────────────────────── | |
| async def event_stream(): | |
| response_id = _resp_id("resp") | |
| item_id = _resp_id("item") | |
| ts = _resp_ts() | |
| def sse(event_type: str, data: dict) -> str: | |
| """Emit a properly-formed SSE frame with both event: and data: lines. | |
| The OpenAI SDK dispatches on the `event:` field — without it most | |
| events are silently dropped.""" | |
| return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" | |
| # 1. response.created | |
| yield sse("response.created", { | |
| "type": "response.created", | |
| "response": { | |
| "id": response_id, "object": "response", | |
| "created_at": ts, "status": "in_progress", "model": model, | |
| "output": [], "usage": None, | |
| }, | |
| }) | |
| # 2. response.in_progress | |
| yield sse("response.in_progress", { | |
| "type": "response.in_progress", | |
| "response": { | |
| "id": response_id, "object": "response", | |
| "created_at": ts, "status": "in_progress", "model": model, | |
| }, | |
| }) | |
| # ── Run _generate() in the background, pinging every 15 s ────────────── | |
| # Without keepalive bytes, Cloudflare (524) and Codex both drop the | |
| # connection while the model is thinking or accumulating tool arguments. | |
| # SSE comment lines (": ping") are invisible to application code but | |
| # reset every proxy's idle-timeout counter. | |
| PING_INTERVAL = 15 # seconds | |
| gen_task: asyncio.Task = asyncio.ensure_future(_generate()) | |
| while not gen_task.done(): | |
| try: | |
| await asyncio.wait_for(asyncio.shield(gen_task), timeout=PING_INTERVAL) | |
| except asyncio.TimeoutError: | |
| yield ": ping\n\n" | |
| except Exception: | |
| break # real error — handled below | |
| try: | |
| text, tool_calls, input_tokens, output_tokens = gen_task.result() | |
| except HTTPException as exc: | |
| yield sse("response.failed", { | |
| "type": "response.failed", | |
| "response": { | |
| "id": response_id, "object": "response", | |
| "created_at": ts, "status": "failed", "model": chosen_model, | |
| "error": {"code": "upstream_error", "message": exc.detail}, | |
| }, | |
| }) | |
| yield "data: [DONE]\n\n" | |
| return | |
| except Exception as exc: | |
| yield sse("response.failed", { | |
| "type": "response.failed", | |
| "response": { | |
| "id": response_id, "object": "response", | |
| "created_at": ts, "status": "failed", "model": chosen_model, | |
| "error": {"code": "upstream_error", "message": str(exc)}, | |
| }, | |
| }) | |
| yield "data: [DONE]\n\n" | |
| return | |
| output_index = 0 | |
| # ── text output (only emitted if there is text content) ────────────── | |
| if text: | |
| yield sse("response.output_item.added", { | |
| "type": "response.output_item.added", | |
| "response_id": response_id, | |
| "output_index": output_index, | |
| "item": {"id": item_id, "type": "message", "role": "assistant", | |
| "status": "in_progress", "content": []}, | |
| }) | |
| yield sse("response.content_part.added", { | |
| "type": "response.content_part.added", | |
| "response_id": response_id, "item_id": item_id, | |
| "output_index": output_index, "content_index": 0, | |
| "part": {"type": "output_text", "text": "", "annotations": []}, | |
| }) | |
| chunk_size = 64 | |
| for i in range(0, len(text), chunk_size): | |
| yield sse("response.output_text.delta", { | |
| "type": "response.output_text.delta", | |
| "response_id": response_id, "item_id": item_id, | |
| "output_index": output_index, "content_index": 0, | |
| "delta": text[i : i + chunk_size], | |
| }) | |
| yield sse("response.output_text.done", { | |
| "type": "response.output_text.done", | |
| "response_id": response_id, "item_id": item_id, | |
| "output_index": output_index, "content_index": 0, | |
| "text": text, | |
| }) | |
| yield sse("response.content_part.done", { | |
| "type": "response.content_part.done", | |
| "response_id": response_id, "item_id": item_id, | |
| "output_index": output_index, "content_index": 0, | |
| "part": {"type": "output_text", "text": text, "annotations": []}, | |
| }) | |
| yield sse("response.output_item.done", { | |
| "type": "response.output_item.done", | |
| "response_id": response_id, "output_index": output_index, | |
| "item": {"id": item_id, "type": "message", "role": "assistant", | |
| "status": "completed", | |
| "content": [{"type": "output_text", "text": text, "annotations": []}]}, | |
| }) | |
| output_index += 1 | |
| # ── tool call outputs (one item per call) ───────────────────────────── | |
| for tc in (tool_calls or []): | |
| fn = tc.get("function", {}) | |
| tc_id = tc.get("id", _resp_id("tool")) | |
| tc_item = { | |
| "id": tc_id, | |
| "type": "function_call", | |
| "call_id": tc_id, | |
| "name": fn.get("name", ""), | |
| "arguments": fn.get("arguments", "{}"), | |
| "status": "completed", | |
| } | |
| yield sse("response.output_item.added", { | |
| "type": "response.output_item.added", | |
| "response_id": response_id, | |
| "output_index": output_index, | |
| "item": {**tc_item, "status": "in_progress"}, | |
| }) | |
| yield sse("response.function_call_arguments.delta", { | |
| "type": "response.function_call_arguments.delta", | |
| "response_id": response_id, "item_id": tc_id, | |
| "output_index": output_index, "call_id": tc_id, | |
| "delta": fn.get("arguments", "{}"), | |
| }) | |
| yield sse("response.function_call_arguments.done", { | |
| "type": "response.function_call_arguments.done", | |
| "response_id": response_id, "item_id": tc_id, | |
| "output_index": output_index, "call_id": tc_id, | |
| "arguments": fn.get("arguments", "{}"), | |
| }) | |
| yield sse("response.output_item.done", { | |
| "type": "response.output_item.done", | |
| "response_id": response_id, "output_index": output_index, | |
| "item": tc_item, | |
| }) | |
| output_index += 1 | |
| # ── response.completed ──────────────────────────────────────────────── | |
| yield sse("response.completed", { | |
| "type": "response.completed", | |
| "response": _build_responses_payload( | |
| chosen_model, text, response_id, input_tokens, output_tokens, tool_calls | |
| ), | |
| }) | |
| yield "data: [DONE]\n\n" | |
| return StreamingResponse( | |
| event_stream(), | |
| media_type="text/event-stream", | |
| headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, | |
| ) |