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:
commit
4e854ba8c2
2 changed files with 2416 additions and 0 deletions
47
src/index.ts
Normal file
47
src/index.ts
Normal 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
228
src/provider.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue