commit 7614b74acfd454bf68f0b973945564c126aba511 Author: Matteo Benedetto Date: Fri Aug 21 19:13:39 2026 +0200 feat: plasmoide KDE Plasma 6 per il credito residuo DeepSeek e OpenRouter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7773828 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b72aaf9 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# AI Credits Balance + +Plasmoide (widget) per KDE Plasma 6 che mostra il **credito residuo** di **DeepSeek** e **OpenRouter**, leggendo le chiavi API da `~/.pi/agent/auth.json` (i provider `deepseek` e `openrouter` del pi agent). + +![dashboard](assets/repository-avatar.png) + +## Dati mostrati + +- **DeepSeek** — saldo totale (`total_balance`), scomposto in `granted` + `top-up`, con badge di disponibilità (`is_available`). Barra opzionale calcolata su un budget di riferimento configurabile. +- **OpenRouter** — credito residuo (`total_credits - total_usage`), usato / totale e percentuale residua con barra. + +## Sorgenti API + +| Servizio | Endpoint | Autenticazione | +|-------------|-------------------------------------------|--------------------------| +| DeepSeek | `GET https://api.deepseek.com/user/balance` | `Authorization: Bearer ` | +| OpenRouter | `GET https://openrouter.ai/api/v1/credits` | `Authorization: Bearer ` (management key) | + +> **Nota OpenRouter:** l'endpoint `/api/v1/credits` richiede una *management key*. Una routing key normale può restituire un errore di autorizzazione. + +## Installazione + +```bash +# copia in ~/.local/share/plasma/plasmoids/ (riavvio plasmashell) +cp -r org.enne2.ai-credits.balance ~/.local/share/plasmoids/ +systemctl --user restart plasma-plasmashell.service +# poi "Aggiungi widget" → "Credito AI Residuo" +``` + +Oppure via `kpackagetool6`: + +```bash +kpackagetool6 -t Plasma/Applet -i org.enne2.ai-credits.balance.tar.gz +``` + +## Configurazione + +Dal plasmoide → **Impostazioni**: + +- Intervallo di aggiornamento (30–3600 s, default 300) +- Soglia gialla/rossa residuo (%) per le barre +- Budget di riferimento DeepSeek ($) per la barra (0 = nessuna barra) +- Mostra/nascondi DeepSeek, OpenRouter \ No newline at end of file diff --git a/assets/repository-avatar.png b/assets/repository-avatar.png new file mode 100644 index 0000000..eee5fa4 Binary files /dev/null and b/assets/repository-avatar.png differ diff --git a/contents/config/main.xml b/contents/config/main.xml new file mode 100644 index 0000000..3a4b1cd --- /dev/null +++ b/contents/config/main.xml @@ -0,0 +1,45 @@ + + + + + + 300 + 30 + 3600 + + Quanto spesso il plasmoide aggiorna i crediti. Minimo 30s. Default 300s (5 minuti). + + + 30 + 0 + 100 + + Sotto questa percentuale di residuo la barra diventa gialla. Si applica alla barra di OpenRouter e (se dsBudget>0) a DeepSeek. + + + 15 + 0 + 100 + + Sotto questa percentuale di residuo la barra diventa rossa. + + + 10.0 + 0 + 100000 + + Il budget iniziale DeepSeek usato per calcolare la percentuale residua e la barra. Imposta a quanto hai caricato inizialmente. 0 = nasconde la barra (mostra solo il valore). + + + true + + + + true + + + + \ No newline at end of file diff --git a/contents/scripts/balance.sh b/contents/scripts/balance.sh new file mode 100755 index 0000000..93c5df4 --- /dev/null +++ b/contents/scripts/balance.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Backend del plasmoide AI Credits Balance. +# Chiama le API di DeepSeek (GET /user/balance) e OpenRouter +# (GET /api/v1/credits) e restituisce un JSON unificato. Gli errori sono +# gestiti per-provider: anche se uno fallisce, l'altro viene comunque riportato. +set -u + +AUTH_JSON="${AI_AUTH_JSON:-$HOME/.pi/agent/auth.json}" +NOW="$(date +%s)" + +DS_KEY="" +OR_KEY="" +if [ -f "$AUTH_JSON" ]; then + DS_KEY="$(jq -r '.["deepseek"].key // empty' "$AUTH_JSON" 2>/dev/null)" + OR_KEY="$(jq -r '.["openrouter"].key // empty' "$AUTH_JSON" 2>/dev/null)" +fi +[ -n "$DS_KEY" ] || DS_KEY="${DEEPSEEK_API_KEY:-}" +[ -n "$OR_KEY" ] || OR_KEY="${OPENROUTER_API_KEY:-}" + +# --- DeepSeek ------------------------------------------------------------- +ds_block() { + if [ -z "$DS_KEY" ]; then + printf '{"ok":false,"error":"no_api_key"}' + return + fi + local resp rc + resp="$(curl -sS --max-time 15 https://api.deepseek.com/user/balance \ + -H "Authorization: Bearer $DS_KEY" 2>/dev/null)" + rc=$? + if [ "$rc" -ne 0 ] || [ -z "$resp" ]; then + printf '{"ok":false,"error":"fetch_failed","detail":"curl exit %s"}' "$rc" + return + fi + if ! printf '%s' "$resp" | jq -e '.balance_infos | type == "array"' >/dev/null 2>&1; then + printf '{"ok":false,"error":"bad_json","detail":"risposta non valida da DeepSeek"}' + return + fi + printf '%s' "$resp" | jq -c '{ + ok:true, + available:(.is_available // false), + currency:((.balance_infos[0].currency) // "USD"), + total:((.balance_infos[0].total_balance) | tonumber), + granted:((.balance_infos[0].granted_balance) | tonumber), + topped_up:((.balance_infos[0].topped_up_balance) | tonumber) + }' 2>/dev/null || printf '{"ok":false,"error":"bad_json","detail":"impossibile elaborare DeepSeek"}' +} + +# --- OpenRouter ----------------------------------------------------------- +or_block() { + if [ -z "$OR_KEY" ]; then + printf '{"ok":false,"error":"no_api_key"}' + return + fi + local resp rc + resp="$(curl -sS --max-time 15 https://openrouter.ai/api/v1/credits \ + -H "Authorization: Bearer $OR_KEY" 2>/dev/null)" + rc=$? + if [ "$rc" -ne 0 ] || [ -z "$resp" ]; then + printf '{"ok":false,"error":"fetch_failed","detail":"curl exit %s"}' "$rc" + return + fi + if ! printf '%s' "$resp" | jq -e '.data.total_credits | type == "number"' >/dev/null 2>&1; then + # 401/403 => la routing key non ha permessi sul /credits (serve management key). + printf '{"ok":false,"error":"bad_json","detail":"risposta non valida (la chiave potrebbe essere una routing key: serve una management key)"}' + return + fi + printf '%s' "$resp" | jq -c '{ + ok:true, + total_credits:(.data.total_credits), + total_usage:(.data.total_usage), + remaining:((.data.total_credits) - (.data.total_usage)) + }' 2>/dev/null || printf '{"ok":false,"error":"bad_json","detail":"impossibile elaborare OpenRouter"}' +} + +DS_JSON="$(ds_block)" +OR_JSON="$(or_block)" + +# ok globale = almeno un provider sano. +GLOBAL_OK="$(jq -n --argjson d "$DS_JSON" --argjson o "$OR_JSON" '($d.ok or $o.ok)')" + +jq -nc \ + --argjson ok "$GLOBAL_OK" \ + --argjson deepseek "$DS_JSON" \ + --argjson openrouter "$OR_JSON" \ + --argjson fetched "$NOW" \ + '{ok:$ok, deepseek:$deepseek, openrouter:$openrouter, fetched_at:$fetched}' + +exit 0 \ No newline at end of file diff --git a/contents/ui/CreditCard.qml b/contents/ui/CreditCard.qml new file mode 100644 index 0000000..9021be7 --- /dev/null +++ b/contents/ui/CreditCard.qml @@ -0,0 +1,120 @@ +/* Card per un servizio AI: saldo residuo grande + barra + sottocampi. */ +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.components 3.0 as PC3 +import org.kde.kirigami as Kirigami + +Rectangle { + id: root + + property string title: "" + property string badge: "" // es. "USD" + property color accent: "#00bcd4" + property string remainingText: "—" // testo grande del residuo (es. "$1.56") + property string subText: "" // riga secondaria + property string footText: "" // riga footer (es. "16% · usati $8.44") + property real progress: 1.0 // 0..1 per la barra; NaN per nasconderla + property bool available: true + property bool hasError: false + property string errorText: "" + + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.preferredHeight: 54 + radius: 6 + color: Qt.rgba(0, 0, 0, 0.26) + border.width: 1 + border.color: root.accent + + ColumnLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 2 + + RowLayout { + Layout.fillWidth: true + spacing: 5 + + Rectangle { + Layout.preferredWidth: 34 + Layout.preferredHeight: 14 + radius: 8 + color: root.accent + PC3.Label { + anchors.centerIn: parent + text: root.badge + color: "#101012" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + PC3.Label { + text: root.title + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + Layout.minimumWidth: 0 + } + Rectangle { + visible: !root.hasError + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: root.available ? root.accent : Kirigami.Theme.negativeTextColor + } + PC3.Label { + text: root.remainingText + color: root.accent + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 3 + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 6 + visible: !root.hasError && !isNaN(root.progress) + Rectangle { + id: track + anchors.fill: parent + radius: 4 + color: Qt.rgba(0, 0, 0, 0.55) + } + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: track.width * Math.max(0, Math.min(1, root.progress)) + height: parent.height + radius: 4 + color: root.accent + } + } + + PC3.Label { + visible: root.hasError + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Kirigami.Theme.negativeTextColor + text: root.errorText + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + + RowLayout { + Layout.fillWidth: true + visible: !root.hasError + PC3.Label { + text: root.subText + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + Layout.fillWidth: true + Layout.minimumWidth: 0 + elide: Text.ElideRight + } + PC3.Label { + text: root.footText + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } +} \ No newline at end of file diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml new file mode 100644 index 0000000..6f773f0 --- /dev/null +++ b/contents/ui/configGeneral.qml @@ -0,0 +1,66 @@ +/* + * configGeneral.qml — pagina di configurazione del plasmoide AI Credits Balance. + */ +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.components 3.0 as PC3 +import org.kde.kirigami as Kirigami + +Item { + id: page + + property alias cfg_refreshIntervalSec: refreshSpin.value + property alias cfg_warnThreshold: warnSpin.value + property alias cfg_critThreshold: critSpin.value + property alias cfg_dsBudget: budgetSpin.value + property alias cfg_showDeepSeek: dsCheck.checked + property alias cfg_showOpenRouter: orCheck.checked + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + text: i18n("Intervallo di aggiornamento (secondi)") + font.bold: true + } + PC3.SpinBox { + id: refreshSpin + from: 30 + to: 3600 + stepSize: 30 + Layout.fillWidth: true + } + + PC3.Label { text: i18n("Soglia residuo giallo (%)"); font.bold: true } + PC3.SpinBox { id: warnSpin; from: 0; to: 100; Layout.fillWidth: true } + + PC3.Label { text: i18n("Soglia residuo rosso (%)"); font.bold: true } + PC3.SpinBox { id: critSpin; from: 0; to: 100; Layout.fillWidth: true } + + PC3.Label { + text: i18n("Budget di riferimento DeepSeek ($)") + font.bold: true + } + PC3.SpinBox { + id: budgetSpin + from: 0 + to: 100000 + stepSize: 1 + Layout.fillWidth: true + } + PC3.Label { + text: i18n("Usato per calcolare % e barra di DeepSeek. 0 = nessuna barra (solo valore).") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + Layout.fillWidth: true + wrapMode: Text.Wrap + } + + PC3.CheckBox { id: dsCheck; text: i18n("Mostra DeepSeek") } + PC3.CheckBox { id: orCheck; text: i18n("Mostra OpenRouter") } + + Item { Layout.fillHeight: true } + } +} \ No newline at end of file diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..d414b71 --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,308 @@ +/* AI Credits Balance — dashboard desktop per KDE Plasma 6. */ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.plasma5support as Plasma5Support +import org.kde.kirigami as Kirigami + +PlasmoidItem { + id: root + + property var data: null + property string lastUpdate: "" + property bool loading: false + + property int refreshSecs: Plasmoid.configuration.refreshIntervalSec + property int warnThreshold: Plasmoid.configuration.warnThreshold + property int critThreshold: Plasmoid.configuration.critThreshold + property real dsBudget: Plasmoid.configuration.dsBudget + property bool showDeepSeek: Plasmoid.configuration.showDeepSeek + property bool showOpenRouter: Plasmoid.configuration.showOpenRouter + + readonly property color dsAccent: "#4f93ff" + readonly property color orAccent: "#00bcd4" + + Plasmoid.title: i18n("AI Credits") + Plasmoid.icon: "view-statistics" + Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground + toolTipMainText: i18n("Credito AI residuo") + toolTipSubText: root.toolTipSub() + + // Desktop-first; la vista compatta è solo fallback per pannelli. + preferredRepresentation: fullRepresentation + switchWidth: 150 + switchHeight: 150 + + Plasma5Support.DataSource { + id: executable + engine: "executable" + connectedSources: [] + + onNewData: function(sourceName, data) { + root.loading = false + executable.disconnectSource(sourceName) + if (data["exit code"] !== 0) { + // lo script non fallisce quasi mai (gestione interna), ma… + return + } + try { + const result = JSON.parse((data["stdout"] || "").trim()) + if (result) { + root.data = result + root.lastUpdate = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss") + } + } catch (e) { + // ignore + } + } + } + + function refresh() { + if (root.loading) return + root.loading = true + const path = Qt.resolvedUrl("../scripts/balance.sh").toString().replace(/^file:\/\//, "") + executable.connectSource("/bin/bash " + shellQuote(path)) + } + function shellQuote(s) { return "'" + String(s).replace(/'/g, "'\"'\"'") + "'" } + + // Colore accent in base alla percentuale residua. + function accentFor(pct, normalAccent) { + if (isNaN(pct)) return normalAccent + if (pct <= root.critThreshold) return Kirigami.Theme.negativeTextColor + if (pct <= root.warnThreshold) return Kirigami.Theme.neutralTextColor + return normalAccent + } + function money(v) { + if (typeof v !== "number" || isNaN(v)) return "—" + return "$" + v.toFixed(2) + } + function pctStr(pct) { + return isNaN(pct) ? "" : Math.round(pct) + "%" + } + + // DeepSeek + function dsData() { return root.data && root.data.deepseek ? root.data.deepseek : null } + function dsRemainingPct() { + const d = dsData() + if (!d || !d.ok || root.dsBudget <= 0) return NaN + return Math.max(0, (d.total / root.dsBudget) * 100) + } + function dsProgress() { + const d = dsData() + if (!d || !d.ok || root.dsBudget <= 0) return NaN + return Math.max(0, Math.min(1, d.total / root.dsBudget)) + } + function dsRemainingText() { + const d = dsData() + if (!d) return "…" + if (!d.ok) return "ERR" + return money(d.total) + } + function dsAccentColor() { + const d = dsData() + if (!d || !d.ok) return Kirigami.Theme.negativeTextColor + const p = dsRemainingPct() + return isNaN(p) ? root.dsAccent : root.accentFor(p, root.dsAccent) + } + function dsSub() { + const d = dsData() + if (!d || !d.ok) return "" + return i18n("Granted %1 · Top-up %2", money(d.granted), money(d.topped_up)) + } + function dsFoot() { + const d = dsData() + if (!d || !d.ok) return "" + const p = dsRemainingPct() + if (isNaN(p)) return d.available ? i18n("disponibile") : i18n("NON disponibile") + return (d.available ? i18n("disponibile · %1", pctStr(p)) : i18n("NON disponibile · %1", pctStr(p))) + } + function dsError() { + const d = dsData() + if (!d || d.ok) return "" + const map = { "no_api_key": i18n("nessuna chiave DeepSeek"), "fetch_failed": i18n("connessione fallita"), "bad_json": i18n("risposta non valida") } + return map[d.error] ? map[d.error] : i18n("errore") + } + + // OpenRouter + function orData() { return root.data && root.data.openrouter ? root.data.openrouter : null } + function orRemaining() { + const d = orData() + return d && d.ok ? d.remaining : NaN + } + function orRemainingPct() { + const d = orData() + if (!d || !d.ok || !d.total_credits) return NaN + return Math.max(0, (d.remaining / d.total_credits) * 100) + } + function orProgress() { + const d = orData() + if (!d || !d.ok || !d.total_credits) return NaN + return Math.max(0, Math.min(1, d.remaining / d.total_credits)) + } + function orRemainingText() { + const d = orData() + if (!d) return "…" + if (!d.ok) return "ERR" + return money(d.remaining) + } + function orAccentColor() { + const d = orData() + if (!d || !d.ok) return Kirigami.Theme.negativeTextColor + return root.accentFor(orRemainingPct(), root.orAccent) + } + function orSub() { + const d = orData() + if (!d || !d.ok) return "" + return i18n("Usati %1 / %2", money(d.total_usage), money(d.total_credits)) + } + function orFoot() { + const d = orData() + if (!d || !d.ok) return "" + return pctStr(orRemainingPct()) + } + function orError() { + const d = orData() + if (!d || d.ok) return "" + const map = { "no_api_key": i18n("nessuna chiave OpenRouter"), "fetch_failed": i18n("connessione fallita"), "bad_json": i18n("risposta non valida (forse serve una management key)") } + return map[d.error] ? map[d.error] : i18n("errore") + } + + function toolTipSub() { + if (!root.data) return i18n("Aggiornamento…") + const d = dsData(), o = orData() + const ds = d && d.ok ? money(d.total) : "—" + const ors = o && o.ok ? money(o.remaining) : "—" + return i18n("DeepSeek %1 · OpenRouter %2", ds, ors) + } + + Component.onCompleted: root.refresh() + Timer { + interval: Math.max(30, root.refreshSecs) * 1000 + repeat: true + running: true + onTriggered: root.refresh() + } + + compactRepresentation: Component { + Item { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + Kirigami.Icon { + anchors.fill: parent + source: Plasmoid.icon + color: root.data && root.data.ok ? root.dsAccent : Kirigami.Theme.disabledTextColor + } + MouseArea { anchors.fill: parent; onClicked: Plasmoid.expanded = !Plasmoid.expanded } + } + } + + fullRepresentation: Component { + Item { + implicitWidth: 224 + implicitHeight: 232 + Layout.preferredWidth: 224 + Layout.preferredHeight: 232 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 5 + + // Header + RowLayout { + Layout.fillWidth: true + Kirigami.Icon { + source: Plasmoid.icon + color: root.dsAccent + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + } + ColumnLayout { + spacing: 0 + PC3.Label { + text: i18n("CREDITO AI") + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 1 + } + PC3.Label { + text: root.data && root.data.ok ? i18n("RESIDUO") : i18n("OFFLINE") + color: root.data && root.data.ok ? root.dsAccent : Kirigami.Theme.negativeTextColor + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 2 + } + } + Item { Layout.fillWidth: true } + Rectangle { + Layout.preferredWidth: 9 + Layout.preferredHeight: 9 + radius: 5 + color: root.data && root.data.ok ? "#7cb342" : Kirigami.Theme.negativeTextColor + } + PC3.Button { + icon.name: "view-refresh" + display: QQC2.AbstractButton.IconOnly + enabled: !root.loading + onClicked: root.refresh() + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.rgba(0, 188 / 255, 212 / 255, 0.45) + } + + CreditCard { + Layout.fillWidth: true + visible: root.showDeepSeek + title: i18n("DeepSeek") + badge: dsData() && dsData().ok ? dsData().currency : "DS" + accent: root.dsAccentColor() + remainingText: root.dsRemainingText() + subText: root.dsSub() + footText: root.dsFoot() + progress: root.dsProgress() + available: dsData() ? dsData().available : false + hasError: dsData() ? !dsData().ok : false + errorText: root.dsError() + } + + CreditCard { + Layout.fillWidth: true + visible: root.showOpenRouter + title: i18n("OpenRouter") + badge: "USD" + accent: root.orAccentColor() + remainingText: root.orRemainingText() + subText: root.orSub() + footText: root.orFoot() + progress: root.orProgress() + available: orData() ? orData().ok : false + hasError: orData() ? !orData().ok : false + errorText: root.orError() + } + + Item { Layout.fillHeight: true } + + // Footer sync + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: root.lastUpdate ? i18n("SYNC %1", root.lastUpdate) : i18n("SYNC…") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: root.loading ? i18n("aggiornamento…") : "" + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } + } + } +} \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..828937c --- /dev/null +++ b/metadata.json @@ -0,0 +1,18 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Id": "org.enne2.ai-credits.balance", + "Name": "AI Credits Balance", + "Name[it]": "Credito AI Residuo", + "Description": "Shows the remaining credit for DeepSeek and OpenRouter, reading the API keys from ~/.pi/agent/auth.json (the 'deepseek' and 'openrouter' providers of the pi agent)", + "Description[it]": "Mostra il credito residuo di DeepSeek e OpenRouter, leggendo le chiavi API da ~/.pi/agent/auth.json (provider 'deepseek' e 'openrouter' di pi agent)", + "Icon": "view-statistics", + "Category": "System Information", + "License": "GPL-3.0", + "Version": "1.0.0", + "Authors": [ + { "Name": "enne2" } + ] + }, + "X-Plasma-API-Minimum-Version": "6.0" +} \ No newline at end of file