Skip to content

Commit

Permalink
Merge pull request #17 from heroku/zw/models-destroy
Browse files Browse the repository at this point in the history
Add 'ai:models:destroy' command
  • Loading branch information
zwhitfield3 authored Sep 27, 2024
2 parents 1518f10 + 38c15b7 commit 48946a1
Show file tree
Hide file tree
Showing 7 changed files with 225 additions and 0 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ USAGE
* [`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:destroy MODELRESOURCE`](#heroku-aimodelsdestroy-modelresource)
* [`heroku ai:models:detach MODEL_RESOURCE`](#heroku-aimodelsdetach-model_resource)
* [`heroku ai:models:list`](#heroku-aimodelslist)

Expand Down Expand Up @@ -124,6 +125,32 @@ EXAMPLES

_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:destroy MODELRESOURCE`

destroy an existing AI model resource

```
USAGE
$ heroku ai:models:destroy [MODELRESOURCE] -a <value> [-c <value>] [-f] [-r <value>]
ARGUMENTS
MODELRESOURCE The resource ID or alias of the model resource to destroy.
FLAGS
-a, --app=<value> (required) app to run command against
-c, --confirm=<value>
-f, --force allow destruction even if connected to other apps
-r, --remote=<value> git remote of app to use
DESCRIPTION
destroy an existing AI model resource
EXAMPLES
$ heroku ai:models:destroy claude-3-5-sonnet-acute-43973
```

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

## `heroku ai:models:detach MODEL_RESOURCE`

Detach a model resource from an app.
Expand Down
38 changes: 38 additions & 0 deletions src/commands/ai/models/destroy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {Args} from '@oclif/core'
import {flags} from '@heroku-cli/command'
import destroyAddon from '../../../lib/ai/models/destroy_addon'
import confirmCommand from '../../../lib/confirmCommand'
import Command from '../../../lib/base'

export default class Destroy extends Command {
static description = 'destroy an existing AI model resource'

static flags = {
app: flags.app({required: true, description: 'app to run command against'}),
confirm: flags.string({char: 'c'}),
force: flags.boolean({char: 'f', description: 'allow destruction even if connected to other apps'}),
remote: flags.remote({description: 'git remote of app to use'}),
}

static args = {
modelResource: Args.string({required: true, description: 'The resource ID or alias of the model resource to destroy.'}),
}

static examples = [
'$ heroku ai:models:destroy claude-3-5-sonnet-acute-43973',
]

public async run(): Promise<void> {
const {flags, args} = await this.parse(Destroy)
const {app, confirm} = flags
const {modelResource} = args
const force = flags.force || process.env.HEROKU_FORCE === '1'

await this.configureHerokuAIClient(modelResource, app)

const aiAddon = this.addon

await confirmCommand(app, confirm)
await destroyAddon(this.config, aiAddon, force)
}
}
23 changes: 23 additions & 0 deletions src/lib/ai/models/destroy_addon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import color from '@heroku-cli/color'
import {ux} from '@oclif/core'
import {Config} from '@oclif/core'
import {APIClient} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'

export default async function (config: Config, addon: Heroku.AddOn, force = false) {
const addonName = addon.name || ''
const herokuClient = new APIClient(config)

ux.action.start(`Destroying ${color.addon(addonName)} in the background.\nThe app will restart when complete...`)

await herokuClient.delete<Heroku.AddOn>(`/apps/${addon.app?.id}/addons/${addon.id}`, {
headers: {'Accept-Expansion': 'plan'},
body: {force},
}).catch(error => {
ux.action.stop('')
const error_ = error.body && error.body.message ? new Error(`The add-on was unable to be destroyed: ${error.body.message}.`) : new Error(`The add-on was unable to be destroyed: ${error}.`)
throw error_
})

ux.action.stop()
}
18 changes: 18 additions & 0 deletions src/lib/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ export class NotFound extends Error {
public readonly id = 'not_found'
}

export class AppNotFound extends Error {
constructor(appIdentifier?: string) {
const message = heredoc`
We can’t find the ${color.app(appIdentifier)} app. Check your spelling.
`
super(message)
}

public readonly statusCode = 404
public readonly id = 'not_found'
}

export class AmbiguousError extends Error {
constructor(public readonly matches: string[], addonIdentifier: string, appIdentifier?: string) {
const message = heredoc`
Expand Down Expand Up @@ -211,11 +223,17 @@ export default abstract class extends Command {
const attachmentNotFound = attachmentResolverError instanceof HerokuAPIError &&
attachmentResolverError.http.statusCode === 404 &&
attachmentResolverError.body.resource === 'add_on attachment'
const appNotFound = attachmentResolverError instanceof 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
}

Expand Down
83 changes: 83 additions & 0 deletions test/commands/ai/models/destroy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {expect} from 'chai'
import {stderr, stdout} from 'stdout-stderr'
import Cmd from '../../../../src/commands/ai/models/destroy'
import stripAnsi from '../../../helpers/strip-ansi'
import {runCommand} from '../../../run-command'
import {mockConfigVars, mockAPIErrors, addon1, addon1Attachment1} from '../../../helpers/fixtures'
import {CLIError} from '@oclif/core/lib/errors'
import nock from 'nock'

describe('ai:models:destroy', function () {
const {env} = process
let api: nock.Scope

beforeEach(function () {
process.env = {}
api = nock('https://api.heroku.com:443')
})

afterEach(function () {
process.env = env
api.done()
nock.cleanAll()
})

context('no model resource is provided', function () {
it('errors when no model resource is provided', async function () {
try {
await runCommand(Cmd)
} catch (error) {
const {message} = error as CLIError
expect(stripAnsi(message)).contains('Missing 1 required arg:')
expect(stripAnsi(message)).contains('modelResource The resource ID or alias of the model resource to destroy.')
}
})
})

it('displays confirmation of AI addon destruction', async function () {
const addonAppId = addon1.app?.id
const addonId = addon1.id
const addonName = addon1.name
const appName = addon1.app?.name

api
.post('/actions/addons/resolve', {app: `${appName}`, addon: `${addonName}`})
.reply(200, [addon1])
.get(`/addons/${addonId}/addon-attachments`)
.reply(200, [addon1Attachment1])
.get(`/apps/${addonAppId}/config-vars`)
.reply(200, mockConfigVars)
.delete(`/apps/${addonAppId}/addons/${addonId}`, {force: false})
.reply(200, {...addon1, state: 'deprovisioned'})

await runCommand(Cmd, [`${addonName}`, '--app', `${appName}`, '--confirm', `${appName}`])
expect(stderr.output).contains(`Destroying ${addonName} in the background.`)
expect(stderr.output).contains('The app will restart when complete...')
expect(stdout.output).to.eq('')
})

it('displays API error message if destroy request fails', async function () {
const addonAppId = addon1.app?.id
const addonId = addon1.id
const addonName = addon1.name
const appName = addon1.app?.name

api
.post('/actions/addons/resolve', {app: `${appName}`, addon: `${addonName}`})
.reply(200, [addon1])
.get(`/addons/${addonId}/addon-attachments`)
.reply(200, [addon1Attachment1])
.get(`/apps/${addonAppId}/config-vars`)
.reply(200, mockConfigVars)
.delete(`/apps/${addonAppId}/addons/${addonId}`, {force: false})
.reply(500, mockAPIErrors.modelsDestroyErrorResponse)

try {
await runCommand(Cmd, [`${addonName}`, '--app', `${appName}`, '--confirm', `${appName}`])
} catch (error) {
const {message} = error as CLIError
expect(stripAnsi(message)).to.contains('The add-on was unable to be destroyed:')
expect(stripAnsi(message)).to.contains(mockAPIErrors.modelsDestroyErrorResponse.message)
}
})
})
10 changes: 10 additions & 0 deletions test/helpers/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,21 @@ export const availableModels = [
},
]

export const mockConfigVars = {
INFERENCE_KEY: 's3cr3t_k3y',
INFERENCE_MODEL_ID: 'claude-3-opus',
INFERENCE_URL: 'inference-eu.heroku.com',
}

export const mockAPIErrors = {
modelsListErrorResponse: {
id: 'error',
message: 'Failed to retrieve the list of available models. Check the Heroku Status page https://status.heroku.com/ for system outages. After all incidents have resolved, try again. You can also see a list of models at https://devcenter.heroku.com/articles/rainbow-unicorn-princess-models.',
},
modelsDestroyErrorResponse: {
id: 'error',
message: 'Example API Error',
},
}

export const addon1: Heroku.AddOn = {
Expand Down
26 changes: 26 additions & 0 deletions test/lib/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,32 @@ describe('attempt a request using the Heroku AI client', function () {
})
})

context('when using an existent model resource name with non-existent app', function () {
beforeEach(async function () {
api
.post('/actions/addons/resolve', {addon: addon1.name, app: 'app2'})
.reply(404, [addon1])
.post('/actions/addon-attachments/resolve', {addon_attachment: addon1.name, app: 'app2'})
.reply(404, {id: 'not_found', message: 'Couldn\'t find that app.', resource: 'app'})
})

it('returns a custom not found error message', async function () {
try {
await runCommand(CommandConfiguredWithResourceName, [
addon1.name as string,
'--app=app2',
])
} catch (error) {
const {message} = error as Error
expect(stripAnsi(message)).to.equal(heredoc`
We can’t find the app2 app. Check your spelling.
`)
}

expect(stdout.output).to.equal('')
})
})

context('when using the add-on service slug and no app, matching multiple model resources', function () {
beforeEach(async function () {
api
Expand Down

0 comments on commit 48946a1

Please sign in to comment.