@heroku/plugin-ai
Version:
Heroku CLI plugin for Heroku AI add-on
286 lines (285 loc) • 15.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AmbiguousError = exports.AppNotFound = exports.NotFound = void 0;
const tslib_1 = require("tslib");
const color_1 = require("@heroku-cli/color");
const command_1 = require("@heroku-cli/command");
const core_1 = require("@oclif/core");
const tsheredoc_1 = tslib_1.__importDefault(require("tsheredoc"));
const api_client_1 = require("@heroku-cli/command/lib/api-client");
const http_call_1 = tslib_1.__importDefault(require("@heroku/http-call"));
class NotFound extends Error {
constructor(addonIdentifier, appIdentifier) {
const message = (0, tsheredoc_1.default) `
We can’t find a model resource called ${color_1.color.yellow(addonIdentifier)}${appIdentifier ? ` on ${color_1.color.app(appIdentifier)}` : ''}.
Run ${color_1.color.cmd(`heroku ai:models:info --app ${appIdentifier ? appIdentifier : '<value>'}`)} to see a list of model resources.
`;
super(message);
}
statusCode = 404;
id = 'not_found';
}
exports.NotFound = NotFound;
class AppNotFound extends Error {
constructor(appIdentifier) {
const message = (0, tsheredoc_1.default) `
We can’t find the ${color_1.color.app(appIdentifier)} app. Check your spelling.
`;
super(message);
}
statusCode = 404;
id = 'not_found';
}
exports.AppNotFound = AppNotFound;
class AmbiguousError extends Error {
matches;
constructor(matches, addonIdentifier, appIdentifier) {
const message = (0, tsheredoc_1.default) `
Multiple model resources match ${color_1.color.yellow(addonIdentifier)}${appIdentifier ? ` on ${color_1.color.app(appIdentifier)}` : ''}: ${matches.map(match => color_1.color.addon(match)).join(', ')}.
Specify the model resource by its alias instead.
`;
super(message);
this.matches = matches;
}
statusCode = 422;
id = 'multiple_matches';
}
exports.AmbiguousError = AmbiguousError;
// This function only exists because the
// heroku-cli-command legacy heroku client
// uses funky logic to prepend 'https://' to the request url
// See logic here --> https://github.com/heroku/heroku-cli-command/blob/32f1010c7a949948aac2b5ce1526dc65824a616c/src/command.ts#L38
const stripHttps = (url) => {
return url.startsWith('https://') ? url.slice(8) : url;
};
class default_1 extends command_1.Command {
_addon;
_addonAttachment;
_addonServiceSlug;
_addonResourceId;
_apiKey;
_apiModelId;
_apiUrl;
_herokuAI;
_defaultInferenceHost = process.env.HEROKU_INFERENCE_HOST || 'us.inference.heroku.com';
async configureHerokuAIClient(addonIdentifier, appIdentifier) {
const defaultHeaders = {
...this.heroku.defaults.headers,
accept: 'application/json',
'user-agent': `heroku-cli-plugin-ai/${process.env.npm_package_version} ${this.config.platform}`,
};
delete defaultHeaders.authorization;
const defaults = http_call_1.default.defaults;
if (addonIdentifier) {
({ addon: this._addon, attachment: this._addonAttachment } = await this.resolveAddonAndAttachment(addonIdentifier, appIdentifier));
const { body: configVars } = await this.heroku.get(`/apps/${this.addonAttachment.app.id}/config-vars`);
this._apiKey = configVars[this.apiKeyConfigVarName];
this._apiModelId = configVars[this.apiModelIdConfigVarName] ||
this.addon.plan.name?.split(':')[1]; // Fallback to plan name (e.g. "heroku-inference:claude-3-haiku" => "claude-3-haiku"
this._apiUrl = configVars[this.apiUrlConfigVarName];
this._addonServiceSlug = this.addon.addon_service.name;
this._addonResourceId = this.addon.id;
defaults.host = stripHttps(this.apiUrl);
defaults.headers = {
...defaultHeaders,
authorization: `Bearer ${this.apiKey}`,
};
}
else {
defaults.host = this.defaultInferenceHost;
defaults.headers = defaultHeaders;
}
this._herokuAI = new command_1.APIClient(this.config);
this._herokuAI.http.defaults = defaults;
this._herokuAI.auth = this._apiKey;
}
/*
* Resolution logic:
* 1. Use the add-on and app identifiers to fetch matching add-ons and attachments from Platform API
* resolver endpoints.
* 2. If we don't get any add-ons or add-on attachments from the resolvers, we throw a NotFound error.
* 3. Try to resolve the add-on through the add-ons resolver response:
* a. If we have no add-ons, we move to the next step.
* b. We deduplicate the resolved add-ons based on their add-on ids.
* c. If we're left with a single add-on, it will be the selected one.
* d. If we still have multiple add-ons, we throw an AmbiguousError.
* 4. If we didn't resolve for an add-on yet, try to do it through the add-on attachments resolver response:
* a. If we have a single attachment, we select it as the resolved attachment and try to resolve the
* add-on fetching its info using the app and add-on ids from the attachment.
* b. If we have multiple attachments, we deduplicate attachments to the same app and filter
* based on add-ons that are accessible to the user through the attached app (we do this
* fetching all add-ons using the app and add-on ids from the remaining attachments and keeping
* only the successful responses).
* - If we're left with no accessible add-ons, we throw a NotFound error.
* - If we still have multiple attachments, we throw an AmbiguousError.
* - If we get a single accessible add-on, we select it as the resolved add-on and its associated
* attachment as the resolved attachment.
* 5. If we resolved for an add-on, check that it's a Managed Inference add-on or throw a NotFound error.
* 6. If we resolved for an add-on but not yet for an attachment:
* a. If we have a single attachment on the resolver response, we select that one.
* b. If not, we try to select the first attachment that matches the resolved add-on by app and add-on id.
* c. If no attachment matched, we fetch all add-on attachments for the resolved add-on and try to
* select the first one that matches the resolved add-on by app id.
* 7. If we get to this point without the add-on or the attachment resolved, throw a NotFound error.
* 8. Return the resolved add-on and attachment.
*/
// eslint-disable-next-line complexity
async resolveAddonAndAttachment(addonIdentifier, appIdentifier) {
let resolvedAddon;
let resolvedAttachment;
// 1. Use the add-on and app identifiers to fetch matching add-ons and attachments from Platform API
// resolver endpoints.
const addonResolverRequest = this.heroku.post('/actions/addons/resolve', {
body: {
addon: addonIdentifier,
app: appIdentifier || null,
},
});
const attachmentResolverRequest = this.heroku.post('/actions/addon-attachments/resolve', {
body: {
addon_attachment: addonIdentifier,
app: appIdentifier || null,
},
});
const [settledAddons, settledAttachments] = await Promise.allSettled([addonResolverRequest, attachmentResolverRequest]);
if (settledAddons.status === 'rejected' && settledAttachments.status === 'rejected') {
// 2. If we don't get any add-ons or add-on attachments from the resolvers, we throw a NotFound error.
this.handleErrors(settledAddons, settledAttachments, addonIdentifier, appIdentifier);
}
const resolvedAddons = settledAddons.status === 'fulfilled' ? settledAddons.value.body : [];
const resolvedAttachments = settledAttachments.status === 'fulfilled' ? settledAttachments.value.body : [];
// 3. If the addon attachment resolved, check for multiple attachments
if (resolvedAttachments && resolvedAttachments.length > 1) {
throw new AmbiguousError(resolvedAttachments.map((a) => a.name), addonIdentifier, appIdentifier);
}
// 4. Try to resolve the add-on through the add-ons resolver response.
if (resolvedAddons.length > 0) {
// The add-on resolver may duplicate add-ons when there's more than one attachment and the user has access to the different
// apps where it's attached, so we dedup here trying to get a single result.
const uniqueAddons = resolvedAddons.filter((addon, index, self) => {
return self.findIndex(a => a.id === addon.id) === index;
});
if (uniqueAddons.length === 1)
resolvedAddon = uniqueAddons[0];
else
throw new AmbiguousError(uniqueAddons.map(a => a.name), addonIdentifier, appIdentifier);
}
// 5. If we didn't resolve for an add-on yet, try to do it through the add-on attachments resolver response.
if (!resolvedAddon) {
if (resolvedAttachments.length === 1) {
resolvedAttachment = resolvedAttachments[0];
({ body: resolvedAddon } = await this.heroku.get(`/apps/${resolvedAttachment.app.id}/addons/${resolvedAttachment.addon.id}`));
}
else if (resolvedAttachments.length > 1) {
const uniqueAppAddons = resolvedAttachments.map(a => {
return { attachment: a, appId: a.app.id, addonId: a.addon.id };
}).filter((appAddon, index, self) => {
return self.findIndex(a => a.appId === appAddon.appId && a.addonId === appAddon.addonId) === index;
});
const addonRequests = uniqueAppAddons.map(a => this.heroku.get(`/apps/${a.appId}/addons/${a.addonId}`));
const settledAddons = await Promise.allSettled(addonRequests);
const accessibleAddons = settledAddons.filter(s => s.status === 'fulfilled').map(s => s.value.body);
if (accessibleAddons.length === 0)
throw new NotFound(addonIdentifier, appIdentifier);
else if (accessibleAddons.length > 1)
throw new AmbiguousError(accessibleAddons.map(a => a.name), addonIdentifier, appIdentifier);
else {
resolvedAddon = accessibleAddons[0];
resolvedAttachment = uniqueAppAddons[settledAddons.findIndex(s => s.status === 'fulfilled')].attachment;
}
}
}
// 6. If we resolved for an add-on, check that it's a Managed Inference add-on or throw a NotFound error.
if (resolvedAddon && resolvedAddon.addon_service.name !== this.addonServiceSlug) {
throw new NotFound(addonIdentifier, appIdentifier);
}
// 7. If we resolved for an add-on but not for an attachment yet, try to resolve the attachment
if (resolvedAddon && !resolvedAttachment) {
resolvedAttachment = resolvedAttachments.length === 1 ?
resolvedAttachments[0] :
resolvedAttachments.find(a => a.addon.id === resolvedAddon.id && a.app.id === resolvedAddon.app.id);
if (!resolvedAttachment) {
const { body: addonAttachments } = await this.heroku.get(`/addons/${resolvedAddon.id}/addon-attachments`);
resolvedAttachment = addonAttachments.find(a => a.app.id === resolvedAddon.app.id);
}
}
// 8. If we get to this point without the add-on or the attachment resolved, throw a NotFound error.
if (!resolvedAddon || !resolvedAttachment)
throw new NotFound(addonIdentifier, appIdentifier);
// 9. Return the resolved add-on and attachment.
return { addon: resolvedAddon, attachment: resolvedAttachment };
}
handleErrors(addonRejection, attachmentRejection, addonIdentifier, appIdentifier) {
const addonResolverError = addonRejection.reason;
const attachmentResolverError = attachmentRejection.reason;
const addonNotFound = addonResolverError instanceof api_client_1.HerokuAPIError &&
addonResolverError.http.statusCode === 404 &&
addonResolverError.body.resource === 'add_on';
const attachmentNotFound = attachmentResolverError instanceof api_client_1.HerokuAPIError &&
attachmentResolverError.http.statusCode === 404 &&
attachmentResolverError.body.resource === 'add_on attachment';
const appNotFound = attachmentResolverError instanceof api_client_1.HerokuAPIError &&
attachmentResolverError.http.statusCode === 404 &&
attachmentResolverError.body.resource === 'app';
let error = addonResolverError;
if (addonNotFound)
error = attachmentNotFound ? new NotFound(addonIdentifier, appIdentifier) : attachmentResolverError;
if (appNotFound)
error = new AppNotFound(appIdentifier);
throw error;
}
get addon() {
if (this._addon)
return this._addon;
core_1.ux.error('Heroku AI API Client not configured.', { exit: 1 });
}
get addonAttachment() {
if (this._addonAttachment)
return this._addonAttachment;
core_1.ux.error('Heroku AI API Client not configured.', { exit: 1 });
}
get addonServiceSlug() {
return process.env.HEROKU_INFERENCE_ADDON ||
this._addonServiceSlug ||
'heroku-inference';
}
get addonResourceId() {
if (this.addon && this._addonResourceId)
return this._addonResourceId;
core_1.ux.error(`Model resource ${color_1.color.addon(this.addon?.name)} isn’t fully provisioned on ${color_1.color.app(this.addon?.app.name)}.`, { exit: 1 });
}
get apiKey() {
if (this.addon && this._apiKey)
return this._apiKey;
core_1.ux.error(`Model resource ${color_1.color.addon(this.addon?.name)} isn’t fully provisioned on ${color_1.color.app(this.addon?.app.name)}.`, { exit: 1 });
}
get apiKeyConfigVarName() {
return `${this.addonAttachment.name.toUpperCase()}_KEY`;
}
get modelAlias() {
return this.addonAttachment.name;
}
get apiModelId() {
return this._apiModelId;
}
get apiModelIdConfigVarName() {
return `${this.addonAttachment.name.toUpperCase()}_MODEL_ID`;
}
get apiUrl() {
if (this.addon && this._apiUrl)
return this._apiUrl;
core_1.ux.error(`Model resource ${color_1.color.addon(this.addon?.name)} isn’t fully provisioned on ${color_1.color.app(this.addon?.app.name)}.`, { exit: 1 });
}
get apiUrlConfigVarName() {
return `${this.addonAttachment.name.toUpperCase()}_URL`;
}
get herokuAI() {
if (this._herokuAI)
return this._herokuAI;
core_1.ux.error('Heroku AI API Client not configured.', { exit: 1 });
}
get defaultInferenceHost() {
return this._defaultInferenceHost;
}
}
exports.default = default_1;