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