commit 3206b67d1f8bfc2c1b3333e931218f14f1f59037 Author: enne2 Date: Thu Sep 3 16:35:56 2026 +0200 TensorX provider: dynamic model discovery, /tensorx management menu diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..69f73de --- /dev/null +++ b/README.md @@ -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/`. +- 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. \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..b71da4e --- /dev/null +++ b/index.ts @@ -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 { + try { + const raw = await readFile(CONFIG_PATH, "utf8"); + const parsed = JSON.parse(raw) as Partial; + 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 { + 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 { + 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> { + const map = new Map(); + 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): 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 { + 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 { + 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; + confirm(title: string, message: string): Promise; + input(title: string, placeholder?: string): Promise; +} + +// --------------------------------------------------------------------------- +// Estensione +// --------------------------------------------------------------------------- + +export default async function (pi: ExtensionAPI): Promise { + 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); + } + }, + }); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..243730d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["index.ts"] +}