Lokaler LM-Studio-Adapter fuer Gemma und Qwen (Skill 10.1.0)

Der TensorX-Wrapper wird providerneutral: opencode-tensorx-adapter.py heisst
jetzt opencode-adapter.py und waehlt ueber --provider {tensorx,lmstudio}
Gateway und Modellvorlage. Der TensorX-Pfad bleibt unveraendert; die vier
bestehenden Regressionstests laufen durch.

Neu fuer den lokalen Betrieb:
- opencode-lmstudio.json fuer google/gemma-4-e4b und qwen/qwen3.8-27b
- Preflight ueber /api/v0/models: Servererreichbarkeit, Modellverfuegbarkeit,
  tool_use-Faehigkeit, geladenes Kontextfenster (--min-context, Standard 32768)
  und genau eine geladene Instanz; --lmstudio-autoload stellt das selbst her
- local_runtime in RawResult.json (Quantisierung, Architektur, Runtime,
  lms-Version, Instanzbezeichner, Kontextfenster) fuer Kap. 4.3
- effort_applied, da der lokale Endpunkt keinen Thinking-Level annimmt

Drei Befunde aus der Inbetriebnahme, alle im Adapter abgefangen: LM Studio
laedt standardmaessig nur 8192 Kontexttokens; ein erneutes lms load erzeugt
eine zweite Instanz und macht das Routing mehrdeutig; Effort ist lokal
wirkungslos. Dazu zwei Korrekturen am gemeinsamen Pfad (Abbruchgrund nur
einmal in errors, saubere lms-Versionskennung).

Enthaelt ausserdem die bislang nicht committeten Laeufe der Iterationen 8
und 9 sowie Versuch 2 (Iterationen 1 bis 3). Der laufende Lauf unter
Iteration 10 ist bewusst nicht enthalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christoph Schwörer
2026-08-31 20:19:33 +02:00
co-authored by Claude Opus 5
parent b369e6115e
commit 611fd0a80c
132 changed files with 83432 additions and 204 deletions
+60 -18
View File
@@ -1,8 +1,8 @@
---
name: run-experiment
description: Führt einen Versuchs-Prompt aus einer Prompt-Datei als messbaren Headless-Lauf mit Claude Code, Codex CLI oder OpenCode über TensorX aus und schreibt ein Messprotokoll mit Start-/Endzeit, Modell, Tokenverbrauch und weiteren Metriken. Verwenden bei "/run-experiment <Pfad-zur-Prompt-Datei>" oder wenn der User einen Versuch/ein Experiment ausführen und tracken will.
description: Führt einen Versuchs-Prompt aus einer Prompt-Datei als messbaren Headless-Lauf mit Claude Code, Codex CLI oder OpenCode (TensorX oder lokales LM Studio) aus und schreibt ein Messprotokoll mit Start-/Endzeit, Modell, Tokenverbrauch und weiteren Metriken. Verwenden bei "/run-experiment <Pfad-zur-Prompt-Datei>" oder wenn der User einen Versuch/ein Experiment ausführen und tracken will.
argument-hint: <Pfad zur Prompt-Datei> <Root-Verzeichnis>
version: 10.0.2
version: 10.1.0
---
# RunExperiment – Versuchslauf mit Messprotokoll
@@ -126,9 +126,15 @@ per stdin übergebenen Text angehängt (siehe Schritt „Prompt zusammenstellen"
2. Aus dem Metadaten-Block der Prompt-Datei (falls vorhanden) Versuch/Prompt-Version übernehmen.
3. **Werkzeugadapter bestimmen und CLI-Pfad auflösen.** Die Modell-ID entscheidet eindeutig:
`claude-*` verwendet Claude Code, OpenAI-IDs wie `gpt-*` oder `o*` verwenden Codex CLI,
`z-ai/*`, `qwen/*` und `moonshotai/*` verwenden OpenCode über den TensorX-Gateway.
`z-ai/*`, `qwen/qwen3.8-flash-next` und `moonshotai/*` verwenden OpenCode mit
`--provider tensorx`, `google/gemma-4-e4b` und `qwen/qwen3.8-27b` verwenden OpenCode mit
`--provider lmstudio` gegen den lokalen LM-Studio-Server.
Keine Modell-ID an eine CLI übergeben, die sie nicht unterstützt.
**Achtung Präfixkollision:** `qwen/qwen3.8-flash-next` läuft remote über TensorX,
`qwen/qwen3.8-27b` lokal über LM Studio. Das Präfix `qwen/` allein entscheidet **nicht** –
maßgeblich ist die vollständige Modell-ID.
Unter Windows liegt `claude` in der Regel **nicht im PATH**. Erst
`Get-Command claude -ErrorAction SilentlyContinue` versuchen; schlägt das fehl, auf die
VSCode-Extension zurückfallen und die höchste Versionsnummer wählen:
@@ -177,6 +183,16 @@ per stdin übergebenen Text angehängt (siehe Schritt „Prompt zusammenstellen"
| `qwen/qwen3.8-flash-next` | Qwen 3.8 Flash Next über TensorX-Gateway |
| `moonshotai/kimi-k3` | Moonshot Kimi K3 – 1-Mio.-Kontext, 2,8T Parameter |
| Lokale Modell-ID (LM Studio) | Einordnung |
|---|---|
| `google/gemma-4-e4b` | Gemma 4 E4B, 7,5B Parameter, lokal über LM Studio |
| `qwen/qwen3.8-27b` | Qwen 3.8 27B, lokal über LM Studio |
Lokale IDs nur anbieten, wenn `lms ls` das Modell als heruntergeladen ausweist. Fehlt es,
den User auf `lms get <ID>` hinweisen und den Download **nicht** ungefragt starten – es
sind mehrere Gigabyte. Lokale Läufe sind eine eigene Versuchsbedingung und nicht mit
Cloud-Läufen poolbar: anderes Kontextfenster, quantisierte Gewichte, kein Effort.
In der Frage den letzten verwendeten Stand nennen, damit der User bewusst wechseln oder
bewusst wiederholen kann (z. B. „Lauf 3 lief mit `claude-sonnet-5`, 11.516.200 Tokens, 23:40").
@@ -610,8 +626,8 @@ Regeln:
`RawResult.json` im Laufverzeichnis lesen und defensiv parsen. Beim Claude-Adapter ist dies die
unveränderte CLI-Antwort; beim Codex-Adapter erzeugt `normalise-codex-result.py` diese Datei aus
`RawEvents.jsonl` und `_meta\final_response.json`; beim OpenCode-Adapter erzeugt
`opencode-tensorx-adapter.py` sie aus dem Sessionexport und `OpenCodeEvents.jsonl` gemäß der
TensorX-Referenz. Relevante Claude-Felder:
`opencode-adapter.py` sie aus dem Sessionexport und `OpenCodeEvents.jsonl` gemäß der
OpenCode-Referenz. Relevante Claude-Felder:
| Feld | Bedeutung |
|---|---|
@@ -1091,22 +1107,45 @@ die angeforderte ID; da `codex exec --json` sie im Ereignisstrom nicht wiederhol
Modellkontrolle im Protokoll `nicht prüfbar`. Temperatur und weitere Sampling-Parameter sind in
diesem CLI-Ablauf nicht steuerbar; Effort und Service-Tier werden dagegen explizit festgelegt.
### Adapter: OpenCode / GLM-, Qwen- und Kimi-Modelle über TensorX
### Adapter: OpenCode / TensorX-Modelle und lokale LM-Studio-Modelle
Dies ist der **primäre Adapter für alle TensorX-Modell-IDs**. Er verwendet OpenCodes
OpenAI-kompatiblen Custom Provider und das Skript `opencode-tensorx-adapter.py`. OpenCode
verwaltet Authentifizierung, Agenten-Loop und Session; der Wrapper erzeugt eine isolierte
Konfiguration, streamt die Rohereignisse, erzwingt Berechtigungen und normalisiert das Ergebnis
nach `RawResult.json`. Es besteht keine Abhängigkeit zu Cline.
Dies ist der **primäre Adapter für alle TensorX-Modell-IDs und für den lokalen
LM-Studio-Betrieb**. Beide nutzen OpenCodes OpenAI-kompatiblen Custom Provider und dasselbe
Skript `opencode-adapter.py`; `--provider` wählt Gateway und Modellvorlage. OpenCode verwaltet
Authentifizierung, Agenten-Loop und Session; der Wrapper erzeugt eine isolierte Konfiguration,
streamt die Rohereignisse, erzwingt Berechtigungen und normalisiert das Ergebnis nach
`RawResult.json`. Es besteht keine Abhängigkeit zu Cline.
Vor jedem TensorX-Lauf die vollständige Referenz
[`references/opencode-tensorx.md`](references/opencode-tensorx.md) lesen und deren Aufruf,
Timeouts, Artefakte und Pflichtprüfungen anwenden. Die Provider- und Modellvorlage liegt in
`opencode-tensorx.json`; API-Keys gehören ausschließlich in OpenCodes Credential-Store.
| `--provider` | Modell-IDs | Betrieb | Vorlage |
|---|---|---|---|
| `tensorx` (Standard) | `z-ai/*`, `qwen/qwen3.8-flash-next`, `moonshotai/*` | Remote `https://api.tensorx.ai/v1` | `opencode-tensorx.json` |
| `lmstudio` | `google/gemma-4-e4b`, `qwen/qwen3.8-27b` | lokal `http://localhost:1234/v1` | `opencode-lmstudio.json` |
Vor jedem Lauf die vollständige Referenz
[`references/opencode-adapter.md`](references/opencode-adapter.md) lesen und deren Aufruf,
Timeouts, Artefakte und Pflichtprüfungen anwenden. API-Keys gehören ausschließlich in OpenCodes
Credential-Store; die Vorlagen enthalten keine.
**Lokaler Betrieb ist eine eigene Versuchsbedingung.** Ein Preflight prüft Server,
Modellverfügbarkeit, Tool-Fähigkeit, geladenes Kontextfenster (`--min-context`, Standard 32768)
und dass genau eine Modellinstanz geladen ist; er bricht sonst mit Exitcode `2` und dem exakt
nötigen `lms`-Befehl ab. `RawResult.json` führt zusätzlich `local_runtime` (Quantisierung,
Architektur, Runtime, `lms`-Version, Kontextfenster) und `context_window` – damit sind die von
Kap. 4.3 geforderten Angaben für lokalen Betrieb erfasst. **Effort ist bei `lmstudio` nicht
steuerbar** (`effort_applied: false`) und im Protokoll so auszuweisen; Kosten sind
definitionsgemäß `0`, Cache-Metriken `nicht erfasst`. Lokale Läufe niemals mit Cloud-Läufen
poolen.
Referenzstand bei Einführung: **OpenCode 1.18.25**. Ein Live-Smoke-Test mit
`qwen/qwen3.8-flash-next`, Variante `low`, bestätigte Headless-Ausführung, Sessionexport,
Reasoning-/Cache-Metriken und die erwartete Textantwort.
Reasoning-/Cache-Metriken und die erwartete Textantwort. Für LM Studio bestätigte ein
Smoke-Test mit `google/gemma-4-e4b` (Q4_K_M, gguf, 32768 Kontexttokens) Preflight,
Providerauflösung, Streaming und Tool-Calling.
**Terminierung kleiner lokaler Modelle.** Im Smoke-Test lief `google/gemma-4-e4b` über 50
Schritte weiter, ohne die geforderte Datei zu schreiben. Da laufend Text erzeugt wird, greift
der Stall-Timeout nicht. Lokale Läufe deshalb immer mit absolutem `--max-runtime` starten und
einen Abbruch als Abbruch protokollieren, nicht als Ergebnis.
### Legacy-Adapter: direkte Python API / GLM-, Qwen- und Kimi-Modelle über TensorX
@@ -1255,8 +1294,10 @@ Reasoning-Effort ist steuerbar (`--effort`).
### Weitere Adapter (für Versuch 4 nachzurüsten)
Kapitel 4 der Arbeit sieht zusätzlich Qwen Code CLI über LM Studio und DeepSeek über die
Cloud-API vor. Diese Adapter sind noch nicht ausgearbeitet. Damit ein
Kapitel 4 der Arbeit sieht zusätzlich DeepSeek über die Cloud-API vor; dieser Adapter ist noch
nicht ausgearbeitet. Der lokale LM-Studio-Betrieb ist seit 10.1.0 über
`opencode-adapter.py --provider lmstudio` abgedeckt – allerdings mit OpenCode als Agenten-Loop
statt der in Kap. 4 genannten Qwen Code CLI. Diese Abweichung gehört ins Protokoll. Damit ein
Lauf als Messpunkt taugt, muss ein Adapter mindestens liefern:
| Pflichtangabe | Zweck |
@@ -1313,6 +1354,7 @@ der Historie unten – im selben Arbeitsschritt.
| Version | Änderung | Grund | Verwendet in |
|---|---|---|---|
| **10.1.0** | **Lokaler LM-Studio-Adapter für `google/gemma-4-e4b` und `qwen/qwen3.8-27b`.** `opencode-tensorx-adapter.py` heißt jetzt `opencode-adapter.py` und wählt über `--provider {tensorx,lmstudio}` Gateway und Modellvorlage; die Referenz heißt entsprechend `references/opencode-adapter.md`. Neue keyfreie Vorlage `opencode-lmstudio.json` (`http://localhost:1234/v1`). Ein Preflight über `/api/v0/models` prüft Servererreichbarkeit, Modellverfügbarkeit, `tool_use`-Fähigkeit, geladenes Kontextfenster (`--min-context`, Standard 32768) und dass genau **eine** Modellinstanz geladen ist; `--lmstudio-autoload` stellt den Sollzustand per `lms unload`/`lms load` selbst her. Das geladene Fenster wird als `limit.context` in die Laufkonfiguration gepinnt. `RawResult.json` erhält `local_runtime` (Quantisierung, Architektur, Runtime, `lms`-Version, Instanzbezeichner, Kontextfenster), `context_window`, `cost_source` und providerübergreifend `effort_applied`. Neue Artefaktdatei `_meta/lmstudio-modelle.json`. Adapter-Version 1.1.0, fünf zusätzliche Unit-Tests. Zwei Korrekturen am gemeinsamen Pfad: Der Abbruchgrund wird nur noch einmal in `errors` vermerkt statt je Sekunde bis zum Prozessende, und die `lms`-Version wird aus dem ANSI-Banner der CLI sauber extrahiert. | Kapitel 4 sieht lokalen Betrieb als eigene Bedingung vor und fordert nach Kap. 4.3 Runtime samt Version und Quantisierungsstufe – beides liefert erst der Preflight. Drei Befunde aus der Inbetriebnahme sind direkt in den Adapter eingeflossen: LM Studio lädt Modelle standardmäßig mit nur 8192 Kontexttokens, was eine Codebasisanalyse stillschweigend abschneiden würde; ein erneutes `lms load` erzeugt eine **zweite** Instanz (`modell:2`), womit die `model`-Angabe der OpenAI-API nicht mehr eindeutig routet; und der lokale Endpunkt nimmt keinen Thinking-Level entgegen, weshalb Effort als nicht steuerbar auszuweisen ist statt als gesetzt. MINOR: neuer Provider und neue Messgrößen; für `--provider tensorx` bleiben Aufruf, Berechtigungen und Metriken unverändert – die vier bestehenden TensorX-Regressionstests laufen unverändert durch, sodass laufende V2-Läufe vergleichbar bleiben. Live-Smoke-Test am 31.08.2026 mit `google/gemma-4-e4b` (Q4_K_M, gguf, 32768 Tokens): Preflight bestanden, Providerauflösung, Streaming und Tool-Calling bestätigt. | ab dem ersten LM-Studio-Lauf |
| **10.0.2** | Ergebnis-Allowlist zusätzlich relativ zur per Git ermittelten Worktree-Wurzel; Adapter-Version 1.0.2. | Der erste Fix deckte den aktiven Root und den kanonischen Pfad ab. OpenCode 1.18.25 matcht ein Ziel innerhalb desselben Repositories jedoch gegen den Pfad relativ zur Worktree-Wurzel. PATCH: weitere Normalisierungsform desselben bereits autorisierten Zielverzeichnisses. | ab dem ersten OpenCode-V2-Lauf |
| **10.0.1** | Der OpenCode-Adapter autorisiert Ergebnisziele zusätzlich mit einem zum aktiven Root relativen Pfad, einschließlich notwendiger `..`-Segmente; Adapter-Version 1.0.1. Regressionstest für Root und Laufverzeichnis in verschiedenen Unterordnern desselben Windows-Git-Worktrees. | OpenCode normalisiert solche Ziele intern worktree-relativ. Die alleinige kanonische Allow-Regel griff daher nicht, obwohl der absolute Werkzeugpfad exakt im erlaubten Ergebnisordner lag. Ein Custom/max-Preflight startete den vorgesehenen Subagenten erfolgreich, konnte anschließend aber keine Ergebnisdatei schreiben. PATCH: korrigiert nur die beabsichtigte Schreibfreigabe. | ab dem ersten OpenCode-V2-Lauf |
| **10.0.0** | **OpenCode wird primärer TensorX-Adapter.** Neue keyfreie Provider-/Modellvorlage `opencode-tensorx.json`, Wrapper `opencode-tensorx-adapter.py`, Unit-Tests und Detailreferenz. Der Wrapper startet `opencode run --pure` mit einer isolierten Laufkonfiguration, streamt JSONL und stderr, exportiert die Session, normalisiert Token-, Tool- und Subagentenmetriken und beendet bei Inaktivität oder Benutzerabbruch den Prozessbaum. `solo`, `builtin` und aus `03_Agents.json` übersetztes `custom` werden unterstützt. Der direkte Python-Adapter bleibt als ausdrücklich gewählter Legacy-Fallback erhalten. | Der direkte Adapter hing bei `qwen/qwen3.8-flash-next` in einem nicht gestreamten HTTP-Aufruf ohne lokalisierbaren Fortschritt. OpenCode liefert inkrementelle Ereignisse, eine persistierte Session und einen klaren Prozesslebenszyklus; außerdem entfällt die Kopplung der TensorX-Authentifizierung an Cline. MAJOR, weil Agentenlaufzeit, Werkzeugsemantik und Metrikquelle eine neue Versuchsbedingung bilden. Live-Smoke-Test am 31.08.2026 mit Qwen/low: Exitcode 0, erwartete Antwort, Sessionexport und vollständige Tokenfelder. | ab dem nächsten TensorX-Lauf; vorherige direkte Python-Läufe bleiben Legacy-Bedingung |
@@ -1,9 +1,19 @@
#!/usr/bin/env python3
"""Headless-Adapter fuer TensorX-Versuchslaeufe ueber OpenCode.
"""Headless-Adapter fuer OpenCode-Versuchslaeufe.
OpenCode verwaltet Provider-Credentials und Agentensitzungen. Dieser Wrapper
erzeugt pro Lauf eine isolierte OpenCode-Konfiguration, streamt JSON-Ereignisse
direkt in den Laufordner und normalisiert die Session nach RawResult.json.
Unterstuetzte Provider (``--provider``):
* ``tensorx`` – Remote-Gateway https://api.tensorx.ai/v1 (GLM, Qwen, Kimi)
* ``lmstudio`` – lokaler LM-Studio-Server http://localhost:1234/v1
Beide Provider durchlaufen denselben Agenten-, Berechtigungs- und Metrikpfad.
Fuer ``lmstudio`` kommt ein Preflight hinzu, der Server, Modellzustand,
Tool-Faehigkeit und geladenes Kontextfenster prueft und die lokale Runtime fuer
die Reproduzierbarkeitsangaben protokolliert.
"""
from __future__ import annotations
@@ -13,20 +23,38 @@ import copy
import json
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
ADAPTER_VERSION = "1.0.2"
PROVIDER_ID = "tensorx"
ADAPTER_VERSION = "1.1.0"
DEFAULT_PROVIDER = "tensorx"
PROVIDER_ID = DEFAULT_PROVIDER
PROVIDERS: dict[str, dict] = {
"tensorx": {
"template": "opencode-tensorx.json",
"adapter": "opencode-tensorx",
"local": False,
},
"lmstudio": {
"template": "opencode-lmstudio.json",
"adapter": "opencode-lmstudio",
"local": True,
"base_url": "http://localhost:1234",
},
}
EFFORTS = ("low", "medium", "high", "xhigh", "max")
MODES = ("solo", "builtin", "custom")
LMSTUDIO_MIN_CONTEXT = 32768
def utc_now() -> str:
@@ -62,11 +90,11 @@ def resolve_opencode(explicit: str | None = None) -> Path:
)
def normalize_model(model: str) -> tuple[str, str]:
if model.startswith(f"{PROVIDER_ID}/"):
upstream = model[len(PROVIDER_ID) + 1 :]
def normalize_model(model: str, provider: str = DEFAULT_PROVIDER) -> tuple[str, str]:
if model.startswith(f"{provider}/"):
upstream = model[len(provider) + 1 :]
return model, upstream
return f"{PROVIDER_ID}/{model}", model
return f"{provider}/{model}", model
def normalized_path(path: Path) -> str:
@@ -177,12 +205,19 @@ def build_run_config(
root: Path,
output_dir: Path,
agents_file: Path | None,
provider: str = DEFAULT_PROVIDER,
context_limit: int | None = None,
) -> dict:
config = copy.deepcopy(base_config)
provider = config.setdefault("provider", {}).setdefault(PROVIDER_ID, {})
models = provider.setdefault("models", {})
provider_config = config.setdefault("provider", {}).setdefault(provider, {})
models = provider_config.setdefault("models", {})
if upstream_model not in models:
models[upstream_model] = {"name": upstream_model}
if context_limit:
# Lokale Server halten nur das tatsaechlich geladene Fenster vor. Ein
# groesseres Limit in der Vorlage wuerde zu serverseitigem Abschneiden
# fuehren und die Messung entwerten.
models[upstream_model].setdefault("limit", {})["context"] = context_limit
config["model"] = model_ref
output_patterns = output_permission_patterns(
@@ -346,6 +381,9 @@ def normalize_result(
duration_s: float,
output_dir: Path,
errors: list[str],
provider: str = DEFAULT_PROVIDER,
effort_applied: bool = True,
local_runtime: dict | None = None,
) -> dict:
info = (session or {}).get("info", {})
messages = (session or {}).get("messages", [])
@@ -421,7 +459,7 @@ def normalize_result(
"reasoning_tokens": reasoning_tokens,
"output_tokens_details": {"thinking_tokens": reasoning_tokens},
}
return {
result = {
"is_error": is_error,
"subtype": subtype,
"duration_ms": int(duration_s * 1000),
@@ -430,8 +468,9 @@ def normalize_result(
or sum(1 for event in events if event.get("type") == "step_finish"),
"model": reported_model,
"model_requested": model_ref.split("/", 1)[-1],
"provider": PROVIDER_ID,
"provider": provider,
"effort": effort,
"effort_applied": effort_applied,
"usage": usage,
"modelUsage": {
reported_model: {
@@ -452,7 +491,7 @@ def normalize_result(
"finish_reason": finish_reason,
"errors": errors,
"session_id": info.get("id", ""),
"adapter": "opencode-tensorx",
"adapter": PROVIDERS.get(provider, {}).get("adapter", f"opencode-{provider}"),
"adapter_version": ADAPTER_VERSION,
"opencode_version": info.get("version", ""),
"mode": mode,
@@ -468,21 +507,315 @@ def normalize_result(
"exit_code": exit_code,
}
if local_runtime is not None:
result["local_runtime"] = local_runtime
result["context_window"] = local_runtime.get("loaded_context_length", 0)
# Lokale Inferenz erzeugt keine Providerkosten. Der Wert ist damit
# keine Messgroesse, sondern definitionsgemaess null.
result["cost"] = 0
result["cost_source"] = "nicht erfasst (lokaler Betrieb)"
return result
# --------------------------------------------------------------------------
# LM Studio: Preflight und Runtime-Metadaten
# --------------------------------------------------------------------------
def resolve_lms(explicit: str | None = None) -> Path | None:
candidates: list[Path] = []
if explicit:
candidates.append(Path(explicit))
for name in ("lms.exe", "lms"):
found = shutil.which(name)
if found:
candidates.append(Path(found))
home = os.environ.get("USERPROFILE") or os.environ.get("HOME")
if home:
candidates.append(Path(home) / ".lmstudio" / "bin" / "lms.exe")
candidates.append(Path(home) / ".lmstudio" / "bin" / "lms")
for candidate in candidates:
if candidate.is_file():
return candidate.resolve()
return None
def http_get_json(url: str, timeout: int = 15) -> dict:
request = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def lmstudio_catalog(base_url: str, timeout: int = 15) -> list[dict]:
"""Modellkatalog des lokalen Servers samt Zustand und Kontextfenster.
``/api/v0/models`` ist die LM-Studio-eigene Erweiterung; sie liefert
zusaetzlich zu ``/v1/models`` Zustand, Quantisierung, Architektur,
Faehigkeiten sowie maximales und geladenes Kontextfenster.
"""
data = http_get_json(f"{base_url.rstrip('/')}/api/v0/models", timeout=timeout)
entries = data.get("data", [])
return [entry for entry in entries if isinstance(entry, dict)]
ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
def lms_version(lms: Path | None) -> str:
"""Versionskennung der lms-CLI.
``lms --version`` gibt ein ANSI-eingefaerbtes Banner aus; verwertbar ist
allein die Zeile mit der Commit-Kennung.
"""
if lms is None:
return ""
completed = subprocess.run(
[str(lms), "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
check=False,
)
for line in ANSI_ESCAPE.sub("", completed.stdout + completed.stderr).splitlines():
cleaned = line.strip()
if cleaned.lower().startswith(("cli commit", "version", "lms ")) and any(
char.isdigit() for char in cleaned
):
return cleaned
return ""
def lmstudio_instances(catalog: list[dict], model: str) -> list[dict]:
"""Alle Katalogeintraege zu einem Modell.
LM Studio vergibt beim wiederholten Laden desselben Modells die Bezeichner
``modell``, ``modell:2``, ``modell:3``. Alle Instanzen beantworten dieselbe
``model``-Angabe der OpenAI-API, weshalb mehrere geladene Instanzen das
Routing mehrdeutig machen.
"""
prefix = f"{model}:"
return [
entry
for entry in catalog
if entry.get("id") == model or str(entry.get("id", "")).startswith(prefix)
]
def run_lms(lms: Path, arguments: list[str], log, timeout: int = 1800) -> None:
command = [str(lms)] + arguments
log("LM Studio: " + " ".join(command))
completed = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
)
if completed.returncode != 0:
raise RuntimeError(
f"'lms {' '.join(arguments)}' schlug fehl "
f"(Exitcode {completed.returncode}): "
+ (completed.stderr or completed.stdout).strip()
)
def lmstudio_reload_model(
lms: Path, model: str, context_length: int, loaded_ids: list[str], log
) -> None:
"""Alle Instanzen des Modells entladen und genau eine neu laden."""
for identifier in loaded_ids:
run_lms(lms, ["unload", identifier], log, timeout=300)
run_lms(
lms,
["load", model, "--context-length", str(context_length), "--yes"],
log,
)
def lmstudio_preflight(
model: str,
base_url: str,
min_context: int,
autoload: bool,
lms_path: str | None,
catalog_dump: Path,
log,
) -> dict:
"""Prueft den lokalen Server und liefert die Runtime-Metadaten des Laufs.
Bricht mit einer handlungsfaehigen Meldung ab, wenn Server, Modell,
Tool-Faehigkeit oder Kontextfenster einen gueltigen Messpunkt unmoeglich
machen. Ein zu kleines Fenster wuerde der Server stillschweigend
abschneiden und die Messung entwerten.
"""
lms = resolve_lms(lms_path)
try:
catalog = lmstudio_catalog(base_url)
except (urllib.error.URLError, OSError) as exc:
hint = f"'{lms}' server start" if lms else "lms server start"
raise RuntimeError(
f"LM-Studio-Server unter {base_url} nicht erreichbar ({exc}). "
f"Server starten mit: {hint}"
) from exc
catalog_dump.write_text(
json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8"
)
def loaded_ids(entries: list[dict]) -> list[str]:
return [
str(item.get("id", ""))
for item in lmstudio_instances(entries, model)
if item.get("state") == "loaded"
]
def select(entries: list[dict]) -> dict | None:
instances = lmstudio_instances(entries, model)
if not instances:
return None
loaded = [item for item in instances if item.get("state") == "loaded"]
if len(loaded) > 1 and not autoload:
raise RuntimeError(
f"Modell '{model}' ist mehrfach geladen "
f"({', '.join(item.get('id', '') for item in loaded)}). Die "
"OpenAI-API kann den Lauf dann keiner Instanz eindeutig zuordnen. "
"Ueberzaehlige Instanzen entladen mit 'lms unload <Bezeichner>' "
"oder den Adapter mit --lmstudio-autoload aufrufen."
)
return loaded[0] if loaded else instances[0]
entry = select(catalog)
if entry is None:
available = ", ".join(
item.get("id", "") for item in catalog if item.get("type") != "embeddings"
)
raise RuntimeError(
f"Modell '{model}' ist in LM Studio nicht vorhanden. "
f"Verfuegbar: {available or 'keine'}. "
f"Herunterladen mit: lms get {model}"
)
capabilities = entry.get("capabilities") or []
if "tool_use" not in capabilities:
raise RuntimeError(
f"Modell '{model}' meldet keine Tool-Faehigkeit (capabilities="
f"{capabilities or 'leer'}). Ein Analyselauf ohne Tool-Calling ist "
"kein gueltiger Messpunkt."
)
max_context = int(entry.get("max_context_length") or 0)
if max_context and max_context < min_context:
raise RuntimeError(
f"Modell '{model}' unterstuetzt hoechstens {max_context} Kontexttokens, "
f"gefordert sind {min_context}. Mit --min-context bewusst absenken "
"und die Abweichung im Protokoll vermerken."
)
loaded_context = int(entry.get("loaded_context_length") or 0)
needs_reload = (
entry.get("state") != "loaded"
or loaded_context < min_context
or len(loaded_ids(catalog)) > 1
)
if needs_reload and autoload:
if lms is None:
raise RuntimeError(
"--lmstudio-autoload benoetigt die 'lms'-CLI; sie wurde weder im "
"PATH noch unter ~/.lmstudio/bin gefunden."
)
target_context = min(min_context, max_context) if max_context else min_context
lmstudio_reload_model(lms, model, target_context, loaded_ids(catalog), log)
catalog = lmstudio_catalog(base_url)
catalog_dump.write_text(
json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8"
)
entry = select(catalog) or entry
loaded_context = int(entry.get("loaded_context_length") or 0)
if entry.get("state") != "loaded":
raise RuntimeError(
f"Modell '{model}' ist nicht geladen (state={entry.get('state')}). "
f"Laden mit: lms load {model} --context-length {min_context} --yes "
"oder den Adapter mit --lmstudio-autoload aufrufen."
)
if loaded_context < min_context:
raise RuntimeError(
f"Modell '{model}' ist mit nur {loaded_context} Kontexttokens geladen, "
f"gefordert sind {min_context}. Ein zu kleines Fenster schneidet die "
"Codebasis stillschweigend ab. Neu laden mit: "
f"lms load {model} --context-length {min_context} --yes"
)
instances = loaded_ids(catalog)
if len(instances) != 1:
raise RuntimeError(
f"Modell '{model}' muss mit genau einer Instanz geladen sein, "
f"gefunden: {', '.join(instances) or 'keine'}. Ueberzaehlige Instanzen "
"mit 'lms unload <Bezeichner>' entfernen."
)
runtime = {
"provider": "lmstudio",
"base_url": base_url,
"lms_path": str(lms) if lms else "",
"lms_version": lms_version(lms),
"model_id": model,
"instance_id": entry.get("id", model),
"publisher": entry.get("publisher", ""),
"arch": entry.get("arch", ""),
"quantization": entry.get("quantization", ""),
"compatibility_type": entry.get("compatibility_type", ""),
"state": entry.get("state", ""),
"capabilities": capabilities,
"max_context_length": max_context,
"loaded_context_length": loaded_context,
}
log(
"LM-Studio-Preflight bestanden: "
f"{runtime['model_id']}; Quantisierung={runtime['quantization'] or 'unbekannt'}; "
f"Kontext={loaded_context}/{max_context or '?'}; "
f"Runtime={runtime['compatibility_type'] or 'unbekannt'}"
)
return runtime
def main() -> int:
parser = argparse.ArgumentParser(
description="TensorX-Versuchslauf ueber OpenCode"
)
parser = argparse.ArgumentParser(description="Versuchslauf ueber OpenCode")
parser.add_argument("--prompt", required=True)
parser.add_argument("--root", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--model", required=True)
parser.add_argument(
"--provider",
default=DEFAULT_PROVIDER,
choices=sorted(PROVIDERS),
help="tensorx = Remote-Gateway, lmstudio = lokaler LM-Studio-Server",
)
parser.add_argument("--effort", default="low", choices=EFFORTS)
parser.add_argument("--mode", default="solo", choices=MODES)
parser.add_argument("--agents")
parser.add_argument("--result-dir")
parser.add_argument("--opencode")
parser.add_argument("--config-template")
parser.add_argument(
"--base-url",
help="Basis-URL des lokalen Servers; Standard http://localhost:1234",
)
parser.add_argument("--lms", help="Pfad zur lms-CLI (nur --provider lmstudio)")
parser.add_argument(
"--min-context",
type=int,
default=LMSTUDIO_MIN_CONTEXT,
help="Mindestgroesse des geladenen Kontextfensters (nur lmstudio)",
)
parser.add_argument(
"--lmstudio-autoload",
action="store_true",
help="Modell bei Bedarf per 'lms load' mit --min-context laden",
)
parser.add_argument(
"--stall-timeout",
type=int,
@@ -500,9 +833,11 @@ def main() -> int:
action="store_true",
help="Leeres Ergebnisse-Verzeichnis nicht als Fehler werten (nur Smoke-Tests)",
)
parser.add_argument("--title", default="run-experiment TensorX")
parser.add_argument("--title", default="run-experiment OpenCode")
args = parser.parse_args()
provider = args.provider
provider_spec = PROVIDERS[provider]
prompt_path = Path(args.prompt).resolve()
root = Path(args.root).resolve()
output_dir = Path(args.output).resolve()
@@ -511,7 +846,7 @@ def main() -> int:
template_path = (
Path(args.config_template).resolve()
if args.config_template
else Path(__file__).with_name("opencode-tensorx.json")
else Path(__file__).with_name(provider_spec["template"])
)
if not prompt_path.is_file():
parser.error(f"Prompt-Datei fehlt: {prompt_path}")
@@ -544,8 +879,31 @@ def main() -> int:
sys.stderr.write(line + "\n")
sys.stderr.flush()
model_ref, upstream_model = normalize_model(args.model)
model_ref, upstream_model = normalize_model(args.model, provider)
base_config = json.loads(template_path.read_text(encoding="utf-8-sig"))
local_runtime: dict | None = None
context_limit: int | None = None
if provider_spec.get("local"):
base_url = args.base_url or provider_spec["base_url"]
try:
local_runtime = lmstudio_preflight(
model=upstream_model,
base_url=base_url,
min_context=args.min_context,
autoload=args.lmstudio_autoload,
lms_path=args.lms,
catalog_dump=meta_dir / "lmstudio-modelle.json",
log=log,
)
except RuntimeError as exc:
log(f"Preflight fehlgeschlagen: {exc}")
return 2
context_limit = local_runtime["loaded_context_length"]
base_config.setdefault("provider", {}).setdefault(provider, {}).setdefault(
"options", {}
)["baseURL"] = f"{base_url.rstrip('/')}/v1"
run_config = build_run_config(
base_config,
model_ref,
@@ -554,12 +912,14 @@ def main() -> int:
root,
output_dir,
agents_file,
provider=provider,
context_limit=context_limit,
)
config_path.write_text(
json.dumps(run_config, indent=2, ensure_ascii=False), encoding="utf-8"
)
model_config = run_config["provider"][PROVIDER_ID]["models"][upstream_model]
model_config = run_config["provider"][provider]["models"][upstream_model]
variants = model_config.get("variants", {})
command = [
str(opencode),
@@ -577,8 +937,15 @@ def main() -> int:
"--dir",
str(root),
]
if args.effort in variants:
effort_applied = args.effort in variants
if effort_applied:
command.extend(["--variant", args.effort])
else:
log(
f"Effort '{args.effort}' wird nicht an den Provider uebergeben: "
f"'{upstream_model}' kennt keine passende Variante. Im Protokoll als "
"nicht steuerbar ausweisen."
)
env = os.environ.copy()
env["OPENCODE_CONFIG"] = str(config_path)
@@ -593,8 +960,9 @@ def main() -> int:
exit_code = -1
log(
f"Start OpenCode {opencode}; Modell={model_ref}; Modus={args.mode}; "
f"Effort={args.effort}; Stall-Timeout={args.stall_timeout}s"
f"Start OpenCode {opencode}; Provider={provider}; Modell={model_ref}; "
f"Modus={args.mode}; Effort={args.effort} (uebergeben={effort_applied}); "
f"Stall-Timeout={args.stall_timeout}s"
)
process = subprocess.Popen(
command,
@@ -648,15 +1016,26 @@ def main() -> int:
except queue.Empty:
pass
# Der Abbruchgrund wird nur einmal vermerkt: Bis der Prozessbaum
# tatsaechlich endet, laeuft die Schleife weiter und wuerde die
# Meldung sonst je Sekunde erneut anhaengen.
now = time.monotonic()
if args.stall_timeout > 0 and now - last_activity > args.stall_timeout:
if (
args.stall_timeout > 0
and not timed_out
and now - last_activity > args.stall_timeout
):
timed_out = True
errors.append(
f"Keine OpenCode-Ausgabe seit {args.stall_timeout} Sekunden"
)
log(errors[-1] + "; Prozessbaum wird beendet")
terminate_process_tree(process)
if args.max_runtime > 0 and now - start_time > args.max_runtime:
if (
args.max_runtime > 0
and not timed_out
and now - start_time > args.max_runtime
):
timed_out = True
errors.append(
f"Maximale Laufzeit von {args.max_runtime} Sekunden ueberschritten"
@@ -702,6 +1081,9 @@ def main() -> int:
duration_s,
output_dir,
errors,
provider=provider,
effort_applied=effort_applied,
local_runtime=local_runtime,
)
if not args.allow_empty_output and not result["written_files"]:
result["errors"].append("Ergebnisse-Verzeichnis ist leer")
@@ -0,0 +1,29 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"lmstudio": {
"npm": "@ai-sdk/openai-compatible",
"name": "LM Studio (lokal)",
"options": {
"baseURL": "http://localhost:1234/v1",
"apiKey": "lm-studio"
},
"models": {
"google/gemma-4-e4b": {
"name": "Gemma 4 E4B (lokal)",
"limit": {
"context": 131072,
"output": 32768
}
},
"qwen/qwen3.8-27b": {
"name": "Qwen 3.8 27B (lokal)",
"limit": {
"context": 262144,
"output": 32768
}
}
}
}
}
}
@@ -0,0 +1,246 @@
# OpenCode-Adapter
Ein Wrapper, zwei Provider. `opencode-adapter.py` startet OpenCode headless und normalisiert den
Lauf nach `RawResult.json`. Welcher Provider gilt, entscheidet `--provider`:
| `--provider` | Modell-IDs | Betrieb | Vorlage |
|---|---|---|---|
| `tensorx` (Standard) | `z-ai/*`, `qwen/qwen3.8-flash-next`, `moonshotai/*` | Remote-Gateway `https://api.tensorx.ai/v1` | `opencode-tensorx.json` |
| `lmstudio` | `google/gemma-4-e4b`, `qwen/qwen3.8-27b` | lokaler LM-Studio-Server `http://localhost:1234/v1` | `opencode-lmstudio.json` |
`qwen/qwen3.8-flash-next` (TensorX) und `qwen/qwen3.8-27b` (lokal) teilen sich das Präfix `qwen/`.
Das Präfix allein bestimmt den Adapter deshalb **nicht** – maßgeblich ist die vollständige
Modell-ID gemäß dieser Tabelle.
Der Abschnitt **LM Studio** unten beschreibt alles, was nur für den lokalen Betrieb gilt.
Alle übrigen Abschnitte gelten für beide Provider.
## Voraussetzungen und Authentifizierung
1. OpenCode installieren und die Version protokollieren:
```powershell
npm install -g opencode-ai
opencode --version
```
2. Nur für `--provider tensorx`: TensorX einmal im OpenCode-Credential-Store anmelden
(`--provider lmstudio` braucht keine Anmeldung – der lokale Server prüft keinen Key):
```powershell
opencode auth login
opencode auth list
```
Die statische Datei `opencode-tensorx.json` enthält Provider, Basis-URL, Modelle und
Effort-Varianten, aber keinen API-Key. Der Adapter liest weder Cline-Dateien noch einen
Cline-Credential-Store. OpenCode löst das Credential selbst auf. Keys niemals in
Laufartefakte, Prompts oder die Konfigurationsvorlage schreiben.
## Modell- und Effort-Mapping
Der Adapter ergänzt intern das Providerpräfix (`tensorx/` bzw. `lmstudio/`). Aus
`qwen/qwen3.8-flash-next` wird daher für OpenCode
`tensorx/qwen/qwen3.8-flash-next`; im Messprotokoll bleibt die ursprüngliche TensorX-ID.
Die Stufen `low`, `medium`, `high`, `xhigh` und `max` werden als OpenCode-Varianten aus der
Providervorlage übergeben. Für Qwen und GLM über TensorX enthält die Variante
`thinking: {type: enabled, level: ...}`; `max` wird auf `xhigh` abgebildet, falls der Provider
keine eigene `max`-Stufe kennt. Nur in der Vorlage vorhandene Varianten werden per
`--variant` gesetzt.
**Bei `lmstudio` ist Effort nicht steuerbar.** Der OpenAI-kompatible Endpunkt von LM Studio
nimmt keinen Thinking-Level entgegen; `opencode-lmstudio.json` deklariert deshalb bewusst keine
Varianten. Der Adapter protokolliert das im `Adapter.log` und setzt `effort_applied: false` in
`RawResult.json`. Der übergebene `--effort`-Wert bleibt als angeforderte Bedingung erhalten, ist
im Protokoll aber unter „Sampling-Parameter" als **nicht steuerbar** auszuweisen – niemals so
darzustellen, als hätte er gewirkt. Reasoning-Tokens liefern die Modelle trotzdem, sofern sie
von sich aus mit `reasoning_content` antworten.
## Isolierte Laufkonfiguration
Für jeden Lauf schreibt der Adapter `_meta/opencode-config.json` und setzt nur für den
Kindprozess `OPENCODE_CONFIG` auf diese Datei. Der Aufruf verwendet `opencode run --pure`,
damit keine interaktive Oberfläche benötigt wird. Der Prompt wird über stdin übergeben.
Die Berechtigungen beginnen mit `deny` und erlauben gezielt:
- Lesen, Suchen, Auflisten sowie eine kleine Read-only-Shell-Allowlist;
- Schreiben und externe Verzeichnisse ausschließlich für das angegebene Ergebnisverzeichnis;
- im Modus `solo` keine Tasks;
- im Modus `builtin` nur OpenCodes `general`- und `explore`-Subagenten;
- im Modus `custom` nur Rollen aus der mit `--agents` übergebenen JSON-Datei.
Für das Ergebnisziel erzeugt der Wrapper kanonische sowie zum aktiven OpenCode-Root und zur
Git-Worktree-Wurzel relative Allow-Patterns. Das ist unter Windows notwendig, wenn Root und
Laufverzeichnis im selben Git-Worktree liegen, das Laufverzeichnis aber außerhalb des
Root-Unterordners liegt.
Webzugriff, Skills, Rückfragen und Weiterdelegation durch Subagenten bleiben gesperrt. Bei
`custom` übersetzt der Adapter `description` und `prompt` jeder Rolle in eine explizite
OpenCode-Subagentenkonfiguration mit demselben Modell wie der Hauptagent.
## LM Studio (nur `--provider lmstudio`)
Lokale Läufe sind eine eigene Versuchsbedingung: kein Netzzugriff, keine Providerkosten,
gewichtsbezogene Reproduzierbarkeitsangaben (Quantisierung, Runtime, Kontextfenster) und ein
Kontextfenster, das der Server beim Laden festlegt.
### Vorbereitung
```powershell
lms server start # OpenAI-kompatibler Endpunkt auf Port 1234
lms ls # heruntergeladene Modelle
lms get qwen/qwen3.8-27b # fehlendes Modell holen (mehrere GB)
lms ps # geladene Instanzen samt Kontextfenster
```
### Preflight des Adapters
Vor dem Start von OpenCode prüft der Adapter über `GET /api/v0/models` und bricht mit
Exitcode `2` und einer Handlungsanweisung ab, wenn eine Bedingung verletzt ist:
| Prüfung | Abbruchgrund |
|---|---|
| Server erreichbar | `lms server start` fehlt |
| Modell vorhanden | nicht heruntergeladen → `lms get <ID>` |
| `capabilities` enthält `tool_use` | ohne Tool-Calling ist kein Analyselauf möglich |
| `max_context_length` ≥ `--min-context` | Modell kann die Bedingung nicht erfüllen |
| Zustand `loaded` und `loaded_context_length` ≥ `--min-context` | zu kleines Fenster schneidet die Codebasis **stillschweigend** ab |
| genau **eine** geladene Instanz | mehrere Instanzen (`modell`, `modell:2`) beantworten dieselbe `model`-Angabe; das Routing wäre nicht reproduzierbar |
`--min-context` ist standardmäßig `32768`. Ein bewusst kleinerer Wert ist zulässig, gehört aber
als abweichende Versuchsbedingung ins Protokoll.
`--lmstudio-autoload` stellt den Sollzustand selbst her: Es entlädt **alle** Instanzen des
Modells und lädt genau eine mit `--min-context` neu. Ohne das Flag meldet der Preflight nur den
exakten `lms`-Befehl. Die Standardgröße von LM Studio (häufig 8192) reicht für eine
Codebasisanalyse nicht.
Das geladene Fenster wird zusätzlich als `limit.context` in die Laufkonfiguration geschrieben,
damit OpenCode nicht mehr Kontext sendet, als der Server vorhält.
### Zusätzliche Laufartefakte und Messfelder
| Datei | Inhalt |
|---|---|
| `_meta/lmstudio-modelle.json` | Rohantwort von `/api/v0/models` zum Zeitpunkt des Preflights |
`RawResult.json` enthält bei lokalen Läufen zusätzlich:
| Messgröße | Feld |
|---|---|
| Kontextfenster des Laufs | `context_window` (= `local_runtime.loaded_context_length`) |
| Quantisierungsstufe | `local_runtime.quantization` |
| Runtime und Architektur | `local_runtime.compatibility_type`, `local_runtime.arch` |
| Runtime-Version | `local_runtime.lms_version` |
| Instanzbezeichner | `local_runtime.instance_id` |
| Endpunkt | `local_runtime.base_url` |
| Kosten | `cost` ist `0`; `cost_source` weist „nicht erfasst (lokaler Betrieb)" aus |
| Effortwirkung | `effort_applied` ist `false` |
Damit sind die von Kapitel 4.3 geforderten Angaben für lokalen Betrieb – Runtime samt Version
und Quantisierungsstufe – vollständig erfasst.
### Laufzeitverhalten
Kleine lokale Modelle beenden eine Aufgabe nicht zuverlässig von selbst; im Smoke-Test lief
`google/gemma-4-e4b` über 50 Schritte weiter, ohne die geforderte Datei zu schreiben. Der
Stall-Timeout greift dabei **nicht**, weil laufend Text erzeugt wird. Für lokale Läufe deshalb
immer ein absolutes `--max-runtime` setzen und einen Abbruch als solchen protokollieren, statt
ihn als Ergebnis zu werten.
## Aufruf
```powershell
$skillDir = "<Verzeichnis des Skills>"
$lauf = "<absoluter Pfad zum Laufverzeichnis>"
$root = "<Root-Verzeichnis der Codebasis>"
$provider = "<tensorx|lmstudio>"
$modell = "<Modell-ID>"
$effort = "<low|medium|high|xhigh|max>"
$modus = "<solo|builtin|custom>"
$agents = "<Agenten-JSON; nur bei custom>"
python "$skillDir\opencode-adapter.py" `
--prompt "$lauf\_meta\combined_prompt.md" `
--root $root `
--output "$lauf\Ergebnisse" `
--provider $provider `
--model $modell `
--effort $effort `
--mode $modus `
--agents $agents `
--stall-timeout 600 `
--max-runtime 0 `
--result-dir $lauf `
--title "run-experiment $modell $modus $effort"
```
Für `--provider lmstudio` kommen hinzu:
```powershell
--min-context 32768 ` # Mindestgröße des geladenen Kontextfensters
--lmstudio-autoload ` # Modell notfalls selbst neu laden
--max-runtime 3600 # absolutes Limit; lokale Modelle terminieren nicht zuverlässig
```
`--base-url` (Standard `http://localhost:1234`) und `--lms` (Pfad zur CLI) sind nur nötig, wenn
Port oder Installationsort abweichen.
`--agents` bei `solo` und `builtin` weglassen. `--stall-timeout 600` beendet den gesamten
OpenCode-Prozessbaum, wenn zehn Minuten lang weder stdout noch stderr Aktivität zeigen.
`--stall-timeout 0` deaktiviert diese Sicherung. `--max-runtime 0` setzt kein absolutes
Laufzeitlimit. `Ctrl+C` beendet ebenfalls den Prozessbaum und persistiert soweit möglich das
Teilergebnis. Ein leeres `Ergebnisse`-Verzeichnis macht den Lauf standardmäßig zu einem Fehler.
Nur ein bewusst textueller Smoke-Test darf diese Prüfung mit `--allow-empty-output` abschalten.
## Laufartefakte und Messfelder
| Datei | Inhalt |
|---|---|
| `OpenCodeEvents.jsonl` | unveränderter, inkrementell geschriebener JSON-Ereignisstrom |
| `OpenCode.log` | OpenCode-stderr, inkrementell geschrieben |
| `Adapter.log` | Start, Lebenszyklus, Abbruchgrund und Abschluss des Wrappers |
| `_meta/opencode-config.json` | tatsächlich verwendete, keyfreie Laufkonfiguration |
| `_meta/opencode-session.json` | exportierte Session, soweit eine Session-ID vorliegt |
| `RawResult.json` | normalisierte Metriken für das gemeinsame Messprotokoll |
Aus `RawResult.json` verwenden:
| Messgröße | Feld |
|---|---|
| Erfolg/Abbruch | `is_error`, `subtype`, `timed_out`, `interrupted`, `exit_code`, `errors` |
| Modellkontrolle | `provider`, `model`, `model_requested` |
| Zeit | `duration_ms`, `start_time`, `end_time` |
| Tokens | `usage.prompt_tokens`, `completion_tokens`, `reasoning_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `total_tokens` |
| Turns und Ende | `num_turns`, `finish_reason` |
| Tools | `tool_call_count`, `tool_call_types`, `tool_calls` |
| Subagenten | `subagent_stats`, `subagent_details` |
| Ergebnisdateien | `written_files` |
| Abschlusstext | `result` |
| Reproduzierbarkeit | `adapter_version`, `opencode_version`, `config_path`, `session_id`, `adapter` |
| Effortwirkung | `effort`, `effort_applied` |
| Lokaler Betrieb | `local_runtime`, `context_window`, `cost_source` (nur `lmstudio`) |
`usage.total_tokens` übernimmt nach Möglichkeit OpenCodes Gesamtwert. Fehlt dieser, addiert
der Adapter Input, Output, Reasoning, Cache-Read und Cache-Write aus den gelieferten
OpenCode-Feldern. Kosten werden nur protokolliert, wenn OpenCode sie liefert; nicht schätzen.
Bei `lmstudio` entstehen keine Providerkosten – `cost` ist definitionsgemäß `0`, nicht
„unbekannt". Cache-Read und Cache-Write liefert der lokale Server nicht; sie sind im Protokoll
als `nicht erfasst` auszuweisen.
## Pflichtprüfung
1. `RawResult.json` existiert und `is_error` ist `false`.
2. `timed_out` und `interrupted` sind `false`; `exit_code` ist `0`.
3. `OpenCode.log` und `errors` enthalten keinen Provider- oder Permissionfehler.
4. Für Analyseversuche ist `Ergebnisse/` nicht leer und `tool_call_count` größer null.
5. `model_requested` entspricht der angeforderten Modell-ID; `provider` entspricht `--provider`.
6. Bei `custom` sind die erwarteten Rollen in `subagent_stats.by_type` nachweisbar.
7. Bei `lmstudio` zusätzlich: `local_runtime.loaded_context_length` ≥ `--min-context`,
`local_runtime.quantization` und `local_runtime.lms_version` sind gefüllt, und
`effort_applied` ist `false` (im Protokoll als nicht steuerbar vermerken).
Ein absichtlich textueller Smoke-Test darf von den Prüfungen 4 und 6 abweichen, muss aber
Antwort, Exitcode, Modell, Sessionexport und Tokenmetriken bestätigen.
@@ -1,134 +0,0 @@
# OpenCode-Adapter für TensorX
Diese Referenz gilt für Modell-IDs mit `z-ai/`, `qwen/` oder `moonshotai/`. Der Skill ruft
TensorX nicht selbst auf, sondern startet OpenCode über `opencode-tensorx-adapter.py`.
## Voraussetzungen und Authentifizierung
1. OpenCode installieren und die Version protokollieren:
```powershell
npm install -g opencode-ai
opencode --version
```
2. TensorX einmal im OpenCode-Credential-Store anmelden:
```powershell
opencode auth login
opencode auth list
```
Die statische Datei `opencode-tensorx.json` enthält Provider, Basis-URL, Modelle und
Effort-Varianten, aber keinen API-Key. Der Adapter liest weder Cline-Dateien noch einen
Cline-Credential-Store. OpenCode löst das Credential selbst auf. Keys niemals in
Laufartefakte, Prompts oder die Konfigurationsvorlage schreiben.
## Modell- und Effort-Mapping
Der Adapter ergänzt intern das Providerpräfix `tensorx/`. Aus
`qwen/qwen3.8-flash-next` wird daher für OpenCode
`tensorx/qwen/qwen3.8-flash-next`; im Messprotokoll bleibt die ursprüngliche TensorX-ID.
Die Stufen `low`, `medium`, `high`, `xhigh` und `max` werden als OpenCode-Varianten aus
`opencode-tensorx.json` übergeben. Für Qwen und GLM enthält die Variante
`thinking: {type: enabled, level: ...}`; `max` wird auf `xhigh` abgebildet, falls der Provider
keine eigene `max`-Stufe kennt. Nur in der Vorlage vorhandene Varianten werden per
`--variant` gesetzt.
## Isolierte Laufkonfiguration
Für jeden Lauf schreibt der Adapter `_meta/opencode-config.json` und setzt nur für den
Kindprozess `OPENCODE_CONFIG` auf diese Datei. Der Aufruf verwendet `opencode run --pure`,
damit keine interaktive Oberfläche benötigt wird. Der Prompt wird über stdin übergeben.
Die Berechtigungen beginnen mit `deny` und erlauben gezielt:
- Lesen, Suchen, Auflisten sowie eine kleine Read-only-Shell-Allowlist;
- Schreiben und externe Verzeichnisse ausschließlich für das angegebene Ergebnisverzeichnis;
- im Modus `solo` keine Tasks;
- im Modus `builtin` nur OpenCodes `general`- und `explore`-Subagenten;
- im Modus `custom` nur Rollen aus der mit `--agents` übergebenen JSON-Datei.
Für das Ergebnisziel erzeugt der Wrapper kanonische sowie zum aktiven OpenCode-Root und zur
Git-Worktree-Wurzel relative Allow-Patterns. Das ist unter Windows notwendig, wenn Root und
Laufverzeichnis im selben Git-Worktree liegen, das Laufverzeichnis aber außerhalb des
Root-Unterordners liegt.
Webzugriff, Skills, Rückfragen und Weiterdelegation durch Subagenten bleiben gesperrt. Bei
`custom` übersetzt der Adapter `description` und `prompt` jeder Rolle in eine explizite
OpenCode-Subagentenkonfiguration mit demselben Modell wie der Hauptagent.
## Aufruf
```powershell
$skillDir = "<Verzeichnis des Skills>"
$lauf = "<absoluter Pfad zum Laufverzeichnis>"
$root = "<Root-Verzeichnis der Codebasis>"
$modell = "<TensorX-Modell-ID>"
$effort = "<low|medium|high|xhigh|max>"
$modus = "<solo|builtin|custom>"
$agents = "<Agenten-JSON; nur bei custom>"
python "$skillDir\opencode-tensorx-adapter.py" `
--prompt "$lauf\_meta\combined_prompt.md" `
--root $root `
--output "$lauf\Ergebnisse" `
--model $modell `
--effort $effort `
--mode $modus `
--agents $agents `
--stall-timeout 600 `
--max-runtime 0 `
--result-dir $lauf `
--title "run-experiment $modell $modus $effort"
```
`--agents` bei `solo` und `builtin` weglassen. `--stall-timeout 600` beendet den gesamten
OpenCode-Prozessbaum, wenn zehn Minuten lang weder stdout noch stderr Aktivität zeigen.
`--stall-timeout 0` deaktiviert diese Sicherung. `--max-runtime 0` setzt kein absolutes
Laufzeitlimit. `Ctrl+C` beendet ebenfalls den Prozessbaum und persistiert soweit möglich das
Teilergebnis. Ein leeres `Ergebnisse`-Verzeichnis macht den Lauf standardmäßig zu einem Fehler.
Nur ein bewusst textueller Smoke-Test darf diese Prüfung mit `--allow-empty-output` abschalten.
## Laufartefakte und Messfelder
| Datei | Inhalt |
|---|---|
| `OpenCodeEvents.jsonl` | unveränderter, inkrementell geschriebener JSON-Ereignisstrom |
| `OpenCode.log` | OpenCode-stderr, inkrementell geschrieben |
| `Adapter.log` | Start, Lebenszyklus, Abbruchgrund und Abschluss des Wrappers |
| `_meta/opencode-config.json` | tatsächlich verwendete, keyfreie Laufkonfiguration |
| `_meta/opencode-session.json` | exportierte Session, soweit eine Session-ID vorliegt |
| `RawResult.json` | normalisierte Metriken für das gemeinsame Messprotokoll |
Aus `RawResult.json` verwenden:
| Messgröße | Feld |
|---|---|
| Erfolg/Abbruch | `is_error`, `subtype`, `timed_out`, `interrupted`, `exit_code`, `errors` |
| Modellkontrolle | `provider`, `model`, `model_requested` |
| Zeit | `duration_ms`, `start_time`, `end_time` |
| Tokens | `usage.prompt_tokens`, `completion_tokens`, `reasoning_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `total_tokens` |
| Turns und Ende | `num_turns`, `finish_reason` |
| Tools | `tool_call_count`, `tool_call_types`, `tool_calls` |
| Subagenten | `subagent_stats`, `subagent_details` |
| Ergebnisdateien | `written_files` |
| Abschlusstext | `result` |
| Reproduzierbarkeit | `adapter_version`, `opencode_version`, `config_path`, `session_id` |
`usage.total_tokens` übernimmt nach Möglichkeit OpenCodes Gesamtwert. Fehlt dieser, addiert
der Adapter Input, Output, Reasoning, Cache-Read und Cache-Write aus den gelieferten
OpenCode-Feldern. Kosten werden nur protokolliert, wenn OpenCode sie liefert; nicht schätzen.
## Pflichtprüfung
1. `RawResult.json` existiert und `is_error` ist `false`.
2. `timed_out` und `interrupted` sind `false`; `exit_code` ist `0`.
3. `OpenCode.log` und `errors` enthalten keinen Provider- oder Permissionfehler.
4. Für Analyseversuche ist `Ergebnisse/` nicht leer und `tool_call_count` größer null.
5. `model_requested` entspricht der angeforderten TensorX-ID; Provider ist `tensorx`.
6. Bei `custom` sind die erwarteten Rollen in `subagent_stats.by_type` nachweisbar.
Ein absichtlich textueller Smoke-Test darf von den Prüfungen 4 und 6 abweichen, muss aber
Antwort, Exitcode, Modell, Sessionexport und Tokenmetriken bestätigen.
@@ -5,13 +5,13 @@ import unittest
from pathlib import Path
ADAPTER_PATH = Path(__file__).with_name("opencode-tensorx-adapter.py")
SPEC = importlib.util.spec_from_file_location("opencode_tensorx_adapter", ADAPTER_PATH)
ADAPTER_PATH = Path(__file__).with_name("opencode-adapter.py")
SPEC = importlib.util.spec_from_file_location("opencode_adapter", ADAPTER_PATH)
ADAPTER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(ADAPTER)
class OpenCodeTensorXAdapterTests(unittest.TestCase):
class OpenCodeAdapterTests(unittest.TestCase):
def test_model_reference_keeps_upstream_slashes(self):
self.assertEqual(
(
@@ -160,5 +160,128 @@ class OpenCodeTensorXAdapterTests(unittest.TestCase):
self.assertEqual(1, len(result["written_files"]))
class OpenCodeLmStudioTests(unittest.TestCase):
def test_model_reference_uses_lmstudio_provider(self):
self.assertEqual(
("lmstudio/google/gemma-4-e4b", "google/gemma-4-e4b"),
ADAPTER.normalize_model("google/gemma-4-e4b", "lmstudio"),
)
self.assertEqual(
("lmstudio/qwen/qwen3.8-27b", "qwen/qwen3.8-27b"),
ADAPTER.normalize_model("lmstudio/qwen/qwen3.8-27b", "lmstudio"),
)
def test_template_declares_both_local_models_without_key(self):
template = json.loads(
(ADAPTER_PATH.parent / "opencode-lmstudio.json").read_text(
encoding="utf-8-sig"
)
)
provider = template["provider"]["lmstudio"]
self.assertEqual(
"http://localhost:1234/v1", provider["options"]["baseURL"]
)
self.assertEqual(
{"google/gemma-4-e4b", "qwen/qwen3.8-27b"},
set(provider["models"]),
)
# Lokale Server pruefen den Key nicht; er darf nur ein Platzhalter sein.
self.assertEqual("lm-studio", provider["options"]["apiKey"])
def test_build_run_config_pins_loaded_context_window(self):
base = json.loads(
(ADAPTER_PATH.parent / "opencode-lmstudio.json").read_text(
encoding="utf-8-sig"
)
)
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir) / "root"
output = root / "run" / "Ergebnisse"
output.mkdir(parents=True)
config = ADAPTER.build_run_config(
base,
"lmstudio/google/gemma-4-e4b",
"google/gemma-4-e4b",
"solo",
root,
output,
None,
provider="lmstudio",
context_limit=32768,
)
model = config["provider"]["lmstudio"]["models"]["google/gemma-4-e4b"]
self.assertEqual(32768, model["limit"]["context"])
self.assertEqual("deny", config["permission"]["task"])
self.assertEqual("deny", config["permission"]["webfetch"])
def test_normalize_result_reports_local_runtime_and_zero_cost(self):
runtime = {
"provider": "lmstudio",
"quantization": "Q4_K_M",
"arch": "gemma4",
"compatibility_type": "gguf",
"loaded_context_length": 32768,
"max_context_length": 131072,
}
session = {
"info": {
"id": "ses_local",
"model": {"id": "google/gemma-4-e4b"},
"tokens": {"input": 10, "output": 4, "reasoning": 2,
"cache": {"read": 0, "write": 0}},
"cost": 0,
},
"messages": [
{
"info": {"role": "assistant", "finish": "stop"},
"parts": [{"type": "text", "text": "Fertig"}],
}
],
}
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir)
(output / "StRS.md").write_text("Inhalt", encoding="utf-8")
result = ADAPTER.normalize_result(
session=session,
events=[],
model_ref="lmstudio/google/gemma-4-e4b",
mode="solo",
effort="high",
exit_code=0,
timed_out=False,
interrupted=False,
duration_s=2.0,
output_dir=output,
errors=[],
provider="lmstudio",
effort_applied=False,
local_runtime=runtime,
)
self.assertEqual("lmstudio", result["provider"])
self.assertEqual("opencode-lmstudio", result["adapter"])
self.assertFalse(result["effort_applied"])
self.assertEqual(32768, result["context_window"])
self.assertEqual("Q4_K_M", result["local_runtime"]["quantization"])
self.assertEqual(0, result["cost"])
self.assertIn("lokaler Betrieb", result["cost_source"])
self.assertEqual(16, result["usage"]["total_tokens"])
def test_tensorx_result_keeps_remote_shape(self):
session = {"info": {"model": {"id": "qwen/qwen3.8-flash-next"},
"tokens": {"input": 1, "output": 1}}, "messages": []}
result = ADAPTER.normalize_result(
session=session, events=[], model_ref="tensorx/qwen/qwen3.8-flash-next",
mode="solo", effort="low", exit_code=0, timed_out=False,
interrupted=False, duration_s=1.0, output_dir=Path("."), errors=[],
)
self.assertEqual("tensorx", result["provider"])
self.assertEqual("opencode-tensorx", result["adapter"])
self.assertTrue(result["effort_applied"])
self.assertNotIn("local_runtime", result)
self.assertNotIn("context_window", result)
if __name__ == "__main__":
unittest.main()