306 lines
16 KiB
Markdown
306 lines
16 KiB
Markdown
# 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.
|