UniGPT provider for pi

Native /login with a verified API key, automatic model discovery from
UniGPT's /models endpoint, per-deployment deployment-id headers, and
model metadata from pi's bundled catalog.
This commit is contained in:
Benjamin Hackl 2026-11-03 20:01:28 +01:00
commit 4e854ba8c2
2 changed files with 2416 additions and 0 deletions

47
src/index.ts Normal file
View file

@ -0,0 +1,47 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { createUnigptProvider, PROVIDER_ID } from "./provider.ts";
const REFRESH_TIMEOUT_MS = 20_000;
type Level = "info" | "warning" | "error";
function report(ctx: ExtensionCommandContext, message: string, level: Level) {
if (ctx.hasUI) ctx.ui.notify(message, level);
// Without a UI (print/JSON modes), keep stdout clean for the agent's output.
else console.error(`[unigpt] ${message}`);
}
export async function refreshCatalog(ctx: ExtensionCommandContext): Promise<void> {
// A status check, unlike resolving auth, never runs a configured key command.
if (!ctx.modelRegistry.getProviderAuthStatus(PROVIDER_ID).configured) {
report(ctx, "UniGPT is not configured. Run /login unigpt first.", "warning");
return;
}
const result = await ctx.modelRegistry.refresh({
providers: [PROVIDER_ID],
force: true,
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
});
const error = result.errors.get(PROVIDER_ID);
if (result.aborted || error) {
const reason = error?.message ?? `no response within ${REFRESH_TIMEOUT_MS / 1000}s`;
report(ctx, `UniGPT refresh failed (${reason}); keeping the cached model list.`, "warning");
return;
}
const count = ctx.modelRegistry.getProvider(PROVIDER_ID)?.getModels().length ?? 0;
report(
ctx,
count > 0
? `UniGPT: ${count} models available. Select one with /model.`
: "UniGPT lists no models for this API key.",
count > 0 ? "info" : "warning",
);
}
export default function unigpt(pi: ExtensionAPI) {
pi.registerProvider(createUnigptProvider());
pi.registerCommand("unigpt-refresh", {
description: "Reload the UniGPT model list now",
handler: (_args, ctx) => refreshCatalog(ctx),
});
}

228
src/provider.ts Normal file
View file

