Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 34 additions & 12 deletions playwright/helpers/app-test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
attempt += 1

try {
await page.goto(path, { waitUntil: 'domcontentloaded' })

Check failure on line 63 in playwright/helpers/app-test-helpers.ts

View workflow job for this annotation

GitHub Actions / E2E (Playwright, webkit, shard 4/4)

[webkit] › playwright/rendering-modes/core.spec.ts:1139:1 › editing-transient missing reference runtime errors are suppressed

1) [webkit] › playwright/rendering-modes/core.spec.ts:1139:1 › editing-transient missing reference runtime errors are suppressed Error: page.goto: Target page, context or browser has been closed at helpers/app-test-helpers.ts:63 61 | 62 | try { > 63 | await page.goto(path, { waitUntil: 'domcontentloaded' }) | ^ 64 | return 65 | } catch (error) { 66 | if (attempt >= 3 || !isRetryableGotoError(error)) { at navigateToApp (/home/runner/work/develop/develop/playwright/helpers/app-test-helpers.ts:63:18) at waitForAppReady (/home/runner/work/develop/develop/playwright/helpers/app-test-helpers.ts:103:3) at waitForInitialRender (/home/runner/work/develop/develop/playwright/helpers/app-test-helpers.ts:169:3) at /home/runner/work/develop/develop/playwright/rendering-modes/core.spec.ts:1142:3
return
} catch (error) {
if (attempt >= 3 || !isRetryableGotoError(error)) {
Expand Down Expand Up @@ -102,7 +102,7 @@
await stubExternalFontRequests(page)
await navigateToApp(page, path)
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
await expect

Check failure on line 105 in playwright/helpers/app-test-helpers.ts

View workflow job for this annotation

GitHub Actions / E2E (Playwright, webkit, shard 4/4)

[webkit] › playwright/rendering-modes/core.spec.ts:1306:1 › requires render button when auto render is disabled

2) [webkit] › playwright/rendering-modes/core.spec.ts:1306:1 › requires render button when auto render is disabled Error: expect(received).toBe(expected) // Object.is equality Expected: true Received: false Call Log: - Timeout 90000ms exceeded while waiting on the predicate at helpers/app-test-helpers.ts:105 103 | await navigateToApp(page, path) 104 | await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() > 105 | await expect | ^ 106 | .poll(async () => { 107 | const statusText = ( 108 | await page.getByRole('status', { name: 'App status' }).textContent() at waitForAppReady (/home/runner/work/develop/develop/playwright/helpers/app-test-helpers.ts:105:3) at waitForInitialRender (/home/runner/work/develop/develop/playwright/helpers/app-test-helpers.ts:169:3) at /home/runner/work/develop/develop/playwright/rendering-modes/core.spec.ts:1307:3
.poll(async () => {
const statusText = (
await page.getByRole('status', { name: 'App status' }).textContent()
Expand Down Expand Up @@ -568,6 +568,7 @@

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')

Expand All @@ -576,21 +577,42 @@
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()
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/modules/chat/api/completions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { chatCompletionsUrl, chatModelOptions, defaultChatModel } from './constants.js'
import { chatCompletionsUrl, defaultChatModel } from './constants.js'
import {
buildChatRequestHeaders,
parseErrorResponse,
Expand Down Expand Up @@ -445,4 +445,4 @@ const requestChatCompletion = async ({
}
}

export { chatModelOptions, defaultChatModel, requestChatCompletion, streamChatCompletion }
export { defaultChatModel, requestChatCompletion, streamChatCompletion }
1 change: 1 addition & 0 deletions src/modules/chat/api/constants.js
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down
88 changes: 88 additions & 0 deletions src/modules/chat/api/models.js
Original file line number Diff line number Diff line change
@@ -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)
}
96 changes: 27 additions & 69 deletions src/modules/chat/drawer.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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') => {
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading