feat: plasmoide KDE Plasma 6 per il credito residuo DeepSeek e OpenRouter

This commit is contained in:
Matteo Benedetto
2026-08-21 19:13:39 +02:00
commit 7614b74acf
9 changed files with 689 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
dist/
+43
View File
@@ -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 <key>` |
| OpenRouter | `GET https://openrouter.ai/api/v1/credits` | `Authorization: Bearer <key>` (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 (303600 s, default 300)
- Soglia gialla/rossa residuo (%) per le barre
- Budget di riferimento DeepSeek ($) per la barra (0 = nessuna barra)
- Mostra/nascondi DeepSeek, OpenRouter
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name=""/>
<group name="General">
<entry name="refreshIntervalSec" type="Int">
<default>300</default>
<min>30</min>
<max>3600</max>
<label>Intervallo di aggiornamento (secondi)</label>
<whatsthis>Quanto spesso il plasmoide aggiorna i crediti. Minimo 30s. Default 300s (5 minuti).</whatsthis>
</entry>
<entry name="warnThreshold" type="Int">
<default>30</default>
<min>0</min>
<max>100</max>
<label>Soglia gialla per il residuo (%)</label>
<whatsthis>Sotto questa percentuale di residuo la barra diventa gialla. Si applica alla barra di OpenRouter e (se dsBudget&gt;0) a DeepSeek.</whatsthis>
</entry>
<entry name="critThreshold" type="Int">
<default>15</default>
<min>0</min>
<max>100</max>
<label>Soglia rossa per il residuo (%)</label>
<whatsthis>Sotto questa percentuale di residuo la barra diventa rossa.</whatsthis>
</entry>
<entry name="dsBudget" type="Double">
<default>10.0</default>
<min>0</min>
<max>100000</max>
<label>Budget di riferimento DeepSeek ($)</label>
<whatsthis>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).</whatsthis>
</entry>
<entry name="showDeepSeek" type="Bool">
<default>true</default>
<label>Mostra DeepSeek</label>
</entry>
<entry name="showOpenRouter" type="Bool">
<default>true</default>
<label>Mostra OpenRouter</label>
</entry>
</group>
</kcfg>
+88
View File
@@ -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
+120
View File
@@ -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
}
}
}
}
+66
View File
@@ -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 }
}
}
+308
View File
@@ -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
}
}
}
}
}
}
+18
View File
@@ -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"
}