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 2e63639..6a8e3b9 100644 --- a/src/modules/chat/api/completions.js +++ b/src/modules/chat/api/completions.js @@ -1,4 +1,4 @@ -import { chatCompletionsUrl, chatModelOptions, defaultChatModel } from './constants.js' +import { chatCompletionsUrl, defaultChatModel } from './constants.js' import { buildChatRequestHeaders, parseErrorResponse, @@ -445,4 +445,4 @@ const requestChatCompletion = async ({ } } -export { chatModelOptions, defaultChatModel, requestChatCompletion, streamChatCompletion } +export { defaultChatModel, 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..a2b3a83 --- /dev/null +++ b/src/modules/chat/api/models.js @@ -0,0 +1,88 @@ +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.prompt === '0') && + (pricing.completion === 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..cf69b7f 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,37 @@ 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() + + const keyPresent = typeof nextKey === 'string' && nextKey.trim().length > 0 + + if (open && keyPresent) { + 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 +215,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 +234,10 @@ export const createChatDrawer = ({ if (open && promptInput instanceof HTMLTextAreaElement) { promptInput.focus() } + + if (open && hasChatKey()) { + void modelPicker.loadModelOptionsFromCatalog() + } } const setChatStatus = (text, level = 'neutral') => { @@ -989,8 +947,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..c0d9e16 --- /dev/null +++ b/src/modules/chat/model-picker.js @@ -0,0 +1,151 @@ +import { chatModelOptions, defaultChatModel, isFreeChatModel } from './api/constants.js' +import { fetchChatModelOptions } from './api/models.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 (!normalizedToken) { + return + } + + 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, + } +}