asnannp commited on
Commit
3f9777e
·
1 Parent(s): 4616098

deploy: sync backend to Space root (learn-lesson HF cache fix)

Browse files
.env.example CHANGED
@@ -54,6 +54,15 @@ AI_TIMEOUT_SECONDS="120"
54
  # Server-side only — never put this in NEXT_PUBLIC_*.
55
  GROQ_API_KEY=""
56
 
 
 
 
 
 
 
 
 
 
57
  # Scale knobs for ~1k concurrent students (raise in production Postgres deploys).
58
  DATABASE_POOL_SIZE="10"
59
  DATABASE_MAX_OVERFLOW="20"
 
54
  # Server-side only — never put this in NEXT_PUBLIC_*.
55
  GROQ_API_KEY=""
56
 
57
+ # Z.AI / GLM free Flash models (text + vision). Server-side only.
58
+ # Text: glm-4.7-flash Vision: glm-4.6v-flash
59
+ # https://docs.z.ai/ base: https://api.z.ai/api/paas/v4/
60
+ ZAI_API_KEY=""
61
+ ZAI_BASE_URL="https://api.z.ai/api/paas/v4"
62
+ ZAI_MODEL_TEXT="glm-4.7-flash"
63
+ ZAI_MODEL_VISION="glm-4.6v-flash"
64
+ ZAI_TIMEOUT_SECONDS="90"
65
+
66
  # Scale knobs for ~1k concurrent students (raise in production Postgres deploys).
67
  DATABASE_POOL_SIZE="10"
68
  DATABASE_MAX_OVERFLOW="20"
.env.huggingface.example CHANGED
@@ -20,6 +20,13 @@ AI_PROVIDER=sarvam
20
  SARVAM_API_KEY=
21
  AI_FALLBACK_TO_MOCK=false
22
 
 
 
 
 
 
 
 
23
  SARVAM_BASE_URL=https://api.sarvam.ai/v1
24
  SARVAM_MODEL_MAIN=sarvam-30b
25
  SARVAM_MODEL_HEAVY=sarvam-105b
 
20
  SARVAM_API_KEY=
21
  AI_FALLBACK_TO_MOCK=false
22
 
23
+ # Free Z.AI / GLM Flash (text + vision). Preferred cheap authoring path.
24
+ ZAI_API_KEY=
25
+ ZAI_BASE_URL=https://api.z.ai/api/paas/v4
26
+ ZAI_MODEL_TEXT=glm-4.7-flash
27
+ ZAI_MODEL_VISION=glm-4.6v-flash
28
+ GROQ_API_KEY=
29
+
30
  SARVAM_BASE_URL=https://api.sarvam.ai/v1
31
  SARVAM_MODEL_MAIN=sarvam-30b
32
  SARVAM_MODEL_HEAVY=sarvam-105b
.env.production.example CHANGED
@@ -57,6 +57,10 @@ AI_FALLBACK_TO_MOCK=false
57
 
58
  # Learn Anything lesson authoring (Groq). Server-only.
59
  GROQ_API_KEY=
 
 
 
 
60
 
61
  # Postgres pool for ~1k concurrent students (tune against your plan limits).
62
  DATABASE_POOL_SIZE=15
 
57
 
58
  # Learn Anything lesson authoring (Groq). Server-only.
59
  GROQ_API_KEY=
60
+ ZAI_API_KEY=
61
+ ZAI_BASE_URL=https://api.z.ai/api/paas/v4
62
+ ZAI_MODEL_TEXT=glm-4.7-flash
63
+ ZAI_MODEL_VISION=glm-4.6v-flash
64
 
65
  # Postgres pool for ~1k concurrent students (tune against your plan limits).
66
  DATABASE_POOL_SIZE=15
app/core/config.py CHANGED
@@ -107,6 +107,29 @@ class Settings(BaseSettings):
107
  groq_api_key: str | None = Field(default=None, validation_alias=AliasChoices("GROQ_API_KEY"))
108
  groq_base_url: str = "https://api.groq.com/openai/v1"
109
  groq_model_main: str = "llama-3.1-8b-instant"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  nvidia_nim_api_key: str | None = Field(default=None, validation_alias=AliasChoices("NVIDIA_NIM_API_KEY"))
111
  nvidia_nim_base_url: str = "https://integrate.api.nvidia.com/v1"
112
  nvidia_nim_model_main: str = "meta/llama-3.1-8b-instruct" # fast (~3-4s), good for quick answers
 
107
  groq_api_key: str | None = Field(default=None, validation_alias=AliasChoices("GROQ_API_KEY"))
108
  groq_base_url: str = "https://api.groq.com/openai/v1"
109
  groq_model_main: str = "llama-3.1-8b-instant"
110
+ # --- Z.AI / GLM (free text + vision Flash models) ---
111
+ # OpenAI-compatible: https://api.z.ai/api/paas/v4/
112
+ # Free: glm-4.7-flash (text), glm-4.6v-flash (vision / image understanding).
113
+ zai_api_key: str | None = Field(
114
+ default=None,
115
+ validation_alias=AliasChoices("ZAI_API_KEY", "Z_AI_API_KEY", "GLM_API_KEY"),
116
+ )
117
+ zai_base_url: str = Field(
118
+ default="https://api.z.ai/api/paas/v4",
119
+ validation_alias=AliasChoices("ZAI_BASE_URL", "Z_AI_BASE_URL"),
120
+ )
121
+ zai_model_text: str = Field(
122
+ default="glm-4.7-flash",
123
+ validation_alias=AliasChoices("ZAI_MODEL_TEXT", "GLM_MODEL_TEXT"),
124
+ )
125
+ zai_model_vision: str = Field(
126
+ default="glm-4.6v-flash",
127
+ validation_alias=AliasChoices("ZAI_MODEL_VISION", "GLM_MODEL_VISION"),
128
+ )
129
+ zai_timeout_seconds: int = Field(
130
+ default=90,
131
+ validation_alias=AliasChoices("ZAI_TIMEOUT_SECONDS"),
132
+ )
133
  nvidia_nim_api_key: str | None = Field(default=None, validation_alias=AliasChoices("NVIDIA_NIM_API_KEY"))
134
  nvidia_nim_base_url: str = "https://integrate.api.nvidia.com/v1"
135
  nvidia_nim_model_main: str = "meta/llama-3.1-8b-instruct" # fast (~3-4s), good for quick answers
app/core/sanitizer.py CHANGED
@@ -49,7 +49,8 @@ _INTERNAL_PATTERN: re.Pattern[str] = re.compile(
49
  # or "mock_" or word-boundary mock when followed by a dash/underscore.
50
  _PROVIDER_PATTERN: re.Pattern[str] = re.compile(
51
  r"\b(sarvam|gemini|openrouter|deepseek|nvidia|"
52
- r"huggingface|cloudflare|groq|replicate|mock[-_])\b",
 
53
  re.IGNORECASE,
54
  )
55
 
 
49
  # or "mock_" or word-boundary mock when followed by a dash/underscore.
50
  _PROVIDER_PATTERN: re.Pattern[str] = re.compile(
51
  r"\b(sarvam|gemini|openrouter|deepseek|nvidia|"
52
+ r"huggingface|cloudflare|groq|replicate|zai|z\.ai|glm|"
53
+ r"mock[-_])\b",
54
  re.IGNORECASE,
55
  )
56
 
app/routes/chat.py CHANGED
@@ -515,27 +515,56 @@ def _chat_provider_candidates(settings: Any, intent: str) -> list[dict[str, Any]
515
  },
516
  })
517
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  primary_adders = {
519
  "openai": add_openai,
520
  "sarvam": add_sarvam,
521
  "openrouter": add_openrouter,
 
 
 
522
  }
523
  # Sarvam's reasoning model is strong for deeper study work, but ordinary
524
  # explanations can spend close to a minute reasoning before showing text.
525
- # Prefer the configured fast OpenRouter model for lightweight chat, then
526
- # retain Sarvam as the first fallback. Heavy exam/PYQ work stays on Sarvam.
527
  if provider_name == "sarvam" and intent not in _CHAT_HEAVY_INTENTS:
 
528
  add_openrouter()
529
  add_sarvam()
530
  elif provider_name == "auto":
 
531
  add_openai()
532
  add_sarvam()
533
  add_openrouter()
 
 
 
 
534
  else:
535
  primary_adders.get(provider_name, add_openai)()
536
 
537
  for name, adder in primary_adders.items():
538
- if provider_name not in {name, "auto"}:
 
 
539
  adder()
540
  return candidates
541
 
@@ -564,6 +593,8 @@ def _chat_completion_request(
564
  request["reasoning_effort"] = candidate["reasoning_effort"]
565
  if "extra_headers" in candidate:
566
  request["extra_headers"] = candidate["extra_headers"]
 
 
567
  return request
568
 
569
 
@@ -571,13 +602,13 @@ def _create_chat_completion(client: Any, request: dict[str, Any]) -> Any:
571
  try:
572
  return client.chat.completions.create(**request)
573
  except TypeError:
574
- if "reasoning_effort" in request:
575
- request = dict(request)
576
- request.pop("reasoning_effort", None)
577
- if "max_completion_tokens" in request:
578
- request["max_tokens"] = request.pop("max_completion_tokens")
579
- return client.chat.completions.create(**request)
580
- raise
581
 
582
 
583
  def _call_ai_chat_sync(
 
515
  },
516
  })
517
 
518
+ def add_zai() -> None:
519
+ if not _has_configured_key(getattr(settings, "zai_api_key", None)):
520
+ return
521
+ base = (settings.zai_base_url or "https://api.z.ai/api/paas/v4").rstrip("/")
522
+ add({
523
+ "provider": "zai",
524
+ "base_url": base,
525
+ "api_key": settings.zai_api_key,
526
+ "model": settings.zai_model_text or "glm-4.7-flash",
527
+ "model_label": f"zai:{settings.zai_model_text or 'glm-4.7-flash'}",
528
+ "timeout": min(float(getattr(settings, "zai_timeout_seconds", 90) or 90), 60.0),
529
+ "token_param": "max_tokens",
530
+ "temperature": 0.3,
531
+ "system_role": "system",
532
+ # Free Flash is faster with thinking disabled.
533
+ "extra_body": {"thinking": {"type": "disabled"}},
534
+ })
535
+
536
  primary_adders = {
537
  "openai": add_openai,
538
  "sarvam": add_sarvam,
539
  "openrouter": add_openrouter,
540
+ "zai": add_zai,
541
+ "z_ai": add_zai,
542
+ "glm": add_zai,
543
  }
