404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
},
|
|
});
|
|
} |