-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #10 from heroku/sbosio/ai-models-create
feat(plugin-ai): Added command 'ai:models:create'
- Loading branch information
Showing
15 changed files
with
714 additions
and
88 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import color from '@heroku-cli/color' | ||
import {flags} from '@heroku-cli/command' | ||
import {Args, ux} from '@oclif/core' | ||
import heredoc from 'tsheredoc' | ||
import createAddon from '../../../lib/ai/models/create_addon' | ||
import Command from '../../../lib/base' | ||
import {HerokuAPIError} from '@heroku-cli/command/lib/api-client' | ||
|
||
export default class Create extends Command { | ||
static args = { | ||
model_name: Args.string({ | ||
description: 'The name of the model to provision access for', | ||
required: true, | ||
}), | ||
} | ||
|
||
static description = 'provision access to an AI model' | ||
static example = heredoc` | ||
# Provision access to an AI model and attach it to your app with a default name: | ||
$ heroku ai:models:create claude-3-5-sonnet --app example-app | ||
# Provision access to an AI model and attach it to your app with a custom name: | ||
$ heroku ai:models:create stable-diffusion-xl --app example-app --as my_sdxl | ||
` | ||
static flags = { | ||
app: flags.app({ | ||
description: 'The name of the Heroku app to attach the model to', | ||
required: true, | ||
}), | ||
as: flags.string({description: 'alias name for model resource'}), | ||
confirm: flags.string({description: 'overwrite existing config vars or existing add-on attachments'}), | ||
remote: flags.remote(), | ||
} | ||
|
||
public async run(): Promise<void> { | ||
const {flags, args} = await this.parse(Create) | ||
const {app, as, confirm} = flags | ||
const {model_name: modelName} = args | ||
|
||
try { | ||
const addon = await createAddon( | ||
this.heroku, | ||
app, | ||
`${this.addonServiceSlug}:${modelName}`, | ||
confirm, | ||
{config: {}, as} | ||
) | ||
|
||
await this.config.runHook('recache', {type: 'addon', app, addon}) | ||
ux.log(`Use ${color.cmd('heroku ai:docs to view documentation')}.`) | ||
} catch (error: unknown) { | ||
this.handleError(error, {as, modelName}) | ||
} | ||
} | ||
|
||
/** | ||
* Error handler | ||
* @param error Error thrown when attempting to create the add-on. | ||
* @param cmdContext Context of the command that failed. | ||
* @returns never | ||
* | ||
* There's a problem with this error handler implementation, because it relies on the specific error message | ||
* returned from API in order to format the error correctly. This is prone to fail if changes are introduced | ||
* upstream on error messages. We should rely on the error `id` but API returns a generic `invalid_params`. | ||
*/ | ||
private handleError(error: unknown, cmdContext: {as?: string, modelName?: string} = {}): never { | ||
if (error instanceof HerokuAPIError && error.body.id === 'invalid_params') { | ||
if (error.body.message?.includes('start with a letter')) { | ||
ux.error( | ||
`${cmdContext.as} is an invalid alias name. It must start with a letter and can only contain uppercase letters, numbers, and underscores.`, | ||
{exit: 1}, | ||
) | ||
} | ||
|
||
if (error.body.message?.includes('add-on plan')) { | ||
ux.error( | ||
`${cmdContext.modelName} is an invalid model name. Run ${color.cmd('heroku ai:models:list')} for a list of valid models.`, | ||
{exit: 1}, | ||
) | ||
} | ||
} | ||
|
||
throw error | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import {ux} from '@oclif/core' | ||
import color from '@heroku-cli/color' | ||
import * as Heroku from '@heroku-cli/schema' | ||
import {APIClient} from '@heroku-cli/command' | ||
import * as util from './util' | ||
|
||
// eslint-disable-next-line max-params | ||
export default async function ( | ||
heroku: APIClient, | ||
app: string, | ||
plan: string, | ||
confirm: string | undefined, | ||
options: {name?: string, config: Record<string, string | boolean>, as?: string}, | ||
) { | ||
async function createAddonRequest(confirmed?: string) { | ||
const body = { | ||
confirm: confirmed, | ||
name: options.name, | ||
config: options.config, | ||
plan: {name: plan}, | ||
attachment: {name: options.as}, | ||
} | ||
|
||
ux.action.start(`Creating ${plan} on ${color.app(app)}`) | ||
|
||
const {body: addon} = await heroku.post<Required<Heroku.AddOn>>(`/apps/${app}/addons`, { | ||
body, | ||
headers: { | ||
'accept-expansion': 'plan', | ||
'x-heroku-legacy-provider-messages': 'true', | ||
}, | ||
}).catch(error => { | ||
ux.action.stop('') | ||
throw error | ||
}) | ||
|
||
ux.action.stop(color.green(util.formatPriceText(addon.plan?.price || ''))) | ||
|
||
return addon | ||
} | ||
|
||
const addon = await util.trapConfirmationRequired<Required<Heroku.AddOn>>(app, confirm, confirm => (createAddonRequest(confirm))) | ||
|
||
if (addon.provision_message) { | ||
ux.log(addon.provision_message) | ||
} | ||
|
||
ux.log( | ||
`Added ${addon.config_vars.map((c: string) => color.configVar(c)).join(', ')} to ${color.app(addon.app.name)}` | ||
) | ||
|
||
return addon | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
/* eslint-disable no-return-await */ | ||
import color from '@heroku-cli/color' | ||
import * as Heroku from '@heroku-cli/schema' | ||
import printf from 'printf' | ||
import confirmCommand from '../../confirmCommand' | ||
|
||
export const trapConfirmationRequired = async function<T> (app: string, confirm: string | undefined, fn: (confirmed?: string) => Promise<T>) { | ||
return await fn(confirm) | ||
.catch(async (error: any) => { | ||
if (!error.body || error.body.id !== 'confirmation_required') | ||
throw error | ||
await confirmCommand(app, confirm, error.body.message) | ||
return await fn(app) | ||
}) | ||
} | ||
|
||
// This function assumes that price.cents will reflect price per month. | ||
// If the API returns any unit other than month | ||
// this function will need to be updated. | ||
export const formatPrice = function ({price, hourly}: {price: Heroku.AddOn['price'] | number, hourly?: boolean}) { | ||
if (!price) return | ||
if (price.contract) return 'contract' | ||
if (price.cents === 0) return 'free' | ||
|
||
// we are using a standardized 720 hours/month | ||
if (hourly) return `~$${((price.cents / 100) / 720).toFixed(3)}/hour` | ||
|
||
const fmt = price.cents % 100 === 0 ? '$%.0f/%s' : '$%.02f/%s' | ||
return printf(fmt, price.cents / 100, price.unit) | ||
} | ||
|
||
export const formatPriceText = function (price: Heroku.AddOn['price']) { | ||
const priceHourly = formatPrice({price, hourly: true}) | ||
const priceMonthly = formatPrice({price, hourly: false}) | ||
if (!priceHourly) return '' | ||
if (priceHourly === 'free' || priceHourly === 'contract') return `${color.green(priceHourly)}` | ||
|
||
return `${color.green(priceHourly)} (max ${priceMonthly})` | ||
} | ||
|
||
export const grandfatheredPrice = function (addon: Heroku.AddOn) { | ||
const price = addon.plan?.price | ||
return Object.assign({}, price, { | ||
cents: addon.billed_price?.cents, | ||
contract: addon.billed_price?.contract, | ||
}) | ||
} | ||
|
||
export const formatState = function (state: string) { | ||
switch (state) { | ||
case 'provisioned': | ||
state = 'created' | ||
break | ||
case 'provisioning': | ||
state = 'creating' | ||
break | ||
case 'deprovisioning': | ||
state = 'destroying' | ||
break | ||
case 'deprovisioned': | ||
state = 'errored' | ||
break | ||
default: | ||
state = '' | ||
} | ||
|
||
return state | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import {color} from '@heroku-cli/color' | ||
import {ux} from '@oclif/core' | ||
import heredoc from 'tsheredoc' | ||
|
||
export default async function confirmCommand(app: string, confirm?: string | undefined, message?: string) { | ||
if (confirm) { | ||
if (confirm === app) return | ||
throw new Error(`Confirmation ${color.bold.red(confirm)} did not match ${color.bold.red(app)}. Aborted.`) | ||
} | ||
|
||
if (!message) { | ||
message = heredoc` | ||
Destructive Action. | ||
This command will affect the app ${color.bold.red(app)}. | ||
` | ||
} | ||
|
||
ux.warn(message) | ||
console.error() | ||
const entered = await ux.prompt( | ||
`To proceed, type ${color.bold.red(app)} or re-run this command with ${color.bold.red('--confirm', app)}`, | ||
{required: true}, | ||
) | ||
if (entered === app) { | ||
return | ||
} | ||
|
||
throw new Error(`Confirmation did not match ${color.bold.red(app)}. Aborted.`) | ||
} |
Empty file.
Oops, something went wrong.