wuenlp_tools.utils.prompting

  1from __future__ import annotations
  2
  3import atexit
  4import json
  5import os
  6import uuid
  7from dataclasses import dataclass
  8from functools import lru_cache
  9from typing import Callable, T, Type
 10
 11import requests
 12import tiktoken
 13from loguru import logger
 14from numba.core.types import Optional
 15from openai import NOT_GIVEN, OpenAI
 16from pydantic import BaseModel
 17from wuenlp import UIMADocument
 18from wuenlp.impl.uima import UIMAAnnotation, UIMASentence
 19
 20from wuenlp_tools.config import get_lightllm_config, get_litellm_metadata
 21from wuenlp_tools.keys import LIGHTLLM_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY
 22from wuenlp_tools.llm import get_service_tier
 23from wuenlp_tools.utils.local_ollama import (
 24    ensure_local_ollama,
 25    ensure_model_pulled,
 26    is_model_not_found_error,
 27    restart_local_ollama,
 28)
 29
 30from wuenlp_tools.utils.model_cost import get_cost
 31
 32RUN_COST = 0
 33
 34litellm_session_id = "wuenlp_" + str(uuid.uuid4())
 35
 36
 37@dataclass(frozen=True)
 38class LLMCompletion:
 39    content: str | BaseModel | dict
 40    reasoning: str | None = None
 41
 42
 43@atexit.register
 44def print_run_cost():
 45    logger.info(f"Total cost of the run: {RUN_COST:.2f} €")
 46
 47
 48@dataclass
 49class LLMArchitecture:
 50    name: str
 51    model: str
 52    max_tokens: int
 53    provider: str  # "openai", "openrouter", "ollama", "local"
 54    free: bool = False
 55
 56    @property
 57    def estimated_num_characters(self):
 58        return int(self.max_tokens * 3.5)
 59
 60    @property
 61    def uses_openai_structured_output(self) -> bool:
 62        return self.provider == "openai"
 63
 64
 65def _is_ollama_like_provider(provider: str | None) -> bool:
 66    return provider in ("ollama", "local")
 67
 68
 69def _ollama_generation_format(
 70    output_format: type[BaseModel] | None,
 71    *,
 72    json: bool,
 73) -> str | dict | None:
 74    """Ollama ``format`` payload for schema- or JSON-constrained generation."""
 75    if isinstance(output_format, type) and issubclass(output_format, BaseModel):
 76        return output_format.model_json_schema()
 77    if json:
 78        return "json"
 79    return None
 80
 81
 82_RESPONSE_FORMAT_DEFAULT = object()
 83
 84
 85def _openrouter_structured_enabled() -> bool:
 86    return os.environ.get("WUENLP_OPENROUTER_STRUCTURED", "1").strip().lower() not in (
 87        "0",
 88        "false",
 89        "no",
 90        "off",
 91    )
 92
 93
 94def _local_llm_timeout() -> float:
 95    raw = os.environ.get("WUENLP_LOCAL_LLM_TIMEOUT", "600").strip()
 96    try:
 97        return float(raw)
 98    except ValueError:
 99        logger.warning("Invalid WUENLP_LOCAL_LLM_TIMEOUT={!r}; using 600s", raw)
