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:
co-authored by
Claude Opus 5
parent
b369e6115e
commit
611fd0a80c
@@ -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 |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+406
-24
@@ -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.
|
||||
+126
-3
@@ -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()
|
||||
Reference in New Issue
Block a user