fix(models): Modell-Liste kommt jetzt von Anthropic — und feste Versionen greifen wirklich

Zwei Fehler. Der zweite war der schlimmere.

1) Die Liste war handgepflegt und damit garantiert veraltet.
   Sie kannte "Opus (aktuell: Opus 5)" — waehrend es laengst Opus 5.5,
   Sonnet 5.5 und Fable 5.1 gab. Der Knopf "↻ Aktualisieren" las nur eine
   lokale Datei neu und aktualisierte damit gar nichts.

   Jetzt kommt die Liste aus Anthropics eigenem Modell-Katalog
   (downloads.claude.ai/model-catalog/v1/catalog.json, Surface 'cc') —
   dieselbe Quelle, aus der sich Claude Code selbst bedient. Oeffentlich,
   ohne API-Key (die Models-API braucht einen; mit dem Max-Abo gibt es keinen).
   6h-Cache plus Datei-Cache, der auch offline und bei abgelaufenem TTL
   einspringt: eine etwas alte echte Liste ist besser als keine.

   Die Tier-Aliase (opus/sonnet/haiku/fable) bleiben vorne und bleiben die
   Empfehlung — sie folgen automatisch der neuesten Version. Darunter die
   exakten Versionen, aeltere ausdruecklich als solche markiert.

2) Eine feste Version liess sich faktisch nicht waehlen.
   extractModel() hatte eine MODEL_MAP und fiel fuer alles Unbekannte STILL
   auf "opus" zurueck. Wer "claude-opus-5-5" setzte, bekam ohne jede Meldung
   das Tier-Default. Schlimmer noch: claude-opus-5 und claude-fable-5 standen
   selbst in der Map und wurden auf ihr Tier eingeschmolzen — eine bewusst
   gewaehlte aeltere Version wurde also durch die neueste ersetzt.

   Volle Modellnamen gehen jetzt unveraendert an die CLI. Die versteht sie
   ("--model: an alias for the latest model or a model's full name") und
   meldet sich selbst klar, wenn sie einen Namen nicht kennt — ehrlicher als
   ein stilles Downgrade. Nur echter Muell faellt noch auf "opus", und das
   wird geloggt.

