| """Modal endpoint for the fine-tuned MiniCPM4.1-8B recipe planner. |
| |
| Runs in its OWN container because MiniCPM4.1's custom code requires |
| transformers 4.x (CacheLayerMixin + is_torch_fx_available), which conflicts |
| with the MiniCPM-V-4.6 vision model in the main app (needs transformers 5.x). |
| |
| Deploy: |
| modal deploy modal_app/planner_endpoint.py |
| |
| The Gradio app calls it via modal.Cls.from_name("cook-with-me-planner", |
| "Planner").infer.remote(prompt, ...). |
| """ |
| from __future__ import annotations |
|
|
| import os |
|
|
| import modal |
|
|
| app = modal.App("cook-with-me-planner") |
|
|
| |
| hf_cache = modal.Volume.from_name("cook-with-me-planner-cache", create_if_missing=True) |
| hf_secret = modal.Secret.from_name("huggingface-secret") |
|
|
| image = ( |
| modal.Image.debian_slim(python_version="3.12") |
| .pip_install( |
| "torch==2.4.0", |
| |
| |
| "transformers>=4.54,<5.0", |
| "huggingface_hub>=0.26,<1.0", |
| "accelerate", |
| "sentencepiece", |
| "safetensors", |
| ) |
| .env({"HF_HOME": "/cache/hf"}) |
| ) |
|
|
| |
| |
| PLANNER_REPO = os.environ.get("COOK_WITH_ME_PLANNER_FT_REPO", "eldinosaur/cook-with-me-planner-8b") |
| BASE_REPO = "openbmb/MiniCPM4.1-8B" |
|
|
|
|
| @app.cls( |
| image=image, |
| gpu="L4", |
| volumes={"/cache": hf_cache}, |
| secrets=[hf_secret], |
| scaledown_window=240, |
| timeout=600, |
| ) |
| class Planner: |
| @modal.enter() |
| def load(self): |
| import torch |
| from transformers import AutoModelForCausalLM, AutoTokenizer |
|
|
| print(f"Loading planner weights from {PLANNER_REPO}...") |
| self.tokenizer = AutoTokenizer.from_pretrained(BASE_REPO, trust_remote_code=True) |
| if self.tokenizer.pad_token is None: |
| self.tokenizer.pad_token = self.tokenizer.eos_token |
| self.model = AutoModelForCausalLM.from_pretrained( |
| PLANNER_REPO, |
| torch_dtype=torch.bfloat16, |
| trust_remote_code=True, |
| device_map="cuda", |
| ).eval() |
| print("Planner ready.") |
|
|
| @modal.method() |
| def infer(self, prompt: str, max_new_tokens: int = 1024, temperature: float = 0.0) -> str: |
| import torch |
|
|
| messages = [{"role": "user", "content": prompt}] |
| |
| try: |
| enc = self.tokenizer.apply_chat_template( |
| messages, |
| add_generation_prompt=True, |
| tokenize=True, |
| return_tensors="pt", |
| return_dict=True, |
| enable_thinking=False, |
| ) |
| except TypeError: |
| enc = self.tokenizer.apply_chat_template( |
| messages, add_generation_prompt=True, tokenize=True, |
| return_tensors="pt", return_dict=True, |
| ) |
|
|
| input_ids = enc["input_ids"].to(self.model.device) |
| input_len = input_ids.shape[1] |
| gen_inputs = {"input_ids": input_ids} |
| if enc.get("attention_mask") is not None: |
| gen_inputs["attention_mask"] = enc["attention_mask"].to(self.model.device) |
|
|
| gen_kwargs = dict(max_new_tokens=max_new_tokens, repetition_penalty=1.05) |
| if temperature and temperature > 0: |
| gen_kwargs.update(do_sample=True, temperature=temperature, top_p=0.9) |
| else: |
| gen_kwargs.update(do_sample=False) |
|
|
| with torch.no_grad(): |
| out = self.model.generate(**gen_inputs, **gen_kwargs) |
| return self.tokenizer.decode(out[0][input_len:], skip_special_tokens=True) |
|
|
|
|
| @app.local_entrypoint() |
| def test(): |
| prompt = ( |
| "You are a creative chef. Available ingredients: tomato, onion, garlic, pasta, olive oil.\n" |
| 'Respond ONLY with JSON: {"options": [{"name": "...", "why": "..."}, {"name": "...", "why": "..."}, {"name": "...", "why": "..."}]}' |
| ) |
| out = Planner().infer.remote(prompt, max_new_tokens=400) |
| print("OUTPUT:\n", out) |
|
|