544
  # Sarvam's reasoning model is strong for deeper study work, but ordinary
545
  # explanations can spend close to a minute reasoning before showing text.
546
+ # Prefer free Z.AI Flash + OpenRouter for lightweight chat, then Sarvam.
547
+ # Heavy exam/PYQ work stays on Sarvam.
548
  if provider_name == "sarvam" and intent not in _CHAT_HEAVY_INTENTS:
549
+ add_zai()
550
  add_openrouter()
551
  add_sarvam()
552
  elif provider_name == "auto":
553
+ add_zai()
554
  add_openai()
555
  add_sarvam()
556
  add_openrouter()
557
+ elif provider_name in {"zai", "z_ai", "glm"}:
558
+ add_zai()
559
+ add_openrouter()
560
+ add_sarvam()
561
  else:
562
  primary_adders.get(provider_name, add_openai)()
563
 
564
  for name, adder in primary_adders.items():
565
+ if provider_name not in {name, "auto", "z_ai", "glm"} and not (
566
+ provider_name == "zai" and name in {"zai", "z_ai", "glm"}
567
+ ):
568
  adder()
569
  return candidates
570
 
 
593
  request["reasoning_effort"] = candidate["reasoning_effort"]
594
  if "extra_headers" in candidate:
595
  request["extra_headers"] = candidate["extra_headers"]
596
+ if "extra_body" in candidate:
597
+ request["extra_body"] = candidate["extra_body"]
598
  return request
599
 
600
 
 
602
  try:
603
  return client.chat.completions.create(**request)
604
  except TypeError:
605
+ request = dict(request)
606
+ # Older openai clients may not accept reasoning_effort / extra_body.
607
+ request.pop("reasoning_effort", None)
608
+ request.pop("extra_body", None)
609
+ if "max_completion_tokens" in request:
610
+ request["max_tokens"] = request.pop("max_completion_tokens")
611
+ return client.chat.completions.create(**request)
612
 
613
 
