Skip to content

Commit

Permalink
Merge pull request #19 from heroku/sbosio/ai-models-attach
Browse files Browse the repository at this point in the history
Adding command 'ai:models:attach'
  • Loading branch information
sbosio authored Sep 26, 2024
2 parents f711385 + cf205ba commit a3a9e01
Show file tree
Hide file tree
Showing 5 changed files with 395 additions and 34 deletions.
29 changes: 29 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:attach MODEL_RESOURCE`](#heroku-aimodelsattach-model_resource)
* [`heroku ai:models:create MODEL_NAME`](#heroku-aimodelscreate-model_name)
* [`heroku ai:models:list`](#heroku-aimodelslist)

Expand Down Expand Up @@ -65,6 +66,34 @@ EXAMPLES
$ heroku ai:models:list
```

## `heroku ai:models:attach MODEL_RESOURCE`

attach an existing model resource to an app

```
USAGE
$ heroku ai:models:attach [MODEL_RESOURCE] -a <value> [--as <value>] [--confirm <value>] [-r <value>]
ARGUMENTS
MODEL_RESOURCE The resource ID or alias of the model resource to attach.
FLAGS
-a, --app=<value> (required) app to run command against
-r, --remote=<value> git remote of app to use
--as=<value> alias name for model resource
--confirm=<value> overwrite existing resource with same name
DESCRIPTION
attach an existing model resource to an app
EXAMPLES
$ heroku ai:models:attach claude-3-5-sonnet-acute-41518 --app example-app
$ heroku ai:models:attach claude-3-5-sonnet-acute-41518 --app example-app --as MY_CS35
```

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

## `heroku ai:models:create MODEL_NAME`

provision access to an AI model
Expand Down
64 changes: 64 additions & 0 deletions src/commands/ai/models/attach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import color from '@heroku-cli/color'
import {flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import {Args, ux} from '@oclif/core'
import {handlePlatformApiErrors, trapConfirmationRequired} from '../../../lib/ai/models/util'
import Command from '../../../lib/base'

export default class Attach extends Command {
static args = {
model_resource: Args.string({
description: 'The resource ID or alias of the model resource to attach.',
required: true,
}),
}

static description = 'attach an existing model resource to an app'
static examples = [
'heroku ai:models:attach claude-3-5-sonnet-acute-41518 --app example-app',
'heroku ai:models:attach claude-3-5-sonnet-acute-41518 --app example-app --as MY_CS35',
]

static flags = {
as: flags.string({description: 'alias name for model resource'}),
confirm: flags.string({description: 'overwrite existing resource with same name'}),
app: flags.app({required: true}),
remote: flags.remote(),
}

public async run(): Promise<void> {
const {flags, args} = await this.parse(Attach)
const {model_resource: modelResource} = args
const {app, as, confirm} = flags

// Here, we purposely resolve the model resource without passing the app name in the flags
// to the configuration method, because the app flag the user passes in is the target app
// where the attachment will be created and it's probably a different app from the one
// where the model resource is provisioned (the billed app).
await this.configureHerokuAIClient(modelResource)
const attachment = await trapConfirmationRequired<Required<Heroku.AddOnAttachment>>(
app, confirm, (confirmed?: string) => this.createAttachment(app, as, confirmed)
)

ux.action.start(`Setting ${color.cyan(attachment.name || '')} config vars and restarting ${color.app(app)}`)
const {body: releases} = await this.heroku.get<Array<Required<Heroku.Release>>>(`/apps/${app}/releases`, {
partial: true, headers: {Range: 'version ..; max=1, order=desc'},
})
ux.action.stop(`done, v${releases[0].version}`)
}

private async createAttachment(app: string, as?: string, confirmed?: string) {
const body = {
name: as, app: {name: app}, addon: {name: this.addon.name}, confirm: confirmed,
}

ux.action.start(`Attaching ${color.addon(this.addon.name || '')}${as ? ' as ' + color.cyan(as) : ''} to ${color.app(app)}`)
const {body: attachment} = await this.heroku.post<Required<Heroku.AddOnAttachment>>('/addon-attachments', {body}).catch(error => {
ux.action.stop('')
handlePlatformApiErrors(error, {as})
})
ux.action.stop()

return attachment
}
}
38 changes: 4 additions & 34 deletions src/commands/ai/models/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ 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 {handlePlatformApiErrors} from '../../../lib/ai/models/util'
import Command from '../../../lib/base'
import {HerokuAPIError} from '@heroku-cli/command/lib/api-client'

export default class Create extends Command {
static args = {
Expand All @@ -17,9 +17,9 @@ export default class Create extends Command {
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
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
heroku ai:models:create stable-diffusion-xl --app example-app --as my_sdxl
`
static flags = {
app: flags.app({
Expand Down Expand Up @@ -48,37 +48,7 @@ export default class Create extends Command {
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})
handlePlatformApiErrors(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
}
}
32 changes: 32 additions & 0 deletions src/lib/ai/models/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable no-return-await */
import color from '@heroku-cli/color'
import {HerokuAPIError} from '@heroku-cli/command/lib/api-client'
import * as Heroku from '@heroku-cli/schema'
import {ux} from '@oclif/core'
import printf from 'printf'
import confirmCommand from '../../confirmCommand'

Expand All @@ -14,6 +16,36 @@ export const trapConfirmationRequired = async function<T> (app: string, confirm:
})
}

/**
* Error handler
* @param error Error thrown when attempting to create the model resource.
* @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`.
*/
export function handlePlatformApiErrors(error: unknown, cmdContext: {as?: string, modelName?: string} = {}): never {
if (error instanceof HerokuAPIError && error.body.id === 'invalid_params') {
if (cmdContext.as && 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 (cmdContext.modelName && 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 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.
Expand Down
Loading

0 comments on commit a3a9e01

Please sign in to comment.