| |
| """Minimal RITS chat-completions client.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import sys |
| import urllib.error |
| import urllib.request |
| from pathlib import Path |
| from urllib.parse import urlsplit, urlunsplit |
|
|
|
|
| def load_dotenv(path: Path) -> None: |
| if not path.exists(): |
| return |
|
|
| for raw_line in path.read_text(encoding="utf-8").splitlines(): |
| line = raw_line.strip() |
| if not line or line.startswith("#") or "=" not in line: |
| continue |
|
|
| key, value = line.split("=", 1) |
| key = key.strip() |
| value = value.strip().strip('"').strip("'") |
| os.environ.setdefault(key, value) |
|
|
|
|
| def model_to_route(model: str) -> str: |
| model_name = model.strip("/").rsplit("/", 1)[-1] |
| return model_name.replace(".", "-") |
|
|
|
|
| def base_url_for_model(base_url: str, model: str) -> str: |
| """Build the RITS base URL from .env and the requested model.""" |
| normalized = base_url.rstrip("/") |
| model_route = model_to_route(model) |
|
|
| split = urlsplit(normalized) |
| parts = [part for part in split.path.split("/") if part] |
| if parts and parts[-1] == "v1": |
| parts = model_route.split("/") + ["v1"] |
| else: |
| parts = parts + model_route.split("/") + ["v1"] |
|
|
| return urlunsplit((split.scheme, split.netloc, "/" + "/".join(parts), "", "")) |
|
|
|
|
| def chat_completion( |
| *, |
| base_url: str, |
| api_key: str, |
| model: str, |
| prompt: str, |
| temperature: float, |
| max_tokens: int, |
| ) -> str: |
| url = f"{base_url.rstrip('/')}/chat/completions" |
| payload = { |
| "model": model, |
| "messages": [{"role": "user", "content": prompt}], |
| "temperature": temperature, |
| "max_tokens": max_tokens, |
| } |
| request = urllib.request.Request( |
| url, |
| data=json.dumps(payload).encode("utf-8"), |
| headers={ |
| "Authorization": f"Bearer {api_key}", |
| "RITS_API_KEY": api_key, |
| "Content-Type": "application/json", |
| "Accept": "application/json", |
| }, |
| method="POST", |
| ) |
|
|
| try: |
| with urllib.request.urlopen(request, timeout=60) as response: |
| data = json.loads(response.read().decode("utf-8")) |
| except urllib.error.HTTPError as exc: |
| detail = exc.read().decode("utf-8", errors="replace") |
| raise RuntimeError(f"RITS returned HTTP {exc.code}: {detail}") from exc |
| except urllib.error.URLError as exc: |
| raise RuntimeError(f"Could not connect to RITS: {exc.reason}") from exc |
|
|
| try: |
| return data["choices"][0]["message"]["content"] |
| except (KeyError, IndexError, TypeError) as exc: |
| raise RuntimeError(f"Unexpected RITS response: {json.dumps(data)}") from exc |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description="Call a RITS-hosted chat model.") |
| parser.add_argument("prompt", nargs="?", help="Prompt to send to the model.") |
| parser.add_argument( |
| "-m", |
| "--model", |
| help="Model name to request. Defaults to LLM_MODEL from .env.", |
| ) |
| parser.add_argument( |
| "--base-url", |
| help="RITS OpenAI-compatible base URL template. Defaults to LLM_BASE_URL from .env.", |
| ) |
| parser.add_argument( |
| "--api-key", |
| help="RITS API key. Defaults to LLM_API_KEY from .env.", |
| ) |
| parser.add_argument( |
| "--input-file", |
| help="Path to a text file containing the prompt to send to the model.", |
| ) |
| parser.add_argument( |
| "--gpus", |
| type=int, |
| default=1, |
| help="Accepted for job compatibility. Defaults to 1 and is not sent to RITS.", |
| ) |
| parser.add_argument("--temperature", type=float, default=0.2) |
| parser.add_argument("--max-tokens", type=int, default=512) |
| return parser.parse_args() |
|
|
|
|
| def prompt_from_args(args: argparse.Namespace) -> str: |
| if args.input_file: |
| try: |
| return Path(args.input_file).read_text(encoding="utf-8") |
| except OSError as exc: |
| raise RuntimeError(f"Could not read input file {args.input_file}: {exc}") from exc |
|
|
| if args.prompt is None: |
| raise RuntimeError("Missing prompt. Pass a prompt argument or use --input-file.") |
|
|
| return args.prompt |
|
|
|
|
| def main() -> int: |
| load_dotenv(Path(".env")) |
| args = parse_args() |
| model = args.model or os.getenv("LLM_MODEL") |
| base_url_template = args.base_url or os.getenv("LLM_BASE_URL") |
| api_key = args.api_key or os.getenv("LLM_API_KEY") |
|
|
| missing = [ |
| name |
| for name, value in { |
| "LLM_API_KEY": api_key, |
| "LLM_BASE_URL": base_url_template, |
| "LLM_MODEL or --model": model, |
| }.items() |
| if not value |
| ] |
| if missing: |
| print(f"Missing required configuration: {', '.join(missing)}", file=sys.stderr) |
| return 2 |
|
|
| base_url = base_url_for_model(base_url_template, model) |
| try: |
| prompt = prompt_from_args(args) |
| result = chat_completion( |
| base_url=base_url, |
| api_key=api_key, |
| model=model, |
| prompt=prompt, |
| temperature=args.temperature, |
| max_tokens=args.max_tokens, |
| ) |
| except RuntimeError as exc: |
| print(exc, file=sys.stderr) |
| return 1 |
|
|
| print(result) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|