-
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 #11 from heroku/eb/add-ai-models-info
feat(plugin-ai): Add 'ai:models:info' command
- Loading branch information
Showing
8 changed files
with
339 additions
and
2 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,105 @@ | ||
import color from '@heroku-cli/color' | ||
import {flags} from '@heroku-cli/command' | ||
import {Args, ux} from '@oclif/core' | ||
import Command from '../../../lib/base' | ||
import {ModelResource} from '../../../lib/ai/types' | ||
import appAddons from '../../../lib/ai/models/app_addons' | ||
import * as Heroku from '@heroku-cli/schema' | ||
|
||
export default class Info extends Command { | ||
static description = 'get the current status of all the AI model resources attached to your app or a specific resource' | ||
static examples = [ | ||
'heroku ai:models:info claude-3-5-sonnet-acute-04281 --app example-app', | ||
'heroku ai:models:info --app example-app', | ||
] | ||
|
||
static flags = { | ||
app: flags.app({required: true}), | ||
remote: flags.remote(), | ||
} | ||
|
||
static args = { | ||
modelResource: Args.string({description: 'The resource ID or alias of the model resource to check.'}), | ||
} | ||
|
||
public async run(): Promise<any> { | ||
const {args, flags} = await this.parse(Info) | ||
const {app} = flags | ||
const {modelResource} = args | ||
const synthesizedModels: Array<ModelResource> = [] | ||
let listOfProvisionedModels: Array<ModelResource> = [] | ||
|
||
const modelInfo = async () => { | ||
const modelInfoResponse = await this.herokuAI.get<ModelResource>(`/models/${this.apiModelId}`, { | ||
headers: {authorization: `Bearer ${this.apiKey}`}, | ||
}) | ||
.catch(error => { | ||
if (error.statusCode === 404) { | ||
ux.warn(`We can’t find a model resource called ${color.yellow(modelResource)}.\nRun ${color.cmd('heroku ai:models:info -a <app>')} to see a list of model resources.`) | ||
} else { | ||
throw error | ||
} | ||
}) | ||
|
||
return modelInfoResponse | ||
} | ||
|
||
const getModelDetails = async (collectedModels: Array<Heroku.AddOn> | string) => { | ||
if (typeof collectedModels === 'string') { | ||
const modelResource = collectedModels | ||
await this.configureHerokuAIClient(modelResource, app) | ||
|
||
const {body: currentModelResource} = await modelInfo() || {body: null} | ||
synthesizedModels.push(currentModelResource!) | ||
} else { | ||
for (const addonModel of collectedModels) { | ||
await this.configureHerokuAIClient(addonModel.modelResource, app) | ||
|
||
const {body: currentModelResource} = await modelInfo() || {body: null} | ||
synthesizedModels.push(currentModelResource!) | ||
} | ||
} | ||
|
||
return synthesizedModels | ||
} | ||
|
||
if (modelResource) { | ||
listOfProvisionedModels = await getModelDetails(modelResource) | ||
} else { | ||
const provisionedModelsInfo: Record<string, string | undefined>[] = [] | ||
const inferenceRegex = /inference/ | ||
const addonsResponse = await appAddons(this.config, app) | ||
|
||
for (const addonInfo of addonsResponse as Array<Heroku.AddOn>) { | ||
const addonType = addonInfo.addon_service?.name || '' | ||
const isModelAddon = inferenceRegex.test(addonType) | ||
|
||
if (isModelAddon) { | ||
provisionedModelsInfo.push({ | ||
addonName: addonInfo.addon_service?.name, | ||
modelResource: addonInfo.name, | ||
modelId: addonInfo.addon_service?.id, | ||
}) | ||
} | ||
} | ||
|
||
listOfProvisionedModels = await getModelDetails(provisionedModelsInfo) | ||
} | ||
|
||
this.displayModelResource(listOfProvisionedModels) | ||
} | ||
|
||
displayModelResource(modelResources: ModelResource[]) { | ||
for (const modelResource of modelResources) { | ||
ux.log() | ||
ux.styledHeader(modelResource.model_id) | ||
ux.styledObject({ | ||
'Base Model ID': modelResource.model_id, | ||
Ready: modelResource.ready, | ||
'Tokens In': modelResource.tokens_in, | ||
'Tokens Out': modelResource.tokens_out, | ||
'Avg Performance': modelResource.avg_performance, | ||
}) | ||
} | ||
} | ||
} |
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,17 @@ | ||
import {Config} from '@oclif/core' | ||
import {APIClient} from '@heroku-cli/command' | ||
import * as Heroku from '@heroku-cli/schema' | ||
|
||
export default async function (config: Config, app: string) { | ||
const herokuClient = new APIClient(config) | ||
|
||
const {body: response} = await herokuClient.get<Heroku.AddOn>(`/apps/${app}/addons`, { | ||
headers: {'Accept-Expansion': 'plan'}, | ||
}).catch(error => { | ||
console.log('ERROR MESSAGE:', error.message) | ||
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_ | ||
}) | ||
|
||
return response | ||
} |
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,171 @@ | ||
import {expect} from 'chai' | ||
import {stdout, stderr} from 'stdout-stderr' | ||
import Cmd from '../../../../src/commands/ai/models/info' | ||
import {runCommand} from '../../../run-command' | ||
import {modelResource, addon1Attachment1, addon1, mockAPIErrors} from '../../../helpers/fixtures' | ||
import nock from 'nock' | ||
import heredoc from 'tsheredoc' | ||
import stripAnsi from '../../../helpers/strip-ansi' | ||
import {CLIError} from '@oclif/core/lib/errors' | ||
|
||
describe('ai:models:info', function () { | ||
const {env} = process | ||
let api: nock.Scope | ||
let herokuAI: nock.Scope | ||
|
||
context('when provisioned model name is provided and is found', function () { | ||
beforeEach(function () { | ||
process.env = {} | ||
api = nock('https://api.heroku.com:443') | ||
herokuAI = nock('https://inference.heroku.com') | ||
}) | ||
|
||
afterEach(function () { | ||
process.env = env | ||
nock.cleanAll() | ||
}) | ||
|
||
it('shows info for a model resource', async function () { | ||
api | ||
.post('/actions/addons/resolve', | ||
{addon: addon1.name, app: addon1Attachment1.app?.name}) | ||
.reply(200, [addon1]) | ||
.get(`/addons/${addon1.id}/addon-attachments`) | ||
.reply(200, [addon1Attachment1]) | ||
.get(`/apps/${addon1Attachment1.app?.id}/config-vars`) | ||
.reply(200, { | ||
INFERENCE_KEY: 's3cr3t_k3y', | ||
INFERENCE_MODEL_ID: 'claude-3-haiku', | ||
INFERENCE_URL: 'inference.heroku.com', | ||
}) | ||
herokuAI | ||
.get('/models/claude-3-haiku') | ||
.reply(200, modelResource) | ||
|
||
await runCommand(Cmd, [ | ||
'inference-regular-74659', | ||
'--app', | ||
'app1', | ||
]) | ||
|
||
expect(stripAnsi(stdout.output)).to.equal(heredoc` | ||
=== claude-3-haiku | ||
Avg Performance: latency 0.4sec, 28 tokens/sec | ||
Base Model ID: claude-3-haiku | ||
Ready: Yes | ||
Tokens In: 0 tokens this period | ||
Tokens Out: 0 tokens this period | ||
`) | ||
|
||
expect(stderr.output).to.eq('') | ||
}) | ||
}) | ||
|
||
context('when provisioned model name is not provided', function () { | ||
// eslint-disable-next-line mocha/no-setup-in-describe | ||
const multipleAddons = Array.from({length: 2}).fill(addon1) | ||
|
||
beforeEach(function () { | ||
process.env = {} | ||
api = nock('https://api.heroku.com:443') | ||
}) | ||
|
||
afterEach(function () { | ||
process.env = env | ||
nock.cleanAll() | ||
}) | ||
|
||
it('shows info for all model resources on specified app', async function () { | ||
api | ||
.post('/actions/addons/resolve', | ||
{addon: addon1.name, app: addon1Attachment1.app?.name}) | ||
.reply(200, [addon1]) | ||
.get(`/addons/${addon1.id}/addon-attachments`) | ||
.reply(200, [addon1Attachment1]) | ||
.get(`/apps/${addon1Attachment1.app?.id}/config-vars`) | ||
.reply(200, { | ||
INFERENCE_KEY: 's3cr3t_k3y', | ||
INFERENCE_MODEL_ID: 'claude-3-haiku', | ||
INFERENCE_URL: 'inference.heroku.com', | ||
}) | ||
herokuAI | ||
.get('/models/claude-3-haiku') | ||
.reply(200, modelResource) | ||
api | ||
.get(`/apps/${addon1.app?.name}/addons`) | ||
.reply(200, multipleAddons) | ||
.post('/actions/addons/resolve', | ||
{addon: addon1.name, app: addon1Attachment1.app?.name}) | ||
.reply(200, [addon1]) | ||
.get(`/addons/${addon1.id}/addon-attachments`) | ||
.reply(200, [addon1Attachment1]) | ||
.get(`/apps/${addon1Attachment1.app?.id}/config-vars`) | ||
.reply(200, { | ||
INFERENCE_KEY: 's3cr3t_k3y', | ||
INFERENCE_MODEL_ID: 'claude-3-haiku', | ||
INFERENCE_URL: 'inference.heroku.com', | ||
}) | ||
herokuAI | ||
.get('/models/claude-3-haiku') | ||
.reply(200, modelResource) | ||
|
||
await runCommand(Cmd, [ | ||
'--app', | ||
'app1', | ||
]) | ||
|
||
expect(stdout.output).to.equal(heredoc` | ||
=== claude-3-haiku | ||
Avg Performance: latency 0.4sec, 28 tokens/sec | ||
Base Model ID: claude-3-haiku | ||
Ready: Yes | ||
Tokens In: 0 tokens this period | ||
Tokens Out: 0 tokens this period | ||
=== claude-3-haiku | ||
Avg Performance: latency 0.4sec, 28 tokens/sec | ||
Base Model ID: claude-3-haiku | ||
Ready: Yes | ||
Tokens In: 0 tokens this period | ||
Tokens Out: 0 tokens this period | ||
`) | ||
}) | ||
}) | ||
|
||
context('when provisioned model name is incorrectly inputted', function () { | ||
const incorrectModelName = 'inference-regular-WRONG' | ||
|
||
beforeEach(function () { | ||
process.env = {} | ||
api = nock('https://api.heroku.com:443') | ||
}) | ||
|
||
afterEach(function () { | ||
process.env = env | ||
nock.cleanAll() | ||
}) | ||
|
||
it('shows an error message', async function () { | ||
api | ||
.post('/actions/addons/resolve', | ||
{addon: incorrectModelName, app: addon1Attachment1.app?.name}) | ||
.reply(404, mockAPIErrors.modelsInfoErrorResponse) | ||
|
||
try { | ||
await runCommand(Cmd, [ | ||
incorrectModelName, | ||
'--app', | ||
'app1', | ||
]) | ||
} catch (error) { | ||
const {message} = error as CLIError | ||
expect(stripAnsi(message)).contains(mockAPIErrors.modelsInfoErrorResponse.message) | ||
} | ||
}) | ||
}) | ||
}) |
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