100        return 600.0
101
102
103def _local_llm_keep_alive() -> str:
104    """Ollama model retention after a request (e.g. ``-1`` = until process exit)."""
105    return os.environ.get("WUENLP_LOCAL_LLM_KEEP_ALIVE", "-1").strip() or "-1"
106
107
108def _local_llm_num_predict() -> int:
109    raw = os.environ.get("WUENLP_LOCAL_LLM_NUM_PREDICT", "512").strip()
110    try:
111        return max(32, int(raw))
112    except ValueError:
113        logger.warning("Invalid WUENLP_LOCAL_LLM_NUM_PREDICT={!r}; using 512", raw)
114        return 512
115
116
117def _is_timeout_error(exc: Exception) -> bool:
118    if type(exc).__name__ in ("TimeoutError", "ReadTimeout", "ConnectTimeout"):
119        return True
120    message = str(exc).lower()
121    return "timeout" in message or "timed out" in message
122
123
124def _openrouter_response_format(
125    output_format: type[BaseModel] | None,
126    *,
127    json: bool,
128) -> dict | None:
129    """``response_format`` for OpenRouter ``chat.completions`` structured output."""
130    if isinstance(output_format, type) and issubclass(output_format, BaseModel):
131        return {
132            "type": "json_schema",
133            "json_schema": {
134                "name": output_format.__name__,
135                "strict": True,
136                "schema": output_format.model_json_schema(),
137            },
138        }
139    if json:
140        return {"type": "json_object"}
141    return None
142
143
144def _is_unsupported_response_format_error(exc: Exception) -> bool:
145    if getattr(exc, "status_code", None) != 400:
146        return False
147    message = str(exc).lower()
148    tokens = (
149        "response_format",
150        "json_schema",
151        "structured output",
152        "structured outputs",
153        "json_object",
154        "invalid schema",
155    )
156    return any(token in message for token in tokens)
157
158
159llama31_70b = LLMArchitecture(
160    name="llama3.1:70b", model="llama3.1:70b", max_tokens=-1, provider="ollama"
161)
162llama31_70b_local = LLMArchitecture(
163    name="llama3.1:70b", model="llama3.1:70b", max_tokens=-1, provider="local"
164)
165llama31_8b = LLMArchitecture(
166    name="llama3.1:8b", model="llama3.1:8b", max_tokens=-1, provider="ollama"
167)
168llama31_8b_local = LLMArchitecture(
169    name="llama3.1:8b", model="llama3.1:8b", max_tokens=-1, provider="local"
170)
171llama32_7b = LLMArchitecture(
172    name="llama3.2:7b", model="llama3.1:8b", max_tokens=-1, provider="ollama"
173)
174llama32_7b_local = LLMArchitecture(
175    name="llama3.2:7b", model="llama3.1:8b", max_tokens=-1, provider="local"
176)
177# gpt4o = LLMArchitecture(name="gpt-4o", model="gpt-4o", max_tokens=128000, provider="openai")
178gpt4mini = LLMArchitecture(
179    name="gpt-4o-mini", model="gpt-4o-mini", max_tokens=128000, provider="openai"
180)
181gpt41mini = LLMArchitecture(
182    name="gpt-4.1-mini", model="gpt-4.1-mini", max_tokens=128000, provider="openai"
183)
184gpt41nano = LLMArchitecture(
185    name="gpt-4.1-nano", model="gpt-4.1-nano", max_tokens=128000, provider="openai"
186)
187
188gpt5mini = LLMArchitecture(
189    name="gpt-5-mini", model="gpt-5-mini", max_tokens=128000, provider="openai"
190)
191gpt5nano = LLMArchitecture(
192    name="gpt-5-nano", model="gpt-5-nano", max_tokens=128000, provider="openai"
193)
194
195gpt52 = LLMArchitecture(
196    name="gpt-5.2", model="gpt-5.2", max_tokens=128000, provider="openai"
197)
198
199o3mini = LLMArchitecture(
200    name="o3-mini", model="o3-mini", max_tokens=128000, provider="openai"
201)
202
203llaemmlein = LLMArchitecture(
204    name="llaemmlein", model="llaemmlein", max_tokens=128000, provider=None
205)
206
207r1 = LLMArchitecture(
208    name="r1",
209    model="deepseek/deepseek-r1",
210    max_tokens=128000,
211    provider="openrouter",
212)
213
214# OpenRouter context windows (see openrouter.ai/google/gemini-*): 1M tokens each.
215_GEMINI_CONTEXT_TOKENS = 1_000_000
216
217gemini3flash = LLMArchitecture(
218    name="gemini3flash",
219    model="google/gemini-3-flash-preview",
220    max_tokens=_GEMINI_CONTEXT_TOKENS,
221    provider="openrouter",
222)
223
224gemini3pro = LLMArchitecture(
225    name="gemini3pro",
226    model="google/gemini-3.1-pro-preview",
227    max_tokens=_GEMINI_CONTEXT_TOKENS,
228    provider="openrouter",
229)
230
231gemini31lite = LLMArchitecture(
232    name="gemini31lite",
233    model="google/gemini-3.1-flash-lite-preview",
234    max_tokens=_GEMINI_CONTEXT_TOKENS,
235    provider="openrouter",
236)
237
238default_llm = gpt5nano
239
240
241class Language(BaseModel):
242    name: str
243
244
245class LLM:
246    def __init__(
247            self,
248            model: LLMArchitecture,
249            system_prompt: str,
250            post_prompt: str = None,
251            cache_maxsize: int = 0,
252            seed=NOT_GIVEN,
253            json: bool = False,
254            output_format: T[BaseModel] = None,
255            base_url: str = "https://ollama.professor-x.de/v1/",
256    ):
257        self.proxy_config = get_lightllm_config()
258        # Merged LiteLLM metadata (system + project level)
259        self.litellm_metadata = get_litellm_metadata()
260        self.local_base_url: str | None = None
261
262        # Local Ollama always bypasses the LightLLM proxy (must hit localhost).
263        use_proxy = self.proxy_config.enabled and model.provider != "local"
264        if use_proxy:
265            self.model_name = self._get_proxied_model_name(model, self.proxy_config)
266            self.openai = OpenAI(
267                api_key=LIGHTLLM_API_KEY, base_url=self.proxy_config.base_url
268            )
269            self.use_proxy = True
270        else:
271            self.model_name = model.model
272            self.use_proxy = False
273
274            if model.provider == "openai":
275                self.openai = OpenAI(api_key=OPENAI_API_KEY)
276            elif model.provider == "openrouter":
277                self.openai = OpenAI(
278                    api_key=OPENROUTER_API_KEY,
279                    base_url="https://openrouter.ai/api/v1/",
280                    timeout=180.0,
281                )
282            elif model.provider == "local":
283                self.local_base_url = ensure_local_ollama()
284                self.openai = OpenAI(
285                    api_key="ollama",
286                    base_url=self.local_base_url,
287                    timeout=_local_llm_timeout(),
288                )
289            else:  # ollama
290                self.openai = OpenAI(api_key=None, base_url=base_url)
291
292        self.model = model
293        self.system_prompt = system_prompt
294        self.post_prompt = ("\n\n" + post_prompt) if post_prompt else ""
295        self.cache_maxsize = cache_maxsize
296        if seed != NOT_GIVEN:
297            logger.warning(f"Seed is currently unsupported and will be ignored!")
298        self.seed = seed
299        self.json = json
300        self.output_format = output_format
301
302        self.with_reasoning = lru_cache(maxsize=self.cache_maxsize)(self.with_reasoning)
303
304    def _get_proxied_model_name(self, model: LLMArchitecture, proxy_config) -> str:
305        """Get model name with appropriate prefix for LightLLM proxy."""
306        prefix = proxy_config.model_prefixes.get(model.provider, "")
307        return f"{prefix}{model.model}"
308
309    def _extract_lightllm_cost(self, response) -> float:
310        """Extract cost from LightLLM response (body usage.cost or response headers)."""
311        usage = getattr(response, "usage", None)
312        if usage is not None:
313            cost = getattr(usage, "cost", None)
314            if cost is not None:
315                return float(cost)
316        raw_response = requests.get(url=self.proxy_config.base_url + f"responses/{response.id}",
317                                    headers={"Authorization": f"Bearer {LIGHTLLM_API_KEY}"})
318        headers = raw_response.headers
319        if headers and "x-litellm-response-cost" in headers:
320            return float(headers["x-litellm-response-cost"])
321        logger.info(f"Could not extract cost from LightLLM response headers: {response}")
322        return 0.0
323
324    def _extract_text(self, response) -> str:
325        """
326        Safely extract plain text from a Responses API response.
327        Avoids relying on response.output_text, which may include None segments.
328        """
329        pieces: list[str] = []
330        try:
331            outputs = getattr(response, "output", None)
332            if outputs is None:
333                # Fallback to output_text if available
334                text = getattr(response, "output_text", None)
335                return text or ""
336
337            for out in outputs:
338                contents = getattr(out, "content", []) or []
339                for content in contents:
340                    txt = None
341                    # New Responses API: content.text.value
342                    try:
343                        text_obj = getattr(content, "text", None)
344                        if text_obj is not None:
345                            txt = getattr(text_obj, "value", None)
346                    except Exception:
347                        txt = None
348
349                    # Older-style: content.text as plain string
350                    if txt is None:
351                        maybe_txt = getattr(content, "text", None)
352                        if isinstance(maybe_txt, str):
353                            txt = maybe_txt
354
355                    if isinstance(txt, str) and txt:
356                        pieces.append(txt)
357        except Exception as e:
358            logger.warning(f"Could not safely extract text from response {response}: {e}")
359            text = getattr(response, "output_text", None)
360            return text or ""
361
362        return "".join(pieces)
363
364    def _uses_responses_parse(self) -> bool:
365        if not self.output_format:
366            return False
367        if self.use_proxy:
368            return True
369        return self.model.provider == "openai"
370
371    @staticmethod
372    def _extract_chat_content(response) -> str:
373        try:
374            message = response.choices[0].message
375            content = getattr(message, "content", None)
376            return content if isinstance(content, str) else ""
377        except (AttributeError, IndexError, TypeError):
378            return ""
379
380    @staticmethod
381    def _extract_reasoning(response) -> str | None:
382        try:
383            message = response.choices[0].message
384        except (AttributeError, IndexError, TypeError):
385            return None
386        for attr in ("reasoning", "thinking"):
387            value = getattr(message, attr, None)
388            if isinstance(value, str) and value.strip():
389                return value
390        return None
391
392    def _parse_chat_output(self, raw: str):
393        if self.output_format is None:
394            return raw
395        try:
396            parsed = json.loads(raw)
397        except json.JSONDecodeError:
398            return raw
399        if isinstance(self.output_format, type) and issubclass(
400            self.output_format, BaseModel
401        ):
402            try:
403                return self.output_format.model_validate(parsed)
404            except Exception:
405                return parsed
406        return parsed
407
408    def _chat_response_format(self) -> dict | None:
409        if self.use_proxy or self.model.provider != "openrouter":
410            return None
411        if not _openrouter_structured_enabled():
412            return None
413        return _openrouter_response_format(self.output_format, json=self.json)
414
415    def _build_extra_body(self, system_role: str) -> dict:
416        extra_body = {}
417        if self.model.provider == "openrouter" and not self.use_proxy:
418            extra_body["include_reasoning"] = True
419            service_tier = get_service_tier()
420            if service_tier is not None:
421                extra_body["service_tier"] = service_tier
422        if not self.use_proxy and _is_ollama_like_provider(self.model.provider):
423            ollama_format = _ollama_generation_format(
424                self.output_format, json=self.json,
425            )
426            if ollama_format is not None:
427                extra_body["format"] = ollama_format
428            extra_body.setdefault("options", {})["num_predict"] = _local_llm_num_predict()
429            if self.model.provider == "local":
430                extra_body["keep_alive"] = _local_llm_keep_alive()
431        if self.use_proxy:
432            extra_body["litellm_session_id"] = litellm_session_id
433            if self.litellm_metadata:
434                metadata = dict(self.litellm_metadata)
435                tags: list[str] = []
436                existing_tags = metadata.get("tags")
437                if isinstance(existing_tags, list):
438                    tags.extend(str(t) for t in existing_tags)
439                for key, value in metadata.items():
440                    if isinstance(value, bool):
441                        tag = f"{key}:{str(value).lower()}"
442                        if tag not in tags:
443                            tags.append(tag)
444                if tags:
445                    metadata["tags"] = tags
446                    extra_body["tags"] = tags
447                extra_body["metadata"] = metadata
448        return extra_body
449
450    def _openrouter_headers(self) -> dict:
451        if not self.use_proxy and self.model.provider == "openrouter":
452            return {"Authorization": f"Bearer {self.openai.api_key}"}
453        return {}
454
455    def _create_completion(
456        self,
457        prompt: str,
458        system_role: str,
459        *,
460        response_format=_RESPONSE_FORMAT_DEFAULT,
461    ):
462        extra_body = self._build_extra_body(system_role)
463        extra_headers = self._openrouter_headers()
464        if self._uses_responses_parse():
465            return self.openai.responses.parse(
466                model=self.model_name,
467                input=[
468                    {"role": system_role, "content": self.system_prompt},
469                    {"role": "user", "content": prompt},
470                ],
471                text_format=self.output_format
472                if self.output_format is not None
473                else {"type": "json_object"}
474                if self.json
475                else {"type": "text"},
476                extra_headers=extra_headers,
477                extra_body=extra_body,
478            )
479        completion_kwargs: dict = {
480            "model": self.model_name,
481            "messages": [
482                {"role": system_role, "content": self.system_prompt},
483                {"role": "user", "content": prompt},
484            ],
485            "extra_headers": extra_headers,
486            "extra_body": extra_body,
487        }
488        if response_format is _RESPONSE_FORMAT_DEFAULT:
489            response_format = self._chat_response_format()
490        if response_format is not None:
491            completion_kwargs["response_format"] = response_format
492        return self.openai.chat.completions.create(**completion_kwargs)
493
494    def _local_ollama_base_url(self) -> str:
495        if self.local_base_url is None:
496            self.local_base_url = ensure_local_ollama()
497        return self.local_base_url
498
499    def _request_with_local_model_retry(self, prompt: str, system_role: str):
500        if self.model.provider == "local":
501            ensure_model_pulled(self._local_ollama_base_url(), self.model_name)
502        try:
503            return self._create_completion(prompt, system_role)
504        except Exception as exc:
505            if self.model.provider == "local" and is_model_not_found_error(exc):
506                logger.info(
507                    "Model {} not found locally; pulling and retrying once",
508                    self.model_name,
509                )
510                ensure_model_pulled(self._local_ollama_base_url(), self.model_name)
511                return self._create_completion(prompt, system_role)
512            if self.model.provider == "local" and _is_timeout_error(exc):
513                logger.warning(
514                    "Local LLM request timed out for {}; restarting ollama and retrying once: {}",
515                    self.model_name,
516                    exc,
517                )
518                new_url = restart_local_ollama()
519                self.local_base_url = new_url
520                self.openai = OpenAI(
521                    api_key="ollama",
522                    base_url=new_url,
523                    timeout=_local_llm_timeout(),
524                )
525                return self._create_completion(prompt, system_role)
526            if (
527                self._chat_response_format() is not None
528                and _is_unsupported_response_format_error(exc)
529            ):
530                logger.warning(
531                    "OpenRouter rejected response_format for {}; "
532                    "retrying once without structured output: {}",
533                    self.model_name,
534                    exc,
535                )
536                return self._create_completion(
537                    prompt, system_role, response_format=None,
538                )
539            raise
540
541    def with_reasoning(self, prompt) -> LLMCompletion:
542        global RUN_COST
543
544        model_max_chars = self.model.estimated_num_characters
545        if model_max_chars > -1 and len(prompt) > model_max_chars:
546            prompt = prompt[:model_max_chars - 3 - len(self.post_prompt)] + "..."
547            logger.warning(f"Truncated prompt to {model_max_chars} characters.")
548        prompt += self.post_prompt
549        logger.debug(f"System prompt is: {self.system_prompt}")
550        shortened_prompt = (
551            prompt if len(prompt) < 1000 else prompt[:1000] + "\n...\n" + prompt[-1000:]
552        )
553        logger.debug(
554            f"Querying {self.model}, seed {self.seed} with prompt: {shortened_prompt}"
555        )
556        system_role = (
557            "system" if not self.model.name[:2] in ("o3", "o4") else "developer"
558        )
559        response = self._request_with_local_model_retry(prompt, system_role)
560
561        if self._uses_responses_parse():
562            if self.output_format is not None:
563                content = response.output_parsed
564                if content is None:
565                    raw_text = self._extract_text(response)
566                    try:
567                        content = json.loads(raw_text)
568                    except json.JSONDecodeError:
569                        content = raw_text
570            else:
571                content = self._extract_text(response)
572            response_reasoning = None
573        else:
574            raw_text = self._extract_chat_content(response)
575            content = self._parse_chat_output(raw_text)
576            response_reasoning = self._extract_reasoning(response)
577        # Cost calculation based on provider/proxy
578        if self.use_proxy:
579            # Extract cost from LightLLM response headers
580            cost = self._extract_lightllm_cost(response)
581        elif self.model.provider == "openai":
582            cost = get_cost(response, self.model.model)
583        elif self.model.provider == "openrouter":
584            url = f"https://openrouter.ai/api/v1/generation?id={response.id}"
585            gen_response = requests.post(
586                url=url,
587                headers={
588                    "Authorization": f"Bearer {OPENROUTER_API_KEY}",
589                    "Content-Type": "application/json",
590                },
591            ).json()
592
593            try:
594                cost = gen_response["total_cost"]
595            except KeyError:
596                cost = 0
597        elif _is_ollama_like_provider(self.model.provider):
598            cost = 0
599        else:
600            cost = 0
601
602        RUN_COST += cost
603
604        logger.debug(f"Model {self.model} says: {content}")
605        if response_reasoning:
606            logger.debug(f"Reasoning: {response_reasoning}")
607        logger.debug(f"Cost: {cost:.2f} €")
608        logger.debug(f"Total cost: {RUN_COST:.2f} €")
609        return LLMCompletion(content=content, reasoning=response_reasoning)
610
611    def __call__(self, prompt) -> str | T:
612        return self.with_reasoning(prompt).content
613
614
615SENTENCE_IN_CONTEXT_PROMPT = "You will be passed a piece of text with one sentence marked in <sentence>...</sentence>. While you should take the context into account, your answer should be specific to the marked sentence. Your task is as follows: "
616
617
618class SentenceInContextLLM(LLM):
619    def __init__(
620            self,
621            model: LLMArchitecture,
622            system_prompt: str,
623            post_prompt: str = None,
624            cache_maxsize: int = 0,
625            seed=NOT_GIVEN,
626            json: bool = False,
627            output_format: T[BaseModel] = None,
628            base_url: str = "https://ollama.professor-x.de/v1/",
629    ):
630        super().__init__(
631            model,
632            SENTENCE_IN_CONTEXT_PROMPT + system_prompt,
633            post_prompt,
634            cache_maxsize,
635            seed,
636            json,
637            output_format,
638            base_url,
639        )
640
641
642class YesNoReason(BaseModel):
643    answer: bool
644    reason: str
645
646
647class YesNo(BaseModel):
648    answer: bool
649
650
651class YesNoLLM(LLM):
652    def __init__(
653            self,
654            model: LLMArchitecture,
655            system_prompt: str,
656            post_prompt: str = None,
657            cache_maxsize: int = 0,
658            seed=NOT_GIVEN,
659            max_tries=3,
660            reason: bool = False,
661            retry_prompt_add: str = "",
662    ):
663        if model.provider == "openai":
664            super().__init__(
665                model,
666                system_prompt,
667                post_prompt,
668                cache_maxsize,
669                seed=seed,
670                output_format=YesNoReason if reason else YesNo,
671            )
672        else:
673            if not post_prompt or "yes" not in post_prompt:
674                logger.warning(
675                    "Post prompt does not contain 'yes'. Adding instruction."
676                )
677                if post_prompt is None:
678                    post_prompt = ""
679                post_prompt = post_prompt + (
680                    "Answer only with yes or no, nothing else."
681                    if not reason
682                    else "Answer with yes or no, followed by a reason"
683                )
684            super(YesNoLLM, self).__init__(
685                model, system_prompt, post_prompt, cache_maxsize, seed=seed, json=False
686            )
687        self.max_tries = max_tries
688        self.retry_prompt_add = (
689            retry_prompt_add
690            if retry_prompt_add.startswith(" ")
691            else " " + retry_prompt_add
692        )
693        self.reason = reason
694
695        if max_tries > 1 and seed != NOT_GIVEN and retry_prompt_add is None:
696            logger.warning(
697                "max_tries > 1 but seed is fixed and no retry prompt is given. This will lead to the same answer every time. Consider adding a retry prompt to avoid this."
698            )
699
700    def __call__(self, prompt) -> YesNo | YesNoReason:
701        if self.model.provider == "openai":
702            response = super().__call__(prompt)
703            return response
704        tries = 0
705        while tries < self.max_tries:
706            if tries > 0 and self.retry_prompt_add:
707                prompt += self.retry_prompt_add
708            response = super().__call__(prompt)
709            response_lower = response.lower().strip("*").strip()
710            if self.is_positive(response_lower) or self.is_negative(response_lower):
711                answer = self.is_positive(response_lower)
712                if self.reason:
713                    return YesNoReason(answer=answer, reason=response)
714                else:
715                    return YesNo(answer=answer)
716            tries += 1
717        raise ValueError(f"Could not get a yes/no answer after {self.max_tries} tries.")
718
719    def is_negative(self, response_lower):
720        return response_lower.startswith("no") or response_lower.startswith("nein")
721
722    def is_positive(self, response_lower):
723        return response_lower.startswith("yes") or response_lower.startswith("ja")
724
725
726# @cache_to_disk(maxsize=-1)
727def get_example_for_schema(schema: BaseModel, query: str):
728    language_llm = LLM(
729        gpt5nano,
730        system_prompt="Return the English name of the language of the provided text.",
731        seed=42,
732        output_format=Language,
733    )
734
735    language = language_llm(query).name
736
737    system_prompt = f"Your job is to guide a language model in generating a response in the provided output format. Your instructions should be in {language}. Address the language model informally."
738    instruction_gpt = LLM(gpt4mini, system_prompt, seed=42)
739    example_gpt = LLM(gpt4mini, system_prompt, seed=42, output_format=schema)
740
741    example: schema = example_gpt(
742        "Give an example of the output format you want the model to generate."
743    )
744    return (
745            instruction_gpt(
746                "Start by telling the model 'Your output should match this format:'."
747            )
748            + "\n"
749            + example.json()
750    )
751
752
753def build_sentence_sample_in_context(
754        sentence: UIMASentence,
755        model: LLMArchitecture,
756        max_context_size: Optional[int] = None,
757) -> str:
758    s = sentence.text
759
760    encoding = tiktoken.get_encoding("cl100k_base")
761
762    context = ["<sentence>" + sentence.text + "</sentence>"]
763    prev_sentence = sentence.previous
764    next_sentence = sentence.next
765    context_size = model.max_tokens if max_context_size is None else max_context_size
766    while (
767            len(
768                encoding.encode(
769                    " ".join(
770                        context
771                        + [
772                            prev_sentence.text if prev_sentence is not None else "",
773                            next_sentence.text if next_sentence is not None else "",
774                        ]
775                    )
776                )
777            )
778            < context_size
779    ):
780        if prev_sentence is not None:
781            context.insert(0, prev_sentence.text)
782            prev_sentence = prev_sentence.previous
783        if next_sentence is not None:
784            context.append(next_sentence.text)
785            next_sentence = next_sentence.next
786
787    context = " ".join(context)
788    return context
789
790
791def build_sentence_samples(
792        doc: UIMADocument,
793        annotation_type: Type[UIMAAnnotation],
794        model: LLMArchitecture,
795        max_context_size: Optional[int] = None,
796        include_labels: bool = True,
797        sentence_to_label: Optional[Callable] = None,
798):
799    samples = []
800
801    if include_labels and sentence_to_label is None:
802        logger.info(
803            "sentence_to_label is not specified. Extracting sentence labels by checking for overlap with given annotation_type."
804        )
805
806    for sentence in doc.sentences:
807        sample_text = build_sentence_sample_in_context(
808            sentence, model, max_context_size=max_context_size
809        )
810        if include_labels:
811            if sentence_to_label is None:
812                sample_label = bool(sentence.overlapping(annotation_type))
813            else:
814                sample_label = sentence_to_label(sentence)
815        else:
816            sample_label = None
817
818        samples.append((sample_text, sample_label))
819
820    return samples
821
822
823if __name__ == "__main__":
824    gpt = YesNoLLM(llaemmlein, "This is a test prompt.", seed=42, reason=True)
825    response = gpt("What is the meaning of life?")
826    print(response)
RUN_COST = 0

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
litellm_session_id = 'wuenlp_a33c9e5e-b9a2-45ff-a60a-cc83249dfbe0'

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

