Files changed (1) hide show
  1. gen.py +160 -36
gen.py CHANGED
@@ -66,6 +66,10 @@ MODEL_MAP = {
66
  FALLBACK_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
67
  FALLBACK_PROVIDER = "groq"
68
 
 
 
 
 
69
 
70
  # ──────────────────────────────────────────────
71
  # CENTRAL ROUTING LOGIC
@@ -253,6 +257,65 @@ def is_cinematic_image_prompt(prompt: str) -> bool:
253
  return False
254
 
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  # ──────────────────────────────────────────────
257
  # IMAGE GENERATION
258
  # ──────────────────────────────────────────────
@@ -682,6 +745,10 @@ async def generate_text(
682
 
683
  await _check_chat_rate_limit(request, authorization, x_client_id)
684
 
 
 
 
 
685
  body["model"] = chosen_model
686
  stream = body.get("stream", False)
687
 
@@ -744,39 +811,79 @@ async def generate_text(
744
  sent_metadata = False
745
  async with httpx.AsyncClient(timeout=None) as client:
746
  async for chunk in stream_primary(client):
 
747
  if not sent_metadata:
748
- meta = {"router_metadata": {"model_name": MODEL_MAP.get(chosen_model, chosen_model)}}
 
 
 
 
749
  yield f"data: {json.dumps(meta)}\n\n"
750
  sent_metadata = True
751
 
752
- # Intercept the final non-[DONE] data chunk and normalize
753
- # the usage block so callers always see consistent field names.
754
- if chunk.startswith("data:") and "[DONE]" not in chunk:
 
 
 
 
755
  raw = chunk[5:].strip()
756
  try:
757
  obj = json.loads(raw)
758
- if isinstance(obj, dict) and "usage" in obj and isinstance(obj["usage"], dict):
759
- u = obj["usage"]
760
- input_tok = u.get("prompt_tokens") or u.get("input_tokens", 0)
761
- output_tok = u.get("completion_tokens") or u.get("output_tokens", 0)
762
- obj["usage"] = {
763
- "prompt_tokens": input_tok,
764
- "completion_tokens": output_tok,
765
- "total_tokens": input_tok + output_tok,
766
- "input_tokens": input_tok,
767
- "output_tokens": output_tok,
768
- }
769
- yield f"data: {json.dumps(obj)}\n\n"
770
- continue
771
  except Exception:
772
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773
 
 
 
 
 
774
  yield chunk
775
 
776
  return StreamingResponse(
777
  event_generator(),
778
  media_type="text/event-stream",
779
- headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
 
 
 
 
780
  )
781
 
782
  # ── non-streaming ─────────────────────────
@@ -789,7 +896,11 @@ async def generate_text(
789
  fb_url, fb_key = _get_provider_url_and_key(FALLBACK_PROVIDER)
790
  fallback_body = dict(body)
791
  fallback_body["model"] = FALLBACK_MODEL
792
- r = await client.post(fb_url, json=fallback_body, headers={"Authorization": f"Bearer {fb_key}"})
 
 
 
 
793
 
794
  content_type = (r.headers.get("content-type") or "").lower()
795
  if "application/json" in content_type:
@@ -798,22 +909,35 @@ async def generate_text(
798
  except Exception:
799
  payload = {"error": "Upstream returned invalid JSON"}
800
  else:
801
- # Normalize usage: upstream may use prompt_tokens/completion_tokens
802
- # (OpenAI/Groq style) — rewrite to a consistent shape and add
803
- # router_metadata so callers always see the same fields.
804
- if "usage" in payload and isinstance(payload["usage"], dict):
805
- u = payload["usage"]
806
- input_tok = u.get("prompt_tokens") or u.get("input_tokens", 0)
807
- output_tok = u.get("completion_tokens") or u.get("output_tokens", 0)
808
- payload["usage"] = {
809
- "prompt_tokens": input_tok,
810
- "completion_tokens": output_tok,
811
- "total_tokens": input_tok + output_tok,
812
- # also include the OpenAI Responses-API names for clients that expect them
813
- "input_tokens": input_tok,
814
- "output_tokens": output_tok,
815
- }
816
- payload.setdefault("router_metadata", {})["model_name"] = MODEL_MAP.get(chosen_model, chosen_model)
 
 
 
 
 
 
 
 
 
 
 
 
 
817
  else:
818
  payload = {
819
  "error": "Upstream returned non-JSON response",
 
66
  FALLBACK_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
67
  FALLBACK_PROVIDER = "groq"
68
 
69
+ # Header that API-key authenticated clients send so we know to stream
70
+ # thinking tokens back to them.
71
+ API_KEY_HEADER = "x-api-key"
72
+
73
 
74
  # ──────────────────────────────────────────────
75
  # CENTRAL ROUTING LOGIC
 
257
  return False
258
 
259
 
260
+ def _is_api_key_request(request: Request) -> bool:
261
+ """
262
+ Return True when the caller authenticated with an API key rather than a
263
+ session cookie / browser auth. We use this to decide whether to forward
264
+ think-tag / reasoning_content tokens to the client.
265
+ """
266
+ return bool(
267
+ request.headers.get(API_KEY_HEADER)
268
+ or request.headers.get("authorization", "").lower().startswith("bearer ")
269
+ )
270
+
271
+
272
+ def _inject_reasoning_into_chunk(obj: Dict[str, Any]) -> Dict[str, Any]:
273
+ """
274
+ Some navy models return thinking tokens in a non-standard
275
+ ``reasoning_content`` field inside each delta. When that field is
276
+ present we wrap it in <think>…</think> and prepend it to the regular
277
+ ``content`` delta so that every SSE-speaking client sees a single,
278
+ unified text stream.
279
+
280
+ The original ``reasoning_content`` field is preserved so clients that
281
+ know about it can still use it directly.
282
+ """
283
+ try:
284
+ delta = obj["choices"][0]["delta"]
285
+ except (KeyError, IndexError, TypeError):
286
+ return obj
287
+
288
+ reasoning = delta.get("reasoning_content") or delta.get("reasoning") or ""
289
+ content = delta.get("content") or ""
290
+
291
+ if reasoning and isinstance(reasoning, str):
292
+ # Wrap in <think> tags and prepend to the visible content delta.
293
+ wrapped = f"<think>{reasoning}</think>"
294
+ delta["content"] = wrapped + content
295
+ # Keep the raw field so native clients can parse it too.
296
+ delta["reasoning_content"] = reasoning
297
+ obj["choices"][0]["delta"] = delta
298
+
299
+ return obj
300
+
301
+
302
+ def _normalize_usage_block(obj: Dict[str, Any]) -> Dict[str, Any]:
303
+ """Rewrite the usage block to a canonical shape (in-place, returns obj)."""
304
+ if "usage" not in obj or not isinstance(obj.get("usage"), dict):
305
+ return obj
306
+ u = obj["usage"]
307
+ input_tok = u.get("prompt_tokens") or u.get("input_tokens", 0)
308
+ output_tok = u.get("completion_tokens") or u.get("output_tokens", 0)
309
+ obj["usage"] = {
310
+ "prompt_tokens": input_tok,
311
+ "completion_tokens": output_tok,
312
+ "total_tokens": input_tok + output_tok,
313
+ "input_tokens": input_tok,
314
+ "output_tokens": output_tok,
315
+ }
316
+ return obj
317
+
318
+
319
  # ──────────────────────────────────────────────
320
  # IMAGE GENERATION
321
  # ──────────────────────────────────────────────
 
745
 
746
  await _check_chat_rate_limit(request, authorization, x_client_id)
747
 
748
+ # Determine whether the caller is an API-key client that should receive
749
+ # raw thinking tokens.
750
+ forward_thinking = _is_api_key_request(request)
751
+
752
  body["model"] = chosen_model
753
  stream = body.get("stream", False)
754
 
 
811
  sent_metadata = False
812
  async with httpx.AsyncClient(timeout=None) as client:
813
  async for chunk in stream_primary(client):
814
+ # ── emit router metadata once as the very first SSE frame ──
815
  if not sent_metadata:
816
+ meta = {
817
+ "router_metadata": {
818
+ "model_name": MODEL_MAP.get(chosen_model, chosen_model)
819
+ }
820
+ }
821
  yield f"data: {json.dumps(meta)}\n\n"
822
  sent_metadata = True
823
 
824
+ # ── pass [DONE] straight through ──────────────────────────
825
+ if "data: [DONE]" in chunk:
826
+ yield chunk
827
+ continue
828
+
829
+ # ── process data: … lines ─────────────────────────────────
830
+ if chunk.startswith("data:"):
831
  raw = chunk[5:].strip()
832
  try:
833
  obj = json.loads(raw)
 
 
 
 
 
 
 
 
 
 
 
 
 
834
  except Exception:
835
+ # Not valid JSON — forward verbatim (keeps partial
836
+ # chunks from blocking the stream).
837
+ yield chunk
838
+ continue
839
+
840
+ if not isinstance(obj, dict):
841
+ yield chunk
842
+ continue
843
+
844
+ # Normalize usage block whenever it appears.
845
+ _normalize_usage_block(obj)
846
+
847
+ # ── thinking / reasoning tokens ───────────────────────
848
+ # Navy models may embed thinking in two ways:
849
+ #
850
+ # 1. As delta.reasoning_content (separate field)
851
+ # 2. Inline inside delta.content wrapped in <think>…</think>
852
+ #
853
+ # For API-key callers we always surface both forms.
854
+ # For browser/session callers we strip reasoning_content
855
+ # so it doesn't confuse UI clients that don't expect it,
856
+ # but <think> tags already present in content are left
857
+ # alone (they arrived that way from upstream).
858
+ if forward_thinking:
859
+ # Merge reasoning_content into content as
860
+ # <think>…</think> and keep the raw field.
861
+ obj = _inject_reasoning_into_chunk(obj)
862
+ else:
863
+ # Strip the non-standard field so browser clients
864
+ # don't see unexpected keys.
865
+ try:
866
+ delta = obj["choices"][0]["delta"]
867
+ delta.pop("reasoning_content", None)
868
+ delta.pop("reasoning", None)
869
+ obj["choices"][0]["delta"] = delta
870
+ except (KeyError, IndexError, TypeError):
871
+ pass
872
 
873
+ yield f"data: {json.dumps(obj)}\n\n"
874
+ continue
875
+
876
+ # ── any other line (comments, keep-alives, …) ─────────────
877
  yield chunk
878
 
879
  return StreamingResponse(
880
  event_generator(),
881
  media_type="text/event-stream",
882
+ headers={
883
+ "Cache-Control": "no-cache",
884
+ "Connection": "keep-alive",
885
+ "X-Accel-Buffering": "no",
886
+ },
887
  )
888
 
889
  # ── non-streaming ─────────────────────────
 
896
  fb_url, fb_key = _get_provider_url_and_key(FALLBACK_PROVIDER)
897
  fallback_body = dict(body)
898
  fallback_body["model"] = FALLBACK_MODEL
899
+ r = await client.post(
900
+ fb_url,
901
+ json=fallback_body,
902
+ headers={"Authorization": f"Bearer {fb_key}"},
903
+ )
904
 
905
  content_type = (r.headers.get("content-type") or "").lower()
906
  if "application/json" in content_type:
 
909
  except Exception:
910
  payload = {"error": "Upstream returned invalid JSON"}
911
  else:
912
+ # Normalize usage fields.
913
+ _normalize_usage_block(payload)
914
+
915
+ # ── thinking tokens in non-streaming responses ────────────────────
916
+ # Some navy models put thinking content in
917
+ # message.reasoning_content. For API-key callers we prepend it to
918
+ # message.content wrapped in <think>…</think>; for others we drop
919
+ # the non-standard field.
920
+ try:
921
+ message = payload["choices"][0]["message"]
922
+ reasoning = (
923
+ message.pop("reasoning_content", None)
924
+ or message.pop("reasoning", None)
925
+ or ""
926
+ )
927
+ if reasoning and isinstance(reasoning, str):
928
+ if forward_thinking:
929
+ existing = message.get("content") or ""
930
+ message["content"] = f"<think>{reasoning}</think>{existing}"
931
+ # Restore the raw field for clients that want it.
932
+ message["reasoning_content"] = reasoning
933
+ # else: already popped — nothing to do.
934
+ payload["choices"][0]["message"] = message
935
+ except (KeyError, IndexError, TypeError):
936
+ pass
937
+
938
+ payload.setdefault("router_metadata", {})["model_name"] = MODEL_MAP.get(
939
+ chosen_model, chosen_model
940
+ )
941
  else:
942
  payload = {
943
  "error": "Upstream returned non-JSON response",