feat(ai): bundle OAuth flows for browsers and share the remote catalog

OAuth flows load node:http only when a callback server starts and use Web Crypto, so they bundle for browsers. Every flow request goes through one replaceable fetch. The new oauth-flows entry registers the flows statically (registerOAuthFlows) and finds a built-in provider's flow (getBuiltinOAuthFlow). Sign in with ChatGPT completes with the pasted address where no server can run.

The pi.dev catalog overlay moves from coding-agent into the new remote-catalog entry, with a configurable request function; coding-agent keeps its retries and User-Agent through a wrapper.
This commit is contained in:
Armin Ronacher 2026-10-30 23:12:37 +01:00
parent 908aa7b74b
commit c3bfd689ef
21 changed files with 623 additions and 284 deletions

View file

@ -46,6 +46,14 @@
"./bun-oauth": {
"types": "./dist/bun-oauth.d.ts",
"import": "./dist/bun-oauth.js"
},
"./oauth-flows": {
"types": "./dist/oauth-flows.d.ts",
"import": "./dist/oauth-flows.js"
},
"./remote-catalog": {
"types": "./dist/remote-catalog.d.ts",
"import": "./dist/remote-catalog.js"
}
},
"bin": {

View file

@ -8,6 +8,7 @@
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { oauthFetch } from "./fetch.ts";
import { generatePKCE } from "./pkce.ts";
const decode = (s: string) => atob(s);
@ -74,7 +75,7 @@ function formatErrorDetails(error: unknown): string {
}
async function postJson(url: string, body: Record<string, string | number>, signal: AbortSignal): Promise<string> {
const response = await fetch(url, {
const response = await oauthFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",

View file

@ -1,14 +1,27 @@
/**
* Loopback OAuth redirect handler shared by the browser sign-in flows.
*
* NOTE: This module uses node:http. It is only reachable through the lazily
* loaded OAuth flow modules, never from browser-facing entry points.
* The server needs node:http, which this module loads only when a server starts. Flow modules
* therefore bundle for browsers, where starting a server fails and flows that support it fall back
* to the pasted redirect URL.
*/
import { createServer, type ServerResponse } from "node:http";
import type { createServer as CreateServer, ServerResponse } from "node:http";
import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts";
import type { ProviderAuthInteraction } from "../types.ts";
// Variable specifier so browser bundlers do not try to resolve node:http.
const importNodeModule = (specifier: string): Promise<unknown> => import(specifier);
/** Load node:http's `createServer`, or undefined where no local server can run (browsers). */
export async function loadCreateServer(): Promise<typeof CreateServer | undefined> {
try {
return ((await importNodeModule("node:http")) as { createServer: typeof CreateServer }).createServer;
} catch {
return undefined;
}
}
export interface OAuthCallbackServerOptions<T> {
/** Provider name used on the browser page, for example `OpenAI`. */
providerName: string;
@ -52,6 +65,8 @@ export async function startOAuthCallbackServer<T>(
): Promise<OAuthCallbackServer<T>> {
const { providerName, signal } = options;
if (signal?.aborted) throw new Error("Login cancelled");
const createServer = await loadCreateServer();
if (!createServer) throw new Error("OAuth callback servers need Node.js");
let resolveWait: (value: T | undefined) => void = () => {};
let rejectWait: (error: Error) => void = () => {};

View file

@ -0,0 +1,23 @@
/**
* The fetch that every OAuth flow sends its requests with.
*
* Browsers cannot reach some providers' auth endpoints because those endpoints send no CORS headers.
* A runtime that routes such requests elsewhere, for example through a relay, replaces this fetch once
* through `registerOAuthFlows`. Until then, flows use the global fetch, looked up on every call so that
* tests and wrappers that replace `globalThis.fetch` keep working.
*/
/** How flows call fetch: always with a URL and an init, never with a `Request`. */
export type OAuthFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
let replacement: OAuthFetch | undefined;
/** Replace the fetch of every OAuth flow, or restore the global fetch with `undefined`. */
export function setOAuthFetch(fetchImpl: OAuthFetch | undefined): void {
replacement = fetchImpl;
}
/** Send one OAuth flow request. */
export function oauthFetch(input: string | URL, init?: RequestInit): Promise<Response> {
return replacement ? replacement(input, init) : fetch(input, init);
}

View file

@ -6,6 +6,7 @@ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts"
import { sleep } from "../../utils/sleep.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthFetch } from "./fetch.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
const decode = (s: string) => atob(s);
@ -146,7 +147,7 @@ async function fetchWithRateLimitRetry(
const requestSignal = retryBudgetSignal ? AbortSignal.any([signal, retryBudgetSignal]) : signal;
const retryDeadline = retryBudgetSignal ? Date.now() + retryPolicy.maxElapsedMs : undefined;
for (let retry = 0; ; retry++) {
const response = await fetch(url, {
const response = await oauthFetch(url, {
...init,
signal: AbortSignal.any([requestSignal, AbortSignal.timeout(5000)]),
});
@ -196,7 +197,7 @@ async function fetchGitHubCopilotModels(
}
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
const response = await oauthFetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status} ${response.statusText}: ${text}`);

View file

@ -10,6 +10,7 @@ import { getProviderEnvValue } from "../../utils/provider-env.ts";
import { sleep } from "../../utils/sleep.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthFetch } from "./fetch.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
@ -57,7 +58,7 @@ async function readJson(response: Response): Promise<Record<string, unknown> | n
}
async function startDeviceAuthorization(oauthHost: string, signal: AbortSignal): Promise<DeviceAuthorization> {
const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, {
const response = await oauthFetch(`${oauthHost}/api/oauth/device_authorization`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@ -132,7 +133,7 @@ async function pollForToken(
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const response = await fetch(`${oauthHost}/api/oauth/token`, {
const response = await oauthFetch(`${oauthHost}/api/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@ -205,7 +206,7 @@ async function refreshToken(oauthHost: string, refreshTokenValue: string, signal
let response: Response;
try {
response = await fetch(`${oauthHost}/api/oauth/token`, {
response = await oauthFetch(`${oauthHost}/api/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",

View file

@ -15,6 +15,7 @@
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthFetch } from "./fetch.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
// Muse Code CLI client id.
@ -60,7 +61,7 @@ function positiveNumber(value: unknown): number | undefined {
}
async function startDeviceAuthorization(signal: AbortSignal): Promise<DeviceAuthorization> {
const response = await fetch(DEVICE_AUTHORIZATION_URL, {
const response = await oauthFetch(DEVICE_AUTHORIZATION_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@ -95,7 +96,7 @@ async function pollForIdentityToken(device: DeviceAuthorization, signal: AbortSi
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const response = await fetch(DEVICE_TOKEN_URL, {
const response = await oauthFetch(DEVICE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@ -133,7 +134,7 @@ async function pollForIdentityToken(device: DeviceAuthorization, signal: AbortSi
/** Exchange an identity token for a Model API key. Keys are valid for about a day. */
async function mintApiKey(identityToken: string, signal: AbortSignal): Promise<OAuthCredential> {
const response = await fetch(API_KEY_MINT_URL, {
const response = await oauthFetch(API_KEY_MINT_URL, {
method: "POST",
headers: {
Accept: "application/json",

View file

@ -2,15 +2,18 @@
* OpenAI Responses API token sharing through Sign in with ChatGPT.
*
* This public-client flow uses no client secret and sends the resulting user
* access token directly to api.openai.com.
* access token directly to api.openai.com. OpenAI redirects only to the loopback
* callback; where no local server can run (browsers), the user pastes the
* address of the redirect page instead.
*/
import { randomBytes } from "node:crypto";
import { createServer, type Server, type ServerResponse } from "node:http";
import type { Server, ServerResponse } from "node:http";
import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts";
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { LoginOptions, OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { generatePKCE } from "./pkce.ts";
import { loadCreateServer } from "./callback-server.ts";
import { oauthFetch } from "./fetch.ts";
import { base64urlEncode, generatePKCE } from "./pkce.ts";
// every login registers a new client with this ID; OpenAI returns the issued client ID in the callback
const DYNAMIC_CLIENT_ID = "dynamic_agent_client";
@ -47,7 +50,7 @@ type TokenResponse = {
};
function randomValue(): string {
return randomBytes(32).toString("base64url");
return base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));
}
function authorizationResultFromCallback(url: URL, expectedState: string): AuthorizationResult {
@ -82,7 +85,10 @@ function sendHtml(response: ServerResponse, status: number, body: string): void
response.end(body);
}
function startCallbackServer(expectedState: string): Promise<CallbackServer> {
/** Start the loopback callback server, or resolve undefined where no local server can run. */
async function startCallbackServer(expectedState: string): Promise<CallbackServer | undefined> {
const createServer = await loadCreateServer();
if (!createServer) return undefined;
return new Promise((resolve, reject) => {
let resolveResult!: (result: AuthorizationResult) => void;
let rejectResult!: (error: Error) => void;
@ -132,7 +138,7 @@ function startCallbackServer(expectedState: string): Promise<CallbackServer> {
}
async function requestToken(body: URLSearchParams, signal: AbortSignal): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
const response = await oauthFetch(TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
@ -240,6 +246,7 @@ async function loginOpenAIChatGPT(
const nonce = randomValue();
// Without this server, the browser's callback would reach whatever else holds the port (another
// pending login or the Codex CLI), which rejects it as a state mismatch. Fail with a clear error instead.
// Where no local server can run at all (browsers), `callback` is undefined and only pasting works.
const callback = await startCallbackServer(state).catch((error: unknown) => {
if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
throw new Error(
@ -279,7 +286,7 @@ async function loginOpenAIChatGPT(
.then((input) => authorizationResultFromManualInput(input, state));
try {
const result = await Promise.race([callback.result, manualCode]);
const result = await (callback ? Promise.race([callback.result, manualCode]) : manualCode);
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
return await exchangeAuthorizationCode(result.code, verifier, result.clientId, interaction.signal);
} catch (error) {
@ -287,13 +294,13 @@ async function loginOpenAIChatGPT(
throw error;
} finally {
manualAbort.abort();
callback.server.close();
callback?.server.close();
// close() only stops accepting new connections. Browsers open spare connections ahead of
// time, and one that has not sent a request yet stays open and attached to this server.
// A later login in the same process starts a new server with a new state, but the browser
// may send that login's callback over the spare connection. This server would then handle
// it and reject it with "OAuth state mismatch", and the new login would never see it.
callback.server.closeAllConnections();
callback?.server.closeAllConnections();
}
}

View file

@ -1,22 +1,15 @@
/**
* OpenAI Codex (ChatGPT OAuth) flow
*
* NOTE: This module uses Node.js crypto and http (via callback-server.ts) for the OAuth callback.
* It is only intended for CLI use, not browser environments.
* Browser login listens for the redirect on a loopback server (Node only, via callback-server.ts);
* without one, it continues with the pasted redirect URL. Device code login needs only fetch.
*/
// NEVER convert to top-level imports - breaks browser/Vite builds
let _randomBytes: typeof import("node:crypto").randomBytes | null = null;
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
import("node:crypto").then((m) => {
_randomBytes = m.randomBytes;
});
}
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { LoginOptions, OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthFetch } from "./fetch.ts";
import { generatePKCE } from "./pkce.ts";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@ -60,10 +53,7 @@ type JwtPayload = {
};
function createState(): string {
if (!_randomBytes) {
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
}
return _randomBytes(16).toString("hex");
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
@ -110,7 +100,7 @@ function decodeJwt(token: string): JwtPayload | null {
async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise<Response> {
try {
return await fetch(input, init);
return await oauthFetch(input, init);
} catch (error) {
if (init.signal?.aborted) {
throw new Error("Login cancelled");
@ -167,7 +157,7 @@ async function exchangeAuthorizationCode(
async function refreshAccessToken(refreshToken: string, signal: AbortSignal): Promise<OAuthToken> {
let response: Response;
try {
response = await fetch(TOKEN_URL, {
response = await oauthFetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({

View file

@ -14,6 +14,7 @@
import { getProviderEnvValue } from "../../utils/provider-env.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts";
import { oauthFetch } from "./fetch.ts";
import { generatePKCE } from "./pkce.ts";
const AUTHORIZE_URL = "https://openrouter.ai/auth";
@ -72,7 +73,7 @@ async function exchangeAuthorizationCode(
let response: Response;
let body: JsonObject = {};
try {
response = await fetch(TOKEN_URL, {
response = await oauthFetch(TOKEN_URL, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),

View file

@ -6,7 +6,7 @@
/**
* Encode bytes as base64url string.
*/
function base64urlEncode(bytes: Uint8Array): string {
export function base64urlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);

View file

@ -13,6 +13,7 @@ import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts";
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { startOAuthCallbackServer } from "./callback-server.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthFetch } from "./fetch.ts";
import { generatePKCE } from "./pkce.ts";
import { trustedHttpUrl } from "./trusted-url.ts";
@ -40,7 +41,7 @@ type DeviceAuthorizationResponse = {
};
async function loadRadiusOAuthDiscovery(gateway: string, signal: AbortSignal): Promise<RadiusOAuthDiscovery> {
const response = await fetch(new URL("/v1/oauth", gateway), {
const response = await oauthFetch(new URL("/v1/oauth", gateway), {
headers: { accept: "application/json" },
signal,
});
@ -100,7 +101,7 @@ async function requestOAuthToken(
): Promise<OAuthCredential> {
let response: Response;
try {
response = await fetch(new URL("/v1/oauth/token", gateway), {
response = await oauthFetch(new URL("/v1/oauth/token", gateway), {
method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body,
@ -191,7 +192,7 @@ async function loginWithBrowser(
async function requestDeviceAuthorization(gateway: string, signal: AbortSignal): Promise<DeviceAuthorizationResponse> {
let response: Response;
try {
response = await fetch(new URL("/v1/oauth/device", gateway), {
response = await oauthFetch(new URL("/v1/oauth/device", gateway), {
method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }),

View file

@ -4,6 +4,7 @@
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthFetch } from "./fetch.ts";
const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access";
@ -64,7 +65,7 @@ function validateVerificationUri(raw: string): string {
async function postForm(url: string, fields: Record<string, string>, signal: AbortSignal): Promise<OAuthHttpResponse> {
let response: Response;
try {
response = await fetch(url, {
response = await oauthFetch(url, {
method: "POST",
headers: {
Accept: "application/json",

View file

@ -1,25 +1,6 @@
import { anthropicOAuth } from "./auth/oauth/anthropic.ts";
import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
import { metaOAuth } from "./auth/oauth/meta.ts";
import { openaiChatGPTOAuth } from "./auth/oauth/openai-chatgpt.ts";
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
import { xaiOAuth } from "./auth/oauth/xai.ts";
import { registerOAuthFlows } from "./oauth-flows.ts";
/** Register OAuth flows statically embedded in the standalone Bun binary. */
export function registerBunOAuthFlows(): void {
registerBundledOAuthFlowLoaders({
anthropic: () => anthropicOAuth,
openaiCodex: () => openaiCodexOAuth,
openaiChatGPT: () => openaiChatGPTOAuth,
githubCopilot: () => githubCopilotOAuth,
openrouter: () => openRouterOAuth,
kimiCoding: () => kimiCodingOAuth,
meta: () => metaOAuth,
xai: () => xaiOAuth,
radius: createRadiusOAuth,
});
registerOAuthFlows();
}

View file

@ -0,0 +1,77 @@
/**
* Every built-in OAuth flow, bundled statically.
*
* Built-in providers load their OAuth flow lazily, through an import path that bundlers cannot follow,
* so Node-only flow code never reaches a bundle by accident. Runtimes that bundle everything up front
* (browsers, standalone binaries) import this entry instead and call `registerOAuthFlows()` once,
* before the first login or token refresh. Importing it loads every flow.
*/
import { anthropicOAuth } from "./auth/oauth/anthropic.ts";
import { type OAuthFetch, setOAuthFetch } from "./auth/oauth/fetch.ts";
import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
import { metaOAuth } from "./auth/oauth/meta.ts";
import { openaiChatGPTOAuth } from "./auth/oauth/openai-chatgpt.ts";
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
import { xaiOAuth } from "./auth/oauth/xai.ts";
import type { OAuthAuth } from "./auth/types.ts";
import { DEFAULT_RADIUS_GATEWAY } from "./providers/radius-config.ts";
export type { OAuthFetch } from "./auth/oauth/fetch.ts";
export interface OAuthFlowOptions {
/**
* Sends every request of every flow, for example one that routes the requests a browser cannot
* make directly through a relay. Default: the global fetch.
*/
fetch?: OAuthFetch;
}
/** Make the lazily loaded OAuth flows of the built-in providers load from this bundle. */
export function registerOAuthFlows(options: OAuthFlowOptions = {}): void {
setOAuthFetch(options.fetch);
registerBundledOAuthFlowLoaders({
anthropic: () => anthropicOAuth,
openaiCodex: () => openaiCodexOAuth,
openaiChatGPT: () => openaiChatGPTOAuth,
githubCopilot: () => githubCopilotOAuth,
openrouter: () => openRouterOAuth,
kimiCoding: () => kimiCodingOAuth,
meta: () => metaOAuth,
xai: () => xaiOAuth,
radius: createRadiusOAuth,
});
}
/**
* The OAuth flow of a built-in provider, by provider ID, for code that signs in without a `Models`
* collection. Radius uses its default gateway.
*/
export function getBuiltinOAuthFlow(providerId: string): OAuthAuth | undefined {
switch (providerId) {
case "anthropic":
return anthropicOAuth;
case "openai":
return openaiChatGPTOAuth;
case "openai-codex":
return openaiCodexOAuth;
case "github-copilot":
return githubCopilotOAuth;
case "openrouter":
return openRouterOAuth;
case "kimi-coding":
return kimiCodingOAuth;
case "meta":
return metaOAuth;
case "xai":
return xaiOAuth;
case "radius":
return createRadiusOAuth({ name: "Radius", gateway: DEFAULT_RADIUS_GATEWAY });
default:
return undefined;
}
}

View file

@ -0,0 +1,249 @@
/**
* Model catalogs published by pi.dev, layered over a provider's built-in models.
*
* pi.dev serves one catalog per provider at `/api/models/providers/<id>`. A wrapped provider restores
* the catalog persisted in the `ModelsStore` before any network access, then refreshes it at most
* every `REMOTE_CATALOG_REFRESH_INTERVAL_MS`, revalidating with the stored ETag. A catalog older than
* the built-in model data is ignored, so a stale pi.dev response never replaces newer shipped data.
*/
import type { Provider, RefreshModelsContext } from "./models.ts";
import type { ModelsStoreEntry } from "./models-store.ts";
import type { AnyModel, ModelType } from "./types.ts";
import { getModelType, isModelType } from "./utils/model-operations.ts";
export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
/**
* Model types this client can consume. Sent as `?types=` so the catalog server
* returns the full-type shard instead of the chat-only one served to clients
* that predate model types. A server that ignores the parameter still returns
* the chat-only shard, which this client handles unchanged.
*/
export const REMOTE_CATALOG_MODEL_TYPES: readonly ModelType[] = ["chat", "image", "classifier"];
/** Sends one catalog request. Apps add retries or headers here (browsers cannot set User-Agent). */
export type RemoteCatalogRequest = (
url: URL,
init: { headers: Record<string, string>; signal: AbortSignal },
) => Promise<Response>;
export interface RemoteCatalogOptions {
/** Origin of the catalog service. Default: https://pi.dev */
baseUrl?: string;
/** When the built-in model data was generated; older remote catalogs are ignored. */
localGeneratedAt?: number;
/** Default: the global fetch. */
request?: RemoteCatalogRequest;
}
/** Replaces the catalog with a credential-specific model list. */
export type PersonalizeCatalog = (
catalog: readonly AnyModel[],
context: RefreshModelsContext,
) => Promise<readonly AnyModel[]>;
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
export function withRemoteCatalog(provider: Provider, options: RemoteCatalogOptions = {}): Provider {
const { localGeneratedAt } = options;
let dynamicModels: readonly AnyModel[] = [];
return {
...provider,
getModels: () =>
mergeModels(
provider.getModels(),
dynamicModels.filter((model) => isModelType(model, "chat")),
),
getAllModels: () => mergeModels(provider.getAllModels?.() ?? provider.getModels(), dynamicModels),
refreshModels: async (context) => {
const stored = context.stored;
const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);
if (
!(await context.publish({
update: () => {
dynamicModels = restored;
},
}))
) {
return;
}
if (!context.allowNetwork || context.signal.aborted) return;
// Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago.
// An entry without lastModified (saved by an older pi, or after a failed first request) is
// always refetched: remoteModels() ignores it, so waiting would leave the overlay empty.
if (
!context.force &&
stored?.checkedAt !== undefined &&
stored.lastModified !== undefined &&
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
) {
return;
}
// Only revalidate when a cached body backs the validator, so a 304 can never
// leave the overlay empty.
const validator = stored && stored.models.length > 0 ? stored.etag : undefined;
const response = await requestCatalog(provider.id, options, context.signal, validator);
if (context.signal.aborted) return;
const checkedAt = Date.now();
// Unchanged: dynamicModels already holds the stored overlay, so only the
// freshness window moves.
if (response.status === 304 && stored) {
await context.publish({ persist: { ...stored, checkedAt } });
return;
}
if (response.status === 404 || response.status === 501) {
await context.publish({
persist: {
...(stored ?? { models: [] }),
checkedAt,
lastModified: 0,
etag: undefined,
},
});
return;
}
if (!response.ok) {
// Transient failure: the cached body and its validator stay valid, so keep the
// etag and let the next refresh revalidate instead of downloading the catalog.
await context.publish({ persist: { ...(stored ?? { models: [] }), checkedAt } });
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
const catalog = await readCatalog(provider.id, response);
if (context.signal.aborted) return;
const entry: ModelsStoreEntry = { ...catalog, checkedAt };
const published = remoteModels(entry, localGeneratedAt);
await context.publish({
persist: entry,
update: () => {
dynamicModels = published;
},
});
},
};
}
/**
* Like `withRemoteCatalog`, but on each network refresh the catalog (built-in models plus a newer
* pi.dev catalog) is passed through `personalize` and the result is saved. The saved entry holds
* the personalized list, not the pi.dev catalog, and replaces the catalog, including offline.
* The refresh interval and etag revalidation are skipped. An empty list (nothing saved yet, or an
* empty catalog saved by an older pi after a failed or missing pi.dev catalog) falls back to the
* built-in models.
*/
export function withPersonalizedCatalog(
provider: Provider,
personalize: PersonalizeCatalog,
options: RemoteCatalogOptions = {},
): Provider {
let personalModels: readonly AnyModel[] = [];
const builtinModels = (): readonly AnyModel[] => provider.getAllModels?.() ?? provider.getModels();
const getAllModels = (): readonly AnyModel[] => (personalModels.length > 0 ? personalModels : builtinModels());
return {
...provider,
getModels: () => getAllModels().filter((model) => isModelType(model, "chat")),
getAllModels,
refreshModels: async (context) => {
const restored = (context.stored?.models ?? []).filter((model) => model.provider === provider.id);
if (
!(await context.publish({
update: () => {
personalModels = restored;
},
}))
) {
return;
}
if (!context.allowNetwork || context.signal.aborted) return;
const response = await requestCatalog(provider.id, options, context.signal);
if (context.signal.aborted) return;
let remote: readonly AnyModel[] = [];
if (response.status !== 404 && response.status !== 501) {
if (!response.ok) throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
const catalog = await readCatalog(provider.id, response);
if (isNewer(catalog.lastModified, options.localGeneratedAt)) remote = catalog.models;
}
const personalized = await personalize(mergeModels(builtinModels(), remote), context);
if (context.signal.aborted) return;
await context.publish({
persist: { models: personalized, checkedAt: Date.now() },
update: () => {
personalModels = personalized;
},
});
},
};
}
function mergeModels<TModel extends AnyModel>(baseline: readonly TModel[], dynamic: readonly TModel[]): TModel[] {
const merged = new Map<string, TModel>();
for (const model of [...baseline, ...dynamic]) merged.set(`${getModelType(model)}\0${model.id}`, model);
return [...merged.values()];
}
/** Whether a pi.dev catalog is newer than the built-in model data. */
function isNewer(lastModified: number | undefined, localGeneratedAt: number | undefined): boolean {
return localGeneratedAt === undefined || (lastModified !== undefined && lastModified > localGeneratedAt);
}
function remoteModels(entry: ModelsStoreEntry | undefined, localGeneratedAt: number | undefined): readonly AnyModel[] {
return entry && isNewer(entry.lastModified, localGeneratedAt) ? entry.models : [];
}
function requestCatalog(
providerId: string,
options: RemoteCatalogOptions,
signal: AbortSignal,
validator?: string,
): Promise<Response> {
const url = new URL(
`/api/models/providers/${encodeURIComponent(providerId)}`,
options.baseUrl ?? DEFAULT_CATALOG_BASE_URL,
);
url.searchParams.set("types", REMOTE_CATALOG_MODEL_TYPES.join(","));
const init = {
headers: { accept: "application/json", ...(validator ? { "if-none-match": validator } : {}) },
signal,
};
return options.request ? options.request(url, init) : fetch(url, init);
}
/** Read a successful pi.dev catalog response. A missing or invalid Last-Modified header is 0. */
async function readCatalog(
providerId: string,
response: Response,
): Promise<{ models: AnyModel[]; lastModified: number; etag: string | undefined }> {
const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
return {
models: parseCatalog(providerId, await response.json()),
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
etag: response.headers.get("etag") ?? undefined,
};
}
function parseCatalog(providerId: string, value: unknown): AnyModel[] {
const entries = Array.isArray(value)
? value
: typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models)
? value.models
: typeof value === "object" && value !== null
? Object.values(value)
: undefined;
if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`);
return entries
.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null && "id" in entry)
.filter(isSupportedModelType)
.map((model) => ({ ...model, provider: providerId }) as AnyModel);
}
function isSupportedModelType(model: { type?: unknown }): boolean {
return (
model.type === undefined ||
(typeof model.type === "string" && REMOTE_CATALOG_MODEL_TYPES.includes(model.type as ModelType))
);
}

View file

@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadCreateServer } from "../src/auth/oauth/callback-server.ts";
import type { OAuthCredential } from "../src/auth/types.ts";
import { getBuiltinOAuthFlow, registerOAuthFlows } from "../src/oauth-flows.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
const tokenResponse = () =>
new Response(JSON.stringify({ access_token: "fresh", refresh_token: "rotated", expires_in: 3600 }), {
headers: { "content-type": "application/json" },
});
const stale: OAuthCredential = { type: "oauth", access: "stale", refresh: "old", expires: 0 };
afterEach(() => {
registerOAuthFlows();
vi.unstubAllGlobals();
});
describe("registerOAuthFlows", () => {
it("sends every flow request through the registered fetch", async () => {
const relay = vi.fn(async () => tokenResponse());
registerOAuthFlows({ fetch: relay });
const refreshed = await anthropicProvider().auth.oauth?.refresh(stale, new AbortController().signal);
expect(refreshed).toMatchObject({ access: "fresh", refresh: "rotated" });
expect(relay).toHaveBeenCalledWith("https://platform.claude.com/v1/oauth/token", expect.anything());
});
it("restores the global fetch without one", async () => {
registerOAuthFlows({ fetch: async () => new Response("relay is gone", { status: 500 }) });
registerOAuthFlows();
const global = vi.fn(async () => tokenResponse());
vi.stubGlobal("fetch", global);
await anthropicProvider().auth.oauth?.refresh(stale, new AbortController().signal);
expect(global).toHaveBeenCalledOnce();
});
});
describe("getBuiltinOAuthFlow", () => {
it("finds the flow of each built-in OAuth provider", () => {
for (const id of [
"anthropic",
"openai",
"openai-codex",
"github-copilot",
"openrouter",
"kimi-coding",
"meta",
"xai",
"radius",
]) {
expect(getBuiltinOAuthFlow(id), id).toBeDefined();
}
expect(getBuiltinOAuthFlow("google")).toBeUndefined();
});
});
describe("loadCreateServer", () => {
it("loads node:http where Node runs", async () => {
expect(typeof (await loadCreateServer())).toBe("function");
});
});

View file

@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.ts";
// Browsers cannot start a local server; this file runs the sign-in as a browser would.
vi.mock("../src/auth/oauth/callback-server.ts", async (importOriginal) => ({
...(await importOriginal<object>()),
loadCreateServer: async () => undefined,
}));
const SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
describe("OpenAI ChatGPT OAuth without a local server", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("completes with the pasted redirect address", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
access_token: "access",
refresh_token: "refresh",
expires_in: 3600,
id_token: "id",
scope: SCOPE,
}),
{ headers: { "content-type": "application/json" } },
),
),
);
let authorizeUrl: URL | undefined;
const credential = await openaiChatGPTOAuth.login(
{
signal: new AbortController().signal,
notify: (event) => {
if (event.type === "auth_url") authorizeUrl = new URL(event.url);
},
prompt: async () => {
const callback = new URL(authorizeUrl?.searchParams.get("redirect_uri") ?? "");
callback.searchParams.set("code", "code");
callback.searchParams.set("state", authorizeUrl?.searchParams.get("state") ?? "");
callback.searchParams.set("client_id", "oaiapp_issued");
return callback.toString();
},
},
{ getDeviceId: () => "e61bbe28-07ef-466d-8e5d-a344f94ab305" },
);
expect(credential).toMatchObject({ access: "access", refresh: "refresh", clientId: "oaiapp_issued" });
});
});

View file

@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { createModels, createProvider, type Model } from "../src/index.ts";
import { REMOTE_CATALOG_MODEL_TYPES, withRemoteCatalog } from "../src/remote-catalog.ts";
function model(id: string): Model<"openai-completions"> {
return {
id,
name: id,
api: "openai-completions",
provider: "example",
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
};
}
const builtin = () =>
createProvider({
id: "example",
auth: { apiKey: { name: "Example", resolve: async () => ({ auth: {} }) } },
models: [model("builtin")],
api: {
stream: () => {
throw new Error("not used");
},
streamSimple: () => {
throw new Error("not used");
},
},
});
describe("withRemoteCatalog", () => {
it("asks the configured catalog service through the app's request function", async () => {
const request = vi.fn(
async () =>
new Response(JSON.stringify([model("remote")]), {
headers: { "last-modified": new Date(2_000).toUTCString(), etag: '"v1"' },
}),
);
const models = createModels();
models.setProvider(
withRemoteCatalog(builtin(), { baseUrl: "https://catalog.test", localGeneratedAt: 1_000, request }),
);
await models.refresh();
const [url, init] = request.mock.calls[0] as unknown as [URL, { headers: Record<string, string> }];
expect(url.href).toBe(
`https://catalog.test/api/models/providers/example?types=${encodeURIComponent(REMOTE_CATALOG_MODEL_TYPES.join(","))}`,
);
expect(init.headers).toEqual({ accept: "application/json" });
expect(models.getModels("example").map((entry) => entry.id)).toEqual(["builtin", "remote"]);
});
});

View file

@ -1,240 +1,46 @@
import type { Provider } from "@earendil-works/pi-ai";
import {
type AnyModel,
getModelType,
isModelType,
type ModelsStoreEntry,
type ModelType,
type Provider,
type RefreshModelsContext,
} from "@earendil-works/pi-ai";
type PersonalizeCatalog,
type RemoteCatalogOptions,
type RemoteCatalogRequest,
withPersonalizedCatalog as withPersonalizedRemoteCatalog,
withRemoteCatalog as withRemoteCatalogOverlay,
} from "@earendil-works/pi-ai/remote-catalog";
import { VERSION } from "../config.ts";
import { fetchWithRetry } from "../utils/management-http.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
export {
type PersonalizeCatalog,
REMOTE_CATALOG_MODEL_TYPES,
REMOTE_CATALOG_REFRESH_INTERVAL_MS,
} from "@earendil-works/pi-ai/remote-catalog";
const REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS = 4_000;
export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
/**
* Model types this client can consume. Sent as `?types=` so the catalog server
* returns the full-type shard instead of the chat-only one served to clients
* that predate model types. A server that ignores the parameter still returns
* the chat-only shard, which this client handles unchanged.
*/
export const REMOTE_CATALOG_MODEL_TYPES: readonly ModelType[] = ["chat", "image", "classifier"];
function isSupportedModelType(model: { type?: unknown }): boolean {
return (
model.type === undefined ||
(typeof model.type === "string" && REMOTE_CATALOG_MODEL_TYPES.includes(model.type as ModelType))
);
}
function mergeModels<TModel extends AnyModel>(baseline: readonly TModel[], dynamic: readonly TModel[]): TModel[] {
const merged = new Map<string, TModel>();
for (const model of [...baseline, ...dynamic]) merged.set(`${getModelType(model)}\0${model.id}`, model);
return [...merged.values()];
}
function parseCatalog(providerId: string, value: unknown): AnyModel[] {
const entries = Array.isArray(value)
? value
: typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models)
? value.models
: typeof value === "object" && value !== null
? Object.values(value)
: undefined;
if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`);
return entries
.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null && "id" in entry)
.filter(isSupportedModelType)
.map((model) => ({ ...model, provider: providerId }) as AnyModel);
}
/** Read a successful pi.dev catalog response. A missing or invalid Last-Modified header is 0. */
async function readCatalog(
providerId: string,
response: Response,
): Promise<{ models: AnyModel[]; lastModified: number; etag: string | undefined }> {
const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
return {
models: parseCatalog(providerId, await response.json()),
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
etag: response.headers.get("etag") ?? undefined,
};
}
/** Whether a pi.dev catalog is newer than the model data shipped with pi. */
function isNewer(lastModified: number | undefined, localGeneratedAt: number | undefined): boolean {
return localGeneratedAt === undefined || (lastModified !== undefined && lastModified > localGeneratedAt);
}
function remoteModels(entry: ModelsStoreEntry | undefined, localGeneratedAt: number | undefined): readonly AnyModel[] {
return entry && isNewer(entry.lastModified, localGeneratedAt) ? entry.models : [];
}
function requestCatalog(
providerId: string,
catalogBaseUrl: string,
signal: AbortSignal,
validator?: string,
): Promise<Response> {
const url = new URL(`/api/models/providers/${encodeURIComponent(providerId)}`, catalogBaseUrl);
url.searchParams.set("types", REMOTE_CATALOG_MODEL_TYPES.join(","));
return fetchWithRetry(
/** Catalog requests from pi: retried, with pi's User-Agent. */
const requestCatalog: RemoteCatalogRequest = (url, init) =>
fetchWithRetry(
url,
{
headers: {
accept: "application/json",
"User-Agent": getPiUserAgent(VERSION),
...(validator ? { "if-none-match": validator } : {}),
},
signal,
},
{ ...init, headers: { ...init.headers, "User-Agent": getPiUserAgent(VERSION) } },
{ attemptTimeoutMs: REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS },
);
}
/** Replaces the catalog with a credential-specific model list. */
export type PersonalizeCatalog = (
catalog: readonly AnyModel[],
context: RefreshModelsContext,
) => Promise<readonly AnyModel[]>;
function catalogOptions(baseUrl: string | undefined, localGeneratedAt: number | undefined): RemoteCatalogOptions {
return { baseUrl, localGeneratedAt, request: requestCatalog };
}
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
export function withRemoteCatalog(
provider: Provider,
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
localGeneratedAt?: number,
): Provider {
let dynamicModels: readonly AnyModel[] = [];
return {
...provider,
getModels: () =>
mergeModels(
provider.getModels(),
dynamicModels.filter((model) => isModelType(model, "chat")),
),
getAllModels: () => mergeModels(provider.getAllModels?.() ?? provider.getModels(), dynamicModels),
refreshModels: async (context) => {
const stored = context.stored;
const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);
if (
!(await context.publish({
update: () => {
dynamicModels = restored;
},
}))
) {
return;
}
if (!context.allowNetwork || context.signal.aborted) return;
// Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago.
// An entry without lastModified (saved by an older pi, or after a failed first request) is
// always refetched: remoteModels() ignores it, so waiting would leave the overlay empty.
if (
!context.force &&
stored?.checkedAt !== undefined &&
stored.lastModified !== undefined &&
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
) {
return;
}
// Only revalidate when a cached body backs the validator, so a 304 can never
// leave the overlay empty.
const validator = stored && stored.models.length > 0 ? stored.etag : undefined;
const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal, validator);
if (context.signal.aborted) return;
const checkedAt = Date.now();
// Unchanged: dynamicModels already holds the stored overlay, so only the
// freshness window moves.
if (response.status === 304 && stored) {
await context.publish({ persist: { ...stored, checkedAt } });
return;
}
if (response.status === 404 || response.status === 501) {
await context.publish({
persist: {
...(stored ?? { models: [] }),
checkedAt,
lastModified: 0,
etag: undefined,
},
});
return;
}
if (!response.ok) {
// Transient failure: the cached body and its validator stay valid, so keep the
// etag and let the next refresh revalidate instead of downloading the catalog.
await context.publish({ persist: { ...(stored ?? { models: [] }), checkedAt } });
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
const catalog = await readCatalog(provider.id, response);
if (context.signal.aborted) return;
const entry: ModelsStoreEntry = { ...catalog, checkedAt };
const published = remoteModels(entry, localGeneratedAt);
await context.publish({
persist: entry,
update: () => {
dynamicModels = published;
},
});
},
};
export function withRemoteCatalog(provider: Provider, catalogBaseUrl?: string, localGeneratedAt?: number): Provider {
return withRemoteCatalogOverlay(provider, catalogOptions(catalogBaseUrl, localGeneratedAt));
}
/**
* Like `withRemoteCatalog`, but on each network refresh the catalog (built-in models plus a newer
* pi.dev catalog) is passed through `personalize` and the result is saved. The saved entry holds
* the personalized list, not the pi.dev catalog, and replaces the catalog, including offline.
* The refresh interval and etag revalidation are skipped. An empty list (nothing saved yet, or an
* empty catalog saved by an older pi after a failed or missing pi.dev catalog) falls back to the
* built-in models.
*/
/** Like `withRemoteCatalog`, but with a credential-specific catalog; see pi-ai's `withPersonalizedCatalog`. */
export function withPersonalizedCatalog(
provider: Provider,
personalize: PersonalizeCatalog,
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
catalogBaseUrl?: string,
localGeneratedAt?: number,
): Provider {
let personalModels: readonly AnyModel[] = [];
const builtinModels = (): readonly AnyModel[] => provider.getAllModels?.() ?? provider.getModels();
const getAllModels = (): readonly AnyModel[] => (personalModels.length > 0 ? personalModels : builtinModels());
return {
...provider,
getModels: () => getAllModels().filter((model) => isModelType(model, "chat")),
getAllModels,
refreshModels: async (context) => {
const restored = (context.stored?.models ?? []).filter((model) => model.provider === provider.id);
if (
!(await context.publish({
update: () => {
personalModels = restored;
},
}))
) {
return;
}
if (!context.allowNetwork || context.signal.aborted) return;
const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal);
if (context.signal.aborted) return;
let remote: readonly AnyModel[] = [];
if (response.status !== 404 && response.status !== 501) {
if (!response.ok) throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
const catalog = await readCatalog(provider.id, response);
if (isNewer(catalog.lastModified, localGeneratedAt)) remote = catalog.models;
}
const personalized = await personalize(mergeModels(builtinModels(), remote), context);
if (context.signal.aborted) return;
await context.publish({
persist: { models: personalized, checkedAt: Date.now() },
update: () => {
personalModels = personalized;
},
});
},
};
return withPersonalizedRemoteCatalog(provider, personalize, catalogOptions(catalogBaseUrl, localGeneratedAt));
}

View file

@ -15,6 +15,8 @@ export const workspaceSourcePaths = {
aiCompat: fileURLToPath(new URL("./packages/ai/src/compat.ts", import.meta.url)),
aiModels: fileURLToPath(new URL("./packages/ai/src/models.ts", import.meta.url)),
aiOAuth: fileURLToPath(new URL("./packages/ai/src/oauth.ts", import.meta.url)),
aiOAuthFlows: fileURLToPath(new URL("./packages/ai/src/oauth-flows.ts", import.meta.url)),
aiRemoteCatalog: fileURLToPath(new URL("./packages/ai/src/remote-catalog.ts", import.meta.url)),
aiApi: fileURLToPath(new URL("./packages/ai/src/api", import.meta.url)),
aiProviders: fileURLToPath(new URL("./packages/ai/src/providers", import.meta.url)),
aiUtils: fileURLToPath(new URL("./packages/ai/src/utils", import.meta.url)),
@ -42,6 +44,8 @@ export default defineConfig({
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: workspaceSourcePaths.aiCompat },
{ find: /^@earendil-works\/pi-ai\/models$/, replacement: workspaceSourcePaths.aiModels },
{ find: /^@earendil-works\/pi-ai\/oauth$/, replacement: workspaceSourcePaths.aiOAuth },
{ find: /^@earendil-works\/pi-ai\/oauth-flows$/, replacement: workspaceSourcePaths.aiOAuthFlows },
{ find: /^@earendil-works\/pi-ai\/remote-catalog$/, replacement: workspaceSourcePaths.aiRemoteCatalog },
{
find: /^@earendil-works\/pi-ai\/utils\/(.+)$/,
replacement: `${workspaceSourcePaths.aiUtils}/$1.ts`,