Nachgewiesen gegen den LIVE-Katalog und die echte CLI im Proxy-Container:
20 Pruefungen gruen, u.a. dass alle 12 gelieferten exakten Versionen den Proxy
unveraendert passieren. `claude --print --model claude-opus-5-5` antwortet.
Stefans aktive Einstellung ist der Alias "opus" — am laufenden Betrieb aendert
sich dadurch nichts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:47:34 +02:00
co-authored by Claude Opus 5
parent 4c0d8ce3fe
commit 09d06011d5
4 changed files with 218 additions and 29 deletions
+131 -6
View File
@@ -552,19 +552,144 @@ function _loadModels() {
return DEFAULT_MODELS;
}
export function handleModels(_req, res) {
// ── Echte Modell-Liste von Anthropic ────────────────────────────────
//
// Anthropic veroeffentlicht einen signierten Modell-Katalog, aus dem auch
// Claude Code selbst seinen Modell-Auswaehler fuellt. Oeffentlich erreichbar,
// KEIN API-Key noetig (die Models-API /v1/models braucht einen; mit dem
// Max-Abo gibt es keinen).
//
// Warum ueberhaupt: die handgepflegte Liste hier veraltete garantiert. Sie
// kannte Opus 5 — waehrend im Katalog laengst Opus 5.5, Sonnet 5.5 und
// Fable 5.1 standen. Ein "↻ Aktualisieren"-Knopf, der nur eine lokale Datei
// neu liest, aktualisiert nichts.
//
// Struktur: surfaces.<surface>.model_selector_config[].models[]
// = { id, name, short_name, description, section }
// 'cc' ist die Claude-Code-Oberflaeche — genau die, durch die ARIA laeuft.
// section 'main' = die vorgeschlagenen, 'overflow' = aeltere/weitere.
const CATALOG_URL = process.env.ARIA_MODEL_CATALOG_URL
|| "https://downloads.claude.ai/model-catalog/v1/catalog.json";
const CATALOG_CACHE_FILE = "/shared/config/model_catalog_cache.json";
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; // 6h — der Katalog gilt eine Woche
const CATALOG_SURFACE = "cc";
let _catalogMem = null; // { at, models }
function _parseCatalog(doc) {
const out = [];
const surfaces = (doc && doc.surfaces && doc.surfaces[CATALOG_SURFACE]) || null;
const configs = surfaces && surfaces.model_selector_config;
if (!Array.isArray(configs)) return out;
for (const cfg of configs) {
for (const m of (cfg.models || [])) {
if (!m || typeof m.id !== "string") continue;
out.push({
id: m.id,
tier: m.short_name ? String(m.short_name).toLowerCase() : m.id,
display_name: m.name || m.id,
description: m.description || "",
section: m.section || "main",
});
}
}
return out;
}
function _readCatalogCache() {
try {
const c = JSON.parse(fs.readFileSync(CATALOG_CACHE_FILE, "utf-8"));
if (Array.isArray(c.models) && c.models.length) return c;
} catch (_) { /* kein Cache */ }
return null;
}
/**
* Liefert die Katalog-Modelle. `force` umgeht den TTL (Diagnostic-Knopf).
* Schlaegt der Abruf fehl, gilt der Cache — auch ein abgelaufener, denn eine
* etwas alte echte Liste ist besser als gar keine.
*/
async function _catalogModels(force) {
const now = Date.now();
if (!force && _catalogMem && (now - _catalogMem.at) < CATALOG_TTL_MS) {
return _catalogMem.models;
}
if (!force) {
const cached = _readCatalogCache();
if (cached && (now - (cached.at || 0)) < CATALOG_TTL_MS) {
_catalogMem = { at: cached.at, models: cached.models };
return cached.models;
}
}
try {
const ctl = AbortSignal.timeout ? AbortSignal.timeout(10000) : undefined;
const r = await fetch(CATALOG_URL, { signal: ctl });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const doc = await r.json();
const models = _parseCatalog(doc);
if (!models.length) throw new Error("Katalog enthaelt keine Modelle fuer Surface " + CATALOG_SURFACE);
_catalogMem = { at: now, models };
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync(CATALOG_CACHE_FILE, JSON.stringify({
at: now, version: doc.version, expires_at: doc.expires_at, models,
}, null, 2));
} catch (e) {
console.error("[aria-models] Katalog-Cache schreiben fehlgeschlagen:", e && e.message);
}
console.error(`[aria-models] Katalog geladen: ${models.length} Modelle (Version ${doc.version})`);
return models;
} catch (e) {
console.error("[aria-models] Katalog-Abruf fehlgeschlagen:", e && e.message);
const cached = _readCatalogCache();
if (cached) {
console.error("[aria-models] nutze Cache vom " + new Date(cached.at || 0).toISOString());
_catalogMem = { at: cached.at, models: cached.models };
return cached.models;
}
return [];
}
}
export async function handleModels(req, res) {
const created = Math.floor(Date.now() / 1000);
const models = _loadModels();
const force = !!(req && req.query && (req.query.refresh === "1" || req.query.refresh === "true"));
// 1) Tier-Aliase zuerst: sie zeigen IMMER auf die aktuelle Version ihres
// Tiers, ganz ohne Pflege. Fuer den Alltag die richtige Wahl.
const tiers = _loadModels().map(m => ({
id: m.id,
tier: m.tier || m.id,
display_name: m.display_name || m.id,
description: m.description || "",
section: "tier",
}));
// 2) Danach die exakten Versionen aus dem Anthropic-Katalog.
const catalog = await _catalogModels(force);
const seen = new Set(tiers.map(t => t.id));
const exact = catalog.filter(m => !seen.has(m.id)).map(m => ({
id: m.id,
tier: m.tier,
display_name: m.display_name
+ (m.section === "main" ? "" : " (ältere Version)"),
description: m.description
|| (m.section === "main" ? "" : "Aeltere Version — nur waehlen wenn gewollt."),
section: m.section,
}));
res.json({
object: "list",
data: models.map(m => ({
catalog_ok: catalog.length > 0,
data: [...tiers, ...exact].map(m => ({
id: m.id,
object: "model",
owned_by: "anthropic",
created,
tier: m.tier || m.id,
display_name: m.display_name || m.id,
description: m.description || "",
tier: m.tier,
display_name: m.display_name,
description: m.description,
section: m.section,
})),
});
}