375 lines
18 KiB
JavaScript
375 lines
18 KiB
JavaScript
import { lazyStream, } from "@earendil-works/pi-ai";
|
|
import { getApiProvider } from "@earendil-works/pi-ai/compat";
|
|
import { clearConfigValueCache, getConfigValueEnvVarNames, isCommandConfigValue, isConfigValueConfigured, resolveConfigValueOrThrow, resolveHeadersOrThrow, } from "./resolve-config-value.js";
|
|
export const clearApiKeyCache = clearConfigValueCache;
|
|
function mergeCompat(base, override) {
|
|
if (!override)
|
|
return base;
|
|
const merged = { ...base, ...override };
|
|
const baseNested = base;
|
|
const overrideNested = override;
|
|
const mergedNested = merged;
|
|
for (const key of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs"]) {
|
|
const baseValue = baseNested?.[key];
|
|
const overrideValue = overrideNested[key];
|
|
if ((typeof baseValue === "object" && baseValue !== null) ||
|
|
(typeof overrideValue === "object" && overrideValue !== null)) {
|
|
mergedNested[key] = { ...baseValue, ...overrideValue };
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
function applyModelOverride(model, override) {
|
|
return {
|
|
...model,
|
|
name: override.name ?? model.name,
|
|
reasoning: override.reasoning ?? model.reasoning,
|
|
thinkingLevelMap: override.thinkingLevelMap
|
|
? { ...model.thinkingLevelMap, ...override.thinkingLevelMap }
|
|
: model.thinkingLevelMap,
|
|
input: override.input ?? model.input,
|
|
cost: override.cost
|
|
? {
|
|
input: override.cost.input ?? model.cost.input,
|
|
output: override.cost.output ?? model.cost.output,
|
|
cacheRead: override.cost.cacheRead ?? model.cost.cacheRead,
|
|
cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite,
|
|
tiers: override.cost.tiers ?? model.cost.tiers,
|
|
}
|
|
: model.cost,
|
|
contextWindow: override.contextWindow ?? model.contextWindow,
|
|
maxTokens: override.maxTokens ?? model.maxTokens,
|
|
compat: mergeCompat(model.compat, override.compat),
|
|
};
|
|
}
|
|
function modelFromJson(providerId, definition, providerConfig, defaults) {
|
|
const api = definition.api ?? providerConfig.api ?? defaults?.api;
|
|
if (!api) {
|
|
throw new Error(`Provider ${providerId}, model ${definition.id}: no "api" specified. Set at provider or model level.`);
|
|
}
|
|
const baseUrl = definition.baseUrl ?? providerConfig.baseUrl ?? defaults?.baseUrl;
|
|
if (!baseUrl)
|
|
throw new Error(`Provider ${providerId}: "baseUrl" is required when defining custom models.`);
|
|
if (definition.contextWindow !== undefined && definition.contextWindow <= 0) {
|
|
throw new Error(`Provider ${providerId}, model ${definition.id}: invalid contextWindow`);
|
|
}
|
|
if (definition.maxTokens !== undefined && definition.maxTokens <= 0) {
|
|
throw new Error(`Provider ${providerId}, model ${definition.id}: invalid maxTokens`);
|
|
}
|
|
return {
|
|
id: definition.id,
|
|
name: definition.name ?? definition.id,
|
|
api: api,
|
|
provider: providerId,
|
|
baseUrl,
|
|
reasoning: definition.reasoning ?? false,
|
|
thinkingLevelMap: definition.thinkingLevelMap,
|
|
input: (definition.input ?? ["text"]),
|
|
cost: definition.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: definition.contextWindow ?? 128000,
|
|
maxTokens: definition.maxTokens ?? 16384,
|
|
headers: undefined,
|
|
compat: mergeCompat(providerConfig.compat, definition.compat),
|
|
};
|
|
}
|
|
function applyModelsJson(providerId, baseModels, config) {
|
|
if (!config)
|
|
return [...baseModels];
|
|
if (config.oauth && !config.baseUrl) {
|
|
throw new Error(`Provider ${providerId}: "baseUrl" is required when "oauth" is set.`);
|
|
}
|
|
const hasOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
|
|
if (!config.models?.length &&
|
|
!config.baseUrl &&
|
|
!config.headers &&
|
|
!config.compat &&
|
|
!hasOverrides &&
|
|
!config.apiKey &&
|
|
!config.oauth &&
|
|
config.authHeader === undefined) {
|
|
throw new Error(`Provider ${providerId}: must specify "baseUrl", "headers", "compat", "modelOverrides", or "models".`);
|
|
}
|
|
const models = baseModels.map((model) => ({
|
|
...model,
|
|
baseUrl: config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl),
|
|
compat: mergeCompat(model.compat, config.compat),
|
|
}));
|
|
for (const definition of config.models ?? []) {
|
|
const existingIndex = models.findIndex((model) => model.id === definition.id);
|
|
const defaults = existingIndex >= 0 ? models[existingIndex] : models[0];
|
|
const model = modelFromJson(providerId, definition, config, defaults);
|
|
if (existingIndex >= 0)
|
|
models[existingIndex] = model;
|
|
else
|
|
models.push(model);
|
|
}
|
|
return models;
|
|
}
|
|
function applyExtension(providerId, models, config) {
|
|
if (!config)
|
|
return [...models];
|
|
if (!config.models) {
|
|
return config.baseUrl ? models.map((model) => ({ ...model, baseUrl: config.baseUrl })) : [...models];
|
|
}
|
|
return config.models.map((definition) => {
|
|
const defaults = models.find((model) => model.id === definition.id) ?? models[0];
|
|
const api = definition.api ?? config.api ?? defaults?.api;
|
|
if (!api) {
|
|
throw new Error(`Provider ${providerId}, model ${definition.id}: no "api" specified. Set at provider or model level.`);
|
|
}
|
|
const baseUrl = definition.baseUrl ?? config.baseUrl ?? defaults?.baseUrl;
|
|
if (!baseUrl)
|
|
throw new Error(`Provider ${providerId}: "baseUrl" is required when defining custom models.`);
|
|
return {
|
|
...definition,
|
|
api,
|
|
provider: providerId,
|
|
baseUrl,
|
|
headers: undefined,
|
|
};
|
|
});
|
|
}
|
|
function adaptOAuth(config) {
|
|
return {
|
|
name: config.name,
|
|
login: async (callbacks) => {
|
|
const credential = await config.login({
|
|
onAuth: (info) => callbacks.notify({ type: "auth_url", ...info }),
|
|
onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }),
|
|
onPrompt: (prompt) => callbacks.prompt({ type: "text", ...prompt }),
|
|
onProgress: (message) => callbacks.notify({ type: "progress", message }),
|
|
onManualCodeInput: () => callbacks.prompt({ type: "manual_code", message: "Paste the authorization code" }),
|
|
onSelect: (prompt) => callbacks.prompt({ type: "select", ...prompt }),
|
|
signal: callbacks.signal,
|
|
});
|
|
return { ...credential, type: "oauth" };
|
|
},
|
|
refresh: async (credential) => ({ ...(await config.refreshToken(credential)), type: "oauth" }),
|
|
toAuth: async (credential) => ({ apiKey: config.getApiKey(credential) }),
|
|
};
|
|
}
|
|
function withConfiguredAuth(auth, headers, authHeader) {
|
|
let mergedHeaders = auth.headers || headers ? { ...auth.headers, ...headers } : undefined;
|
|
if (authHeader) {
|
|
if (!auth.apiKey)
|
|
throw new Error("authHeader requires a resolved API key");
|
|
mergedHeaders = { ...mergedHeaders, Authorization: `Bearer ${auth.apiKey}` };
|
|
}
|
|
return { ...auth, headers: mergedHeaders };
|
|
}
|
|
function configuredApiKey(config, extension) {
|
|
return extension?.apiKey ?? config?.apiKey;
|
|
}
|
|
function configuredHeaders(config, extension) {
|
|
if (!config?.headers && !extension?.headers)
|
|
return undefined;
|
|
return { ...config?.headers, ...extension?.headers };
|
|
}
|
|
async function configContextEnv(values, ctx, explicit) {
|
|
const env = { ...explicit };
|
|
for (const name of new Set(values.flatMap(getConfigValueEnvVarNames))) {
|
|
if (env[name] !== undefined)
|
|
continue;
|
|
const value = await ctx.env(name);
|
|
if (value !== undefined)
|
|
env[name] = value;
|
|
}
|
|
return Object.keys(env).length > 0 ? env : undefined;
|
|
}
|
|
function composeApiKeyAuth(providerId, base, config, extension) {
|
|
const inherited = base?.auth.apiKey;
|
|
const rawKey = configuredApiKey(config, extension);
|
|
const oauth = extension?.oauth ?? base?.auth.oauth;
|
|
// OAuth-only providers get no fabricated API-key login method.
|
|
if (!inherited && rawKey === undefined && oauth)
|
|
return undefined;
|
|
const rawHeaders = configuredHeaders(config, extension);
|
|
const authHeader = extension?.authHeader ?? config?.authHeader ?? false;
|
|
return {
|
|
name: inherited?.name ?? "API key",
|
|
login: inherited?.login ??
|
|
(async (interaction) => ({
|
|
type: "api_key",
|
|
key: await interaction.prompt({ type: "secret", message: "Enter API key" }),
|
|
})),
|
|
check: async (input) => {
|
|
if (input.credential) {
|
|
if (inherited?.check)
|
|
return inherited.check(input);
|
|
if (input.credential.key)
|
|
return { type: "api_key", source: "stored credential" };
|
|
const resolved = await inherited?.resolve(input);
|
|
return resolved ? { type: "api_key", source: resolved.source } : undefined;
|
|
}
|
|
if (rawKey !== undefined) {
|
|
if (isCommandConfigValue(rawKey))
|
|
return { type: "api_key", source: "configured API key" };
|
|
const envNames = getConfigValueEnvVarNames(rawKey);
|
|
for (const name of envNames) {
|
|
if ((await input.ctx.env(name)) === undefined)
|
|
return undefined;
|
|
}
|
|
return { type: "api_key", source: "configured API key" };
|
|
}
|
|
if (inherited?.check)
|
|
return inherited.check(input);
|
|
const resolved = await inherited?.resolve(input);
|
|
return resolved ? { type: "api_key", source: resolved.source } : undefined;
|
|
},
|
|
resolve: async (input) => {
|
|
let result;
|
|
if (input.credential) {
|
|
result = inherited
|
|
? await inherited.resolve(input)
|
|
: input.credential.key
|
|
? { auth: { apiKey: input.credential.key }, env: input.credential.env, source: "stored credential" }
|
|
: undefined;
|
|
}
|
|
else if (rawKey !== undefined) {
|
|
const env = await configContextEnv([rawKey], input.ctx);
|
|
const key = resolveConfigValueOrThrow(rawKey, `API key for provider "${providerId}"`, env);
|
|
result = inherited
|
|
? await inherited.resolve({ ...input, credential: { type: "api_key", key } })
|
|
: { auth: { apiKey: key }, source: "configured API key" };
|
|
}
|
|
else {
|
|
result = await inherited?.resolve(input);
|
|
}
|
|
if (!result)
|
|
return undefined;
|
|
const explicitEnv = { ...(input.credential?.env ?? {}), ...(result.env ?? {}) };
|
|
const headerEnv = await configContextEnv(Object.values(rawHeaders ?? {}), input.ctx, explicitEnv);
|
|
const headers = resolveHeadersOrThrow(rawHeaders, `provider "${providerId}"`, headerEnv);
|
|
return { ...result, auth: withConfiguredAuth(result.auth, headers, authHeader) };
|
|
},
|
|
};
|
|
}
|
|
function composeOAuthAuth(providerId, base, config, extension) {
|
|
const oauth = extension?.oauth ? adaptOAuth(extension.oauth) : base?.auth.oauth;
|
|
if (!oauth)
|
|
return undefined;
|
|
const rawHeaders = configuredHeaders(config, extension);
|
|
const authHeader = extension?.authHeader ?? config?.authHeader ?? false;
|
|
return {
|
|
...oauth,
|
|
toAuth: async (credential) => {
|
|
const auth = await oauth.toAuth(credential);
|
|
const env = credential.env;
|
|
const headers = resolveHeadersOrThrow(rawHeaders, `provider "${providerId}"`, typeof env === "object" && env !== null ? env : undefined);
|
|
return withConfiguredAuth(auth, headers, authHeader);
|
|
},
|
|
};
|
|
}
|
|
function rawModelHeaders(model, config, extension) {
|
|
const definition = config?.models?.find((entry) => entry.id === model.id);
|
|
const extensionModel = extension?.models?.find((entry) => entry.id === model.id);
|
|
const headers = {
|
|
...config?.modelOverrides?.[model.id]?.headers,
|
|
...definition?.headers,
|
|
...extensionModel?.headers,
|
|
};
|
|
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
}
|
|
export function validateExtensionProvider(providerId, base, modelsConfig, extension) {
|
|
if (extension.streamSimple && !extension.api) {
|
|
throw new Error(`Provider ${providerId}: "api" is required when registering streamSimple.`);
|
|
}
|
|
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], modelsConfig), extension);
|
|
}
|
|
/** Compose built-in, models.json, and extension layers without reading credentials. */
|
|
export function composeModelProvider(providerId, base, modelConfig, extension) {
|
|
const config = modelConfig.getProvider(providerId);
|
|
let extensionOAuthCredential;
|
|
let refreshedExtensionModels;
|
|
const currentExtension = () => extension && refreshedExtensionModels ? { ...extension, models: refreshedExtensionModels } : extension;
|
|
// models.json modelOverrides are the topmost user-config layer: they apply once,
|
|
// after custom-model upserts, extension model replacement, and legacy OAuth projection.
|
|
const getModels = () => {
|
|
let models = applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), currentExtension());
|
|
if (extensionOAuthCredential && extension?.oauth?.modifyModels) {
|
|
models = extension.oauth.modifyModels(models, extensionOAuthCredential);
|
|
}
|
|
return models.map((model) => {
|
|
const override = config?.modelOverrides?.[model.id];
|
|
return override ? applyModelOverride(model, override) : model;
|
|
});
|
|
};
|
|
// Validate eagerly so registration/reload reports structural errors immediately.
|
|
getModels();
|
|
const apiKey = composeApiKeyAuth(providerId, base, config, extension);
|
|
const oauth = composeOAuthAuth(providerId, base, config, extension);
|
|
if (!apiKey && !oauth)
|
|
throw new Error(`Provider ${providerId}: no authentication method configured.`);
|
|
const supportsBaseApi = (model) => base?.getModels().some((entry) => entry.api === model.api) ?? false;
|
|
const streamWith = (model, context, options, simple) => lazyStream(model, async () => {
|
|
if (extension?.streamSimple && model.api === extension.api) {
|
|
return extension.streamSimple(model, context, options);
|
|
}
|
|
if (base && supportsBaseApi(model)) {
|
|
return simple
|
|
? base.streamSimple(model, context, options)
|
|
: base.stream(model, context, options);
|
|
}
|
|
const api = getApiProvider(model.api);
|
|
if (!api)
|
|
throw new Error(`No API provider registered for api: ${model.api}`);
|
|
return simple
|
|
? api.streamSimple(model, context, options)
|
|
: api.stream(model, context, options);
|
|
});
|
|
return {
|
|
id: providerId,
|
|
name: extension?.name ?? config?.name ?? base?.name ?? extension?.oauth?.name ?? providerId,
|
|
baseUrl: extension?.baseUrl ?? config?.baseUrl ?? base?.baseUrl,
|
|
headers: base?.headers,
|
|
auth: { ...(apiKey ? { apiKey } : {}), ...(oauth ? { oauth } : {}) },
|
|
getModels,
|
|
refreshModels: base?.refreshModels || extension?.refreshModels || extension?.oauth?.modifyModels
|
|
? async (context) => {
|
|
await base?.refreshModels?.(context);
|
|
if (extension?.refreshModels) {
|
|
const refreshed = await extension.refreshModels(context);
|
|
if (!context.signal?.aborted) {
|
|
// Validate before publishing the new synchronous list.
|
|
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), {
|
|
...extension,
|
|
models: refreshed,
|
|
});
|
|
refreshedExtensionModels = refreshed;
|
|
}
|
|
}
|
|
extensionOAuthCredential = context.credential?.type === "oauth" ? context.credential : undefined;
|
|
}
|
|
: undefined,
|
|
filterModels: base?.filterModels
|
|
? (models, credential) => base.filterModels(models, credential)
|
|
: undefined,
|
|
stream: (model, context, options) => streamWith(model, context, options, false),
|
|
streamSimple: (model, context, options) => streamWith(model, context, options, true),
|
|
};
|
|
}
|
|
export function resolveConfiguredModelHeaders(model, config, extension, env) {
|
|
return resolveHeadersOrThrow(rawModelHeaders(model, config, extension), `model "${model.provider}/${model.id}"`, env);
|
|
}
|
|
export function resolveCompatibilityRequestConfig(model, config, extension) {
|
|
const configured = resolveHeadersOrThrow({ ...configuredHeaders(config, extension), ...rawModelHeaders(model, config, extension) }, `model "${model.provider}/${model.id}"`);
|
|
return {
|
|
headers: model.headers || configured ? { ...model.headers, ...configured } : undefined,
|
|
authHeader: extension?.authHeader ?? config?.authHeader ?? false,
|
|
};
|
|
}
|
|
export function configuredRequestAuthStatus(config, extension) {
|
|
const value = configuredApiKey(config, extension);
|
|
if (value === undefined)
|
|
return undefined;
|
|
if (isCommandConfigValue(value))
|
|
return { configured: true, source: "models_json_command" };
|
|
const names = getConfigValueEnvVarNames(value);
|
|
if (names.length > 0) {
|
|
return isConfigValueConfigured(value)
|
|
? { configured: true, source: "environment", label: names.join(", ") }
|
|
: { configured: false };
|
|
}
|
|
return { configured: true, source: extension?.apiKey !== undefined ? "fallback" : "models_json_key" };
|
|
}
|
|
//# sourceMappingURL=provider-composer.js.map
|