@ -0,0 +1,228 @@
import {
createProvider,
type Model,
type Provider,
} from "@earendil-works/pi-ai";
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
export const PROVIDER_ID = "unigpt";
export const BASE_URL = "https://api.mygpt.uni-graz.at/api/v1/llms/openai";
export const WEB_URL = "https://unigpt.uni-graz.at";
/** pi's own defaults for custom models.json entries, used when an ID is not in pi's catalog. */
export const FALLBACK_CONTEXT_WINDOW = 128_000;
export const FALLBACK_MAX_TOKENS = 16_384;
export const REQUEST_TIMEOUT_MS = 15_000;
type Fetch = typeof globalThis.fetch;
/** Capabilities of a deployment, as far as they can be known locally. */
export interface ModelMetadata {
name: string;
input: ("text" | "image")[];
contextWindow: number;
maxTokens: number;
}
export type MetadataLookup = (id: string) => ModelMetadata | undefined;
/**
* UniGPT's catalog returns bare deployment IDs, and those IDs are OpenAI model IDs.
* Look them up in pi's bundled catalog, which pi keeps current, instead of keeping a
* table here. OpenAI and Azure list different limits for some models; take the
* conservative intersection so pi never plans for more than either host allows.
*/
export const lookupCatalogMetadata: MetadataLookup = (id) => {
const matches = (["openai", "azure"] as const).flatMap((provider) =>
getBuiltinModels(provider).filter((model) => model.id === id),
);
if (matches.length === 0) return undefined;
return {
name: matches[0].name,
input: matches.every((model) => model.input.includes("image")) ? ["text", "image"] : ["text"],
contextWindow: Math.min(...matches.map((model) => model.contextWindow)),
maxTokens: Math.min(...matches.map((model) => model.maxTokens)),
};
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isValidId(id: unknown): id is string {
// IDs become an HTTP header value, so reject padding and control characters.
return typeof id === "string" && id.length > 0 && id.trim() === id && !/[\u0000-\u001f\u007f]/.test(id);
}
export interface ParsedCatalog {
ids: string[];
/** Entries that were present but unusable, e.g. missing or malformed IDs. */
skipped: number;
}
/**
* Skip individual bad entries so one odd deployment cannot hide all others, but reject
* a malformed or entirely unusable catalog rather than replacing a good cache with it.
*/
export function parseCatalog(payload: unknown): ParsedCatalog {
if (!isRecord(payload) || !Array.isArray(payload.data)) {
throw new Error("UniGPT returned an invalid model catalog: expected a data array.");
}
const ids = payload.data
.map((entry: unknown) => (isRecord(entry) ? entry.id : undefined))
.filter(isValidId);
if (payload.data.length > 0 && ids.length === 0) {
throw new Error("UniGPT returned a model catalog without any valid model IDs.");
}
return { ids: [...new Set(ids)], skipped: payload.data.length - ids.length };
}
export function makeModel(
id: string,
baseUrl: string,
lookup: MetadataLookup = lookupCatalogMetadata,
): Model<"openai-responses"> {
const known = lookup(id);
return {
id,
name: known?.name ?? id,
provider: PROVIDER_ID,
api: "openai-responses",
baseUrl,
headers: { "deployment-id": id },
input: known?.input ?? ["text"],
// UniGPT ignores reasoning.effort and applies a fixed effort per deployment, so
// exposing pi's thinking levels would offer controls that have no effect.
reasoning: false,
contextWindow: known?.contextWindow ?? FALLBACK_CONTEXT_WINDOW,
maxTokens: known?.maxTokens ?? FALLBACK_MAX_TOKENS,
// University users are not billed per token; zero disables cost estimates.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
}
function statusError(response: Response): Error {
const status = response.status;
if (status === 401 || status === 403) {
return new Error(`UniGPT rejected the API key (HTTP ${status}). Check your key and API access.`);
}
if (status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? ` Retry in ${Math.ceil(retryAfter)}s.` : " Try again shortly.";
return new Error(`UniGPT is rate-limiting requests (HTTP 429).${wait}`);
}
return new Error(`UniGPT model discovery failed (HTTP ${status}). Try again later.`);
}
export async function fetchCatalog(
baseUrl: string,
apiKey: string,
signal: AbortSignal,
fetchImpl: Fetch = globalThis.fetch,
timeoutMs = REQUEST_TIMEOUT_MS,
): Promise<ParsedCatalog> {
signal.throwIfAborted();
const timeout = AbortSignal.timeout(timeoutMs);
const requestSignal = AbortSignal.any([signal, timeout]);
// Caller cancellation propagates as-is; our own deadline becomes a readable error.
const checkAborted = () => {
signal.throwIfAborted();
if (timeout.aborted) {
throw new Error(`UniGPT did not respond within ${Math.round(timeoutMs / 1000)}s. Try again later.`);
}
};
let response: Response;
try {
response = await fetchImpl(`${baseUrl.replace(/\/+$/, "")}/models`, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
signal: requestSignal,
// Do not forward a credential to a redirect target.
redirect: "error",
});
} catch {
checkAborted();
throw new Error("Could not reach the UniGPT model catalog. Check the endpoint and network connection.");
}
if (!response.ok) {
// Do not echo server bodies: they could contain credentials or other private data.
void response.body?.cancel().catch(() => {});
throw statusError(response);
}
let payload: unknown;
try {
payload = await response.json();
} catch {
checkAborted();
throw new Error("UniGPT returned a model catalog that is not valid JSON.");
}
checkAborted();
return parseCatalog(payload);
}
export interface UnigptProviderOptions {
/** Primarily useful for tests; users can override baseUrl through models.json. */
baseUrl?: string;
fetch?: Fetch;
lookup?: MetadataLookup;
}
export function createUnigptProvider(options: UnigptProviderOptions = {}): Provider<"openai-responses"> {
const baseUrl = options.baseUrl ?? BASE_URL;
const fetchImpl = options.fetch ?? globalThis.fetch;
const lookup = options.lookup ?? lookupCatalogMetadata;
return createProvider<"openai-responses">({
id: PROVIDER_ID,
name: "UniGPT",
baseUrl,
models: [],
api: openAIResponsesApi(),
auth: {
apiKey: {
name: "UniGPT API key",
async login(interaction) {
interaction.signal.throwIfAborted();
interaction.notify({
type: "info",
message: "Paste your UniGPT API key. Pi stores it in its private auth.json.",
links: [{ url: WEB_URL, label: "UniGPT (University of Graz)" }],
});
const key = (await interaction.prompt({
type: "secret",
message: "UniGPT API key",
})).trim();
interaction.signal.throwIfAborted();
if (!key) throw new Error("An API key is required.");
interaction.notify({ type: "progress", message: "Checking API access…" });
const { ids, skipped } = await fetchCatalog(baseUrl, key, interaction.signal, fetchImpl);
const skippedNote = skipped > 0 ? ` (${skipped} malformed catalog entries ignored)` : "";
interaction.notify({
type: "info",
message: ids.length > 0
? `API key accepted: ${ids.length} UniGPT models available${skippedNote}. Select one with /model.`
: "API key accepted, but UniGPT lists no models for it yet. Ask your UniGPT administrator, then run /unigpt-refresh.",
});
return { type: "api_key", key };
},
async resolve({ credential, signal }) {
signal.throwIfAborted();
if (!credential?.key) return undefined;
return {
auth: { apiKey: credential.key },
env: credential.env,
source: "stored credential",
};
},
},
},
async fetchModels(context) {
context.signal.throwIfAborted();
if (context.credential?.type !== "api_key" || !context.credential.key) {
throw new Error("Run /login unigpt before discovering models.");
}
const endpoint = context.baseUrl ?? baseUrl;
const { ids } = await fetchCatalog(endpoint, context.credential.key, context.signal, fetchImpl);
return ids.map((id) => makeModel(id, endpoint, lookup));
},
});
}