Skip to content

Commit

Permalink
Merge pull request #10 from heroku/sbosio/ai-models-create
Browse files Browse the repository at this point in the history
feat(plugin-ai): Added command 'ai:models:create'
  • Loading branch information
zwhitfield3 authored Sep 25, 2024
2 parents ee703dd + 1d04a35 commit f711385
Show file tree
Hide file tree
Showing 15 changed files with 714 additions and 88 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ USAGE
<!-- commands -->
* [`heroku ai:docs`](#heroku-aidocs)
* [`heroku ai:models`](#heroku-aimodels)
* [`heroku ai:models:create MODEL_NAME`](#heroku-aimodelscreate-model_name)
* [`heroku ai:models:list`](#heroku-aimodelslist)

## `heroku ai:docs`
Expand Down Expand Up @@ -64,6 +65,35 @@ EXAMPLES
$ heroku ai:models:list
```

## `heroku ai:models:create MODEL_NAME`

provision access to an AI model

```
USAGE
$ heroku ai:models:create [MODEL_NAME] -a <value> [--as <value>] [--confirm <value>] [-r <value>]
ARGUMENTS
MODEL_NAME The name of the model to provision access for
FLAGS
-a, --app=<value> (required) The name of the Heroku app to attach the model to
-r, --remote=<value> git remote of app to use
--as=<value> alias name for model resource
--confirm=<value> overwrite existing config vars or existing add-on attachments
DESCRIPTION
provision access to an AI model
EXAMPLES
# 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
```

_See code: [dist/commands/ai/models/create.ts](https://github.com/heroku/heroku-cli-plugin-integration/blob/v0.0.0/dist/commands/ai/models/create.ts)_

## `heroku ai:models:list`

list available AI models to provision access to
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@oclif/core": "^2.16.0",
"@oclif/plugin-help": "^5",
"open": "^8.4.2",
"printf": "^0.6.1",
"tsheredoc": "^1"
},
"devDependencies": {
Expand Down
84 changes: 84 additions & 0 deletions src/commands/ai/models/create.ts
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
}
}
53 changes: 53 additions & 0 deletions src/lib/ai/models/create_addon.ts
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
}
68 changes: 68 additions & 0 deletions src/lib/ai/models/util.ts
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
}
2 changes: 1 addition & 1 deletion src/lib/ai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export type ModelInfo = {
/**
* Object schema for Model Status endpoint responses.
*/
export type ModelInstance = {
export type ModelResource = {
plan: ModelName
created: string
tokens_in: string
Expand Down
27 changes: 13 additions & 14 deletions src/lib/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {HerokuAPIError} from '@heroku-cli/command/lib/api-client'
export class NotFound extends Error {
constructor(addonIdentifier: string, appIdentifier?: string) {
const message = heredoc`
We can’t find a model instance called ${color.yellow(addonIdentifier)}${appIdentifier ? ` on ${color.app(appIdentifier)}` : ''}.
Run ${color.cmd(`heroku ai:models:info --app ${appIdentifier ? appIdentifier : '<value>'}`)} to see a list of model instances.
We can’t find a model resource called ${color.yellow(addonIdentifier)}${appIdentifier ? ` on ${color.app(appIdentifier)}` : ''}.
Run ${color.cmd(`heroku ai:models:info --app ${appIdentifier ? appIdentifier : '<value>'}`)} to see a list of model resources.
`
super(message)
}
Expand All @@ -22,8 +22,8 @@ export class NotFound extends Error {
export class AmbiguousError extends Error {
constructor(public readonly matches: string[], addonIdentifier: string, appIdentifier?: string) {
const message = heredoc`
Multiple model instances match ${color.yellow(addonIdentifier)}${appIdentifier ? ` on ${color.app(appIdentifier)}` : ''}: ${matches.map(match => color.addon(match)).join(', ')}.
Specify the model instance by its name instead.
Multiple model resources match ${color.yellow(addonIdentifier)}${appIdentifier ? ` on ${color.app(appIdentifier)}` : ''}: ${matches.map(match => color.addon(match)).join(', ')}.
Specify the model resource by its name instead.
`
super(message)
}
Expand All @@ -36,7 +36,6 @@ export default abstract class extends Command {
private _addon?: Required<Heroku.AddOn>
private _addonAttachment?: Required<Heroku.AddOnAttachment>
private _addonServiceSlug?: string
private _inferenceAddonSlugs = ['inference', 'inference-staging']
private _apiKey?: string
private _apiModelId?: string
private _apiUrl?: string
Expand All @@ -62,6 +61,7 @@ export default abstract class extends Command {
this._apiModelId = configVars[this.apiModelIdConfigVarName] ||
this.addon.plan.name?.split(':')[1] // Fallback to plan name (e.g. "inference:claude-3-haiku" => "claude-3-haiku"
this._apiUrl = configVars[this.apiUrlConfigVarName]
this._addonServiceSlug = this.addon.addon_service.name
this._herokuAI.defaults.host = this.apiUrl
this._herokuAI.defaults.headers = {
...defaultHeaders,
Expand Down Expand Up @@ -174,7 +174,7 @@ export default abstract class extends Command {
}

// 5. If we resolved for an add-on, check that it's a Managed Inference add-on or throw a NotFound error.
if (resolvedAddon && !this._inferenceAddonSlugs.includes(resolvedAddon.addon_service.name as string))
if (resolvedAddon && resolvedAddon.addon_service.name !== this.addonServiceSlug)
throw new NotFound(addonIdentifier, appIdentifier)

// 6. If we resolved for an add-on but not for an attachment yet, try to resolve the attachment
Expand Down Expand Up @@ -234,17 +234,16 @@ export default abstract class extends Command {
}

get addonServiceSlug(): string {
if (this._addonServiceSlug)
return this._addonServiceSlug

ux.error('Heroku AI API Client not configured.', {exit: 1})
return this._addonServiceSlug ||
process.env.HEROKU_INFERENCE_ADDON ||
'inference'
}

get apiKey(): string {
if (this._apiKey)
if (this.addon && this._apiKey)
return this._apiKey

ux.error(`Model instance ${color.addon(this.addon?.name)} isn’t fully provisioned on ${color.app(this.addon?.app.name)}.`, {exit: 1})
ux.error(`Model resource ${color.addon(this.addon?.name)} isn’t fully provisioned on ${color.app(this.addon?.app.name)}.`, {exit: 1})
}

get apiKeyConfigVarName(): string {
Expand All @@ -260,10 +259,10 @@ export default abstract class extends Command {
}

get apiUrl(): string {
if (this._apiUrl)
if (this.addon && this._apiUrl)
return this._apiUrl

ux.error(`Model instance ${color.addon(this.addon?.name)} isn’t fully provisioned on ${color.app(this.addon?.app.name)}.`, {exit: 1})
ux.error(`Model resource ${color.addon(this.addon?.name)} isn’t fully provisioned on ${color.app(this.addon?.app.name)}.`, {exit: 1})
}

get apiUrlConfigVarName(): string {
Expand Down
29 changes: 29 additions & 0 deletions src/lib/confirmCommand.ts
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 removed test/commands/ai/models/.keep
Empty file.
Loading

0 comments on commit f711385

Please sign in to comment.