UNPKG

@heroku/plugin-ai

Version:
286 lines (285 loc) 15.3 kB
"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;