Skip to content

Commit 3225fc0

Browse files
feat: load available models.
1 parent 784e1b0 commit 3225fc0

5 files changed

Lines changed: 277 additions & 71 deletions

File tree

src/modules/chat/api/completions.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { chatCompletionsUrl, chatModelOptions, defaultChatModel } from './constants.js'
1+
import {
2+
chatCompletionsUrl,
3+
chatModelOptions,
4+
defaultChatModel,
5+
isFreeChatModel,
6+
} from './constants.js'
7+
import { fetchChatModelOptions } from './models.js'
28
import {
39
buildChatRequestHeaders,
410
parseErrorResponse,
@@ -445,4 +451,11 @@ const requestChatCompletion = async ({
445451
}
446452
}
447453

448-
export { chatModelOptions, defaultChatModel, requestChatCompletion, streamChatCompletion }
454+
export {
455+
chatModelOptions,
456+
defaultChatModel,
457+
fetchChatModelOptions,
458+
isFreeChatModel,
459+
requestChatCompletion,
460+
streamChatCompletion,
461+
}

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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 pricing.prompt === '0' && pricing.completion === '0'
22+
}
23+
24+
const sortModelEntries = entries => {
25+
return [...entries].sort((left, right) => {
26+
if (left.isFree !== right.isFree) {
27+
return left.isFree ? -1 : 1
28+
}
29+
30+
return left.id.localeCompare(right.id)
31+
})
32+
}
33+
34+
const normalizeModelOptions = models => {
35+
const normalizedModels = Array.isArray(models) ? models : []
36+
const byModelId = new Map()
37+
38+
for (const model of normalizedModels) {
39+
const modelId = toText(model?.id)
40+
if (!modelId || !supportsTools(model)) {
41+
continue
42+
}
43+
44+
byModelId.set(modelId, {
45+
id: modelId,
46+
isFree: isFreeModel(model),
47+
})
48+
}
49+
50+
const sortedModelIds = sortModelEntries(Array.from(byModelId.values())).map(
51+
entry => entry.id,
52+
)
53+
54+
if (sortedModelIds.length === 0) {
55+
return chatModelOptions
56+
}
57+
58+
return [...new Set([defaultChatModel, ...sortedModelIds])]
59+
}
60+
61+
const buildCatalogRequestHeaders = token => {
62+
const normalizedToken = toText(token)
63+
if (!normalizedToken) {
64+
return undefined
65+
}
66+
67+
return {
68+
Authorization: `Bearer ${normalizedToken}`,
69+
}
70+
}
71+
72+
export const fetchChatModelOptions = async ({ token, signal } = {}) => {
73+
const response = await fetch(chatModelsUrl, {
74+
method: 'GET',
75+
headers: buildCatalogRequestHeaders(token),
76+
signal,
77+
})
78+
79+
if (!response.ok) {
80+
throw new Error(`Model catalog request failed with status ${response.status}`)
81+
}
82+
83+
const body = await response.json()
84+
return normalizeModelOptions(body?.data)
85+
}

src/modules/chat/drawer.js

Lines changed: 25 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,35 @@ 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+
if (open) {
186+
void modelPicker.loadModelOptionsFromCatalog({ force: true })
187+
}
196188
},
197189
})
198190

199191
const getChatKey = () => keyControls.getKey()
200192
const hasChatKey = () => keyControls.hasKey()
201193

194+
const modelPicker = createChatModelPicker({
195+
modelSelect,
196+
getChatKey,
197+
resetModelAccessStatus: () => {
198+
if (isModelAccessStatusMessage(statusNode?.textContent)) {
199+
setChatStatus('Idle', 'neutral')
200+
}
201+
},
202+
})
203+
202204
const syncComposerAvailability = () => {
203205
const keyPresent = hasChatKey()
204206

@@ -211,57 +213,7 @@ export const createChatDrawer = ({
211213
}
212214
}
213215

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-
}
216+
const getSelectedModel = () => modelPicker.getSelectedModel()
265217

266218
const setOpen = nextOpen => {
267219
open = nextOpen === true
@@ -280,6 +232,10 @@ export const createChatDrawer = ({
280232
if (open && promptInput instanceof HTMLTextAreaElement) {
281233
promptInput.focus()
282234
}
235+
236+
if (open) {
237+
void modelPicker.loadModelOptionsFromCatalog()
238+
}
283239
}
284240

285241
const setChatStatus = (text, level = 'neutral') => {
@@ -989,8 +945,8 @@ export const createChatDrawer = ({
989945

990946
toggleButton?.setAttribute('aria-expanded', 'false')
991947
drawer?.setAttribute('hidden', '')
992-
initializeModelOptions()
993-
syncModelSelectionForKey(getChatKey())
948+
modelPicker.initializeModelOptions()
949+
modelPicker.syncModelSelectionForKey(getChatKey())
994950
syncComposerAvailability()
995951
syncRepositoryLabel()
996952
ensureUndoActionsNode()

0 commit comments

Comments
 (0)