614
  def _call_ai_chat_sync(
app/services/ai_provider.py CHANGED
@@ -2937,6 +2937,89 @@ class NvidiaNimAIProvider(SarvamAIProvider):
2937
  raise _FatalAPIError(f"NVIDIA NIM auth error {status}") from exc
2938
  raise AIProviderError(f"NVIDIA NIM call failed: {_safe_error(exc)}") from exc
2939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2940
  # ── Deep/quality tasks: defer to the next provider (Sarvam) in the chain ──
2941
  def _defer(self, name: str):
2942
  raise AIProviderError(f"NVIDIA NIM defers '{name}' to a quality provider.")
@@ -3659,12 +3742,25 @@ def get_ai_provider() -> BaseAIProvider:
3659
  try: providers.append(NvidiaNimAIProvider())
3660
  except Exception: pass
3661
 
3662
- # 0. Fast path: NVIDIA NIM first for quick tasks (~3-4s). Deep tasks defer
 
 
 
 
 
 
 
 
 
 
 
 
 
3663
  # to Sarvam automatically. Only when FAST_PROVIDER_ENABLED=true.
3664
  # When the primary is explicitly Sarvam, do NOT let the fast path preempt
3665
  # it — Sarvam (sarvam-30b/105b) must answer the main study content, with
3666
  # NVIDIA demoted to a fallback at the end of the chain.
3667
- if settings.fast_provider_enabled and provider_name not in {"mock", "sarvam"}:
3668
  add_nvidia_fast()
3669
 
3670
  # 1. Add the primary provider first
@@ -3675,6 +3771,7 @@ def get_ai_provider() -> BaseAIProvider:
3675
  elif provider_name == "sarvam": add_sarvam()
3676
  elif provider_name == "openrouter": add_openrouter()
3677
  elif provider_name == "gemini": add_gemini()
 
3678
  elif provider_name == "mock": return MockAIProvider()
3679
 
3680
  # 2. Add others as fallback backups
@@ -3682,6 +3779,8 @@ def get_ai_provider() -> BaseAIProvider:
3682
  if provider_name not in {"sarvam", "auto"}: add_sarvam()
3683
  if provider_name not in {"openrouter", "auto"}: add_openrouter()
3684
  if provider_name not in {"gemini", "auto"}: add_gemini()
 
 
3685
  # NVIDIA as resilience fallback when Sarvam is primary (it was skipped above)
3686
  if provider_name == "sarvam" and settings.fast_provider_enabled:
3687
  add_nvidia_fast()
@@ -3722,13 +3821,27 @@ def log_ai_mode() -> None:
3722
  print(f">>> {msg}")
3723
  elif provider_name == "auto":
3724
  logger.info(
3725
- "AI MODE: auto (OpenAI=%s, Sarvam=%s, OpenRouter=%s, Gemini=%s) | fallback_to_mock=%s",
3726
  bool(settings.openai_api_key),
3727
  bool(settings.sarvam_api_key),
3728
  bool(settings.openrouter_api_key),
3729
  bool(settings.gemini_api_key),
 
3730
  fallback,
3731
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
3732
  elif provider_name == "sarvam":
3733
  has_key = bool(settings.sarvam_api_key)
3734
  if has_key:
 
2937
  raise _FatalAPIError(f"NVIDIA NIM auth error {status}") from exc
2938
  raise AIProviderError(f"NVIDIA NIM call failed: {_safe_error(exc)}") from exc
2939
 
2940
+
2941
+ class ZaiAIProvider(SarvamAIProvider):
2942
+ """Z.AI free Flash text provider (OpenAI-compatible).
2943
+
2944
+ Uses glm-4.7-flash for all structured study tasks. Free tier is rate-limited;
2945
+ keep this early in the chain for cost control, with Sarvam/OpenRouter as backup.
2946
+ """
2947
+
2948
+ def __init__(self) -> None:
2949
+ settings = get_settings()
2950
+ if not settings.zai_api_key:
2951
+ raise AIProviderError("ZAI_API_KEY is missing.")
2952
+ try:
2953
+ from openai import OpenAI
2954
+ except ImportError as exc:
2955
+ raise AIProviderError("openai package is not installed.") from exc
2956
+ self._settings = settings
2957
+ base = (settings.zai_base_url or "https://api.z.ai/api/paas/v4").rstrip("/")
2958
+ self._client = OpenAI(base_url=base, api_key=settings.zai_api_key)
2959
+ self._fallback = MockAIProvider()
2960
+ self._text_model = settings.zai_model_text or "glm-4.7-flash"
2961
+ self.model_name = f"zai:{self._text_model}"
2962
+ self.is_fallback = False
2963
+ self.fallback_reason = None
2964
+ self.last_error_code: int | None = None
2965
+
2966
+ def _get_model_for_task(self, task_name: str) -> str:
2967
+ return self._text_model
2968
+
2969
+ def _call_model(
2970
+ self,
2971
+ model_id: str,
2972
+ system_msg: str,
2973
+ prompt: str,
2974
+ *,
2975
+ max_tokens: int = 2048,
2976
+ temperature: float = 0.2,
2977
+ reasoning_effort: str = "low", # ignored — we disable GLM thinking for speed
2978
+ ) -> str:
2979
+ started_at = time.perf_counter()
2980
+ try:
2981
+ # Z.AI free Flash: disable chain-of-thought for faster JSON tasks.
2982
+ response = self._client.chat.completions.create(
2983
+ model=model_id,
2984
+ messages=[
2985
+ {"role": "system", "content": system_msg},
2986
+ {"role": "user", "content": prompt},
2987
+ ],
2988
+ temperature=temperature,
2989
+ max_tokens=max_tokens,
2990
+ timeout=float(self._settings.zai_timeout_seconds or 90),
2991
+ extra_body={"thinking": {"type": "disabled"}},
2992
+ )
2993
+ msg = response.choices[0].message
2994
+ content = (getattr(msg, "content", None) or "").strip()
2995
+ reasoning = (getattr(msg, "reasoning_content", None) or "").strip()
2996
+ text = content or reasoning
2997
+ logger.info(
2998
+ "AI call timing provider=zai model=%s elapsed_ms=%s max_tokens=%s",
2999
+ model_id,
3000
+ int((time.perf_counter() - started_at) * 1000),
3001
+ max_tokens,
3002
+ )
3003
+ if not text:
3004
+ raise AIProviderError("Z.AI returned empty content.")
3005
+ return text
3006
+ except _FatalAPIError:
3007
+ raise
3008
+ except Exception as exc: # noqa: BLE001
3009
+ logger.info(
3010
+ "AI call timing provider=zai model=%s failed elapsed_ms=%s",
3011
+ model_id,
3012
+ int((time.perf_counter() - started_at) * 1000),
3013
+ )
3014
+ code = _http_status_code(exc)
3015
+ if code in (401, 403):
3016
+ self.last_error_code = code
3017
+ raise _FatalAPIError(code, f"Z.AI API key error (HTTP {code})") from exc
3018
+ if code:
3019
+ self.last_error_code = code
3020
+ logger.warning("Z.AI HTTP %s for model=%s", code, model_id)
3021
+ raise AIProviderError(f"Z.AI call failed: {_safe_error(exc)}") from exc
3022
+
3023
  # ── Deep/quality tasks: defer to the next provider (Sarvam) in the chain ──
3024
  def _defer(self, name: str):
3025
  raise AIProviderError(f"NVIDIA NIM defers '{name}' to a quality provider.")
 
3742
  try: providers.append(NvidiaNimAIProvider())
3743
  except Exception: pass
3744
 
3745
+ def add_zai():
3746
+ if has_configured_key(settings.zai_api_key):
3747
+ try: providers.append(ZaiAIProvider())
3748
+ except Exception: pass
3749
+
3750
+ # 0. Free Z.AI Flash first when available (cost control). Skip when primary
3751
+ # is explicitly mock. If AI_PROVIDER=zai, it is also the primary below.
3752
+ if provider_name not in {"mock"} and provider_name != "zai":
3753
+ # Prefer free Flash early for auto / cost-sensitive chains; for explicit
3754
+ # paid primaries (openai/sarvam) keep Z.AI as a cheap backup later.
3755
+ if provider_name in {"auto", "openrouter", "gemini"}:
3756
+ add_zai()
3757
+
3758
+ # 0b. Fast path: NVIDIA NIM first for quick tasks (~3-4s). Deep tasks defer
3759
  # to Sarvam automatically. Only when FAST_PROVIDER_ENABLED=true.
3760
  # When the primary is explicitly Sarvam, do NOT let the fast path preempt
3761
  # it — Sarvam (sarvam-30b/105b) must answer the main study content, with
3762
  # NVIDIA demoted to a fallback at the end of the chain.
3763
+ if settings.fast_provider_enabled and provider_name not in {"mock", "sarvam", "zai"}:
3764
  add_nvidia_fast()
3765
 
3766
  # 1. Add the primary provider first
 
3771
  elif provider_name == "sarvam": add_sarvam()
3772
  elif provider_name == "openrouter": add_openrouter()
3773
  elif provider_name == "gemini": add_gemini()
3774
+ elif provider_name in {"zai", "z_ai", "glm"}: add_zai()
3775
  elif provider_name == "mock": return MockAIProvider()
3776
 
3777
  # 2. Add others as fallback backups
 
3779
  if provider_name not in {"sarvam", "auto"}: add_sarvam()
3780
  if provider_name not in {"openrouter", "auto"}: add_openrouter()
3781
  if provider_name not in {"gemini", "auto"}: add_gemini()
3782
+ if provider_name not in {"zai", "z_ai", "glm", "auto", "openrouter", "gemini"}:
3783
+ add_zai()
3784
  # NVIDIA as resilience fallback when Sarvam is primary (it was skipped above)
3785
  if provider_name == "sarvam" and settings.fast_provider_enabled:
3786
  add_nvidia_fast()
 
3821
  print(f">>> {msg}")
3822
  elif provider_name == "auto":
3823
  logger.info(
3824
+ "AI MODE: auto (OpenAI=%s, Sarvam=%s, OpenRouter=%s, Gemini=%s, ZAI=%s) | fallback_to_mock=%s",
3825
  bool(settings.openai_api_key),
3826
  bool(settings.sarvam_api_key),
3827
  bool(settings.openrouter_api_key),
3828
  bool(settings.gemini_api_key),
3829
+ bool(settings.zai_api_key),
3830
  fallback,
3831
  )
3832
+ elif provider_name in {"zai", "z_ai", "glm"}:
3833
+ has_key = bool(settings.zai_api_key)
3834
+ if has_key:
3835
+ msg = (
3836
+ f"AI MODE: Z.AI (text={settings.zai_model_text}, "
3837
+ f"vision={settings.zai_model_vision}) | fallback_to_mock={fallback}"
3838
+ )
3839
+ logger.info(msg)
3840
+ print(f">>> {msg}")
3841
+ else:
3842
+ msg = "AI MODE: zai configured but ZAI_API_KEY is EMPTY — falling back to other providers"
3843
+ logger.warning(msg)
3844
+ print(f">>> {msg}")
3845
  elif provider_name == "sarvam":
3846
  has_key = bool(settings.sarvam_api_key)
3847
  if has_key:
app/services/learn_lesson_builder.py CHANGED
@@ -176,9 +176,47 @@ def _call_groq(prompt: str, api_key: str, max_tokens: int = 8000) -> dict[str, A
176
  )
177
 
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  def _script_llm_providers() -> list[dict[str, Any]]:
180
- """Lesson-script providers: Groq OpenAI-compatible chat."""
181
  providers: list[dict[str, Any]] = []
 
 
 
 
 
 
 
 
 
182
  groq = _env("GROQ_API_KEY")
183
  if groq.startswith(("gsk-", "gsk_")) and len(groq) >= 20:
184
  providers.append(
@@ -511,7 +549,8 @@ def _generate_with_retries(
511
  chain = providers if providers is not None else _script_llm_providers()
512
  if not chain:
513
  raise LessonBuildError(
514
- "No lesson LLM key configured. Set GROQ_API_KEY for Learn Anything authoring."
 
515
  )
516
  for provider in chain:
517
  name = str(provider.get("name") or "llm")
@@ -556,7 +595,7 @@ def generate_lesson_script(
556
  providers = _script_llm_providers()
557
  if not providers:
558
  logger.warning(
559
- "GROQ_API_KEY missing/invalid; serving starter reading class."
560
  )
561
  return _starter_reading_script(
562
  topic=topic, lesson_title=lesson_title, level=level
 
176
  )
177
 
178
 
179
+ def _call_zai(prompt: str, api_key: str, max_tokens: int = 8000) -> dict[str, Any]:
180
+ """Free Z.AI GLM-4.7-Flash for lesson authoring (OpenAI-compatible)."""
181
+ base = (_env("ZAI_BASE_URL") or _env("Z_AI_BASE_URL") or "https://api.z.ai/api/paas/v4").rstrip("/")
182
+ model = _env("ZAI_MODEL_TEXT") or _env("GLM_MODEL_TEXT") or "glm-4.7-flash"
183
+ # Prefer the shared client when settings are loaded; fall back to raw HTTP.
184
+ try:
185
+ from app.services.zai_client import chat_completions, extract_message_text
186
+
187
+ result = chat_completions(
188
+ model=model,
189
+ messages=[{"role": "user", "content": prompt}],
190
+ max_tokens=max_tokens,
191
+ temperature=0.35,
192
+ response_format={"type": "json_object"},
193
+ thinking_disabled=True,
194
+ )
195
+ content = extract_message_text(result)
196
+ return json.loads(content)
197
+ except Exception:
198
+ return _call_openai_compatible_json(
199
+ url=f"{base}/chat/completions",
200
+ api_key=api_key,
201
+ model=model,
202
+ prompt=prompt,
203
+ max_tokens=max_tokens,
204
+ provider_label="Z.AI",
205
+ )
206
+
207
+
208
  def _script_llm_providers() -> list[dict[str, Any]]:
209
+ """Lesson-script providers: free Z.AI Flash first, then Groq."""
210
  providers: list[dict[str, Any]] = []
211
+ zai = _env("ZAI_API_KEY") or _env("Z_AI_API_KEY") or _env("GLM_API_KEY")
212
+ if zai and len(zai) >= 16:
213
+ providers.append(
214
+ {
215
+ "name": "zai",
216
+ "key": zai,
217
+ "call": lambda prompt, key=zai: _call_zai(prompt, key),
218
+ }
219
+ )
220
  groq = _env("GROQ_API_KEY")
221
  if groq.startswith(("gsk-", "gsk_")) and len(groq) >= 20:
222
  providers.append(
 
549
  chain = providers if providers is not None else _script_llm_providers()
550
  if not chain:
551
  raise LessonBuildError(
552
+ "No lesson LLM key configured. Set ZAI_API_KEY (free GLM-4.7-Flash) "
553
+ "or GROQ_API_KEY for Learn Anything authoring."
554
  )
555
  for provider in chain:
556
  name = str(provider.get("name") or "llm")
 
595
  providers = _script_llm_providers()
596
  if not providers:
597
  logger.warning(
598
+ "ZAI_API_KEY/GROQ_API_KEY missing/invalid; serving starter reading class."
599
  )
600
  return _starter_reading_script(
601
  topic=topic, lesson_title=lesson_title, level=level
app/services/provider_adapters.py CHANGED
@@ -121,12 +121,48 @@ class CloudflareWorkersAITextAdapter(BaseTextAdapter):
121
  return TextProviderResponse(text=text.strip(), provider=self.provider_name, model=model, raw=data)
122
 
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  def get_text_adapter(provider: str) -> BaseTextAdapter:
125
- normalized = provider.strip().lower()
126
  if normalized == "openrouter":
127
  return OpenRouterTextAdapter()
128
  if normalized == "nvidia_nim":
129
  return NvidiaNimTextAdapter()
130
  if normalized == "cloudflare_workers_ai":
131
  return CloudflareWorkersAITextAdapter()
 
 
132
  raise ProviderAdapterError("Provider adapter is not implemented.")
 
121
  return TextProviderResponse(text=text.strip(), provider=self.provider_name, model=model, raw=data)
122
 
123
 
124
+ class ZaiTextAdapter(BaseTextAdapter):
125
+ """Free Z.AI GLM-4.7-Flash text adapter (OpenAI-compatible)."""
126
+
127
+ provider_name = "zai"
128
+
129
+ def __init__(self) -> None:
130
+ settings = get_settings()
131
+ if not settings.zai_api_key:
132
+ raise ProviderAdapterError("Provider is not configured.", status_code=401)
133
+ try:
134
+ from openai import OpenAI
135
+ except ImportError as exc:
136
+ raise ProviderAdapterError("OpenAI-compatible client is not installed.") from exc
137
+ self._settings = settings
138
+ base = (settings.zai_base_url or "https://api.z.ai/api/paas/v4").rstrip("/")
139
+ self._client = OpenAI(base_url=base, api_key=settings.zai_api_key)
140
+ self._model = settings.zai_model_text or "glm-4.7-flash"
141
+
142
+ def generate_text(self, prompt: str, *, system: str | None = None, max_tokens: int = 2048) -> TextProviderResponse:
143
+ response = self._client.chat.completions.create(
144
+ model=self._model,
145
+ messages=[
146
+ {"role": "system", "content": system or "Return a concise, safe answer."},
147
+ {"role": "user", "content": prompt},
148
+ ],
149
+ max_tokens=max_tokens,
150
+ temperature=0.2,
151
+ timeout=float(self._settings.zai_timeout_seconds or 90),
152
+ extra_body={"thinking": {"type": "disabled"}},
153
+ )
154
+ text = response.choices[0].message.content or ""
155
+ return TextProviderResponse(text=text.strip(), provider=self.provider_name, model=self._model)
156
+
157
+
158
  def get_text_adapter(provider: str) -> BaseTextAdapter:
159
+ normalized = provider.strip().lower().replace("-", "_")
160
  if normalized == "openrouter":
161
  return OpenRouterTextAdapter()
162
  if normalized == "nvidia_nim":
163
  return NvidiaNimTextAdapter()
164
  if normalized == "cloudflare_workers_ai":
165
  return CloudflareWorkersAITextAdapter()
166
+ if normalized in {"zai", "z_ai", "glm"}:
167
+ return ZaiTextAdapter()
168
  raise ProviderAdapterError("Provider adapter is not implemented.")
app/services/provider_registry.py CHANGED
@@ -73,6 +73,10 @@ def normalize_provider_name(provider: str) -> str:
73
  "nvidia": "nvidia_nim",
74
  "nim": "nvidia_nim",
75
  "hf": "huggingface",
 
 
 
 
76
  }
77
  return aliases.get(normalized, normalized)
78
 
@@ -115,6 +119,17 @@ def get_provider_registry() -> dict[str, ProviderRegistryEntry]:
115
  required_settings=("groq_api_key",),
116
  estimated_cost_per_1k_units_usd=0.0001,
117
  ),
 
 
 
 
 
 
 
 
 
 
 
118
  "nvidia_nim": ProviderRegistryEntry(
119
  name="nvidia_nim",
120
  kind="text",
 
73
  "nvidia": "nvidia_nim",
74
  "nim": "nvidia_nim",
75
  "hf": "huggingface",
76
+ "z_ai": "zai",
77
+ "z.ai": "zai",
78
+ "glm": "zai",
79
+ "zai_glm": "zai",
80
  }
81
  return aliases.get(normalized, normalized)
82
 
 
119
  required_settings=("groq_api_key",),
120
  estimated_cost_per_1k_units_usd=0.0001,
121
  ),
122
+ "zai": ProviderRegistryEntry(
123
+ name="zai",
124
+ kind="text",
125
+ # Free Flash: text (glm-4.7-flash) + vision OCR path (glm-4.6v-flash).
126
+ task_types=TEXT_TASKS | frozenset({"ocr"}),
127
+ quality_tiers=frozenset({"cheap", "premium"}),
128
+ required_settings=("zai_api_key",),
129
+ aliases=("z_ai", "glm"),
130
+ estimated_cost_per_1k_units_usd=0,
131
+ free_provider=True,
132
+ ),
133
  "nvidia_nim": ProviderRegistryEntry(
134
  name="nvidia_nim",
135
  kind="text",
app/services/provider_router.py CHANGED
@@ -112,10 +112,13 @@ def _candidate_order(task_type: str, quality_tier: str, language: str) -> list[s
112
  return _dedupe(order)
113
 
114
  if task_type == "chat":
115
- return _dedupe([current_default, "openrouter", "gemini", "sarvam", "groq", "mock"])
116
 
117
- cheap_text = ["openrouter", "groq", "cloudflare_workers_ai", "huggingface", "gemini", "mock"]
118
- premium_text = ["sarvam", "gemini", "nvidia_nim", "openrouter", "groq", "mock"]
 
 
 
119
  if task_type in {"scene_plan", "video_script"}:
120
  return _dedupe(cheap_text if quality_tier != "premium" else premium_text)
121
  if quality_tier == "premium":
 
112
  return _dedupe(order)
113
 
114
  if task_type == "chat":
115
+ return _dedupe([current_default, "zai", "openrouter", "gemini", "sarvam", "groq", "mock"])
116
 
117
+ if task_type == "ocr":
118
+ return _dedupe(["zai", "gemini", "openrouter", "mock"])
119
+
120
+ cheap_text = ["zai", "openrouter", "groq", "cloudflare_workers_ai", "huggingface", "gemini", "mock"]
121
+ premium_text = ["sarvam", "gemini", "nvidia_nim", "zai", "openrouter", "groq", "mock"]
122
  if task_type in {"scene_plan", "video_script"}:
123
  return _dedupe(cheap_text if quality_tier != "premium" else premium_text)
124
  if quality_tier == "premium":
app/services/zai_client.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared Z.AI / GLM OpenAI-compatible client (free Flash models).
2
+
3
+ Text: glm-4.7-flash
4
+ Vision: glm-4.6v-flash (image understanding / OCR-style description)
5
+
6
+ Docs: https://docs.z.ai/ — base URL https://api.z.ai/api/paas/v4/
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import urllib.error
14
+ import urllib.request
15
+ from typing import Any
16
+
17
+ from app.core.config import get_settings
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class ZaiClientError(RuntimeError):
23
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
24
+ super().__init__(message)
25
+ self.status_code = status_code
26
+
27
+
28
+ def zai_configured() -> bool:
29
+ settings = get_settings()
30
+ key = (settings.zai_api_key or "").strip()
31
+ return len(key) >= 16
32
+
33
+
34
+ def _chat_url() -> str:
35
+ settings = get_settings()
36
+ base = (settings.zai_base_url or "https://api.z.ai/api/paas/v4").rstrip("/")
37
+ return f"{base}/chat/completions"
38
+
39
+
40
+ def chat_completions(
41
+ *,
42
+ model: str,
43
+ messages: list[dict[str, Any]],
44
+ max_tokens: int = 2048,
45
+ temperature: float = 0.25,
46
+ response_format: dict[str, Any] | None = None,
47
+ thinking_disabled: bool = True,
48
+ timeout: float | None = None,
49
+ ) -> dict[str, Any]:
50
+ """Call Z.AI chat completions. Returns the parsed JSON body."""
51
+ settings = get_settings()
52
+ api_key = (settings.zai_api_key or "").strip()
53
+ if len(api_key) < 16:
54
+ raise ZaiClientError("ZAI_API_KEY is missing or invalid.", status_code=401)
55
+
56
+ payload: dict[str, Any] = {
57
+ "model": model,
58
+ "messages": messages,
59
+ "temperature": temperature,
60
+ "max_tokens": max_tokens,
61
+ }
62
+ # Flash free tier is faster with thinking off for structured tasks.
63
+ if thinking_disabled:
64
+ payload["thinking"] = {"type": "disabled"}
65
+ if response_format is not None:
66
+ payload["response_format"] = response_format
67
+
68
+ request = urllib.request.Request(
69
+ _chat_url(),
70
+ data=json.dumps(payload).encode("utf-8"),
71
+ headers={
72
+ "Content-Type": "application/json",
73
+ "Authorization": f"Bearer {api_key}",
74
+ "User-Agent": "DocDoe-ZAI/1.0",
75
+ },
76
+ method="POST",
77
+ )
78
+ wait = float(timeout if timeout is not None else settings.zai_timeout_seconds)
79
+ try:
80
+ with urllib.request.urlopen(request, timeout=wait) as response:
81
+ return json.loads(response.read().decode("utf-8"))
82
+ except urllib.error.HTTPError as exc:
83
+ body = ""
84
+ try:
85
+ body = exc.read().decode("utf-8", errors="replace")[:500]
86
+ except Exception:
87
+ body = ""
88
+ raise ZaiClientError(
89
+ f"Z.AI HTTP {exc.code}: {body or exc.reason}",
90
+ status_code=exc.code,
91
+ ) from exc
92
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
93
+ raise ZaiClientError(f"Z.AI request failed: {exc}") from exc
94
+
95
+
96
+ def extract_message_text(result: dict[str, Any]) -> str:
97
+ """Pull assistant text from a chat.completions response."""
98
+ try:
99
+ msg = result["choices"][0]["message"]
100
+ except (KeyError, IndexError, TypeError) as exc:
101
+ raise ZaiClientError("Z.AI response missing choices/message.") from exc
102
+ content = msg.get("content")
103
+ if isinstance(content, list):
104
+ content = "".join(
105
+ part.get("text", "") if isinstance(part, dict) else str(part)
106
+ for part in content
107
+ )
108
+ text = (content or "").strip()
109
+ if not text:
110
+ # Some GLM responses put useful text only in reasoning_content.
111
+ reasoning = (msg.get("reasoning_content") or "").strip()
112
+ if reasoning:
113
+ return reasoning
114
+ return text
115
+
116
+
117
+ def generate_text(
118
+ prompt: str,
119
+ *,
120
+ system: str | None = None,
121
+ max_tokens: int = 2048,
122
+ temperature: float = 0.25,
123
+ json_mode: bool = False,
124
+ ) -> str:
125
+ settings = get_settings()
126
+ messages: list[dict[str, Any]] = []
127
+ if system:
128
+ messages.append({"role": "system", "content": system})
129
+ messages.append({"role": "user", "content": prompt})
130
+ result = chat_completions(
131
+ model=settings.zai_model_text,
132
+ messages=messages,
133
+ max_tokens=max_tokens,
134
+ temperature=temperature,
135
+ response_format={"type": "json_object"} if json_mode else None,
136
+ thinking_disabled=True,
137
+ )
138
+ text = extract_message_text(result)
139
+ if not text:
140
+ raise ZaiClientError("Z.AI returned empty content.")
141
+ return text
142
+
143
+
144
+ def describe_image(
145
+ *,
146
+ image_url: str,
147
+ prompt: str = "Describe this image clearly for a student. Extract any text, diagrams, formulas, and labels.",
148
+ max_tokens: int = 1500,
149
+ ) -> str:
150
+ """Vision: GLM-4.6V-Flash free model. image_url may be https or data: URL."""
151
+ settings = get_settings()
152
+ messages = [
153
+ {
154
+ "role": "user",
155
+ "content": [
156
+ {"type": "text", "text": prompt},
157
+ {"type": "image_url", "image_url": {"url": image_url}},
158
+ ],
159
+ }
160
+ ]
161
+ result = chat_completions(
162
+ model=settings.zai_model_vision,
163
+ messages=messages,
164
+ max_tokens=max_tokens,
165
+ temperature=0.2,
166
+ thinking_disabled=True,
167
+ )
168
+ text = extract_message_text(result)
169
+ if not text:
170
+ raise ZaiClientError("Z.AI vision returned empty content.")
171
+ logger.info("Z.AI vision ok model=%s chars=%s", settings.zai_model_vision, len(text))
172
+ return text