@dataclass(frozen=True)
class LLMCompletion:
38@dataclass(frozen=True)
39class LLMCompletion:
40    content: str | BaseModel | dict
41    reasoning: str | None = None
LLMCompletion( content: str | pydantic.main.BaseModel | dict, reasoning: str | None = None)
content: str | pydantic.main.BaseModel | dict
reasoning: str | None = None
@dataclass
class LLMArchitecture:
49@dataclass
50class LLMArchitecture:
51    name: str
52    model: str
53    max_tokens: int
54    provider: str  # "openai", "openrouter", "ollama", "local"
55    free: bool = False
56
57    @property
58    def estimated_num_characters(self):
59        return int(self.max_tokens * 3.5)
60
61    @property
62    def uses_openai_structured_output(self) -> bool:
63        return self.provider == "openai"
LLMArchitecture( name: str, model: str, max_tokens: int, provider: str, free: bool = False)
name: str
model: str
max_tokens: int
provider: str
free: bool = False

bool(x) -> bool

Returns True when the argument x is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

estimated_num_characters
57    @property
58    def estimated_num_characters(self):
59        return int(self.max_tokens * 3.5)
uses_openai_structured_output: bool
61    @property
62    def uses_openai_structured_output(self) -> bool:
63        return self.provider == "openai"
llama31_70b = LLMArchitecture(name='llama3.1:70b', model='llama3.1:70b', max_tokens=-1, provider='ollama', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

llama31_70b_local = LLMArchitecture(name='llama3.1:70b', model='llama3.1:70b', max_tokens=-1, provider='local', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

llama31_8b = LLMArchitecture(name='llama3.1:8b', model='llama3.1:8b', max_tokens=-1, provider='ollama', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

llama31_8b_local = LLMArchitecture(name='llama3.1:8b', model='llama3.1:8b', max_tokens=-1, provider='local', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

llama32_7b = LLMArchitecture(name='llama3.2:7b', model='llama3.1:8b', max_tokens=-1, provider='ollama', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

llama32_7b_local = LLMArchitecture(name='llama3.2:7b', model='llama3.1:8b', max_tokens=-1, provider='local', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gpt4mini = LLMArchitecture(name='gpt-4o-mini', model='gpt-4o-mini', max_tokens=128000, provider='openai', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gpt41mini = LLMArchitecture(name='gpt-4.1-mini', model='gpt-4.1-mini', max_tokens=128000, provider='openai', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gpt41nano = LLMArchitecture(name='gpt-4.1-nano', model='gpt-4.1-nano', max_tokens=128000, provider='openai', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gpt5mini = LLMArchitecture(name='gpt-5-mini', model='gpt-5-mini', max_tokens=128000, provider='openai', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gpt5nano = LLMArchitecture(name='gpt-5-nano', model='gpt-5-nano', max_tokens=128000, provider='openai', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gpt52 = LLMArchitecture(name='gpt-5.2', model='gpt-5.2', max_tokens=128000, provider='openai', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

o3mini = LLMArchitecture(name='o3-mini', model='o3-mini', max_tokens=128000, provider='openai', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

llaemmlein = LLMArchitecture(name='llaemmlein', model='llaemmlein', max_tokens=128000, provider=None, free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

r1 = LLMArchitecture(name='r1', model='deepseek/deepseek-r1', max_tokens=128000, provider='openrouter', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gemini3flash = LLMArchitecture(name='gemini3flash', model='google/gemini-3-flash-preview', max_tokens=1000000, provider='openrouter', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gemini3pro = LLMArchitecture(name='gemini3pro', model='google/gemini-3.1-pro-preview', max_tokens=1000000, provider='openrouter', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

gemini31lite = LLMArchitecture(name='gemini31lite', model='google/gemini-3.1-flash-lite-preview', max_tokens=1000000, provider='openrouter', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

default_llm = LLMArchitecture(name='gpt-5-nano', model='gpt-5-nano', max_tokens=128000, provider='openai', free=False)

LLMArchitecture(name: 'str', model: 'str', max_tokens: 'int', provider: 'str', free: 'bool' = False)

class Language(pydantic.main.BaseModel):
242class Language(BaseModel):
243    name: str

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
name: str = PydanticUndefined
class LLM:
246class LLM:
247    def __init__(
248            self,
249            model: LLMArchitecture,
250            system_prompt: str,
251            post_prompt: str = None,
252            cache_maxsize: int = 0,
253            seed=NOT_GIVEN,
254            json: bool = False,
255            output_format: T[BaseModel] = None,
256            base_url: str = "https://ollama.professor-x.de/v1/",
257    ):
258        self.proxy_config = get_lightllm_config()
259        # Merged LiteLLM metadata (system + project level)
260        self.litellm_metadata = get_litellm_metadata()
261        self.local_base_url: str | None = None
262
263        # Local Ollama always bypasses the LightLLM proxy (must hit localhost).
264        use_proxy = self.proxy_config.enabled and model.provider != "local"
265        if use_proxy:
266            self.model_name = self._get_proxied_model_name(model, self.proxy_config)
267            self.openai = OpenAI(
268                api_key=LIGHTLLM_API_KEY, base_url=self.proxy_config.base_url
269            )
270            self.use_proxy = True
271        else:
272            self.model_name = model.model
273            self.use_proxy = False
274
275            if model.provider == "openai":
276                self.openai = OpenAI(api_key=OPENAI_API_KEY)
277            elif model.provider == "openrouter":
278                self.openai = OpenAI(
279                    api_key=OPENROUTER_API_KEY,
280                    base_url="https://openrouter.ai/api/v1/",
281                    timeout=180.0,
282                )
283            elif model.provider == "local":
284                self.local_base_url = ensure_local_ollama()
285                self.openai = OpenAI(
286                    api_key="ollama",
287                    base_url=self.local_base_url,
288                    timeout=_local_llm_timeout(),
289                )
290            else:  # ollama
291                self.openai = OpenAI(api_key=None, base_url=base_url)
292
293        self.model = model
294        self.system_prompt = system_prompt
295        self.post_prompt = ("\n\n" + post_prompt) if post_prompt else ""
296        self.cache_maxsize = cache_maxsize
297        if seed != NOT_GIVEN:
298            logger.warning(f"Seed is currently unsupported and will be ignored!")
299        self.seed = seed
300        self.json = json
301        self.output_format = output_format
302
303        self.with_reasoning = lru_cache(maxsize=self.cache_maxsize)(self.with_reasoning)
304
305    def _get_proxied_model_name(self, model: LLMArchitecture, proxy_config) -> str:
306        """Get model name with appropriate prefix for LightLLM proxy."""
307        prefix = proxy_config.model_prefixes.get(model.provider, "")
308        return f"{prefix}{model.model}"
309
310    def _extract_lightllm_cost(self, response) -> float:
311        """Extract cost from LightLLM response (body usage.cost or response headers)."""
312        usage = getattr(response, "usage", None)
313        if usage is not None:
314            cost = getattr(usage, "cost", None)
315            if cost is not None:
316                return float(cost)
317        raw_response = requests.get(url=self.proxy_config.base_url + f"responses/{response.id}",
318                                    headers={"Authorization": f"Bearer {LIGHTLLM_API_KEY}"})
319        headers = raw_response.headers
320        if headers and "x-litellm-response-cost" in headers:
321            return float(headers["x-litellm-response-cost"])
322        logger.info(f"Could not extract cost from LightLLM response headers: {response}")
323        return 0.0
324
325    def _extract_text(self, response) -> str:
326        """
327        Safely extract plain text from a Responses API response.
328        Avoids relying on response.output_text, which may include None segments.
329        """
330        pieces: list[str] = []
331        try:
332            outputs = getattr(response, "output", None)
333            if outputs is None:
334                # Fallback to output_text if available
335                text = getattr(response, "output_text", None)
336                return text or ""
337
338            for out in outputs:
339                contents = getattr(out, "content", []) or []
340                for content in contents:
341                    txt = None
342                    # New Responses API: content.text.value
343                    try:
344                        text_obj = getattr(content, "text", None)
345                        if text_obj is not None:
346                            txt = getattr(text_obj, "value", None)
347                    except Exception:
348                        txt = None
349
350                    # Older-style: content.text as plain string
351                    if txt is None:
352                        maybe_txt = getattr(content, "text", None)
353                        if isinstance(maybe_txt, str):
354                            txt = maybe_txt
355
356                    if isinstance(txt, str) and txt:
357                        pieces.append(txt)
358        except Exception as e:
359            logger.warning(f"Could not safely extract text from response {response}: {e}")
360            text = getattr(response, "output_text", None)
361            return text or ""
362
363        return "".join(pieces)
364
365    def _uses_responses_parse(self) -> bool:
366        if not self.output_format:
367            return False
368        if self.use_proxy:
369            return True
370        return self.model.provider == "openai"
371
372    @staticmethod
373    def _extract_chat_content(response) -> str:
374        try:
375            message = response.choices[0].message
376            content = getattr(message, "content", None)
377            return content if isinstance(content, str) else ""
378        except (AttributeError, IndexError, TypeError):
379            return ""
380
381    @staticmethod
382    def _extract_reasoning(response) -> str | None:
383        try:
384            message = response.choices[0].message
385        except (AttributeError, IndexError, TypeError):
386            return None
387        for attr in ("reasoning", "thinking"):
388            value = getattr(message, attr, None)
389            if isinstance(value, str) and value.strip():
390                return value
391        return None
392
393    def _parse_chat_output(self, raw: str):
394        if self.output_format is None:
395            return raw
396        try:
397            parsed = json.loads(raw)
398        except json.JSONDecodeError:
399            return raw
400        if isinstance(self.output_format, type) and issubclass(
401            self.output_format, BaseModel
402        ):
403            try:
404                return self.output_format.model_validate(parsed)
405            except Exception:
406                return parsed
407        return parsed
408
409    def _chat_response_format(self) -> dict | None:
410        if self.use_proxy or self.model.provider != "openrouter":
411            return None
412        if not _openrouter_structured_enabled():
413            return None
414        return _openrouter_response_format(self.output_format, json=self.json)
415
416    def _build_extra_body(self, system_role: str) -> dict:
417        extra_body = {}
418        if self.model.provider == "openrouter" and not self.use_proxy:
419            extra_body["include_reasoning"] = True
420            service_tier = get_service_tier()
421            if service_tier is not None:
422                extra_body["service_tier"] = service_tier
423        if not self.use_proxy and _is_ollama_like_provider(self.model.provider):
424            ollama_format = _ollama_generation_format(
425                self.output_format, json=self.json,
426            )
427            if ollama_format is not None:
428                extra_body["format"] = ollama_format
429            extra_body.setdefault("options", {})["num_predict"] = _local_llm_num_predict()
430            if self.model.provider == "local":
431                extra_body["keep_alive"] = _local_llm_keep_alive()
432        if self.use_proxy:
433            extra_body["litellm_session_id"] = litellm_session_id
434            if self.litellm_metadata:
435                metadata = dict(self.litellm_metadata)
436                tags: list[str] = []
437                existing_tags = metadata.get("tags")
438                if isinstance(existing_tags, list):
439                    tags.extend(str(t) for t in existing_tags)
440                for key, value in metadata.items():
441                    if isinstance(value, bool):
442                        tag = f"{key}:{str(value).lower()}"
443                        if tag not in tags:
444                            tags.append(tag)
445                if tags:
446                    metadata["tags"] = tags
447                    extra_body["tags"] = tags
448                extra_body["metadata"] = metadata
449        return extra_body
450
451    def _openrouter_headers(self) -> dict:
452        if not self.use_proxy and self.model.provider == "openrouter":
453            return {"Authorization": f"Bearer {self.openai.api_key}"}
454        return {}
455
456    def _create_completion(
457        self,
458        prompt: str,
459        system_role: str,
460        *,
461        response_format=_RESPONSE_FORMAT_DEFAULT,
462    ):
463        extra_body = self._build_extra_body(system_role)
464        extra_headers = self._openrouter_headers()
465        if self._uses_responses_parse():
466            return self.openai.responses.parse(
467                model=self.model_name,
468                input=[
469                    {"role": system_role, "content": self.system_prompt},
470                    {"role": "user", "content": prompt},
471                ],
472                text_format=self.output_format
473                if self.output_format is not None
474                else {"type": "json_object"}
475                if self.json
476                else {"type": "text"},
477                extra_headers=extra_headers,
478                extra_body=extra_body,
479            )
480        completion_kwargs: dict = {
481            "model": self.model_name,
482            "messages": [
483                {"role": system_role, "content": self.system_prompt},
484                {"role": "user", "content": prompt},
485            ],
486            "extra_headers": extra_headers,
487            "extra_body": extra_body,
488        }
489        if response_format is _RESPONSE_FORMAT_DEFAULT:
490            response_format = self._chat_response_format()
491        if response_format is not None:
492            completion_kwargs["response_format"] = response_format
493        return self.openai.chat.completions.create(**completion_kwargs)
494
495    def _local_ollama_base_url(self) -> str:
496        if self.local_base_url is None:
497            self.local_base_url = ensure_local_ollama()
498        return self.local_base_url
499
500    def _request_with_local_model_retry(self, prompt: str, system_role: str):
501        if self.model.provider == "local":
502            ensure_model_pulled(self._local_ollama_base_url(), self.model_name)
503        try:
504            return self._create_completion(prompt, system_role)
505        except Exception as exc:
506            if self.model.provider == "local" and is_model_not_found_error(exc):
507                logger.info(
508                    "Model {} not found locally; pulling and retrying once",
509                    self.model_name,
510                )
511                ensure_model_pulled(self._local_ollama_base_url(), self.model_name)
512                return self._create_completion(prompt, system_role)
513            if self.model.provider == "local" and _is_timeout_error(exc):
514                logger.warning(
515                    "Local LLM request timed out for {}; restarting ollama and retrying once: {}",
516                    self.model_name,
517                    exc,
518                )
519                new_url = restart_local_ollama()
520                self.local_base_url = new_url
521                self.openai = OpenAI(
522                    api_key="ollama",
523                    base_url=new_url,
524                    timeout=_local_llm_timeout(),
525                )
526                return self._create_completion(prompt, system_role)
527            if (
528                self._chat_response_format() is not None
529                and _is_unsupported_response_format_error(exc)
530            ):
531                logger.warning(
532                    "OpenRouter rejected response_format for {}; "
533                    "retrying once without structured output: {}",
534                    self.model_name,
535                    exc,
536                )
537                return self._create_completion(
538                    prompt, system_role, response_format=None,
539                )
540            raise
541
542    def with_reasoning(self, prompt) -> LLMCompletion:
543        global RUN_COST
544
545        model_max_chars = self.model.estimated_num_characters
546        if model_max_chars > -1 and len(prompt) > model_max_chars:
547            prompt = prompt[:model_max_chars - 3 - len(self.post_prompt)] + "..."
548            logger.warning(f"Truncated prompt to {model_max_chars} characters.")
549        prompt += self.post_prompt
550        logger.debug(f"System prompt is: {self.system_prompt}")
551        shortened_prompt = (
552            prompt if len(prompt) < 1000 else prompt[:1000] + "\n...\n" + prompt[-1000:]
553        )
554        logger.debug(
555            f"Querying {self.model}, seed {self.seed} with prompt: {shortened_prompt}"
556        )
557        system_role = (
558            "system" if not self.model.name[:2] in ("o3", "o4") else "developer"
559        )
560        response = self._request_with_local_model_retry(prompt, system_role)
561
562        if self._uses_responses_parse():
563            if self.output_format is not None:
564                content = response.output_parsed
565                if content is None:
566                    raw_text = self._extract_text(response)
567                    try:
568                        content = json.loads(raw_text)
569                    except json.JSONDecodeError:
570                        content = raw_text
571            else:
572                content = self._extract_text(response)
573            response_reasoning = None
574        else:
575            raw_text = self._extract_chat_content(response)
576            content = self._parse_chat_output(raw_text)
577            response_reasoning = self._extract_reasoning(response)
578        # Cost calculation based on provider/proxy
579        if self.use_proxy:
580            # Extract cost from LightLLM response headers
581            cost = self._extract_lightllm_cost(response)
582        elif self.model.provider == "openai":
583            cost = get_cost(response, self.model.model)
584        elif self.model.provider == "openrouter":
585            url = f"https://openrouter.ai/api/v1/generation?id={response.id}"
586            gen_response = requests.post(
587                url=url,
588                headers={
589                    "Authorization": f"Bearer {OPENROUTER_API_KEY}",
590                    "Content-Type": "application/json",
591                },
592            ).json()
593
594            try:
595                cost = gen_response["total_cost"]
596            except KeyError:
597                cost = 0
598        elif _is_ollama_like_provider(self.model.provider):
599            cost = 0
600        else:
601            cost = 0
602
603        RUN_COST += cost
604
605        logger.debug(f"Model {self.model} says: {content}")
606        if response_reasoning:
607            logger.debug(f"Reasoning: {response_reasoning}")
608        logger.debug(f"Cost: {cost:.2f} €")
609        logger.debug(f"Total cost: {RUN_COST:.2f} €")
610        return LLMCompletion(content=content, reasoning=response_reasoning)
611
612    def __call__(self, prompt) -> str | T:
613        return self.with_reasoning(prompt).content
LLM( model: LLMArchitecture, system_prompt: str, post_prompt: str = None, cache_maxsize: int = 0, seed=NOT_GIVEN, json: bool = False, output_format: 'T[BaseModel]' = None, base_url: str = 'https://ollama.professor-x.de/v1/')
247    def __init__(
248            self,
249            model: LLMArchitecture,
250            system_prompt: str,
251            post_prompt: str = None,
252            cache_maxsize: int = 0,
253            seed=NOT_GIVEN,
254            json: bool = False,
255            output_format: T[BaseModel] = None,
256            base_url: str = "https://ollama.professor-x.de/v1/",
257    ):
258        self.proxy_config = get_lightllm_config()
259        # Merged LiteLLM metadata (system + project level)
260        self.litellm_metadata = get_litellm_metadata()
261        self.local_base_url: str | None = None
262
263        # Local Ollama always bypasses the LightLLM proxy (must hit localhost).
264        use_proxy = self.proxy_config.enabled and model.provider != "local"
265        if use_proxy:
266            self.model_name = self._get_proxied_model_name(model, self.proxy_config)
267            self.openai = OpenAI(
268                api_key=LIGHTLLM_API_KEY, base_url=self.proxy_config.base_url
269            )
270            self.use_proxy = True
271        else:
272            self.model_name = model.model
273            self.use_proxy = False
274
275            if model.provider == "openai":
276                self.openai = OpenAI(api_key=OPENAI_API_KEY)
277            elif model.provider == "openrouter":
278                self.openai = OpenAI(
279                    api_key=OPENROUTER_API_KEY,
280                    base_url="https://openrouter.ai/api/v1/",
281                    timeout=180.0,
282                )
283            elif model.provider == "local":
284                self.local_base_url = ensure_local_ollama()
285                self.openai = OpenAI(
286                    api_key="ollama",
287                    base_url=self.local_base_url,
288                    timeout=_local_llm_timeout(),
289                )
290            else:  # ollama
291                self.openai = OpenAI(api_key=None, base_url=base_url)
292
293        self.model = model
294        self.system_prompt = system_prompt
295        self.post_prompt = ("\n\n" + post_prompt) if post_prompt else ""
296        self.cache_maxsize = cache_maxsize
297        if seed != NOT_GIVEN:
298            logger.warning(f"Seed is currently unsupported and will be ignored!")
299        self.seed = seed
300        self.json = json
301        self.output_format = output_format
302
303        self.with_reasoning = lru_cache(maxsize=self.cache_maxsize)(self.with_reasoning)
proxy_config
litellm_metadata
local_base_url: str | None
model
system_prompt
post_prompt
cache_maxsize
seed
json
output_format
def with_reasoning(self, prompt) -> LLMCompletion:
542    def with_reasoning(self, prompt) -> LLMCompletion:
543        global RUN_COST
544
545        model_max_chars = self.model.estimated_num_characters
546        if model_max_chars > -1 and len(prompt) > model_max_chars:
547            prompt = prompt[:model_max_chars - 3 - len(self.post_prompt)] + "..."
548            logger.warning(f"Truncated prompt to {model_max_chars} characters.")
549        prompt += self.post_prompt
550        logger.debug(f"System prompt is: {self.system_prompt}")
551        shortened_prompt = (
552            prompt if len(prompt) < 1000 else prompt[:1000] + "\n...\n" + prompt[-1000:]
553        )
554        logger.debug(
555            f"Querying {self.model}, seed {self.seed} with prompt: {shortened_prompt}"
556        )
557        system_role = (
558            "system" if not self.model.name[:2] in ("o3", "o4") else "developer"
559        )
560        response = self._request_with_local_model_retry(prompt, system_role)
561
562        if self._uses_responses_parse():
563            if self.output_format is not None:
564                content = response.output_parsed
565                if content is None:
566                    raw_text = self._extract_text(response)
567                    try:
568                        content = json.loads(raw_text)
569                    except json.JSONDecodeError:
570                        content = raw_text
571            else:
572                content = self._extract_text(response)
573            response_reasoning = None
574        else:
575            raw_text = self._extract_chat_content(response)
576            content = self._parse_chat_output(raw_text)
577            response_reasoning = self._extract_reasoning(response)
578        # Cost calculation based on provider/proxy
579        if self.use_proxy:
580            # Extract cost from LightLLM response headers
581            cost = self._extract_lightllm_cost(response)
582        elif self.model.provider == "openai":
583            cost = get_cost(response, self.model.model)
584        elif self.model.provider == "openrouter":
585            url = f"https://openrouter.ai/api/v1/generation?id={response.id}"
586            gen_response = requests.post(
587                url=url,
588                headers={
589                    "Authorization": f"Bearer {OPENROUTER_API_KEY}",
590                    "Content-Type": "application/json",
591                },
592            ).json()
593
594            try:
595                cost = gen_response["total_cost"]
596            except KeyError:
597                cost = 0
598        elif _is_ollama_like_provider(self.model.provider):
599            cost = 0
600        else:
601            cost = 0
602
603        RUN_COST += cost
604
605        logger.debug(f"Model {self.model} says: {content}")
606        if response_reasoning:
607            logger.debug(f"Reasoning: {response_reasoning}")
608        logger.debug(f"Cost: {cost:.2f} €")
609        logger.debug(f"Total cost: {RUN_COST:.2f} €")
610        return LLMCompletion(content=content, reasoning=response_reasoning)
SENTENCE_IN_CONTEXT_PROMPT = 'You will be passed a piece of text with one sentence marked in <sentence>...</sentence>. While you should take the context into account, your answer should be specific to the marked sentence. Your task is as follows: '

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

class SentenceInContextLLM(LLM):
619class SentenceInContextLLM(LLM):
620    def __init__(
621            self,
622            model: LLMArchitecture,
623            system_prompt: str,
624            post_prompt: str = None,
625            cache_maxsize: int = 0,
626            seed=NOT_GIVEN,
627            json: bool = False,
628            output_format: T[BaseModel] = None,
629            base_url: str = "https://ollama.professor-x.de/v1/",
630    ):
631        super().__init__(
632            model,
633            SENTENCE_IN_CONTEXT_PROMPT + system_prompt,
634            post_prompt,
635            cache_maxsize,
636            seed,
637            json,
638            output_format,
639            base_url,
640        )
SentenceInContextLLM( model: LLMArchitecture, system_prompt: str, post_prompt: str = None, cache_maxsize: int = 0, seed=NOT_GIVEN, json: bool = False, output_format: 'T[BaseModel]' = None, base_url: str = 'https://ollama.professor-x.de/v1/')
620    def __init__(
621            self,
622            model: LLMArchitecture,
623            system_prompt: str,
624            post_prompt: str = None,
625            cache_maxsize: int = 0,
626            seed=NOT_GIVEN,
627            json: bool = False,
628            output_format: T[BaseModel] = None,
629            base_url: str = "https://ollama.professor-x.de/v1/",
630    ):
631        super().__init__(
632            model,
633            SENTENCE_IN_CONTEXT_PROMPT + system_prompt,
634            post_prompt,
635            cache_maxsize,
636            seed,
637            json,
638            output_format,
639            base_url,
640        )
class YesNoReason(pydantic.main.BaseModel):
643class YesNoReason(BaseModel):
644    answer: bool
645    reason: str

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
answer: bool = PydanticUndefined
reason: str = PydanticUndefined
class YesNo(pydantic.main.BaseModel):
648class YesNo(BaseModel):
649    answer: bool

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
answer: bool = PydanticUndefined
class YesNoLLM(LLM):
652class YesNoLLM(LLM):
653    def __init__(
654            self,
655            model: LLMArchitecture,
656            system_prompt: str,
657            post_prompt: str = None,
658            cache_maxsize: int = 0,
659            seed=NOT_GIVEN,
660            max_tries=3,
661            reason: bool = False,
662            retry_prompt_add: str = "",
663    ):
664        if model.provider == "openai":
665            super().__init__(
666                model,
667                system_prompt,
668                post_prompt,
669                cache_maxsize,
670                seed=seed,
671                output_format=YesNoReason if reason else YesNo,
672            )
673        else:
674            if not post_prompt or "yes" not in post_prompt:
675                logger.warning(
676                    "Post prompt does not contain 'yes'. Adding instruction."
677                )
678                if post_prompt is None:
679                    post_prompt = ""
680                post_prompt = post_prompt + (
681                    "Answer only with yes or no, nothing else."
682                    if not reason
683                    else "Answer with yes or no, followed by a reason"
684                )
685            super(YesNoLLM, self).__init__(
686                model, system_prompt, post_prompt, cache_maxsize, seed=seed, json=False
687            )
688        self.max_tries = max_tries
689        self.retry_prompt_add = (
690            retry_prompt_add
691            if retry_prompt_add.startswith(" ")
692            else " " + retry_prompt_add
693        )
694        self.reason = reason
695
696        if max_tries > 1 and seed != NOT_GIVEN and retry_prompt_add is None:
697            logger.warning(
698                "max_tries > 1 but seed is fixed and no retry prompt is given. This will lead to the same answer every time. Consider adding a retry prompt to avoid this."
699            )
700
701    def __call__(self, prompt) -> YesNo | YesNoReason:
702        if self.model.provider == "openai":
703            response = super().__call__(prompt)
704            return response
705        tries = 0
706        while tries < self.max_tries:
707            if tries > 0 and self.retry_prompt_add:
708                prompt += self.retry_prompt_add
709            response = super().__call__(prompt)
710            response_lower = response.lower().strip("*").strip()
711            if self.is_positive(response_lower) or self.is_negative(response_lower):
712                answer = self.is_positive(response_lower)
713                if self.reason:
714                    return YesNoReason(answer=answer, reason=response)
715                else:
716                    return YesNo(answer=answer)
717            tries += 1
718        raise ValueError(f"Could not get a yes/no answer after {self.max_tries} tries.")
719
720    def is_negative(self, response_lower):
721        return response_lower.startswith("no") or response_lower.startswith("nein")
722
723    def is_positive(self, response_lower):
724        return response_lower.startswith("yes") or response_lower.startswith("ja")
YesNoLLM( model: LLMArchitecture, system_prompt: str, post_prompt: str = None, cache_maxsize: int = 0, seed=NOT_GIVEN, max_tries=3, reason: bool = False, retry_prompt_add: str = '')
653    def __init__(
654            self,
655            model: LLMArchitecture,
656            system_prompt: str,
657            post_prompt: str = None,
658            cache_maxsize: int = 0,
659            seed=NOT_GIVEN,
660            max_tries=3,
661            reason: bool = False,
662            retry_prompt_add: str = "",
663    ):
664        if model.provider == "openai":
665            super().__init__(
666                model,
667                system_prompt,
668                post_prompt,
669                cache_maxsize,
670                seed=seed,
671                output_format=YesNoReason if reason else YesNo,
672            )
673        else:
674            if not post_prompt or "yes" not in post_prompt:
675                logger.warning(
676                    "Post prompt does not contain 'yes'. Adding instruction."
677                )
678                if post_prompt is None:
679                    post_prompt = ""
680                post_prompt = post_prompt + (
681                    "Answer only with yes or no, nothing else."
682                    if not reason
683                    else "Answer with yes or no, followed by a reason"
684                )
685            super(YesNoLLM, self).__init__(
686                model, system_prompt, post_prompt, cache_maxsize, seed=seed, json=False
687            )
688        self.max_tries = max_tries
689        self.retry_prompt_add = (
690            retry_prompt_add
691            if retry_prompt_add.startswith(" ")
692            else " " + retry_prompt_add
693        )
694        self.reason = reason
695
696        if max_tries > 1 and seed != NOT_GIVEN and retry_prompt_add is None:
697            logger.warning(
698                "max_tries > 1 but seed is fixed and no retry prompt is given. This will lead to the same answer every time. Consider adding a retry prompt to avoid this."
699            )
max_tries
retry_prompt_add
reason
def is_negative(self, response_lower):
720    def is_negative(self, response_lower):
721        return response_lower.startswith("no") or response_lower.startswith("nein")
def is_positive(self, response_lower):
723    def is_positive(self, response_lower):
724        return response_lower.startswith("yes") or response_lower.startswith("ja")
def get_example_for_schema(schema: pydantic.main.BaseModel, query: str):
728def get_example_for_schema(schema: BaseModel, query: str):
729    language_llm = LLM(
730        gpt5nano,
731        system_prompt="Return the English name of the language of the provided text.",
732        seed=42,
733        output_format=Language,
734    )
735
736    language = language_llm(query).name
737
738    system_prompt = f"Your job is to guide a language model in generating a response in the provided output format. Your instructions should be in {language}. Address the language model informally."
739    instruction_gpt = LLM(gpt4mini, system_prompt, seed=42)
740    example_gpt = LLM(gpt4mini, system_prompt, seed=42, output_format=schema)
741
742    example: schema = example_gpt(
743        "Give an example of the output format you want the model to generate."
744    )
745    return (
746            instruction_gpt(
747                "Start by telling the model 'Your output should match this format:'."
748            )
749            + "\n"
750            + example.json()
751    )
def build_sentence_sample_in_context( sentence: wuenlp.impl.uima.UIMANLPStructs.UIMASentence, model: LLMArchitecture, max_context_size: 'Optional[int]' = None) -> str:
754def build_sentence_sample_in_context(
755        sentence: UIMASentence,
756        model: LLMArchitecture,
757        max_context_size: Optional[int] = None,
758) -> str:
759    s = sentence.text
760
761    encoding = tiktoken.get_encoding("cl100k_base")
762
763    context = ["<sentence>" + sentence.text + "</sentence>"]
764    prev_sentence = sentence.previous
765    next_sentence = sentence.next
766    context_size = model.max_tokens if max_context_size is None else max_context_size
767    while (
768            len(
769                encoding.encode(
770                    " ".join(
771                        context
772                        + [
773                            prev_sentence.text if prev_sentence is not None else "",
774                            next_sentence.text if next_sentence is not None else "",
775                        ]
776                    )
777                )
778            )
779            < context_size
780    ):
781        if prev_sentence is not None:
782            context.insert(0, prev_sentence.text)
783            prev_sentence = prev_sentence.previous
784        if next_sentence is not None:
785            context.append(next_sentence.text)
786            next_sentence = next_sentence.next
787
788    context = " ".join(context)
789    return context
def build_sentence_samples( doc: wuenlp.impl.uima.UIMANLPStructs.UIMADocument, annotation_type: Type[wuenlp.impl.uima.UIMANLPStructs.UIMAAnnotation], model: LLMArchitecture, max_context_size: 'Optional[int]' = None, include_labels: bool = True, sentence_to_label: 'Optional[Callable]' = None):
792def build_sentence_samples(
793        doc: UIMADocument,
794        annotation_type: Type[UIMAAnnotation],
795        model: LLMArchitecture,
796        max_context_size: Optional[int] = None,
797        include_labels: bool = True,
798        sentence_to_label: Optional[Callable] = None,
799):
800    samples = []
801
802    if include_labels and sentence_to_label is None:
803        logger.info(
804            "sentence_to_label is not specified. Extracting sentence labels by checking for overlap with given annotation_type."
805        )
806
807    for sentence in doc.sentences:
808        sample_text = build_sentence_sample_in_context(
809            sentence, model, max_context_size=max_context_size
810        )
811        if include_labels:
812            if sentence_to_label is None:
813                sample_label = bool(sentence.overlapping(annotation_type))
814            else:
815                sample_label = sentence_to_label(sentence)
816        else:
817            sample_label = None
818
819        samples.append((sample_text, sample_label))
820
821    return samples