neues modell

This commit is contained in:
Christoph Schwörer
2026-09-01 08:12:07 +02:00
parent 611fd0a80c
commit 654339464e
109 changed files with 70505 additions and 27 deletions
+227 -22
View File
@@ -36,7 +36,7 @@ from datetime import datetime, timezone
from pathlib import Path
ADAPTER_VERSION = "1.1.0"
ADAPTER_VERSION = "2.2.0"
DEFAULT_PROVIDER = "tensorx"
PROVIDER_ID = DEFAULT_PROVIDER
PROVIDERS: dict[str, dict] = {
@@ -155,20 +155,78 @@ def output_permission_patterns(
return patterns
# Schreibende und bauende Kommandos, spiegelbildlich zur Denylist des
# Claude-Code-Adapters. Reihenfolge ist bedeutsam: OpenCode wertet die Regeln
# der Reihe nach aus, die zuletzt passende gewinnt. Das Catch-all steht deshalb
# zuerst, die Sperren danach.
SHELL_DENY_COMMANDS = (
# Dateien loeschen, verschieben, ueberschreiben (POSIX)
"rm *",
"rmdir *",
"mv *",
"cp *",
"dd *",
"truncate *",
"chmod *",
"chown *",
"ln *",
"tee *",
"sed -i*",
# Git, schreibend
"git checkout*",
"git restore*",
"git clean*",
"git reset*",
"git add*",
"git commit*",
"git push*",
"git fetch*",
"git pull*",
"git remote*",
# Bauen und Paketverwaltung
"dotnet *",
"msbuild *",
"npm install*",
"nuget *",
# PowerShell, schreibend
"Remove-Item *",
"Move-Item *",
"Copy-Item *",
"New-Item *",
"Set-Content *",
"Add-Content *",
"Clear-Content *",
"Out-File *",
"Set-ItemProperty *",
"Rename-Item *",
)
def readonly_shell_permissions() -> dict[str, str]:
return {
"*": "deny",
"rg *": "allow",
"git status*": "allow",
"git ls-files*": "allow",
"git rev-parse*": "allow",
"Get-ChildItem *": "allow",
"Get-Content *": "allow",
"Select-String *": "allow",
"Test-Path *": "allow",
"Resolve-Path *": "allow",
"where.exe *": "allow",
}
"""Denylist schreibender und bauender Shell-Kommandos.
Ab Skill 12.0.0 eine **Denylist** statt der vorherigen Allowlist: Alles ist
erlaubt, ausser den ausdruecklich gesperrten schreibenden und bauenden
Kommandos. Damit entspricht die Werkzeugfreiheit der des Claude-Code-
Adapters, der ebenfalls mit einer Denylist arbeitet - Voraussetzung dafuer,
dass Laeufe beider Adapter als Werkzeugvergleich lesbar sind.
Die Allowlist konnte Kommandos nur als Praefix treffen und scheiterte
deshalb an Pipelines: ``Get-ChildItem ... | Format-Table ...`` blieb
gesperrt, obwohl ``Get-ChildItem`` erlaubt war.
OpenCode wertet die Regeln der Reihe nach aus; die **zuletzt passende Regel
gewinnt**. Das Catch-all muss deshalb zuerst stehen. Python-Dicts erhalten
ihre Einfuegereihenfolge, ``json.dump`` schreibt sie unveraendert.
Wie beim Claude-Adapter gilt: Mustervergleich auf Kommandozeilen ist nicht
lueckenlos. Die belastbare Read-only-Garantie bleibt der Vorher/Nachher-
Vergleich per ``git status``; die Denylist senkt das Risiko, sie ersetzt
die Verifikation nicht.
"""
permissions = {"*": "allow"}
permissions.update({muster: "deny" for muster in SHELL_DENY_COMMANDS})
return permissions
def task_permissions(mode: str, custom_names: list[str]) -> str | dict[str, str]:
@@ -507,6 +565,27 @@ def normalize_result(
"exit_code": exit_code,
}
# Ein laufender Subagent taucht in der exportierten Session weder mit
# eigenen Nachrichten noch mit Tokens auf. Wird der Lauf abgebrochen,
# waehrend ein `task` noch laeuft, meldet OpenCode deshalb 0 Tokens - obwohl
# gearbeitet wurde. Diese Null ist kein Messwert und darf nicht als solcher
# ins Protokoll wandern.
laufende_tasks = [
tool
for tool in tools
if tool["name"] in ("task", "subagent") and tool["status"] == "running"
]
result["usage_captured"] = not (total_tokens == 0 and (tools or assistants))
if not result["usage_captured"]:
result["usage_note"] = (
"nicht erfasst: OpenCode weist der Session keine Tokens zu"
+ (
f"; {len(laufende_tasks)} Subagent(en) liefen beim Abbruch noch"
if laufende_tasks
else ""
)
)
if local_runtime is not None:
result["local_runtime"] = local_runtime
result["context_window"] = local_runtime.get("loaded_context_length", 0)
@@ -624,14 +703,39 @@ def run_lms(lms: Path, arguments: list[str], log, timeout: int = 1800) -> None:
def lmstudio_reload_model(
lms: Path, model: str, context_length: int, loaded_ids: list[str], log
lms: Path,
model: str,
context_length: int,
log,
parallel: int = 4,
gpu: str = "max",
) -> None:
"""Alle Instanzen des Modells entladen und genau eine neu laden."""
for identifier in loaded_ids:
run_lms(lms, ["unload", identifier], log, timeout=300)
"""Saemtliche Modelle entladen und genau das angeforderte neu laden.
Entladen wird **alles**, nicht nur andere Instanzen desselben Modells: Ein
nebenher geladenes zweites Modell belegt VRAM, das dem Lauf dann fehlt.
Gemessen wurde der Extremfall - Gemma und Qwen 27B gleichzeitig geladen,
15.836 von 16.303 MiB belegt, der Lauf fiel auf Bruchteile seines
Durchsatzes zurueck.
``parallel=1`` gibt dem einen Agentenlauf den gesamten KV-Cache; jeder
weitere Slot teilt ihn auf, ohne dass ein Einzellauf davon profitiert.
``gpu=max`` erzwingt die vollstaendige Auslagerung auf die GPU.
"""
run_lms(lms, ["unload", "--all"], log, timeout=300)
run_lms(
lms,
["load", model, "--context-length", str(context_length), "--yes"],
[
"load",
model,
"--context-length",
str(context_length),
"--parallel",
str(parallel),
"--gpu",
gpu,
"--yes",
],
log,
)
@@ -644,6 +748,9 @@ def lmstudio_preflight(
lms_path: str | None,
catalog_dump: Path,
log,
parallel: int = 4,
gpu: str = "max",
context_target: str = "max",
) -> dict:
"""Prueft den lokalen Server und liefert die Runtime-Metadaten des Laufs.
@@ -673,6 +780,21 @@ def lmstudio_preflight(
if item.get("state") == "loaded"
]
def fremde_geladene(entries: list[dict]) -> list[str]:
"""Geladene Modelle, die nicht das angeforderte sind.
Sie belegen VRAM, das dem Lauf fehlt, und veraendern damit dessen
Durchsatz - eine Versuchsbedingung, die nicht unbemerkt bleiben darf.
"""
eigene = {str(item.get("id", "")) for item in lmstudio_instances(entries, model)}
return [
str(item.get("id", ""))
for item in entries
if item.get("state") == "loaded"
and str(item.get("id", "")) not in eigene
and item.get("type") != "embeddings"
]
def select(entries: list[dict]) -> dict | None:
instances = lmstudio_instances(entries, model)
if not instances:
@@ -720,6 +842,7 @@ def lmstudio_preflight(
entry.get("state") != "loaded"
or loaded_context < min_context
or len(loaded_ids(catalog)) > 1
or bool(fremde_geladene(catalog))
)
if needs_reload and autoload:
if lms is None:
@@ -727,8 +850,19 @@ def lmstudio_preflight(
"--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)
# Freien VRAM in Kontext investieren statt verfallen lassen: Gemessen
# kostet das Modellmaximum kaum Speicher und kein Tempo (gemma-4-e4b:
# 131.072 statt 32.768 Kontext -> 6.854 statt 5.162 MiB, 46,8 statt
# 48,3 tok/s). Ein groesseres Fenster ist fuer eine Codebasisanalyse
# unmittelbar wirksam.
if context_target == "max":
target_context = max_context or min_context
else:
target_context = int(context_target)
target_context = max(target_context, min_context)
lmstudio_reload_model(
lms, model, target_context, log, parallel=parallel, gpu=gpu
)
catalog = lmstudio_catalog(base_url)
catalog_dump.write_text(
json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8"
@@ -749,6 +883,15 @@ def lmstudio_preflight(
"Codebasis stillschweigend ab. Neu laden mit: "
f"lms load {model} --context-length {min_context} --yes"
)
fremde = fremde_geladene(catalog)
if fremde:
raise RuntimeError(
"Neben '" + model + "' sind weitere Modelle geladen ("
+ ", ".join(fremde)
+ "). Sie belegen VRAM, das dem Lauf fehlt, und veraendern dessen "
"Durchsatz. Mit 'lms unload --all' entladen oder den Adapter mit "
"--lmstudio-autoload aufrufen."
)
instances = loaded_ids(catalog)
if len(instances) != 1:
raise RuntimeError(
@@ -772,6 +915,9 @@ def lmstudio_preflight(
"capabilities": capabilities,
"max_context_length": max_context,
"loaded_context_length": loaded_context,
"parallel_slots": parallel,
"gpu_offload": gpu,
"alleiniges_modell": True,
}
log(
"LM-Studio-Preflight bestanden: "
@@ -811,6 +957,29 @@ def main() -> int:
default=LMSTUDIO_MIN_CONTEXT,
help="Mindestgroesse des geladenen Kontextfensters (nur lmstudio)",
)
parser.add_argument(
"--lmstudio-parallel",
type=int,
default=4,
help=(
"Gleichzeitige Vorhersage-Slots beim Laden (nur lmstudio). Jeder "
"Slot teilt den KV-Cache; ein einzelner Agentenlauf profitiert von "
"1 und verliert bei mehr."
),
)
parser.add_argument(
"--lmstudio-context",
default="max",
help=(
"Kontextfenster beim Laden: 'max' fuer das Modellmaximum oder eine "
"Tokenzahl (nur lmstudio). Nie kleiner als --min-context."
),
)
parser.add_argument(
"--lmstudio-gpu",
default="max",
help="GPU-Offload beim Laden: off, max oder 0..1 (nur lmstudio)",
)
parser.add_argument(
"--lmstudio-autoload",
action="store_true",
@@ -820,7 +989,11 @@ def main() -> int:
"--stall-timeout",
type=int,
default=600,
help="Sekunden ohne stdout/stderr bis zum Abbruch; 0 deaktiviert",
help=(
"Sekunden ohne stdout/stderr bis zum Abbruch; 0 deaktiviert. "
"In den Modi builtin und custom zwingend 0 - waehrend ein Subagent "
"arbeitet, sendet OpenCode keine Ereignisse."
),
)
parser.add_argument(
"--max-runtime",
@@ -855,6 +1028,35 @@ def main() -> int:
if not template_path.is_file():
parser.error(f"OpenCode-Konfiguration fehlt: {template_path}")
# OpenCode meldet keinen Fortschritt, solange ein Subagent arbeitet: Der
# Ereignisstrom schweigt fuer die gesamte Dauer des Subagentenlaufs. Ein
# positiver Stall-Timeout beendet einen Lauf mit Subagenten deshalb
# zuverlaessig zu frueh und erzeugt eine Fehlmessung, die wie ein Haenger
# aussieht. Der Fall wird nicht stillschweigend korrigiert, sondern
# abgelehnt, damit die Entscheidung bewusst faellt.
if args.mode != "solo" and args.stall_timeout > 0:
parser.error(
f"Modus '{args.mode}' laesst Subagenten zu; waehrend deren Laufzeit "
"sendet OpenCode keine Ereignisse. Ein Stall-Timeout von "
f"{args.stall_timeout}s wuerde den Lauf abbrechen, sobald ein "
"Subagent arbeitet. '--stall-timeout 0' setzen und die Laufzeit "
"ueber '--max-runtime' begrenzen."
)
# Lokale Inferenz ist um Groessenordnungen langsamer als ein Cloud-Gateway.
# OpenCode sendet Ereignisse nur an Schrittgrenzen, sodass ein einzelner
# Schritt die Stille beliebig lange ausdehnen kann - bei qwen/qwen3.8-27b
# ueber 15 Minuten. Ein Stall-Timeout misst dann Modellgeschwindigkeit
# statt Haenger.
if provider_spec.get("local") and args.stall_timeout > 0:
parser.error(
f"Provider '{provider}' laeuft lokal; ein einzelner Schritt kann "
"laenger dauern als jeder sinnvolle Stall-Timeout, ohne dass ein "
f"Ereignis faellt. Ein Stall-Timeout von {args.stall_timeout}s "
"wuerde die Modellgeschwindigkeit als Haenger werten. "
"'--stall-timeout 0' setzen und die Laufzeit ueber "
"'--max-runtime' begrenzen."
)
opencode = resolve_opencode(args.opencode)
output_dir.mkdir(parents=True, exist_ok=True)
result_dir.mkdir(parents=True, exist_ok=True)
@@ -895,6 +1097,9 @@ def main() -> int:
lms_path=args.lms,
catalog_dump=meta_dir / "lmstudio-modelle.json",
log=log,
parallel=args.lmstudio_parallel,
gpu=args.lmstudio_gpu,
context_target=args.lmstudio_context,
)
except RuntimeError as exc:
log(f"Preflight fehlgeschlagen: {exc}")