Skip to content

Commit 2c2cd87

Browse files
feat: load available models. (#149)
1 parent 784e1b0 commit 2c2cd87

6 files changed

Lines changed: 303 additions & 83 deletions

File tree

playwright/helpers/app-test-helpers.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,7 @@ export const connectByotWithSingleRepo = async (
568568

569569
const workspacesRepositoryFilter = page.getByLabel('Workspace repository filter')
570570
await expect(workspacesRepositoryFilter).toBeVisible()
571+
await expect(workspacesRepositoryFilter).toBeEnabled()
571572
await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop')
572573
await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop')
573574

@@ -576,21 +577,42 @@ export const connectByotWithSingleRepo = async (
576577
name: 'Initialize',
577578
exact: true,
578579
})
580+
const storedWorkspace = page.getByLabel('Stored workspace')
579581

580-
if (await initializeButton.isVisible()) {
582+
await expect
583+
.poll(async () => {
584+
if (await initializeButton.isVisible()) {
585+
return 'initialize'
586+
}
587+
588+
if (await storedWorkspace.isVisible()) {
589+
const workspaceValue = await storedWorkspace
590+
.locator('option:not([value=""])')
591+
.first()
592+
.getAttribute('value')
593+
594+
if (workspaceValue) {
595+
return 'stored'
596+
}
597+
}
598+
599+
return ''
600+
})
601+
.not.toBe('')
602+
603+
const autoOpenMode = (await initializeButton.isVisible()) ? 'initialize' : 'stored'
604+
605+
if (autoOpenMode === 'initialize') {
581606
await initializeButton.click()
582607
} else {
583-
const storedWorkspace = page.getByLabel('Stored workspace')
584-
if (await storedWorkspace.isVisible()) {
585-
const workspaceValue = await storedWorkspace
586-
.locator('option:not([value=""])')
587-
.first()
588-
.getAttribute('value')
589-
590-
if (workspaceValue) {
591-
await storedWorkspace.selectOption(workspaceValue)
592-
await page.getByRole('button', { name: 'Open', exact: true }).click()
593-
}
608+
const workspaceValue = await storedWorkspace
609+
.locator('option:not([value=""])')
610+
.first()
611+
.getAttribute('value')
612+
613+
if (workspaceValue) {
614+
await storedWorkspace.selectOption(workspaceValue)
615+
await page.getByRole('button', { name: 'Open', exact: true }).click()
594616
}
595617
}
596618
}

src/modules/chat/api/completions.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { chatCompletionsUrl, chatModelOptions, defaultChatModel } from './constants.js'
1+
import { chatCompletionsUrl, defaultChatModel } from './constants.js'
22
import {
33
buildChatRequestHeaders,
44
parseErrorResponse,
@@ -445,4 +445,4 @@ const requestChatCompletion = async ({
445445
}
446446
}
447447

448-
export { chatModelOptions, defaultChatModel, requestChatCompletion, streamChatCompletion }
448+
export { defaultChatModel, requestChatCompletion, streamChatCompletion }

src/modules/chat/api/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const chatCompletionsUrl = 'https://openrouter.ai/api/v1/chat/completions'
2+
export const chatModelsUrl = 'https://openrouter.ai/api/v1/models'
23
export const openRouterKeysUrl = 'https://openrouter.ai/keys'
34

45
/* The free router auto-selects a free model, so it survives free-slug churn. */

src/modules/chat/api/models.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { chatModelOptions, chatModelsUrl, defaultChatModel } from './constants.js'
2+
3+
const toText = value => (typeof value === 'string' ? value.trim() : '')
4+
5+
const supportsTools = model => {
6+
const supportedParameters = Array.isArray(model?.supported_parameters)
7+
? model.supported_parameters
8+
: []
9+
10+
return supportedParameters.some(parameter =>
11+
typeof parameter === 'string' ? parameter.toLowerCase() === 'tools' : false,
12+
)
13+
}
14+
15+
const isFreeModel = model => {
16+
const pricing = model?.pricing
17+
if (!pricing || typeof pricing !== 'object') {
18+
return false
19+
}
20+
21+
return (
22+
(pricing.prompt === 0 || pricing.prompt === '0') &&
23+
(pricing.completion === 0 || pricing.completion === '0')
24+
)
25+
}
26+
27+
const sortModelEntries = entries => {
28+
return [...entries].sort((left, right) => {
29+
if (left.isFree !== right.isFree) {
30+
return left.isFree ? -1 : 1
31+
}
32+
33+
return left.id.localeCompare(right.id)
34+
})
35+
}
36+
37+
const normalizeModelOptions = models => {
38+
const normalizedModels = Array.isArray(models) ? models : []
39+
const byModelId = new Map()
40+
41+
for (const model of normalizedModels) {
42+
const modelId = toText(model?.id)
43+
if (!modelId || !supportsTools(model)) {
44+
continue
45+
}
46+
47+
byModelId.set(modelId, {
48+
id: modelId,
49+
isFree: isFreeModel(model),
50+
})
51+
}
52+
53+
const sortedModelIds = sortModelEntries(Array.from(byModelId.values())).map(
54+
entry => entry.id,
55+
)
56+
57+
if (sortedModelIds.length === 0) {
58+
return chatModelOptions
59+
}
60+
61+
return [...new Set([defaultChatModel, ...sortedModelIds])]
62+
}
63+
64+
const buildCatalogRequestHeaders = token => {
65+
const normalizedToken = toText(token)
66+
if (!normalizedToken) {
67+
return undefined
68+
}
69+
70+
return {
71+
Authorization: `Bearer ${normalizedToken}`,
72+
}
73+
}
74+
75+
export const fetchChatModelOptions = async ({ token, signal } = {}) => {
76+
const response = await fetch(chatModelsUrl, {
77+
method: 'GET',
78+
headers: buildCatalogRequestHeaders(token),
79+
signal,
80+
})
81+
82+
if (!response.ok) {
83+
throw new Error(`Model catalog request failed with status ${response.status}`)
84+
}
85+
86+
const body = await response.json()
87+
return normalizeModelOptions(body?.data)
88+
}

src/modules/chat/drawer.js

Lines changed: 27 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
1-
import {
2-
chatModelOptions,
3-
defaultChatModel,
4-
requestChatCompletion,
5-
streamChatCompletion,
6-
} from './api/completions.js'
1+
import { requestChatCompletion, streamChatCompletion } from './api/completions.js'
72
import {
83
formatModelAccessErrorMessage,
94
isCredentialError,
105
isModelAccessError,
116
isModelAccessStatusMessage,
127
toChatText,
13-
toModelId,
148
toRepositoryLabel,
159
toRepositoryUrl,
1610
} from './utils.js'
1711
import { createChatKeyControls } from './key-controls.js'
12+
import { createChatModelPicker } from './model-picker.js'
1813
import {
1914
buildActiveTabEditorContext,
2015
normalizeWorkspaceTabContext,
@@ -177,28 +172,37 @@ export const createChatDrawer = ({
177172
pendingAbortController = null
178173
}
179174

180-
const setModelSelectDisabled = isDisabled => {
181-
if (!(modelSelect instanceof HTMLSelectElement)) {
182-
return
183-
}
184-
185-
modelSelect.disabled = isDisabled
186-
}
187-
188175
const keyControls = createChatKeyControls({
189176
root: keyRoot,
190177
input: keyInput,
191178
addButton: keyAddButton,
192179
deleteButton: keyDeleteButton,
193180
onKeyChange: nextKey => {
194-
syncModelSelectionForKey(nextKey)
181+
modelPicker.invalidateCatalogCache()
182+
modelPicker.syncModelSelectionForKey(nextKey)
195183
syncComposerAvailability()
184+
185+
const keyPresent = typeof nextKey === 'string' && nextKey.trim().length > 0
186+
187+
if (open && keyPresent) {
188+
void modelPicker.loadModelOptionsFromCatalog({ force: true })
189+
}
196190
},
197191
})
198192

199193
const getChatKey = () => keyControls.getKey()
200194
const hasChatKey = () => keyControls.hasKey()
201195

196+
const modelPicker = createChatModelPicker({
197+
modelSelect,
198+
getChatKey,
199+
resetModelAccessStatus: () => {
200+
if (isModelAccessStatusMessage(statusNode?.textContent)) {
201+
setChatStatus('Idle', 'neutral')
202+
}
203+
},
204+
})
205+
202206
const syncComposerAvailability = () => {
203207
const keyPresent = hasChatKey()
204208

@@ -211,57 +215,7 @@ export const createChatDrawer = ({
211215
}
212216
}
213217

214-
const replaceModelOptions = ({ modelIds, selectedModel }) => {
215-
if (!(modelSelect instanceof HTMLSelectElement)) {
216-
return
217-
}
218-
219-
const nextSelectedModel = toModelId(selectedModel)
220-
const nextModelIds = [...new Set([defaultChatModel, ...modelIds])]
221-
222-
modelSelect.replaceChildren()
223-
224-
for (const modelId of nextModelIds) {
225-
const option = document.createElement('option')
226-
option.value = modelId
227-
option.textContent = modelId
228-
option.selected = modelId === nextSelectedModel
229-
modelSelect.append(option)
230-
}
231-
232-
if (!nextModelIds.includes(nextSelectedModel)) {
233-
modelSelect.value = defaultChatModel
234-
}
235-
}
236-
237-
const getSelectedModel = () => {
238-
if (!(modelSelect instanceof HTMLSelectElement)) {
239-
return defaultChatModel
240-
}
241-
242-
return toModelId(modelSelect.value)
243-
}
244-
245-
const initializeModelOptions = () => {
246-
replaceModelOptions({
247-
modelIds: chatModelOptions,
248-
selectedModel: defaultChatModel,
249-
})
250-
}
251-
252-
const syncModelSelectionForKey = key => {
253-
const keyPresent = typeof key === 'string' && key.trim().length > 0
254-
255-
setModelSelectDisabled(!keyPresent)
256-
257-
if (!keyPresent && modelSelect instanceof HTMLSelectElement) {
258-
modelSelect.value = defaultChatModel
259-
}
260-
261-
if (keyPresent && isModelAccessStatusMessage(statusNode?.textContent)) {
262-
setChatStatus('Idle', 'neutral')
263-
}
264-
}
218+
const getSelectedModel = () => modelPicker.getSelectedModel()
265219

266220
const setOpen = nextOpen => {
267221
open = nextOpen === true
@@ -280,6 +234,10 @@ export const createChatDrawer = ({
280234
if (open && promptInput instanceof HTMLTextAreaElement) {
281235
promptInput.focus()
282236
}
237+
238+
if (open && hasChatKey()) {
239+
void modelPicker.loadModelOptionsFromCatalog()
240+
}
283241
}
284242

285243
const setChatStatus = (text, level = 'neutral') => {
@@ -989,8 +947,8 @@ export const createChatDrawer = ({
989947

990948
toggleButton?.setAttribute('aria-expanded', 'false')
991949
drawer?.setAttribute('hidden', '')
992-
initializeModelOptions()
993-
syncModelSelectionForKey(getChatKey())
950+
modelPicker.initializeModelOptions()
951+
modelPicker.syncModelSelectionForKey(getChatKey())
994952
syncComposerAvailability()
995953
syncRepositoryLabel()
996954
ensureUndoActionsNode()

0 commit comments

Comments
 (0)