From 3225fc05331842a1bc3ef8b2361e63810aa3beec Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 7 Sep 2026 09:40:33 -0500 Subject: [PATCH 1/2] feat: load available models. --- src/modules/chat/api/completions.js | 17 +++- src/modules/chat/api/constants.js | 1 + src/modules/chat/api/models.js | 85 ++++++++++++++++ src/modules/chat/drawer.js | 94 +++++------------ src/modules/chat/model-picker.js | 151 ++++++++++++++++++++++++++++ 5 files changed, 277 insertions(+), 71 deletions(-) create mode 100644 src/modules/chat/api/models.js create mode 100644 src/modules/chat/model-picker.js diff --git a/src/modules/chat/api/completions.js b/src/modules/chat/api/completions.js index 2e63639..ff916fc 100644 --- a/src/modules/chat/api/completions.js +++ b/src/modules/chat/api/completions.js @@ -1,4 +1,10 @@ -import { chatCompletionsUrl, chatModelOptions, defaultChatModel } from './constants.js' +import { + chatCompletionsUrl, + chatModelOptions, + defaultChatModel, + isFreeChatModel, +} from './constants.js' +import { fetchChatModelOptions } from './models.js' import { buildChatRequestHeaders, parseErrorResponse, @@ -445,4 +451,11 @@ const requestChatCompletion = async ({ } } -export { chatModelOptions, defaultChatModel, requestChatCompletion, streamChatCompletion } +export { + chatModelOptions, + defaultChatModel, + fetchChatModelOptions, + isFreeChatModel, + requestChatCompletion, + streamChatCompletion, +} diff --git a/src/modules/chat/api/constants.js b/src/modules/chat/api/constants.js index f427018..63ba871 100644 --- a/src/modules/chat/api/constants.js +++ b/src/modules/chat/api/constants.js @@ -1,4 +1,5 @@ export const chatCompletionsUrl = 'https://openrouter.ai/api/v1/chat/completions' +export const chatModelsUrl = 'https://openrouter.ai/api/v1/models' export const openRouterKeysUrl = 'https://openrouter.ai/keys' /* The free router auto-selects a free model, so it survives free-slug churn. */ diff --git a/src/modules/chat/api/models.js b/src/modules/chat/api/models.js new file mode 100644 index 0000000..31a7921 --- /dev/null +++ b/src/modules/chat/api/models.js @@ -0,0 +1,85 @@ +import { chatModelOptions, chatModelsUrl, defaultChatModel } from './constants.js' + +const toText = value => (typeof value === 'string' ? value.trim() : '') + +const supportsTools = model => { + const supportedParameters = Array.isArray(model?.supported_parameters) + ? model.supported_parameters + : [] + + return supportedParameters.some(parameter => + typeof parameter === 'string' ? parameter.toLowerCase() === 'tools' : false, + ) +} + +const isFreeModel = model => { + const pricing = model?.pricing + if (!pricing || typeof pricing !== 'object') { + return false + } + + return pricing.prompt === '0' && pricing.completion === '0' +} + +const sortModelEntries = entries => { + return [...entries].sort((left, right) => { + if (left.isFree !== right.isFree) { + return left.isFree ? -1 : 1 + } + + return left.id.localeCompare(right.id) + }) +} + +const normalizeModelOptions = models => { + const normalizedModels = Array.isArray(models) ? models : [] + const byModelId = new Map() + + for (const model of normalizedModels) { + const modelId = toText(model?.id) + if (!modelId || !supportsTools(model)) { + continue + } + + byModelId.set(modelId, { + id: modelId, + isFree: isFreeModel(model), + }) + } + + const sortedModelIds = sortModelEntries(Array.from(byModelId.values())).map( + entry => entry.id, + ) + + if (sortedModelIds.length === 0) { + return chatModelOptions + } + + return [...new Set([defaultChatModel, ...sortedModelIds])] +} + +const buildCatalogRequestHeaders = token => { + const normalizedToken = toText(token) + if (!normalizedToken) { + return undefined + } + + return { + Authorization: `Bearer ${normalizedToken}`, + } +} + +export const fetchChatModelOptions = async ({ token, signal } = {}) => { + const response = await fetch(chatModelsUrl, { + method: 'GET', + headers: buildCatalogRequestHeaders(token), + signal, + }) + + if (!response.ok) { + throw new Error(`Model catalog request failed with status ${response.status}`) + } + + const body = await response.json() + return normalizeModelOptions(body?.data) +} diff --git a/src/modules/chat/drawer.js b/src/modules/chat/drawer.js index 89383e1..ca28304 100644 --- a/src/modules/chat/drawer.js +++ b/src/modules/chat/drawer.js @@ -1,20 +1,15 @@ -import { - chatModelOptions, - defaultChatModel, - requestChatCompletion, - streamChatCompletion, -} from './api/completions.js' +import { requestChatCompletion, streamChatCompletion } from './api/completions.js' import { formatModelAccessErrorMessage, isCredentialError, isModelAccessError, isModelAccessStatusMessage, toChatText, - toModelId, toRepositoryLabel, toRepositoryUrl, } from './utils.js' import { createChatKeyControls } from './key-controls.js' +import { createChatModelPicker } from './model-picker.js' import { buildActiveTabEditorContext, normalizeWorkspaceTabContext, @@ -177,28 +172,35 @@ export const createChatDrawer = ({ pendingAbortController = null } - const setModelSelectDisabled = isDisabled => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return - } - - modelSelect.disabled = isDisabled - } - const keyControls = createChatKeyControls({ root: keyRoot, input: keyInput, addButton: keyAddButton, deleteButton: keyDeleteButton, onKeyChange: nextKey => { - syncModelSelectionForKey(nextKey) + modelPicker.invalidateCatalogCache() + modelPicker.syncModelSelectionForKey(nextKey) syncComposerAvailability() + + if (open) { + void modelPicker.loadModelOptionsFromCatalog({ force: true }) + } }, }) const getChatKey = () => keyControls.getKey() const hasChatKey = () => keyControls.hasKey() + const modelPicker = createChatModelPicker({ + modelSelect, + getChatKey, + resetModelAccessStatus: () => { + if (isModelAccessStatusMessage(statusNode?.textContent)) { + setChatStatus('Idle', 'neutral') + } + }, + }) + const syncComposerAvailability = () => { const keyPresent = hasChatKey() @@ -211,57 +213,7 @@ export const createChatDrawer = ({ } } - const replaceModelOptions = ({ modelIds, selectedModel }) => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return - } - - const nextSelectedModel = toModelId(selectedModel) - const nextModelIds = [...new Set([defaultChatModel, ...modelIds])] - - modelSelect.replaceChildren() - - for (const modelId of nextModelIds) { - const option = document.createElement('option') - option.value = modelId - option.textContent = modelId - option.selected = modelId === nextSelectedModel - modelSelect.append(option) - } - - if (!nextModelIds.includes(nextSelectedModel)) { - modelSelect.value = defaultChatModel - } - } - - const getSelectedModel = () => { - if (!(modelSelect instanceof HTMLSelectElement)) { - return defaultChatModel - } - - return toModelId(modelSelect.value) - } - - const initializeModelOptions = () => { - replaceModelOptions({ - modelIds: chatModelOptions, - selectedModel: defaultChatModel, - }) - } - - const syncModelSelectionForKey = key => { - const keyPresent = typeof key === 'string' && key.trim().length > 0 - - setModelSelectDisabled(!keyPresent) - - if (!keyPresent && modelSelect instanceof HTMLSelectElement) { - modelSelect.value = defaultChatModel - } - - if (keyPresent && isModelAccessStatusMessage(statusNode?.textContent)) { - setChatStatus('Idle', 'neutral') - } - } + const getSelectedModel = () => modelPicker.getSelectedModel() const setOpen = nextOpen => { open = nextOpen === true @@ -280,6 +232,10 @@ export const createChatDrawer = ({ if (open && promptInput instanceof HTMLTextAreaElement) { promptInput.focus() } + + if (open) { + void modelPicker.loadModelOptionsFromCatalog() + } } const setChatStatus = (text, level = 'neutral') => { @@ -989,8 +945,8 @@ export const createChatDrawer = ({ toggleButton?.setAttribute('aria-expanded', 'false') drawer?.setAttribute('hidden', '') - initializeModelOptions() - syncModelSelectionForKey(getChatKey()) + modelPicker.initializeModelOptions() + modelPicker.syncModelSelectionForKey(getChatKey()) syncComposerAvailability() syncRepositoryLabel() ensureUndoActionsNode() diff --git a/src/modules/chat/model-picker.js b/src/modules/chat/model-picker.js new file mode 100644 index 0000000..4bea56a --- /dev/null +++ b/src/modules/chat/model-picker.js @@ -0,0 +1,151 @@ +import { + chatModelOptions, + defaultChatModel, + fetchChatModelOptions, + isFreeChatModel, +} from './api/completions.js' +import { toModelId } from './utils.js' + +export const createChatModelPicker = ({ + modelSelect, + getChatKey, + resetModelAccessStatus, +}) => { + let loadedCatalogToken = null + let pendingCatalogLoadPromise = null + + const setModelSelectDisabled = isDisabled => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + modelSelect.disabled = isDisabled + } + + const replaceModelOptions = ({ modelIds, selectedModel }) => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + const nextSelectedModel = toModelId(selectedModel) + const nextModelIds = [...new Set([defaultChatModel, ...modelIds])] + const freeModelIds = [] + const paidModelIds = [] + + for (const modelId of nextModelIds) { + if (isFreeChatModel(modelId)) { + freeModelIds.push(modelId) + } else { + paidModelIds.push(modelId) + } + } + + modelSelect.replaceChildren() + + const appendGroupedOptions = (label, ids) => { + if (ids.length === 0) { + return + } + + const group = document.createElement('optgroup') + group.label = label + + for (const modelId of ids) { + const option = document.createElement('option') + option.value = modelId + option.textContent = modelId + option.selected = modelId === nextSelectedModel + group.append(option) + } + + modelSelect.append(group) + } + + appendGroupedOptions('Free', freeModelIds) + appendGroupedOptions('Paid', paidModelIds) + + if (!nextModelIds.includes(nextSelectedModel)) { + modelSelect.value = defaultChatModel + } + } + + const getSelectedModel = () => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return defaultChatModel + } + + return toModelId(modelSelect.value) + } + + const initializeModelOptions = () => { + replaceModelOptions({ + modelIds: chatModelOptions, + selectedModel: defaultChatModel, + }) + } + + const loadModelOptionsFromCatalog = async ({ force = false } = {}) => { + if (!(modelSelect instanceof HTMLSelectElement)) { + return + } + + const token = getChatKey() + const normalizedToken = typeof token === 'string' ? token.trim() : '' + + if (!force && pendingCatalogLoadPromise) { + await pendingCatalogLoadPromise + return + } + + if (!force && loadedCatalogToken === normalizedToken) { + return + } + + const selectedModel = getSelectedModel() + const catalogLoadPromise = fetchChatModelOptions({ token: normalizedToken }) + .then(modelIds => { + replaceModelOptions({ + modelIds, + selectedModel, + }) + loadedCatalogToken = normalizedToken + }) + .catch(() => { + /* Keep fallback options when catalog loading fails. */ + }) + .finally(() => { + if (pendingCatalogLoadPromise === catalogLoadPromise) { + pendingCatalogLoadPromise = null + } + }) + + pendingCatalogLoadPromise = catalogLoadPromise + await catalogLoadPromise + } + + const syncModelSelectionForKey = key => { + const keyPresent = typeof key === 'string' && key.trim().length > 0 + + setModelSelectDisabled(!keyPresent) + + if (!keyPresent && modelSelect instanceof HTMLSelectElement) { + modelSelect.value = defaultChatModel + } + + if (keyPresent) { + resetModelAccessStatus?.() + } + } + + const invalidateCatalogCache = () => { + loadedCatalogToken = null + } + + return { + getSelectedModel, + initializeModelOptions, + loadModelOptionsFromCatalog, + syncModelSelectionForKey, + invalidateCatalogCache, + } +} From 9196b1c9017716a9a9b5ee79ab85c4de86959dbb Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 7 Sep 2026 10:14:20 -0500 Subject: [PATCH 2/2] refactor: address comments and failing webkit. --- playwright/helpers/app-test-helpers.ts | 46 +++++++++++++++++++------- src/modules/chat/api/completions.js | 17 ++-------- src/modules/chat/api/models.js | 5 ++- src/modules/chat/drawer.js | 6 ++-- src/modules/chat/model-picker.js | 12 +++---- 5 files changed, 50 insertions(+), 36 deletions(-) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index 066bbda..8e3f0a6 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -568,6 +568,7 @@ export const connectByotWithSingleRepo = async ( const workspacesRepositoryFilter = page.getByLabel('Workspace repository filter') await expect(workspacesRepositoryFilter).toBeVisible() + await expect(workspacesRepositoryFilter).toBeEnabled() await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop') await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop') @@ -576,21 +577,42 @@ export const connectByotWithSingleRepo = async ( name: 'Initialize', exact: true, }) + const storedWorkspace = page.getByLabel('Stored workspace') - if (await initializeButton.isVisible()) { + await expect + .poll(async () => { + if (await initializeButton.isVisible()) { + return 'initialize' + } + + if (await storedWorkspace.isVisible()) { + const workspaceValue = await storedWorkspace + .locator('option:not([value=""])') + .first() + .getAttribute('value') + + if (workspaceValue) { + return 'stored' + } + } + + return '' + }) + .not.toBe('') + + const autoOpenMode = (await initializeButton.isVisible()) ? 'initialize' : 'stored' + + if (autoOpenMode === 'initialize') { await initializeButton.click() } else { - const storedWorkspace = page.getByLabel('Stored workspace') - if (await storedWorkspace.isVisible()) { - const workspaceValue = await storedWorkspace - .locator('option:not([value=""])') - .first() - .getAttribute('value') - - if (workspaceValue) { - await storedWorkspace.selectOption(workspaceValue) - await page.getByRole('button', { name: 'Open', exact: true }).click() - } + const workspaceValue = await storedWorkspace + .locator('option:not([value=""])') + .first() + .getAttribute('value') + + if (workspaceValue) { + await storedWorkspace.selectOption(workspaceValue) + await page.getByRole('button', { name: 'Open', exact: true }).click() } } } diff --git a/src/modules/chat/api/completions.js b/src/modules/chat/api/completions.js index ff916fc..6a8e3b9 100644 --- a/src/modules/chat/api/completions.js +++ b/src/modules/chat/api/completions.js @@ -1,10 +1,4 @@ -import { - chatCompletionsUrl, - chatModelOptions, - defaultChatModel, - isFreeChatModel, -} from './constants.js' -import { fetchChatModelOptions } from './models.js' +import { chatCompletionsUrl, defaultChatModel } from './constants.js' import { buildChatRequestHeaders, parseErrorResponse, @@ -451,11 +445,4 @@ const requestChatCompletion = async ({ } } -export { - chatModelOptions, - defaultChatModel, - fetchChatModelOptions, - isFreeChatModel, - requestChatCompletion, - streamChatCompletion, -} +export { defaultChatModel, requestChatCompletion, streamChatCompletion } diff --git a/src/modules/chat/api/models.js b/src/modules/chat/api/models.js index 31a7921..a2b3a83 100644 --- a/src/modules/chat/api/models.js +++ b/src/modules/chat/api/models.js @@ -18,7 +18,10 @@ const isFreeModel = model => { return false } - return pricing.prompt === '0' && pricing.completion === '0' + return ( + (pricing.prompt === 0 || pricing.prompt === '0') && + (pricing.completion === 0 || pricing.completion === '0') + ) } const sortModelEntries = entries => { diff --git a/src/modules/chat/drawer.js b/src/modules/chat/drawer.js index ca28304..cf69b7f 100644 --- a/src/modules/chat/drawer.js +++ b/src/modules/chat/drawer.js @@ -182,7 +182,9 @@ export const createChatDrawer = ({ modelPicker.syncModelSelectionForKey(nextKey) syncComposerAvailability() - if (open) { + const keyPresent = typeof nextKey === 'string' && nextKey.trim().length > 0 + + if (open && keyPresent) { void modelPicker.loadModelOptionsFromCatalog({ force: true }) } }, @@ -233,7 +235,7 @@ export const createChatDrawer = ({ promptInput.focus() } - if (open) { + if (open && hasChatKey()) { void modelPicker.loadModelOptionsFromCatalog() } } diff --git a/src/modules/chat/model-picker.js b/src/modules/chat/model-picker.js index 4bea56a..c0d9e16 100644 --- a/src/modules/chat/model-picker.js +++ b/src/modules/chat/model-picker.js @@ -1,9 +1,5 @@ -import { - chatModelOptions, - defaultChatModel, - fetchChatModelOptions, - isFreeChatModel, -} from './api/completions.js' +import { chatModelOptions, defaultChatModel, isFreeChatModel } from './api/constants.js' +import { fetchChatModelOptions } from './api/models.js' import { toModelId } from './utils.js' export const createChatModelPicker = ({ @@ -92,6 +88,10 @@ export const createChatModelPicker = ({ const token = getChatKey() const normalizedToken = typeof token === 'string' ? token.trim() : '' + if (!normalizedToken) { + return + } + if (!force && pendingCatalogLoadPromise) { await pendingCatalogLoadPromise return