From cff06c8fd600d904e9e26e71287fcd1d3b20faf2 Mon Sep 17 00:00:00 2001 From: Tim Peters Date: Sat, 17 Oct 2026 23:19:32 +0200 Subject: [PATCH] Initial commit --- README.md | 89 +++++++++++++++++++++++++++ together-sync | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 README.md create mode 100755 together-sync diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dbd0ed --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Together-Sync + +Serverlose Chat-Modelle von Together.ai direkt in die OpenCode-Konfiguration übernehmen. + +Das Skript `together-sync` lädt die aktuellen Modelle von `https://api.together.xyz/v1/models?serverless=true` (nur Typ `chat`), bietet sie zur Auswahl an und schreibt die getroffene Auswahl als vollständigen Sync in `~/.config/opencode/opencode.jsonc` unter `providers.togetherai.models`. + +## Hintergrund + +OpenCode bezieht seinen Modelkatalog aus der Community-Datenbank [models.dev](https://models.dev/providers/togetherai). Dort sind etwa 30 Modelle für `togetherai` gelistet, gegenüber über 200 Serverless-Modellen bei [Together.ai](https://www.together.ai/models). Wie in den OpenCode-Dokumenten beschrieben, wird der Katalog aus Models.dev generiert – nur dort vorhandene Modelle erscheinen im `/models`-Picker. + +Das ist bewusst so gestaltet: Nur wenige Modelle eignen sich gleichermaßen für Codegenerierung und Tool-Calling. OpenCode stellt daher gezielt getestete, für das Programmieren geeignete Modelle bereit. Neue Together-Releases benötigen erst einen PR auf `models.dev` und ein OpenCode-Release, bevor sie nativ verfügbar sind, während Together sein Serverless-Angebot kontinuierlich aktualisiert. + +Dieses Skript überbrückt die Lücke, indem es die Live-API direkt abfragt und die ausgewählten Modelle mit passenden `limit`- und `cost`-Angaben in die `opencode.jsonc` einträgt – ohne manuelle Pflege. + +## Voraussetzungen + +- Python 3 (nur Standardbibliothek) +- Together.ai-API-Schlüssel +- OpenCode (optional, nur für die Vorauswahl beim Erstlauf) +- Optional: `zenity` für grafischen Dialog, sonst Terminal + +## Authentifizierung + +Reihenfolge der Schlüsselsuche: + +1. Umgebungsvariable `TOGETHER_API_KEY` +2. `~/.local/share/opencode/auth.json` unter `togetherai.key` + +```bash +export TOGETHER_API_KEY="tgp_..." +``` + +## Installation + +```bash +git clone git@git.tim-peters.org:Tim/Opencode-TogetherAI.git +cd Opencode-TogetherAI +chmod +x together-sync + +# Optional ins PATH legen +ln -s "$PWD/together-sync" ~/.local/bin/together-sync +``` + +## Anwendung + +```bash +./together-sync +``` + +Ablauf: + +1. Lädt alle verfügbaren Serverless-Chat-Modelle. +2. Vorauswahl: bereits konfigurierte Modelle; beim Erstlauf (leere Config) die von OpenCode angebotenen Together-Modelle über `opencode api`. +3. Auswahl-Dialog: + - **Mit `zenity`**: Checkliste mit Modell, Kontextlänge und Preis ($/Mio. Input→Output) + - **Ohne `zenity`**: nummerierte Terminal-Liste mit Eingabe wie `1,3,7`, `a` für alle, leer zum Abbrechen +4. Abbruch oder leere Auswahl ändert nichts. +5. Die bestätigte Auswahl ersetzt `providers.togetherai.models` vollständig. Vorher wird automatisch ein Backup als `opencode.jsonc.bak-YYYYMMDD-HHMMSS` erstellt. + +### Konfigurationsausgabe + +Für jedes Modell wird ein Eintrag erzeugt: + +```json +{ + "providers": { + "togetherai": { + "models": { + "meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": { + "name": "Meta-Llama-3.1-405B-Instruct-Turbo", + "limit": { "context": 131072 }, + "cost": { "input": 3.5, "output": 3.5, "cache_read": 0.5 } + } + } + } + } +} +``` + +Andere Top-Level-Schlüssel in `opencode.jsonc` bleiben unverändert. Eine unveränderte Auswahl wird erkannt und nicht erneut geschrieben. + +## Pfade + +| Pfad | Zweck | +|------|-------| +| `~/.config/opencode/opencode.jsonc` | OpenCode-Konfiguration | +| `~/.local/share/opencode/auth.json` | Fallback für den API-Schlüssel | + +Die Pfade sind im Skript als `CONFIG` und `AUTH` definiert. \ No newline at end of file diff --git a/together-sync b/together-sync new file mode 100755 index 0000000..cb742cb --- /dev/null +++ b/together-sync @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Together.ai-Serverless-Chat-Modelle per Checkbox-Dialog in die OpenCode-Config übernehmen. + +Vorauswahl = bereits konfigurierte Modelle (providers.togetherai.models); bei leerer +Config die im OpenCode-Katalog verfügbaren. Esc/Abbruch oder leere Auswahl ändert +nichts; bestätigte Auswahl ersetzt als Voll-Sync die models-Map (Backup vorher). +""" +import json +import os +import shutil +import subprocess +import sys +import urllib.request +from datetime import datetime + +CONFIG = os.path.expanduser("~/.config/opencode/opencode.jsonc") +AUTH = os.path.expanduser("~/.local/share/opencode/auth.json") +# urllib-Default-UA wird von Together (Cloudflare) gebannt -> eigener UA nötig +HEADERS = {"User-Agent": "text-tool/1.0"} + + +def api_key(): + if os.environ.get("TOGETHER_API_KEY"): + return os.environ["TOGETHER_API_KEY"] + try: + return json.load(open(AUTH))["togetherai"]["key"] + except Exception: + sys.exit("Kein API-Key: TOGETHER_API_KEY setzen oder %s vorhanden." % AUTH) + + +def fetch(url, key): + req = urllib.request.Request(url, headers={**HEADERS, "Authorization": "Bearer " + key}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + + +def serverless_chat(key): + ms = fetch("https://api.together.xyz/v1/models?serverless=true", key) + if isinstance(ms, dict): + ms = ms.get("data", []) + chat = [m for m in ms if m.get("type") == "chat"] + chat.sort(key=lambda m: (-m.get("created", 0), m["id"])) + return chat + + +def configured_models(): + """Bereits konfigurierte togetherai-Modelle (die Vorauswahl).""" + try: + m = json.load(open(CONFIG))["providers"]["togetherai"]["models"] + return set(m) + except Exception: + return set() + + +def opencode_models(): + """Erstlauf-Fallback: Modell-IDs, die OpenCode aus dem Katalog anbietet.""" + try: + out = subprocess.run(["opencode", "api", "get", "/api/model"], + capture_output=True, text=True, timeout=30, check=True).stdout + data = json.loads(out).get("data", []) + return {m["id"] for m in data + if m.get("providerID") == "togetherai" and m.get("enabled") is not False} + except Exception as e: + print("warn: OpenCode-Modelle nicht ermittelbar (%s) — keine Vorauswahl" % e, + file=sys.stderr) + return set() + + +def entry(m): + e = {"name": m.get("display_name") or m["id"]} + if m.get("context_length"): + e["limit"] = {"context": m["context_length"]} + p = m.get("pricing") or {} + c = {"input": round(p.get("input", 0), 6), "output": round(p.get("output", 0), 6)} + if p.get("cached_input"): + c["cache_read"] = round(p["cached_input"], 6) + e["cost"] = c + return e + + +def zenity_pick(chat, avail): + rows = [] + for m in chat: + p = m.get("pricing") or {} + rows += ["TRUE" if m["id"] in avail else "FALSE", m["id"], + str(m.get("context_length") or "?"), + "%g / %g" % (p.get("input", 0), p.get("output", 0))] + cmd = ["zenity", "--list", "--checklist", + "--title=Together.ai → OpenCode", + "--text=Zu übernehmende Modelle anhaken:", + "--column=Anwahl", "--column=Modell", "--column=Kontext", + "--column=$/1M in→out", "--print-column=2", + "--width=980", "--height=720"] + rows + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + return None # Esc/Abbruch + return [x for x in r.stdout.strip().split("|") if x] + + +def terminal_pick(chat, avail): + for i, m in enumerate(chat, 1): + p = m.get("pricing") or {} + print("%2d %-45s ctx %-8s %g / %g %s" % ( + i, m["id"], m.get("context_length") or "?", + p.get("input", 0), p.get("output", 0), "*" if m["id"] in avail else "")) + raw = input("Nummern (z. B. 1,3,7; a=alle; leer=Abbruch): ").strip() + if not raw: + return None + if raw == "a": + return [m["id"] for m in chat] + try: + return [chat[int(n) - 1]["id"] for n in raw.replace(" ", "").split(",")] + except (ValueError, IndexError): + return None + + +def merge(selected_ids, chat): + with open(CONFIG) as f: + text = f.read() + try: + cfg = json.loads(text) + except ValueError as e: + sys.exit("Config ist kein reines JSON (%s) — bitte manuell einfügen." % e) + old_keys = set(cfg) + entry_by_id = {m["id"]: entry(m) for m in chat} + models = {i: entry_by_id[i] for i in sorted(selected_ids)} + prov = cfg.setdefault("providers", {}).setdefault("togetherai", {}) + if prov.get("models") == models: + print("Config unverändert.", file=sys.stderr) + return + bak = CONFIG + ".bak-" + datetime.now().strftime("%Y%m%d-%H%M%S") + shutil.copy2(CONFIG, bak) + prov["models"] = models + tmp = CONFIG + ".tmp" + with open(tmp, "w") as f: + f.write(json.dumps(cfg, indent=2, ensure_ascii=False) + "\n") + os.replace(tmp, CONFIG) + # Self-Check: Config parst, alte Keys unangetastet, Auswahl vollständig + new = json.load(open(CONFIG)) + assert old_keys <= set(new), "Top-Level-Keys verloren!" + assert new["providers"]["togetherai"]["models"] == models + print("%d Modelle übernommen, Backup: %s" % (len(models), bak), file=sys.stderr) + + +def main(): + chat = serverless_chat(api_key()) + assert chat, "Keine Chat-Modelle in der serverless-Liste?" + ids = {m["id"] for m in chat} + cfg_ids = configured_models() + if cfg_ids: + pre = cfg_ids & ids + src = "letzte Auswahl" + else: + pre = opencode_models() & ids + src = "OpenCode-Katalog (Erstlauf)" + print("%d Serverless-Chat-Modelle, %d vorausgewählt (%s)" + % (len(chat), len(pre), src), file=sys.stderr) + pick = (zenity_pick if shutil.which("zenity") else terminal_pick)(chat, pre) + if not pick: + sys.exit("Abgebrochen — nichts geändert.") + assert all(i in ids for i in pick), "Unbekannte Modell-ID in Auswahl!" + merge(pick, chat) + + +if __name__ == "__main__": + main() \ No newline at end of file