mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
908aa7b74b
commit
c3bfd689ef
21 changed files with 623 additions and 284 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = () => {};
|
||||
|
|
|
|||
23
packages/ai/src/auth/oauth/fetch.ts
Normal file
23
packages/ai/src/auth/oauth/fetch.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
77
packages/ai/src/oauth-flows.ts
Normal file
77
packages/ai/src/oauth-flows.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
249
packages/ai/src/remote-catalog.ts
Normal file
249
packages/ai/src/remote-catalog.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
63
packages/ai/test/oauth-flows.test.ts
Normal file
63
packages/ai/test/oauth-flows.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
53
packages/ai/test/openai-chatgpt-oauth-paste.test.ts
Normal file
53
packages/ai/test/openai-chatgpt-oauth-paste.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
56
packages/ai/test/remote-catalog.test.ts
Normal file
56
packages/ai/test/remote-catalog.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue