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
12 changed files with 2416 additions and 0 deletions
27
.github/workflows/ci.yml
vendored
Normal file
27
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [22, 24]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm run ci
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Benjamin Hackl
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
126
README.md
Normal file
126
README.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# pi-unigpt
|
||||||
|
|
||||||
|
Use [UniGPT](https://unigpt.uni-graz.at), the University of Graz's MyGPT
|
||||||
|
platform, as a model provider in [pi](https://pi.dev).
|
||||||
|
|
||||||
|
- Log in with **`/login unigpt`**; your key is checked before pi saves it.
|
||||||
|
- Models are discovered automatically. No model list to maintain.
|
||||||
|
- Context size, output limit, and image support come from pi's own model
|
||||||
|
catalog, so they stay current as pi updates.
|
||||||
|
- Tool calling, streaming, and image input work through pi's built-in
|
||||||
|
OpenAI Responses support.
|
||||||
|
|
||||||
|
Requires pi **1.5.x** and Node.js **22.19+**.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pi install git:github.com/behackl/pi-unigpt
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start pi (or run `/reload` in a running session) and log in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/login unigpt
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste your UniGPT API key into the masked dialog, **not into the chat**. Pi
|
||||||
|
confirms how many models your key can use. Pick one with `/model`; UniGPT
|
||||||
|
models are listed under `unigpt`.
|
||||||
|
|
||||||
|
To try the extension without installing it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pi -e git:github.com/behackl/pi-unigpt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `/login unigpt` | Save and verify your API key |
|
||||||
|
| `/logout` | Remove the saved key from pi (it stays valid at UniGPT) |
|
||||||
|
| `/model` | Pick a model; also reloads the model list |
|
||||||
|
| `/unigpt-refresh` | Reload the UniGPT model list now and show how many models you have |
|
||||||
|
|
||||||
|
## How models are described
|
||||||
|
|
||||||
|
UniGPT's API only lists deployment IDs such as `gpt-5.4`. Those IDs are
|
||||||
|
OpenAI model names, so the extension looks each one up in pi's bundled model
|
||||||
|
catalog and uses its name, context window, output limit, and image support.
|
||||||
|
Where OpenAI and Azure list different limits, the smaller one is used.
|
||||||
|
|
||||||
|
IDs that pi does not know get pi's standard defaults for custom models:
|
||||||
|
128k context, 16k output, text only. Update pi to pick up newer models.
|
||||||
|
|
||||||
|
Two things are deliberately not taken from the catalog:
|
||||||
|
|
||||||
|
- **Thinking levels are off for every model.** UniGPT ignores the requested
|
||||||
|
reasoning effort and uses a fixed one per deployment, so pi's thinking
|
||||||
|
levels would have no effect. Reasoning models still reason.
|
||||||
|
- **Costs are shown as zero.** That means "not tracked", not "free".
|
||||||
|
|
||||||
|
You can override any value in `~/.pi/agent/models.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"unigpt": {
|
||||||
|
"modelOverrides": {
|
||||||
|
"gpt-5.4": { "contextWindow": 200000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Your API key
|
||||||
|
|
||||||
|
Pi stores the key under `unigpt` in `~/.pi/agent/auth.json`. That file is
|
||||||
|
private to your user account but **not encrypted**. To keep the key out of
|
||||||
|
it, replace the stored value with an environment variable or a password
|
||||||
|
manager command; pi resolves either when it needs the key:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unigpt": { "type": "api_key", "key": "${UNIGPT_API_KEY}" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unigpt": { "type": "api_key", "key": "!op read op://Private/UniGPT/credential" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading from a models.json setup
|
||||||
|
|
||||||
|
If you configured UniGPT by hand before, delete the `unigpt` entry under
|
||||||
|
`providers` in `~/.pi/agent/models.json` (overrides as shown above are
|
||||||
|
fine). Otherwise your old model list and key take precedence over the
|
||||||
|
extension. Then run `/reload` and `/login unigpt`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **No UniGPT models in `/model`:** run `/unigpt-refresh`; it reports the
|
||||||
|
exact problem (rejected key, unreachable server, empty model list).
|
||||||
|
- **`pi -p` says a UniGPT model was not found:** print mode only reads the
|
||||||
|
saved model list and never fetches it. Start pi interactively once after
|
||||||
|
logging in; later print runs reuse that list.
|
||||||
|
- **A specific model fails with HTTP 500:** some deployments are
|
||||||
|
misconfigured on the server side; try another model and tell the UniGPT
|
||||||
|
team.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
pnpm run ci # type check + tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use fake keys and a local HTTP server; they never contact UniGPT or
|
||||||
|
read your pi configuration.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
53
package.json
Normal file
53
package.json
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"name": "pi-unigpt",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "UniGPT (University of Graz) provider for pi: /login with an API key, automatic model discovery.",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Benjamin Hackl",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/behackl/pi-unigpt.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/behackl/pi-unigpt#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/behackl/pi-unigpt/issues"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"pi",
|
||||||
|
"pi-extension",
|
||||||
|
"unigpt",
|
||||||
|
"mygpt",
|
||||||
|
"uni-graz"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./src/index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test test/*.test.ts",
|
||||||
|
"ci": "pnpm run check && pnpm test"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-ai": ">=1.5.0 <2",
|
||||||
|
"@earendil-works/pi-coding-agent": ">=1.5.0 <2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@earendil-works/pi-ai": "1.5.0",
|
||||||
|
"@earendil-works/pi-coding-agent": "1.5.0",
|
||||||
|
"@types/node": "^22.19.0",
|
||||||
|
"tsx": "^4.20.0",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1445
pnpm-lock.yaml
generated
Normal file
1445
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# No dependency needs install scripts: tsx ships esbuild's binary as an
|
||||||
|
# optional dependency, and the others are transitive pi-ai dependencies.
|
||||||
|
allowBuilds:
|
||||||
|
'@google/genai': false
|
||||||
|
esbuild: false
|
||||||
|
protobufjs: false
|
||||||
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));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
67
test/command.test.ts
Normal file
67
test/command.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { refreshCatalog } from "../src/index.ts";
|
||||||
|
|
||||||
|
interface Fake {
|
||||||
|
configured?: boolean;
|
||||||
|
hasUI?: boolean;
|
||||||
|
result?: { aborted: boolean; errors: Map<string, Error> };
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeContext(fake: Fake) {
|
||||||
|
const notices: { message: string; level: string }[] = [];
|
||||||
|
const refreshes: unknown[] = [];
|
||||||
|
const ctx = {
|
||||||
|
hasUI: fake.hasUI ?? true,
|
||||||
|
ui: { notify: (message: string, level: string) => notices.push({ message, level }) },
|
||||||
|
modelRegistry: {
|
||||||
|
getProviderAuthStatus: () => ({ configured: fake.configured ?? true }),
|
||||||
|
getProviderAuth: () => { throw new Error("must not resolve credentials"); },
|
||||||
|
refresh: async (options: unknown) => {
|
||||||
|
refreshes.push(options);
|
||||||
|
return fake.result ?? { aborted: false, errors: new Map() };
|
||||||
|
},
|
||||||
|
getProvider: () => ({ getModels: () => Array.from({ length: fake.count ?? 0 }) }),
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionCommandContext;
|
||||||
|
return { ctx, notices, refreshes };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("refresh asks to log in without resolving credentials or calling the network", async () => {
|
||||||
|
const { ctx, notices, refreshes } = fakeContext({ configured: false });
|
||||||
|
await refreshCatalog(ctx);
|
||||||
|
assert.equal(refreshes.length, 0);
|
||||||
|
assert.deepEqual(notices, [{ message: "UniGPT is not configured. Run /login unigpt first.", level: "warning" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh targets only UniGPT, bounded by a deadline, and reports the model count", async () => {
|
||||||
|
const { ctx, notices, refreshes } = fakeContext({ count: 3 });
|
||||||
|
await refreshCatalog(ctx);
|
||||||
|
const [options] = refreshes as { providers: string[]; signal: AbortSignal }[];
|
||||||
|
assert.deepEqual(options.providers, ["unigpt"]);
|
||||||
|
assert.ok(options.signal instanceof AbortSignal);
|
||||||
|
assert.deepEqual(notices, [{ message: "UniGPT: 3 models available. Select one with /model.", level: "info" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh reports errors, timeouts, and empty catalogs as warnings", async () => {
|
||||||
|
const failed = fakeContext({ result: { aborted: false, errors: new Map([["unigpt", new Error("HTTP 503")]]) } });
|
||||||
|
await refreshCatalog(failed.ctx);
|
||||||
|
assert.match(failed.notices[0].message, /refresh failed \(HTTP 503\); keeping the cached/);
|
||||||
|
const timedOut = fakeContext({ result: { aborted: true, errors: new Map() } });
|
||||||
|
await refreshCatalog(timedOut.ctx);
|
||||||
|
assert.match(timedOut.notices[0].message, /no response within 20s/);
|
||||||
|
const empty = fakeContext({ count: 0 });
|
||||||
|
await refreshCatalog(empty.ctx);
|
||||||
|
assert.deepEqual(empty.notices, [{ message: "UniGPT lists no models for this API key.", level: "warning" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without a UI, refresh reports on stderr instead of staying silent", async (t) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
t.mock.method(console, "error", (line: string) => lines.push(line));
|
||||||
|
const { ctx, notices } = fakeContext({ hasUI: false, count: 2 });
|
||||||
|
await refreshCatalog(ctx);
|
||||||
|
assert.equal(notices.length, 0);
|
||||||
|
assert.deepEqual(lines, ["[unigpt] UniGPT: 2 models available. Select one with /model."]);
|
||||||
|
});
|
||||||
372
test/provider.test.ts
Normal file
372
test/provider.test.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
|
import { test, type TestContext } from "node:test";
|
||||||
|
import {
|
||||||
|
createModels,
|
||||||
|
InMemoryCredentialStore,
|
||||||
|
InMemoryModelsStore,
|
||||||
|
type AuthEvent,
|
||||||
|
type AuthInteraction,
|
||||||
|
} from "@earendil-works/pi-ai";
|
||||||
|
import {
|
||||||
|
BASE_URL,
|
||||||
|
createUnigptProvider,
|
||||||
|
FALLBACK_CONTEXT_WINDOW,
|
||||||
|
FALLBACK_MAX_TOKENS,
|
||||||
|
fetchCatalog,
|
||||||
|
lookupCatalogMetadata,
|
||||||
|
makeModel,
|
||||||
|
parseCatalog,
|
||||||
|
type ModelMetadata,
|
||||||
|
} from "../src/provider.ts";
|
||||||
|
|
||||||
|
const KEY = "test-key-not-a-real-secret";
|
||||||
|
const signal = () => new AbortController().signal;
|
||||||
|
const catalog = (...ids: string[]) => Response.json({ data: ids.map((id) => ({ id })) });
|
||||||
|
|
||||||
|
function interaction(key = KEY, events: AuthEvent[] = []): AuthInteraction {
|
||||||
|
return { prompt: async () => key, notify: (event) => events.push(event) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = async (...args: Parameters<typeof fetchCatalog>) => (await fetchCatalog(...args)).ids;
|
||||||
|
|
||||||
|
async function runtime(fetchImpl: typeof fetch, withCredential = true) {
|
||||||
|
const credentials = new InMemoryCredentialStore();
|
||||||
|
if (withCredential) {
|
||||||
|
await credentials.modify("unigpt", async () => ({ type: "api_key", key: KEY }));
|
||||||
|
}
|
||||||
|
const modelsStore = new InMemoryModelsStore();
|
||||||
|
const models = createModels({ credentials, modelsStore });
|
||||||
|
const provider = createUnigptProvider({ fetch: fetchImpl });
|
||||||
|
models.setProvider(provider);
|
||||||
|
return { models, provider, credentials, modelsStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parses IDs, deduplicates in catalog order, and permits an empty catalog", () => {
|
||||||
|
assert.deepEqual(parseCatalog({ data: [{ id: "second" }, { id: "first" }, { id: "second" }] }), { ids: ["second", "first"], skipped: 0 });
|
||||||
|
assert.deepEqual(parseCatalog({ data: [] }), { ids: [], skipped: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips unusable entries but rejects malformed or entirely unusable catalogs", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseCatalog({ data: [{ id: "good" }, null, {}, { id: 42 }, { id: "" }, { id: " padded " }, { id: "header\ninjection" }] }),
|
||||||
|
{ ids: ["good"], skipped: 6 },
|
||||||
|
);
|
||||||
|
for (const payload of [null, [], {}, { data: {} }, { data: [null] }, { data: [{ id: "a\r\nb" }] }]) {
|
||||||
|
assert.throws(() => parseCatalog(payload), /invalid|without any valid/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const known: Record<string, ModelMetadata> = {
|
||||||
|
"model-a": { name: "Model A", input: ["text", "image"], contextWindow: 272_000, maxTokens: 128_000 },
|
||||||
|
};
|
||||||
|
const lookup = (id: string) => known[id];
|
||||||
|
|
||||||
|
test("known deployments take pi's catalog metadata; each gets its own header", () => {
|
||||||
|
const a = makeModel("model-a", BASE_URL, lookup);
|
||||||
|
const b = makeModel("model-b", BASE_URL, lookup);
|
||||||
|
assert.deepEqual(a.headers, { "deployment-id": "model-a" });
|
||||||
|
assert.deepEqual(b.headers, { "deployment-id": "model-b" });
|
||||||
|
assert.equal(a.provider, "unigpt");
|
||||||
|
assert.equal(a.api, "openai-responses");
|
||||||
|
assert.equal(a.name, "Model A");
|
||||||
|
assert.deepEqual(a.input, ["text", "image"]);
|
||||||
|
assert.equal(a.contextWindow, 272_000);
|
||||||
|
assert.equal(a.maxTokens, 128_000);
|
||||||
|
// UniGPT ignores reasoning.effort, so no deployment advertises thinking controls.
|
||||||
|
assert.equal(a.reasoning, false);
|
||||||
|
assert.deepEqual(a.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||||
|
assert.equal(a.promptCache, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown deployments fall back to pi's custom-model defaults", () => {
|
||||||
|
const b = makeModel("model-b", BASE_URL, lookup);
|
||||||
|
assert.equal(b.name, "model-b");
|
||||||
|
assert.deepEqual(b.input, ["text"]);
|
||||||
|
assert.equal(b.contextWindow, FALLBACK_CONTEXT_WINDOW);
|
||||||
|
assert.equal(b.maxTokens, FALLBACK_MAX_TOKENS);
|
||||||
|
assert.equal(b.reasoning, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("catalog lookup uses pi's bundled OpenAI/Azure entries and takes the conservative limit", () => {
|
||||||
|
const gpt4o = lookupCatalogMetadata("gpt-4o");
|
||||||
|
assert.ok(gpt4o, "pi's catalog should know gpt-4o");
|
||||||
|
assert.deepEqual(gpt4o.input, ["text", "image"]);
|
||||||
|
assert.ok(gpt4o.contextWindow >= 100_000 && gpt4o.maxTokens >= 4_096);
|
||||||
|
assert.equal(lookupCatalogMetadata("definitely-not-a-model"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discovery uses bearer auth, no deployment header, a timeout signal, and no redirects", async () => {
|
||||||
|
const fakeFetch: typeof fetch = async (url, options) => {
|
||||||
|
assert.equal(url, BASE_URL + "/models");
|
||||||
|
const headers = new Headers(options?.headers);
|
||||||
|
assert.equal(headers.get("Authorization"), `Bearer ${KEY}`);
|
||||||
|
assert.equal(headers.get("deployment-id"), null);
|
||||||
|
assert.equal(headers.get("Accept"), "application/json");
|
||||||
|
assert.equal(options?.redirect, "error");
|
||||||
|
assert.ok(options?.signal instanceof AbortSignal);
|
||||||
|
return catalog("model-a");
|
||||||
|
};
|
||||||
|
assert.deepEqual(await ids(BASE_URL + "///", KEY, signal(), fakeFetch), ["model-a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unconfigured providers do not fetch or become available", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const { models } = await runtime(async () => { calls++; return catalog("a"); }, false);
|
||||||
|
assert.deepEqual(await models.getAvailable("unigpt"), []);
|
||||||
|
assert.equal(await models.getAuth("unigpt"), undefined);
|
||||||
|
assert.equal((await models.refresh()).errors.size, 0);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native login masks input, validates access, and lets pi persist the credential", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const events: AuthEvent[] = [];
|
||||||
|
const { models, credentials } = await runtime(async () => { calls++; return catalog("a", "b"); }, false);
|
||||||
|
await models.login("unigpt", "api_key", {
|
||||||
|
prompt: async (prompt) => {
|
||||||
|
assert.equal(prompt.type, "secret");
|
||||||
|
return ` ${KEY} `;
|
||||||
|
},
|
||||||
|
notify: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.ok(!JSON.stringify(events).includes(KEY));
|
||||||
|
assert.ok(events.some((e) => e.type === "info" && e.links?.some((link) => link.url.startsWith("https://unigpt.uni-graz.at"))));
|
||||||
|
assert.ok(events.some((e) => e.type === "info" && /2 UniGPT models available/.test(e.message)));
|
||||||
|
assert.deepEqual(await credentials.read("unigpt"), { type: "api_key", key: KEY });
|
||||||
|
assert.equal((await models.refresh()).errors.size, 0);
|
||||||
|
assert.deepEqual(models.getModels("unigpt").map((m) => m.id), ["a", "b"]);
|
||||||
|
assert.equal((await models.getAvailable("unigpt")).length, 2);
|
||||||
|
await models.logout("unigpt");
|
||||||
|
assert.equal(await credentials.read("unigpt"), undefined);
|
||||||
|
assert.deepEqual(await models.getAvailable("unigpt"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty and rejected API keys never overwrite a saved credential", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const { models, credentials } = await runtime(async () => { calls++; return new Response("private upstream body", { status: 401 }); });
|
||||||
|
await assert.rejects(models.login("unigpt", "api_key", interaction(" ")), /required/);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
await assert.rejects(models.login("unigpt", "api_key", interaction("invalid-new-key")), /401/);
|
||||||
|
assert.deepEqual(await credentials.read("unigpt"), { type: "api_key", key: KEY });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with an empty catalog still saves the key but explains the empty model list", async () => {
|
||||||
|
const events: AuthEvent[] = [];
|
||||||
|
const { models, credentials } = await runtime(async () => catalog(), false);
|
||||||
|
await models.login("unigpt", "api_key", interaction(KEY, events));
|
||||||
|
assert.deepEqual(await credentials.read("unigpt"), { type: "api_key", key: KEY });
|
||||||
|
assert.ok(events.some((e) => e.type === "info" && /lists no models/.test(e.message)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelled login never saves a key or sends validation traffic", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let calls = 0;
|
||||||
|
const { models, credentials } = await runtime(async () => { calls++; return catalog("a"); }, false);
|
||||||
|
await assert.rejects(models.login("unigpt", "api_key", {
|
||||||
|
signal: controller.signal,
|
||||||
|
prompt: async () => { controller.abort(); return KEY; },
|
||||||
|
notify: () => {},
|
||||||
|
}), { name: "AbortError" });
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(await credentials.read("unigpt"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discovery errors are useful but never echo secrets or server error bodies", async () => {
|
||||||
|
for (const status of [401, 403, 404, 429, 500]) {
|
||||||
|
await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), async () => new Response(KEY, { status })), (error: Error) => {
|
||||||
|
assert.match(error.message, new RegExp(String(status)));
|
||||||
|
assert.ok(!error.message.includes(KEY));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), async () => { throw new Error(KEY); }), (error: Error) => {
|
||||||
|
assert.match(error.message, /Could not reach/);
|
||||||
|
assert.ok(!error.message.includes(KEY));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), async () => new Response("not-json")), /not valid JSON/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rate limits surface Retry-After", async () => {
|
||||||
|
const limited = async () => new Response(null, { status: 429, headers: { "Retry-After": "30" } });
|
||||||
|
await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), limited), /rate-limiting.*Retry in 30s/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stalled endpoint produces a readable timeout, not an AbortError", async () => {
|
||||||
|
const stalled: typeof fetch = (_url, options) => new Promise<Response>((_resolve, reject) => {
|
||||||
|
options!.signal!.addEventListener("abort", () => reject(options!.signal!.reason), { once: true });
|
||||||
|
});
|
||||||
|
await assert.rejects(fetchCatalog(BASE_URL, KEY, signal(), stalled, 20), (error: Error) => {
|
||||||
|
assert.equal(error.name, "Error");
|
||||||
|
assert.match(error.message, /did not respond within/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discovery propagates cancellation to fetch", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let started!: () => void;
|
||||||
|
const ready = new Promise<void>((resolve) => { started = resolve; });
|
||||||
|
const promise = fetchCatalog(BASE_URL, KEY, controller.signal, async (_url: unknown, options?: RequestInit) => {
|
||||||
|
started();
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
options!.signal!.addEventListener("abort", () => reject(options!.signal!.reason), { once: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await ready;
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(promise, { name: "AbortError" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh persists catalog metadata only, restores offline, and keeps cache on failure", async () => {
|
||||||
|
let fail = false;
|
||||||
|
const { models, provider, credentials, modelsStore } = await runtime(async () => {
|
||||||
|
if (fail) return new Response("unavailable", { status: 503 });
|
||||||
|
return catalog("a", "b");
|
||||||
|
});
|
||||||
|
assert.equal((await models.refresh()).errors.size, 0);
|
||||||
|
const stored = await modelsStore.read("unigpt");
|
||||||
|
assert.ok(stored);
|
||||||
|
assert.ok(!JSON.stringify(stored).includes(KEY));
|
||||||
|
assert.deepEqual(stored.models.map((m) => m.id), ["a", "b"]);
|
||||||
|
fail = true;
|
||||||
|
assert.equal((await models.refresh()).errors.size, 1);
|
||||||
|
assert.deepEqual(provider.getModels().map((m) => m.id), ["a", "b"]);
|
||||||
|
|
||||||
|
let calls = 0;
|
||||||
|
const offline = createModels({ credentials, modelsStore });
|
||||||
|
offline.setProvider(createUnigptProvider({ fetch: async () => { calls++; throw new Error("no network"); } }));
|
||||||
|
assert.equal((await offline.refresh({ allowNetwork: false })).errors.size, 0);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.deepEqual(offline.getModels("unigpt").map((m) => m.id), ["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed refresh preserves the previous catalog; partial and valid refreshes replace it", async () => {
|
||||||
|
let response = () => catalog("a", "b");
|
||||||
|
const { models } = await runtime(async () => response());
|
||||||
|
await models.refresh();
|
||||||
|
response = () => Response.json({ data: [{}, null] });
|
||||||
|
assert.equal((await models.refresh()).errors.size, 1);
|
||||||
|
assert.deepEqual(models.getModels("unigpt").map((m) => m.id), ["a", "b"]);
|
||||||
|
response = () => Response.json({ data: [{ id: "a" }, {}] });
|
||||||
|
assert.equal((await models.refresh()).errors.size, 0);
|
||||||
|
assert.deepEqual(models.getModels("unigpt").map((m) => m.id), ["a"]);
|
||||||
|
response = () => catalog("b", "c");
|
||||||
|
await models.refresh();
|
||||||
|
assert.deepEqual(models.getModels("unigpt").map((m) => m.id), ["b", "c"]);
|
||||||
|
response = () => catalog();
|
||||||
|
await models.refresh();
|
||||||
|
assert.deepEqual(models.getModels("unigpt"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discovery honors the effective base URL supplied by pi", async () => {
|
||||||
|
const alternate = "https://gateway.example.test/v1";
|
||||||
|
const provider = createUnigptProvider({ fetch: async (url) => {
|
||||||
|
assert.equal(url, alternate + "/models");
|
||||||
|
return catalog("a");
|
||||||
|
} });
|
||||||
|
await provider.refreshModels!({
|
||||||
|
credential: { type: "api_key", key: KEY },
|
||||||
|
baseUrl: alternate,
|
||||||
|
signal: signal(),
|
||||||
|
allowNetwork: true,
|
||||||
|
publish: async (publication) => { publication.update?.(); return true; },
|
||||||
|
});
|
||||||
|
assert.equal(provider.getModels()[0].baseUrl, alternate);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A local Responses endpoint plus a configured runtime whose catalog lists `ids`. */
|
||||||
|
async function localGateway(t: TestContext, ids: string[], handler: (req: IncomingMessage, res: ServerResponse) => void) {
|
||||||
|
const server = createServer(handler);
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
t.after(() => new Promise<void>((resolve) => { server.closeAllConnections(); server.close(() => resolve()); }));
|
||||||
|
const address = server.address();
|
||||||
|
assert.ok(address && typeof address !== "string");
|
||||||
|
const credentials = new InMemoryCredentialStore();
|
||||||
|
await credentials.modify("unigpt", async () => ({ type: "api_key", key: KEY }));
|
||||||
|
const models = createModels({ credentials });
|
||||||
|
models.setProvider(createUnigptProvider({
|
||||||
|
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||||
|
fetch: async () => catalog(...ids),
|
||||||
|
lookup: () => undefined,
|
||||||
|
}));
|
||||||
|
await models.refresh();
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendEvents(res: ServerResponse, events: unknown[]) {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
||||||
|
for (const event of events) res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
|
res.end("data: [DONE]\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("real Responses transport sends deployment/auth headers, streams text, and records usage", async (t) => {
|
||||||
|
const requests: { url?: string; headers: IncomingMessage["headers"]; body: Record<string, unknown> }[] = [];
|
||||||
|
const models = await localGateway(t, ["deployment-a", "deployment-b"], (req, res) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk) => { body += chunk; });
|
||||||
|
req.on("end", () => {
|
||||||
|
requests.push({ url: req.url, headers: req.headers, body: JSON.parse(body) });
|
||||||
|
const item = { id: "msg_1", type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Hello!", annotations: [] }] };
|
||||||
|
sendEvents(res, [
|
||||||
|
{ type: "response.created", response: { id: "resp_1", status: "in_progress" } },
|
||||||
|
{ type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } },
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_1", output_index: 0, content_index: 0, delta: "Hello!" },
|
||||||
|
{ type: "response.output_item.done", output_index: 0, item },
|
||||||
|
{ type: "response.completed", response: { id: "resp_1", status: "completed", output: [item], usage: { input_tokens: 12, output_tokens: 3, total_tokens: 15 } } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (const id of ["deployment-a", "deployment-b"]) {
|
||||||
|
const model = models.getModel("unigpt", id)!;
|
||||||
|
const stream = models.streamSimple(model, { messages: [{ role: "user", content: "Hello", timestamp: Date.now() }] });
|
||||||
|
const eventTypes: string[] = [];
|
||||||
|
for await (const event of stream) eventTypes.push(event.type);
|
||||||
|
const result = await stream.result();
|
||||||
|
assert.equal(result.stopReason, "stop", result.errorMessage);
|
||||||
|
assert.ok(eventTypes.includes("text_delta"));
|
||||||
|
assert.equal(result.content[0].type, "text");
|
||||||
|
assert.equal(result.usage.input, 12);
|
||||||
|
assert.equal(result.usage.output, 3);
|
||||||
|
}
|
||||||
|
assert.equal(requests.length, 2);
|
||||||
|
for (const [index, request] of requests.entries()) {
|
||||||
|
const id = ["deployment-a", "deployment-b"][index];
|
||||||
|
assert.equal(request.url, "/v1/responses");
|
||||||
|
assert.equal(request.headers.authorization, `Bearer ${KEY}`);
|
||||||
|
assert.equal(request.headers["deployment-id"], id);
|
||||||
|
assert.equal(request.body.model, id);
|
||||||
|
assert.equal(request.body.stream, true);
|
||||||
|
assert.equal(request.body.reasoning, undefined);
|
||||||
|
assert.equal(request.body.max_output_tokens, FALLBACK_MAX_TOKENS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Responses transport preserves structured tool calls", async (t) => {
|
||||||
|
const models = await localGateway(t, ["a"], (req, res) => {
|
||||||
|
req.resume();
|
||||||
|
req.on("end", () => {
|
||||||
|
const item = { type: "function_call", id: "fc_1", call_id: "call_1", name: "ping", arguments: '{"target":"api"}', status: "completed" };
|
||||||
|
sendEvents(res, [
|
||||||
|
{ type: "response.created", response: { id: "resp_2", status: "in_progress" } },
|
||||||
|
{ type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "" } },
|
||||||
|
{ type: "response.function_call_arguments.delta", item_id: "fc_1", output_index: 0, delta: item.arguments },
|
||||||
|
{ type: "response.output_item.done", output_index: 0, item },
|
||||||
|
{ type: "response.completed", response: { id: "resp_2", status: "completed", output: [item], usage: { input_tokens: 5, output_tokens: 4, total_tokens: 9 } } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const result = await models.completeSimple(models.getModel("unigpt", "a")!, {
|
||||||
|
messages: [{ role: "user", content: "Ping the API", timestamp: Date.now() }],
|
||||||
|
tools: [{ name: "ping", description: "Ping a target", parameters: { type: "object", properties: { target: { type: "string" } }, required: ["target"] } }],
|
||||||
|
});
|
||||||
|
assert.equal(result.stopReason, "toolUse", result.errorMessage);
|
||||||
|
const call = result.content.find((part) => part.type === "toolCall");
|
||||||
|
assert.ok(call && call.type === "toolCall");
|
||||||
|
assert.equal(call.name, "ping");
|
||||||
|
assert.deepEqual(call.arguments, { target: "api" });
|
||||||
|
});
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"test/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue