Files
2026-09-01 08:12:07 +02:00

306 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.
Die Shell-Rechte sind seit Skill 12.0.0 eine **Denylist**, spiegelbildlich zum
Claude-Code-Adapter: Erlaubt ist alles, gesperrt sind ausdrücklich die schreibenden und
bauenden Kommandos – `rm`, `rmdir`, `mv`, `cp`, `dd`, `truncate`, `chmod`, `chown`, `ln`,
`tee`, `sed -i`, die schreibenden `git`-Kommandos einschließlich `fetch`, `pull` und `remote`,
`dotnet`, `msbuild`, `npm install`, `nuget` sowie `Remove-Item`, `Move-Item`, `Copy-Item`,
`New-Item`, `Set-Content`, `Add-Content`, `Clear-Content`, `Out-File`, `Set-ItemProperty` und
`Rename-Item`.
**Die Reihenfolge ist bedeutsam.** OpenCode wertet die Regeln der Reihe nach aus; die *zuletzt
passende* Regel gewinnt – nicht die spezifischste, und `deny` gewinnt nicht automatisch. Das
Catch-all `"*": "allow"` muss deshalb **zuerst** stehen, die Sperren danach. Python-Dicts
erhalten ihre Einfügereihenfolge, `json.dump` schreibt sie unverändert.
Die frühere Allowlist konnte Kommandos nur als Präfix treffen und scheiterte deshalb an
Pipelines: `Get-ChildItem ... | Format-Table ...` blieb gesperrt, obwohl `Get-ChildItem`
erlaubt war. Zugleich war die Werkzeugfreiheit nicht mit der des Claude-Adapters vergleichbar.
Wie beim Claude-Adapter gilt: Mustervergleich auf Kommandozeilen ist **nicht lückenlos**. Die
belastbare Read-only-Garantie bleibt der Vorher/Nachher-Vergleich per `git status`; die
Denylist senkt das Risiko, sie ersetzt die Verifikation nicht.
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.
### Speicherhygiene und Ladeparameter
Der lokale Durchsatz haengt fast vollstaendig davon ab, ob Gewichte und KV-Cache in den VRAM
passen. Gemessen am 01.09.2026 auf einer RTX 5080 Laptop GPU (16.303 MiB):
| Konfiguration | VRAM belegt | Generierung |
|---|---:|---:|
| `gemma-4-e4b`, 32k, parallel 1 | 5.162 MiB | 48,3 tok/s |
| `gemma-4-e4b`, 32k, parallel 4 | 5.222 MiB | 47,7 tok/s |
| `gemma-4-e4b`, **131k**, parallel 4 | 6.854 MiB | 46,8 tok/s |
| `qwen3.8-27b`, 32k, `--gpu max` | 15.696 MiB (308 frei) | Abbruch nach 10 min |
| beide Modelle gleichzeitig geladen | 15.836 MiB (168 frei) | 0,028 Mio. Tokens/h |
Daraus die drei Regeln, die der Preflight seit Adapter 2.2.0 durchsetzt:
1. **Genau ein Modell ist geladen.** `--lmstudio-autoload` entlaedt `--all` und laedt nur das
angeforderte Modell; ein fremdes geladenes Modell fuehrt sonst zum Abbruch. Ein nebenher
geladenes Modell belegt VRAM, das dem Lauf fehlt, und veraendert dessen Durchsatz um
Groessenordnungen.
2. **Der Kontext wird auf das Modellmaximum geladen** (`--lmstudio-context max`, Standard).
Freier VRAM ist in Kontext besser investiert als ungenutzt: vierfaches Fenster fuer 1,7 GB
und 1,5 tok/s. `--min-context` bleibt die Gueltigkeitsschwelle, nicht die Ladegroesse.
3. **`--lmstudio-gpu max`** erzwingt die vollstaendige Auslagerung, **`--lmstudio-parallel 4`**
ist messbar kostenneutral und hilft, wenn Subagenten nebenlaeufig anfragen.
**Modelle jenseits der VRAM-Grenze sind nicht messbar.** `qwen3.8-27b` belegt mit 17,74 GB
Gewichten mehr, als die Karte hat; der Rest laeuft auf der CPU. Auch eine kleinere
Quantisierung loest das nicht, weil der KV-Cache eines 27B-Modells bei brauchbarem Kontext
mehrere GB zusaetzlich fordert. Fuer eine 16-GB-Karte ist die 9B-Klasse die groesste, die mit
vollem Fenster hineinpasst - dann aber in hoher Quantisierung, um den Speicher zu nutzen.
### 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.
**In den Modi `builtin` und `custom` ist `--stall-timeout 0` zwingend.** OpenCode sendet
**keine Ereignisse, solange ein Subagent arbeitet** – der Ereignisstrom schweigt für dessen
gesamte Laufzeit. Jeder positive Stall-Timeout bricht den Lauf deshalb ab, sobald der erste
Subagent startet, und erzeugt eine Fehlmessung, die wie ein Hänger aussieht. Der Adapter lehnt
diese Kombination seit Version 1.3.0 mit einer Fehlermeldung ab, statt sie stillschweigend zu
korrigieren. Die Laufzeit wird in diesen Modi ausschließlich über `--max-runtime` begrenzt. `--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.