TensorX provider: dynamic model discovery, /tensorx management menu

This commit is contained in:
enne2
2026-09-03 16:35:56 +02:00
commit 3206b67d1f
4 changed files with 462 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+46
View File
@@ -0,0 +1,46 @@
# TensorX provider per pi-agent
Estensione pi che registra **TensorX** (https://api.tensorx.ai) come provider di modelli
OpenAI-compatible, con scoperta dinamica dei modelli e menù di gestione.
## Funzionamento
- All'avvio la factory async legge la config (`~/.pi/agent/tensorx.json`) e, se è presente
una API key, interroga:
- `GET /v1/models` — elenco modelli disponibili per la chiave
- `GET /v1/model/info` — metadati (best-effort): `max_input_tokens`, `max_output_tokens`,
`supports_reasoning`, `supports_vision`, `mode` (i modelli non-LLM vengono filtrati)
e registra il provider `tensorx` (`openai-completions`). Se la rete fallisce usa la cache locale.
- I modelli compaiono nel selettore `/model` come `tensorx/<id>`.
- Se non c'è una chiave, il provider non viene registrato: usa `/tensorx` per configurarla.
## Comando `/tensorx`
Menù interattivo:
- **Aggiorna elenco modelli** — fetch live + re-registrazione immediata (senza `/reload`)
- **Modifica API key** — input + validazione live contro `/v1/models`
- **Mostra stato** — endpoint, sorgente chiave, numero modelli, ultima sincronizzazione
- **Esci**
Sottocomandi diretti: `/tensorx refresh`, `/tensorx key`, `/tensorx status`.
## Config — `~/.pi/agent/tensorx.json`
```json
{
"apiKey": "la-tua-chiave",
"baseUrl": "https://api.tensorx.ai/v1",
"updatedAt": "2026-01-01T00:00:00.000Z",
"models": [ { "id": "z-ai/glm-5.2", "reasoning": true, "contextWindow": 1048576, "maxTokens": 131072 } ]
}
```
- File creato automaticamente con permessi `600` (contiene la chiave in chiaro, come `auth.json`).
- Precedenza chiave: config → variabile d'ambiente `TENSORX_API_KEY`.
- `baseUrl` opzionale (utile per test o endpoint self-hosted).
## Note
- I costi non sono pubblicati via API: rimangono a zero (nessun tracciamento costi).
- I modelli con `supports_reasoning: true` ricevono `reasoning_effort` quando il thinking è attivo.
+404
View File
@@ -0,0 +1,404 @@
/**
* TensorX provider per pi-agent.
*
* Registra TensorX (https://api.tensorx.ai, endpoint OpenAI-compatible) come provider
* di modelli con scoperta dinamica (GET /v1/models + GET /v1/model/info) e un menù
* di gestione (/tensorx) per aggiornare l'elenco modelli e modificare l'API key.
*
* Config: ~/.pi/agent/tensorx.json (apiKey, baseUrl?, cache modelli)
* Chiave: config > env TENSORX_API_KEY
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
// ---------------------------------------------------------------------------
// Costanti e tipi
// ---------------------------------------------------------------------------
const PROVIDER_ID = "tensorx";
const PROVIDER_NAME = "TensorX";
const DEFAULT_BASE_URL = "https://api.tensorx.ai/v1";
const CONFIG_PATH = join(homedir(), ".pi", "agent", "tensorx.json");
const FETCH_TIMEOUT_MS = 12_000;
interface CachedModel {
id: string;
ownedBy?: string;
created?: number;
reasoning?: boolean;
vision?: boolean;
contextWindow?: number;
maxTokens?: number;
}
interface TensorXConfig {
apiKey: string | null;
baseUrl?: string;
updatedAt: string | null;
models: CachedModel[];
}
interface RawModel {
id: string;
owned_by?: string;
created?: number;
}
interface TensorXModelInfo {
mode?: string | null;
max_input_tokens?: number | null;
max_output_tokens?: number | null;
supports_reasoning?: boolean | null;
supports_vision?: boolean | null;
}
type ProviderModelDef = {
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
};
// Stato runtime per /tensorx status
const state = {
registered: false,
modelsCount: 0,
keySource: null as "config" | "env" | null,
baseUrl: DEFAULT_BASE_URL,
updatedAt: null as string | null,
lastError: null as string | null,
};
// Notifiche differite alla session_start (la factory non ha ctx.ui)
let startupNotice: { message: string; kind: "info" | "warning" } | null = null;
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const emptyConfig = (): TensorXConfig => ({ apiKey: null, updatedAt: null, models: [] });
async function loadConfig(): Promise<TensorXConfig> {
try {
const raw = await readFile(CONFIG_PATH, "utf8");
const parsed = JSON.parse(raw) as Partial<TensorXConfig>;
return {
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : null,
baseUrl: typeof parsed.baseUrl === "string" && parsed.baseUrl.trim() ? parsed.baseUrl.trim() : DEFAULT_BASE_URL,
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null,
models: Array.isArray(parsed.models) ? parsed.models.filter((m) => m && typeof m.id === "string") : [],
};
} catch {
return emptyConfig();
}
}
async function saveConfig(cfg: TensorXConfig): Promise<void> {
await mkdir(join(CONFIG_PATH, ".."), { recursive: true });
await writeFile(CONFIG_PATH, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
await chmod(CONFIG_PATH, 0o600);
}
function resolveApiKey(cfg: TensorXConfig): { key: string | null; source: "config" | "env" | null } {
const fromConfig = cfg.apiKey?.trim() ?? "";
if (fromConfig) return { key: fromConfig, source: "config" };
const fromEnv = process.env.TENSORX_API_KEY?.trim() ?? "";
if (fromEnv) return { key: fromEnv, source: "env" };
return { key: null, source: null };
}
// La config-value syntax di pi interpreta "$ENV" e "!command": neutralizziamo
// ogni "$" e il "!" iniziale così la chiave viene usata letteralmente.
function escapeApiKeyValue(key: string): string {
const escaped = key.replaceAll("$", "$$");
if (escaped.startsWith("!")) return `$!${escaped.slice(1)}`;
return escaped;
}
// ---------------------------------------------------------------------------
// API TensorX
// ---------------------------------------------------------------------------
async function fetchModels(baseUrl: string, key: string): Promise<RawModel[]> {
const response = await fetch(`${baseUrl}/models`, {
headers: { Authorization: `Bearer ${key}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}${response.status === 401 ? " — API key non valida" : ""}`);
}
const payload = (await response.json()) as { data?: RawModel[] };
const data = Array.isArray(payload.data) ? payload.data : [];
return data.filter((m) => m && typeof m.id === "string");
}
async function fetchModelInfo(baseUrl: string, key: string): Promise<Map<string, TensorXModelInfo>> {
const map = new Map<string, TensorXModelInfo>();
try {
const response = await fetch(`${baseUrl}/model/info`, {
headers: { Authorization: `Bearer ${key}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return map;
const payload = (await response.json()) as
| Array<{ model_name?: string; model_info?: TensorXModelInfo }>
| { data?: Array<{ model_name?: string; model_info?: TensorXModelInfo }> };
const entries = Array.isArray(payload) ? payload : (payload.data ?? []);
for (const entry of entries) {
if (entry?.model_name && entry.model_info) map.set(entry.model_name, entry.model_info);
}
} catch {
// best-effort: senza info usiamo i default
}
return map;
}
const positive = (n: number | null | undefined) => (typeof n === "number" && n > 0 ? n : undefined);
// I modelli non-LLM (audio/embedding/...) vengono esclusi quando il campo mode lo indica.
const NON_CHAT_MODE = /audio|tts|stt|speech|whisper|embed|rerank|image|vision-only/i;
function buildModelDefs(raw: RawModel[], infoMap: Map<string, TensorXModelInfo>): ProviderModelDef[] {
const defs: ProviderModelDef[] = [];
for (const m of raw) {
const info = infoMap.get(m.id);
if (info?.mode && NON_CHAT_MODE.test(info.mode)) continue;
defs.push({
id: m.id,
name: m.id,
reasoning: info?.supports_reasoning === true,
input: info?.supports_vision === true ? ["text", "image"] : ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: positive(info?.max_input_tokens) ?? 128_000,
maxTokens: positive(info?.max_output_tokens) ?? 8_192,
});
}
return defs;
}
function cacheToDefs(models: CachedModel[]): ProviderModelDef[] {
return models.map((m) => ({
id: m.id,
name: m.id,
reasoning: m.reasoning === true,
input: m.vision === true ? (["text", "image"] as const) : (["text"] as const),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: positive(m.contextWindow) ?? 128_000,
maxTokens: positive(m.maxTokens) ?? 8_192,
}));
}
function defsToCache(defs: ProviderModelDef[], raw: RawModel[]): CachedModel[] {
const byId = new Map(raw.map((m) => [m.id, m]));
return defs.map((d) => ({
id: d.id,
ownedBy: byId.get(d.id)?.owned_by,
created: byId.get(d.id)?.created,
reasoning: d.reasoning,
vision: d.input.includes("image"),
contextWindow: d.contextWindow,
maxTokens: d.maxTokens,
}));
}
// ---------------------------------------------------------------------------
// Registrazione provider
// ---------------------------------------------------------------------------
function registerProvider(pi: ExtensionAPI, defs: ProviderModelDef[], key: string, keySource: "config" | "env"): void {
pi.registerProvider(PROVIDER_ID, {
name: PROVIDER_NAME,
baseUrl: state.baseUrl,
apiKey: keySource === "env" ? "$TENSORX_API_KEY" : escapeApiKeyValue(key),
api: "openai-completions",
models: defs,
});
state.registered = true;
state.modelsCount = defs.length;
state.lastError = null;
}
/** Scarica i modelli live; se riesce aggiorna cache e provider. Ritorna un messaggio. */
async function refreshModels(pi: ExtensionAPI, cfg: TensorXConfig): Promise<{ ok: boolean; message: string }> {
const { key, source } = resolveApiKey(cfg);
if (!key || !source) {
return { ok: false, message: "Nessuna API key configurata: usa «Modifica API key» nel menù." };
}
try {
const raw = await fetchModels(state.baseUrl, key);
if (raw.length === 0) {
state.lastError = "L'endpoint /v1/models ha restituito una lista vuota";
return { ok: false, message: "L'endpoint /v1/models ha restituito una lista vuota." };
}
const infoMap = await fetchModelInfo(state.baseUrl, key);
const defs = buildModelDefs(raw, infoMap);
if (defs.length === 0) {
state.lastError = "Nessun modello LLM dopo il filtro dei modelli non-chat";
return { ok: false, message: "Nessun modello LLM trovato dopo il filtro (tutti esclusi come non-chat)." };
}
cfg.models = defsToCache(defs, raw);
cfg.updatedAt = new Date().toISOString();
state.updatedAt = cfg.updatedAt;
state.keySource = source;
await saveConfig(cfg);
registerProvider(pi, defs, key, source);
const infoNote = infoMap.size > 0 ? ` (+${infoMap.size} metadati)` : "";
return { ok: true, message: `TensorX aggiornato: ${defs.length} modelli registrati${infoNote}.` };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
state.lastError = message;
return { ok: false, message: `Aggiornamento fallito: ${message}. La cache locale è rimasta invariata.` };
}
}
// ---------------------------------------------------------------------------
// Menù /tensorx
// ---------------------------------------------------------------------------
async function menuUpdateModels(pi: ExtensionAPI, cfg: TensorXConfig, ui: ExtensionContextUI): Promise<void> {
ui.notify("Aggiornamento modelli da api.tensorx.ai…", "info");
const result = await refreshModels(pi, cfg);
ui.notify(result.message, result.ok ? "info" : "error");
}
async function menuChangeKey(pi: ExtensionAPI, cfg: TensorXConfig, ui: ExtensionContextUI): Promise<void> {
const current = cfg.apiKey ? "configurata nel file config" : "non configurata";
const key = await ui.input(`TensorX API key (attuale: ${current}):`, "incolla la chiave");
if (key === undefined) return; // annullato
const trimmed = key.trim();
if (!trimmed) {
ui.notify("Chiave vuota: nessuna modifica.", "warning");
return;
}
// Validazione live prima di salvare
ui.notify("Validazione della chiave…", "info");
let valid = false;
let validationError = "";
try {
const raw = await fetchModels(state.baseUrl, trimmed);
valid = raw.length >= 0; // HTTP 200 → chiave accettata
} catch (error) {
validationError = error instanceof Error ? error.message : String(error);
}
if (!valid) {
const proceed = await ui.confirm(
"Chiave non valida?",
`La verifica contro ${state.baseUrl}/models è fallita (${validationError}). Salvare comunque?`,
);
if (!proceed) {
ui.notify("Chiave non salvata.", "info");
return;
}
}
cfg.apiKey = trimmed;
cfg.updatedAt = new Date().toISOString();
state.updatedAt = cfg.updatedAt;
await saveConfig(cfg);
ui.notify("API key salvata in ~/.pi/agent/tensorx.json.", "info");
// Aggiorna subito l'elenco modelli con la nuova chiave
const result = await refreshModels(pi, cfg);
ui.notify(result.message, result.ok ? "info" : "error");
}
function menuStatus(ui: ExtensionContextUI, cfg: TensorXConfig): void {
const { source } = resolveApiKey(cfg);
const lines = [
`Endpoint: ${state.baseUrl}`,
`API key: ${source === "config" ? "da config (~/.pi/agent/tensorx.json)" : source === "env" ? "da env TENSORX_API_KEY" : "NON configurata"}`,
`Provider: ${state.registered ? `attivo, ${state.modelsCount} modelli` : "non registrato"}`,
`Ultima sincronizzazione: ${state.updatedAt ?? "mai"}`,
];
if (state.lastError) lines.push(`Ultimo errore: ${state.lastError}`);
ui.notify(lines.join("\n"), source ? "info" : "warning");
}
interface ExtensionContextUI {
notify(text: string, kind?: "info" | "warning" | "error"): void;
select(title: string, options: string[]): Promise<string | undefined>;
confirm(title: string, message: string): Promise<boolean>;
input(title: string, placeholder?: string): Promise<string | undefined>;
}
// ---------------------------------------------------------------------------
// Estensione
// ---------------------------------------------------------------------------
export default async function (pi: ExtensionAPI): Promise<void> {
const cfg = await loadConfig();
state.baseUrl = cfg.baseUrl ?? DEFAULT_BASE_URL;
state.updatedAt = cfg.updatedAt;
const { key, source } = resolveApiKey(cfg);
state.keySource = source;
if (key) {
const result = await refreshModels(pi, cfg);
if (!result.ok) {
// Rete fallita o lista vuota: prova la cache locale
if (cfg.models.length > 0 && source) {
registerProvider(pi, cacheToDefs(cfg.models), key, source);
startupNotice = {
message: `TensorX: fetch fallita (${result.message.replace(/^Aggiornamento fallito: /, "")}) — usati ${cfg.models.length} modelli dalla cache.`,
kind: "warning",
};
} else {
startupNotice = { message: `TensorX: ${result.message} Usa /tensorx per gestire il provider.`, kind: "warning" };
}
}
} else if (cfg.models.length > 0) {
startupNotice = {
message: `TensorX: nessuna API key configurata (cache con ${cfg.models.length} modelli non registrata). Usa /tensorx → Modifica API key.`,
kind: "warning",
};
} else {
startupNotice = {
message: "TensorX: nessuna API key configurata. Usa /tensorx per impostarla e abilitare il provider.",
kind: "info",
};
}
pi.on("session_start", async (event, ctx) => {
if (startupNotice && (event.reason === "startup" || event.reason === "reload")) {
ctx.ui.notify(startupNotice.message, startupNotice.kind);
startupNotice = null;
}
});
pi.registerCommand("tensorx", {
description: "TensorX: aggiorna modelli, modifica API key, stato provider",
handler: async (args, ctx) => {
const live = await loadConfig();
const ui: ExtensionContextUI = ctx.ui;
const sub = args?.trim();
if (sub === "refresh") return void (await menuUpdateModels(pi, live, ui));
if (sub === "key") return void (await menuChangeKey(pi, live, ui));
if (sub === "status") return void menuStatus(ui, live);
for (;;) {
const choice = await ui.select(
"TensorX — gestore provider",
[
`Aggiorna elenco modelli${state.registered ? ` (${state.modelsCount} attuali)` : ""}`,
"Modifica API key",
"Mostra stato",
"Esci",
],
);
if (choice === undefined || choice.startsWith("Esci")) break;
if (choice.startsWith("Aggiorna")) await menuUpdateModels(pi, live, ui);
else if (choice.startsWith("Modifica")) await menuChangeKey(pi, live, ui);
else if (choice.startsWith("Mostra")) menuStatus(ui, live);
}
},
});
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"skipLibCheck": true
},
"include": ["index.ts"]
}