From 72add42afdbe26e3298f4d29f313b44a6f9a70cc Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 18 Jul 2026 12:51:40 +0000 Subject: [PATCH 1/2] refactor: extract local provider model discovery from task-models Move Ollama/vLLM/LiteLLM discovery, recommendation scoring, and qualification into a dedicated module so settings commands stay thin. --- .../trpc/commands/task-models/index.test.ts | 228 -------- .../src/trpc/commands/task-models/index.ts | 503 +--------------- .../local-provider-discovery.test.ts | 303 ++++++++++ .../task-models/local-provider-discovery.ts | 543 ++++++++++++++++++ 4 files changed, 861 insertions(+), 716 deletions(-) create mode 100644 apps/web/src/trpc/commands/task-models/local-provider-discovery.test.ts create mode 100644 apps/web/src/trpc/commands/task-models/local-provider-discovery.ts diff --git a/apps/web/src/trpc/commands/task-models/index.test.ts b/apps/web/src/trpc/commands/task-models/index.test.ts index 7742c7018..3976803c8 100644 --- a/apps/web/src/trpc/commands/task-models/index.test.ts +++ b/apps/web/src/trpc/commands/task-models/index.test.ts @@ -72,10 +72,7 @@ import { getTaskModelProviderSetupCommand, getTaskModelSettingsCommand, deleteTaskModelProviderCommand, - discoverProviderModelsCommand, - getRecommendedLocalProviderModels, lookupTaskModelCommand, - qualifyProviderModelCommand, refreshTaskModelMetadataCommand, saveTaskModelProviderCommand, updateTaskModelSettingsCommand, @@ -122,55 +119,6 @@ function buildMockAuth( } as UserAuthSuccess; } -describe('getRecommendedLocalProviderModels', () => { - it('prefers capable coding models and excludes tiny or specialized models', () => { - const recommended = getRecommendedLocalProviderModels([ - { - modelId: 'ollama/tinyllama:1.1b', - displayName: 'tinyllama:1.1b', - family: null, - metadata: null, - }, - { - modelId: 'ollama/nomic-embed-text', - displayName: 'nomic-embed-text', - family: null, - metadata: null, - }, - { - modelId: 'ollama/llama3.3:70b', - displayName: 'llama3.3:70b', - family: null, - metadata: null, - }, - { - modelId: 'ollama/qwen3-coder:30b', - displayName: 'qwen3-coder:30b', - family: null, - metadata: null, - }, - ]); - - expect(recommended.map((model) => model.modelId)).toEqual([ - 'ollama/qwen3-coder:30b', - 'ollama/llama3.3:70b', - ]); - }); - - it('does not automatically choose unknown local model aliases', () => { - expect( - getRecommendedLocalProviderModels([ - { - modelId: 'litellm/team-default', - displayName: 'team-default', - family: null, - metadata: null, - }, - ]), - ).toEqual([]); - }); -}); - describe('lookupTaskModelCommand', () => { // Settings reads now depend on which provider env keys are configured // (recommended models of connected providers join the catalog), so clear @@ -315,182 +263,6 @@ describe('lookupTaskModelCommand', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('discovers Ollama models from /api/tags and prefixes their IDs', async () => { - fetchMock.mockResolvedValue( - new Response(JSON.stringify({ models: [{ name: 'qwen3:8b' }] }), { - headers: { 'content-type': 'application/json' }, - }), - ); - - await expect( - discoverProviderModelsCommand(buildMockAuth(), { - provider: 'ollama', - baseUrl: 'http://ollama.example', - }), - ).resolves.toMatchObject({ - error: null, - modelCount: 1, - recommendedModels: [{ modelId: 'ollama/qwen3:8b' }], - models: [ - { - modelId: 'ollama/qwen3:8b', - displayName: 'qwen3:8b', - }, - ], - }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://ollama.example/api/tags', - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - - it('falls back to the OpenAI models endpoint when Ollama tags are unavailable', async () => { - fetchMock - .mockResolvedValueOnce(new Response(null, { status: 404 })) - .mockResolvedValueOnce( - new Response(JSON.stringify({ data: [{ id: 'llama3.3' }] }), { - headers: { 'content-type': 'application/json' }, - }), - ); - - await expect( - discoverProviderModelsCommand(buildMockAuth(), { - provider: 'ollama', - baseUrl: 'http://ollama.example/v1', - }), - ).resolves.toMatchObject({ - error: null, - models: [{ modelId: 'ollama/llama3.3' }], - }); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - 'http://ollama.example/v1/models', - expect.anything(), - ); - }); - - it('uses saved LiteLLM credentials and metadata when discovering models', async () => { - mockGetPersistedEnvironmentVariableValues.mockResolvedValue({ - LITELLM_BASE_URL: 'https://litellm.example/v1', - LITELLM_API_KEY: 'saved-key', - }); - fetchMock - .mockResolvedValueOnce( - new Response(JSON.stringify({ data: [{ id: 'azure/gpt-4o' }] }), { - headers: { 'content-type': 'application/json' }, - }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: [ - { - model_name: 'azure/gpt-4o', - model_info: { max_input_tokens: 128_000 }, - }, - ], - }), - { headers: { 'content-type': 'application/json' } }, - ), - ); - - await expect( - discoverProviderModelsCommand(buildMockAuth(), { provider: 'litellm' }), - ).resolves.toMatchObject({ - models: [ - { - modelId: 'litellm/azure/gpt-4o', - metadata: expect.objectContaining({ contextWindow: 128_000 }), - }, - ], - }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://litellm.example/v1/models', - expect.objectContaining({ - headers: { Authorization: 'Bearer saved-key' }, - }), - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - 'https://litellm.example/model/info', - expect.objectContaining({ - headers: { Authorization: 'Bearer saved-key' }, - }), - ); - }); - - it('qualifies a provider model with a streaming tool request', async () => { - fetchMock.mockResolvedValue( - new Response( - 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"name":"ping"}}]}}]}\n\n', - { - headers: { 'content-type': 'text/event-stream' }, - }, - ), - ); - - await expect( - qualifyProviderModelCommand(buildMockAuth(), { - provider: 'vllm', - baseUrl: 'https://vllm.example/v1', - apiKey: 'submitted-key', - modelId: 'vllm/qwen3', - }), - ).resolves.toEqual({ success: true }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://vllm.example/v1/chat/completions', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: 'Bearer submitted-key', - }), - body: expect.stringContaining('"stream":true'), - }), - ); - }); - - it('rejects streams that do not call the qualification tool', async () => { - fetchMock.mockResolvedValue( - new Response('data: {"choices":[{"delta":{"content":"pong"}}]}\n\n', { - headers: { 'content-type': 'text/event-stream' }, - }), - ); - - await expect( - qualifyProviderModelCommand(buildMockAuth(), { - provider: 'ollama', - baseUrl: 'http://ollama.example', - modelId: 'ollama/qwen3', - }), - ).resolves.toEqual({ - success: false, - error: expect.stringContaining('did not call the required tool'), - }); - }); - - it('returns provider compatibility details from qualification errors', async () => { - fetchMock.mockResolvedValue( - new Response( - JSON.stringify({ detail: 'tools are unsupported for this model' }), - { - status: 422, - headers: { 'content-type': 'application/json' }, - }, - ), - ); - - await expect( - qualifyProviderModelCommand(buildMockAuth(), { - provider: 'vllm', - baseUrl: 'https://vllm.example', - modelId: 'vllm/qwen3', - }), - ).resolves.toEqual({ - success: false, - error: expect.stringContaining('tools are unsupported for this model'), - }); - }); - it('uses discovery data when looking up a local provider model', async () => { mockGetPersistedEnvironmentVariableValues.mockResolvedValue({ VLLM_BASE_URL: 'https://vllm.example/v1', diff --git a/apps/web/src/trpc/commands/task-models/index.ts b/apps/web/src/trpc/commands/task-models/index.ts index 15738e260..699ddc034 100644 --- a/apps/web/src/trpc/commands/task-models/index.ts +++ b/apps/web/src/trpc/commands/task-models/index.ts @@ -61,67 +61,20 @@ import { buildAutoAddedTaskModelSettings, collectConnectedTaskModelProviderIds, } from './auto-add-models'; +import { + discoverProviderModels, + getLocalTaskModelProviderIdFromModelId, + qualifyProviderModel, + type LocalProviderConnectionInput, + type LocalTaskModelProviderId, + type TaskModelLookupResult, +} from './local-provider-discovery'; import type { UserAuthSuccess } from '@/types'; +export { getRecommendedLocalProviderModels } from './local-provider-discovery'; + const DEFAULT_DEPLOYMENT_ID = 'default'; const MODEL_METADATA_FETCH_TIMEOUT_MS = 10_000; -const LOCAL_PROVIDER_REQUEST_TIMEOUT_MS = 15_000; - -const LOCAL_TASK_MODEL_PROVIDER_IDS = ['ollama', 'vllm', 'litellm'] as const; -type LocalTaskModelProviderId = (typeof LOCAL_TASK_MODEL_PROVIDER_IDS)[number]; - -type LocalProviderConnectionInput = { - baseUrl?: string; - apiKey?: string; -}; - -type LocalProviderConnection = { - baseUrl: string; - apiKey: string | null; -}; - -type LocalProviderModelResponse = { - data?: Array<{ - id?: string; - name?: string; - model?: string; - model_name?: string; - model_info?: Record; - }>; - models?: Array<{ - id?: string; - name?: string; - model?: string; - model_name?: string; - model_info?: Record; - }>; - model_info?: Record>; -}; - -const LOCAL_MODEL_RECOMMENDATION_FAMILIES = [ - { pattern: /qwen(?:[\d.-]*)(?:[-_:]?(?:coder|code))?/i, score: 100 }, - { pattern: /devstral/i, score: 98 }, - { pattern: /gpt-oss/i, score: 96 }, - { pattern: /glm/i, score: 92 }, - { pattern: /mistral/i, score: 88 }, - { pattern: /deepseek/i, score: 86 }, - { pattern: /llama/i, score: 82 }, - { pattern: /gemma/i, score: 76 }, -] as const; - -const UNSUITABLE_LOCAL_MODEL_PATTERN = - /(?:^|[-_:/.])(tiny|embed(?:ding)?|rerank(?:er)?|guard|moderation|vision|vl|ocr|whisper|tts|nomic|all-minilm)(?:$|[-_:/.])/i; -const LOCAL_MODEL_PARAMETER_COUNT_PATTERN = - /(?:^|[-_:/.])(\d+(?:\.\d+)?)b(?:$|[-_:/.])/i; - -const LOCAL_PROVIDER_CONNECTION_ENV: Record< - LocalTaskModelProviderId, - { baseUrl: string; apiKey?: string } -> = { - ollama: { baseUrl: 'OLLAMA_BASE_URL' }, - vllm: { baseUrl: 'VLLM_BASE_URL', apiKey: 'VLLM_API_KEY' }, - litellm: { baseUrl: 'LITELLM_BASE_URL', apiKey: 'LITELLM_API_KEY' }, -}; function assertAdmin(auth: UserAuthSuccess): asserts auth is UserAuthSuccess { if (!auth.isAdmin) { @@ -1240,335 +1193,12 @@ function extractOpenRouterMetadata( }; } -type TaskModelLookupResult = { - modelId: string; - displayName: string | null; - family: string | null; - metadata: TaskModelMetadata | null; -}; - -function getLocalModelRecommendationScore(model: TaskModelLookupResult) { - const name = `${model.modelId} ${model.displayName ?? ''}`.toLowerCase(); - - if (UNSUITABLE_LOCAL_MODEL_PATTERN.test(name)) { - return null; - } - - const family = LOCAL_MODEL_RECOMMENDATION_FAMILIES.find(({ pattern }) => - pattern.test(name), - ); - if (!family) { - return null; - } - - const parameterCount = Number( - LOCAL_MODEL_PARAMETER_COUNT_PATTERN.exec(name)?.[1] ?? 0, - ); - if (parameterCount > 0 && parameterCount < 7) { - return null; - } - - const codingBonus = /(?:coder|code)/i.test(name) ? 8 : 0; - return family.score + codingBonus + Math.min(parameterCount, 100) / 100; -} - -/** - * Endpoint providers advertise their installed models rather than a stable - * catalog. Keep the automatic choice deliberately conservative: only known - * general-purpose families are eligible, and small or specialized models are - * left for an operator to enable manually from Models settings. - */ -export function getRecommendedLocalProviderModels( - models: readonly TaskModelLookupResult[], -) { - return models - .flatMap((model) => { - const score = getLocalModelRecommendationScore(model); - return score === null ? [] : [{ model, score }]; - }) - .sort( - (left, right) => - right.score - left.score || - left.model.modelId.localeCompare(right.model.modelId), - ) - .map(({ model }) => model); -} - -type LocalProviderDiscoveryResult = { - models: TaskModelLookupResult[]; - modelCount: number; - recommendedModels: TaskModelLookupResult[]; - error: string | null; -}; - -function getLocalProviderBaseUrl(baseUrl: string, path: string) { - const normalized = baseUrl.replace(/\/+$/u, ''); - const withoutV1 = normalized.endsWith('/v1') - ? normalized.slice(0, -'/v1'.length) - : normalized; - - return path.startsWith('/v1/') - ? `${normalized.endsWith('/v1') ? normalized : `${normalized}/v1`}${path.slice('/v1'.length)}` - : `${withoutV1}${path}`; -} - -function getLocalProviderError( - provider: LocalTaskModelProviderId, - response: Response, -) { - const label = - provider === 'vllm' - ? 'vLLM' - : provider === 'litellm' - ? 'LiteLLM' - : 'Ollama'; - - if (response.status === 401 || response.status === 403) { - return `${label} rejected the API key. Check the saved credentials.`; - } - if (response.status === 404) { - return `${label} did not recognize this endpoint. Check the endpoint URL and API compatibility.`; - } - if (response.status === 429) { - return `${label} is rate limiting requests. Try again shortly.`; - } - if (response.status >= 500) { - return `${label} returned a server error (${response.status}). Check that the provider is healthy.`; - } - return `${label} returned HTTP ${response.status}.`; -} - -function getLocalProviderNetworkError(provider: LocalTaskModelProviderId) { - const label = - provider === 'vllm' - ? 'vLLM' - : provider === 'litellm' - ? 'LiteLLM' - : 'Ollama'; - return `Could not reach ${label}. Check the endpoint URL and network access from Roomote.`; -} - -async function getQualificationError( - provider: LocalTaskModelProviderId, - response: Response, -) { - const baseError = getLocalProviderError(provider, response); - if (response.status !== 400 && response.status !== 422) { - return baseError; - } - - try { - const body = (await response.text()).trim(); - if (!body) { - return baseError; - } - const parsed = JSON.parse(body) as { - error?: { message?: unknown }; - detail?: unknown; - }; - const detail = - (typeof parsed.error?.message === 'string' && parsed.error.message) || - (typeof parsed.detail === 'string' && parsed.detail) || - body; - return `${baseError} ${detail.slice(0, 300)}`; - } catch { - return baseError; - } -} - -async function resolveLocalProviderConnection( - provider: LocalTaskModelProviderId, - input?: LocalProviderConnectionInput, -): Promise { - const envNames = LOCAL_PROVIDER_CONNECTION_ENV[provider]; - const persisted = await getPersistedEnvironmentVariableValues([ - envNames.baseUrl, - ...(envNames.apiKey ? [envNames.apiKey] : []), - ]); - const baseUrl = - input?.baseUrl?.trim() || - process.env[envNames.baseUrl]?.trim() || - persisted[envNames.baseUrl]?.trim(); - - if (!baseUrl) { - return null; - } - - try { - const url = new URL(baseUrl); - if (!['http:', 'https:'].includes(url.protocol)) { - return null; - } - } catch { - return null; - } - - return { - baseUrl, - apiKey: - input?.apiKey?.trim() || - (envNames.apiKey ? process.env[envNames.apiKey] : null) || - (envNames.apiKey ? persisted[envNames.apiKey] : null) || - null, - }; -} - -function buildLocalProviderHeaders(connection: LocalProviderConnection) { - return connection.apiKey - ? { Authorization: `Bearer ${connection.apiKey}` } - : undefined; -} - -function getNumber(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) && value > 0 - ? value - : null; -} - -function buildLocalProviderModel( - provider: LocalTaskModelProviderId, - slug: string, - displayName?: string, - info?: Record, -): TaskModelLookupResult { - const metadataPatch = { - contextWindow: - getNumber(info?.max_input_tokens) ?? - getNumber(info?.max_tokens) ?? - getNumber(info?.context_window), - inputPricePerToken: getNumber(info?.input_cost_per_token), - outputPricePerToken: getNumber(info?.output_cost_per_token), - }; - const hasMetadata = Object.values(metadataPatch).some( - (value) => value !== null, - ); - const model = buildTaskModelOption({ - id: `${provider}/${slug}`, - displayName: displayName?.trim() || slug, - metadata: hasMetadata ? mergeMetadata(null, metadataPatch) : null, - }); - - return { - modelId: model.id, - displayName: model.displayName, - family: model.family, - metadata: model.metadata ?? null, - }; -} - -async function fetchLocalProviderModels( - provider: LocalTaskModelProviderId, - connection: LocalProviderConnection, -): Promise { - const paths = - provider === 'ollama' ? ['/api/tags', '/v1/models'] : ['/v1/models']; - let lastError: string | null = null; - - for (const path of paths) { - try { - const response = await fetch( - getLocalProviderBaseUrl(connection.baseUrl, path), - { - headers: buildLocalProviderHeaders(connection), - signal: AbortSignal.timeout(LOCAL_PROVIDER_REQUEST_TIMEOUT_MS), - }, - ); - if (!response.ok) { - lastError = getLocalProviderError(provider, response); - continue; - } - - const payload = (await response.json()) as LocalProviderModelResponse; - let litellmModelInfo = payload.model_info; - if (provider === 'litellm') { - try { - const infoResponse = await fetch( - getLocalProviderBaseUrl(connection.baseUrl, '/model/info'), - { - headers: buildLocalProviderHeaders(connection), - signal: AbortSignal.timeout(LOCAL_PROVIDER_REQUEST_TIMEOUT_MS), - }, - ); - if (infoResponse.ok) { - const infoPayload = - (await infoResponse.json()) as LocalProviderModelResponse; - const infoEntries = Object.fromEntries( - (infoPayload.data ?? []).flatMap((entry) => { - const modelName = entry.model_name ?? entry.id; - return modelName && entry.model_info - ? [[modelName, entry.model_info]] - : []; - }), - ); - litellmModelInfo = { - ...litellmModelInfo, - ...infoPayload.model_info, - ...infoEntries, - }; - } - } catch { - // LiteLLM's metadata endpoint is optional; model discovery still works. - } - } - const entries = path === '/api/tags' ? payload.models : payload.data; - const models = (entries ?? []) - .map((entry) => { - const slug = - entry.id?.trim() || - entry.name?.trim() || - entry.model?.trim() || - entry.model_name?.trim(); - return slug - ? buildLocalProviderModel( - provider, - slug, - entry.model_name ?? entry.name, - entry.model_info ?? litellmModelInfo?.[slug], - ) - : null; - }) - .filter((model): model is TaskModelLookupResult => model !== null) - .sort((left, right) => left.modelId.localeCompare(right.modelId)); - - return { - models, - modelCount: models.length, - recommendedModels: getRecommendedLocalProviderModels(models), - error: null, - }; - } catch { - lastError = getLocalProviderNetworkError(provider); - } - } - - return { - models: [], - modelCount: 0, - recommendedModels: [], - error: lastError ?? getLocalProviderNetworkError(provider), - }; -} - export async function discoverProviderModelsCommand( auth: UserAuthSuccess, input: { provider: LocalTaskModelProviderId } & LocalProviderConnectionInput, -): Promise { +) { assertAdmin(auth); - const connection = await resolveLocalProviderConnection( - input.provider, - input, - ); - - if (!connection) { - return { - models: [], - modelCount: 0, - recommendedModels: [], - error: 'Save a valid endpoint URL before discovering models.', - }; - } - - return fetchLocalProviderModels(input.provider, connection); + return discoverProviderModels(input); } export async function qualifyProviderModelCommand( @@ -1577,110 +1207,9 @@ export async function qualifyProviderModelCommand( provider: LocalTaskModelProviderId; modelId: string; } & LocalProviderConnectionInput, -): Promise<{ success: true } | { success: false; error: string }> { +) { assertAdmin(auth); - const connection = await resolveLocalProviderConnection( - input.provider, - input, - ); - const modelId = input.modelId.trim(); - - if (!connection) { - return { - success: false, - error: 'Save a valid endpoint URL before qualifying a model.', - }; - } - if (!modelId) { - return { success: false, error: 'Choose a model before qualifying it.' }; - } - - try { - const response = await fetch( - getLocalProviderBaseUrl(connection.baseUrl, '/v1/chat/completions'), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...buildLocalProviderHeaders(connection), - }, - body: JSON.stringify({ - model: modelId.replace(`${input.provider}/`, ''), - messages: [{ role: 'user', content: 'Reply with pong.' }], - stream: true, - tool_choice: { - type: 'function', - function: { name: 'ping' }, - }, - tools: [ - { - type: 'function', - function: { - name: 'ping', - description: 'Returns a short health-check response.', - parameters: { type: 'object', properties: {} }, - }, - }, - ], - }), - signal: AbortSignal.timeout(LOCAL_PROVIDER_REQUEST_TIMEOUT_MS), - }, - ); - if (!response.ok) { - return { - success: false, - error: await getQualificationError(input.provider, response), - }; - } - - if (!response.headers.get('content-type')?.includes('text/event-stream')) { - return { - success: false, - error: - 'The provider returned a non-streaming response. Check OpenAI-compatible streaming support.', - }; - } - - const reader = response.body?.getReader(); - if (!reader) { - return { - success: false, - error: - 'The provider accepted the request but did not return a streaming response body.', - }; - } - - const decoder = new TextDecoder(); - let streamText = ''; - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) { - break; - } - streamText += decoder.decode(chunk.value, { stream: true }); - } - } finally { - reader.releaseLock(); - } - - if ( - !streamText.includes('"tool_calls"') || - !streamText.includes('"ping"') - ) { - return { - success: false, - error: - 'The model streamed a response but did not call the required tool. Choose a model with OpenAI-compatible tool calling.', - }; - } - return { success: true }; - } catch { - return { - success: false, - error: getLocalProviderNetworkError(input.provider), - }; - } + return qualifyProviderModel(input); } /** @@ -1750,9 +1279,7 @@ export async function lookupTaskModelCommand( }; } - const localProvider = LOCAL_TASK_MODEL_PROVIDER_IDS.find((provider) => - modelId.startsWith(`${provider}/`), - ); + const localProvider = getLocalTaskModelProviderIdFromModelId(modelId); if (localProvider) { const discovery = await discoverProviderModelsCommand(auth, { provider: localProvider, diff --git a/apps/web/src/trpc/commands/task-models/local-provider-discovery.test.ts b/apps/web/src/trpc/commands/task-models/local-provider-discovery.test.ts new file mode 100644 index 000000000..ed6d7ebd5 --- /dev/null +++ b/apps/web/src/trpc/commands/task-models/local-provider-discovery.test.ts @@ -0,0 +1,303 @@ +import { + discoverProviderModels, + getRecommendedLocalProviderModels, + LOCAL_TASK_MODEL_PROVIDER_IDS, + qualifyProviderModel, +} from './local-provider-discovery'; + +const { mockGetPersistedEnvironmentVariableValues } = vi.hoisted(() => ({ + mockGetPersistedEnvironmentVariableValues: vi.fn(), +})); + +vi.mock('../environment-variables', () => ({ + getPersistedEnvironmentVariableValues: + mockGetPersistedEnvironmentVariableValues, +})); + +describe('LOCAL_TASK_MODEL_PROVIDER_IDS', () => { + it('comes from setup catalog endpoint providers with dynamic models', () => { + expect([...LOCAL_TASK_MODEL_PROVIDER_IDS].sort()).toEqual([ + 'litellm', + 'ollama', + 'vllm', + ]); + }); +}); + +describe('getRecommendedLocalProviderModels', () => { + it('prefers capable coding models and excludes tiny or specialized models', () => { + const recommended = getRecommendedLocalProviderModels([ + { + modelId: 'ollama/tinyllama:1.1b', + displayName: 'tinyllama:1.1b', + family: null, + metadata: null, + }, + { + modelId: 'ollama/nomic-embed-text', + displayName: 'nomic-embed-text', + family: null, + metadata: null, + }, + { + modelId: 'ollama/llama3.3:70b', + displayName: 'llama3.3:70b', + family: null, + metadata: null, + }, + { + modelId: 'ollama/qwen3-coder:30b', + displayName: 'qwen3-coder:30b', + family: null, + metadata: null, + }, + ]); + + expect(recommended.map((model) => model.modelId)).toEqual([ + 'ollama/qwen3-coder:30b', + 'ollama/llama3.3:70b', + ]); + }); + + it('does not automatically choose unknown local model aliases', () => { + expect( + getRecommendedLocalProviderModels([ + { + modelId: 'litellm/team-default', + displayName: 'team-default', + family: null, + metadata: null, + }, + ]), + ).toEqual([]); + }); +}); + +describe('discoverProviderModels', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + mockGetPersistedEnvironmentVariableValues.mockResolvedValue({}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('discovers Ollama models from /api/tags and prefixes their IDs', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ models: [{ name: 'qwen3:8b' }] }), { + headers: { 'content-type': 'application/json' }, + }), + ); + + await expect( + discoverProviderModels({ + provider: 'ollama', + baseUrl: 'http://ollama.example', + }), + ).resolves.toMatchObject({ + error: null, + modelCount: 1, + recommendedModels: [{ modelId: 'ollama/qwen3:8b' }], + models: [ + { + modelId: 'ollama/qwen3:8b', + displayName: 'qwen3:8b', + }, + ], + }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://ollama.example/api/tags', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('falls back to the OpenAI models endpoint when Ollama tags are unavailable', async () => { + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ id: 'llama3.3' }] }), { + headers: { 'content-type': 'application/json' }, + }), + ); + + await expect( + discoverProviderModels({ + provider: 'ollama', + baseUrl: 'http://ollama.example/v1', + }), + ).resolves.toMatchObject({ + error: null, + models: [{ modelId: 'ollama/llama3.3' }], + }); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'http://ollama.example/v1/models', + expect.anything(), + ); + }); + + it('discovers vLLM models from the OpenAI-compatible models endpoint', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { + headers: { 'content-type': 'application/json' }, + }), + ); + + await expect( + discoverProviderModels({ + provider: 'vllm', + baseUrl: 'https://vllm.example/v1', + apiKey: 'submitted-key', + }), + ).resolves.toMatchObject({ + error: null, + models: [{ modelId: 'vllm/qwen3' }], + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://vllm.example/v1/models', + expect.objectContaining({ + headers: { Authorization: 'Bearer submitted-key' }, + }), + ); + }); + + it('uses saved LiteLLM credentials and metadata when discovering models', async () => { + mockGetPersistedEnvironmentVariableValues.mockResolvedValue({ + LITELLM_BASE_URL: 'https://litellm.example/v1', + LITELLM_API_KEY: 'saved-key', + }); + fetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ id: 'azure/gpt-4o' }] }), { + headers: { 'content-type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: [ + { + model_name: 'azure/gpt-4o', + model_info: { max_input_tokens: 128_000 }, + }, + ], + }), + { headers: { 'content-type': 'application/json' } }, + ), + ); + + await expect( + discoverProviderModels({ provider: 'litellm' }), + ).resolves.toMatchObject({ + models: [ + { + modelId: 'litellm/azure/gpt-4o', + metadata: expect.objectContaining({ contextWindow: 128_000 }), + }, + ], + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://litellm.example/v1/models', + expect.objectContaining({ + headers: { Authorization: 'Bearer saved-key' }, + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://litellm.example/model/info', + expect.objectContaining({ + headers: { Authorization: 'Bearer saved-key' }, + }), + ); + }); +}); + +describe('qualifyProviderModel', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + mockGetPersistedEnvironmentVariableValues.mockResolvedValue({}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('qualifies a provider model with a streaming tool request', async () => { + fetchMock.mockResolvedValue( + new Response( + 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"name":"ping"}}]}}]}\n\n', + { + headers: { 'content-type': 'text/event-stream' }, + }, + ), + ); + + await expect( + qualifyProviderModel({ + provider: 'vllm', + baseUrl: 'https://vllm.example/v1', + apiKey: 'submitted-key', + modelId: 'vllm/qwen3', + }), + ).resolves.toEqual({ success: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://vllm.example/v1/chat/completions', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer submitted-key', + }), + body: expect.stringContaining('"stream":true'), + }), + ); + }); + + it('rejects streams that do not call the qualification tool', async () => { + fetchMock.mockResolvedValue( + new Response('data: {"choices":[{"delta":{"content":"pong"}}]}\n\n', { + headers: { 'content-type': 'text/event-stream' }, + }), + ); + + await expect( + qualifyProviderModel({ + provider: 'ollama', + baseUrl: 'http://ollama.example', + modelId: 'ollama/qwen3', + }), + ).resolves.toEqual({ + success: false, + error: expect.stringContaining('did not call the required tool'), + }); + }); + + it('returns provider compatibility details from qualification errors', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ detail: 'tools are unsupported for this model' }), + { + status: 422, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + + await expect( + qualifyProviderModel({ + provider: 'vllm', + baseUrl: 'https://vllm.example', + modelId: 'vllm/qwen3', + }), + ).resolves.toEqual({ + success: false, + error: expect.stringContaining('tools are unsupported for this model'), + }); + }); +}); diff --git a/apps/web/src/trpc/commands/task-models/local-provider-discovery.ts b/apps/web/src/trpc/commands/task-models/local-provider-discovery.ts new file mode 100644 index 000000000..dcfae2731 --- /dev/null +++ b/apps/web/src/trpc/commands/task-models/local-provider-discovery.ts @@ -0,0 +1,543 @@ +import { + SETUP_MODEL_PROVIDER_CATALOG, + buildTaskModelOption, + getModelProviderLabel, + getSetupModelProvider, + getSetupModelProviderAdditionalEnvFields, + type SetupModelProviderId, + type TaskModelMetadata, +} from '@roomote/types'; + +import { getPersistedEnvironmentVariableValues } from '../environment-variables'; +import { mergeMetadata } from './models-dev'; + +const LOCAL_PROVIDER_REQUEST_TIMEOUT_MS = 15_000; + +const LOCAL_ENDPOINT_PROVIDER_CATALOG = SETUP_MODEL_PROVIDER_CATALOG.filter( + ( + provider, + ): provider is (typeof SETUP_MODEL_PROVIDER_CATALOG)[number] & { + authKind: 'endpoint'; + dynamicModels: true; + envVarName: string; + } => + provider.authKind === 'endpoint' && + provider.dynamicModels === true && + typeof provider.envVarName === 'string', +); + +export const LOCAL_TASK_MODEL_PROVIDER_IDS = + LOCAL_ENDPOINT_PROVIDER_CATALOG.map((provider) => provider.id); + +export type LocalTaskModelProviderId = + (typeof LOCAL_ENDPOINT_PROVIDER_CATALOG)[number]['id']; + +export type LocalProviderConnectionInput = { + baseUrl?: string; + apiKey?: string; +}; + +type LocalProviderConnection = { + baseUrl: string; + apiKey: string | null; +}; + +type LocalProviderModelResponse = { + data?: Array<{ + id?: string; + name?: string; + model?: string; + model_name?: string; + model_info?: Record; + }>; + models?: Array<{ + id?: string; + name?: string; + model?: string; + model_name?: string; + model_info?: Record; + }>; + model_info?: Record>; +}; + +const LOCAL_MODEL_RECOMMENDATION_FAMILIES = [ + { pattern: /qwen(?:[\d.-]*)(?:[-_:]?(?:coder|code))?/i, score: 100 }, + { pattern: /devstral/i, score: 98 }, + { pattern: /gpt-oss/i, score: 96 }, + { pattern: /glm/i, score: 92 }, + { pattern: /mistral/i, score: 88 }, + { pattern: /deepseek/i, score: 86 }, + { pattern: /llama/i, score: 82 }, + { pattern: /gemma/i, score: 76 }, +] as const; + +const UNSUITABLE_LOCAL_MODEL_PATTERN = + /(?:^|[-_:/.])(tiny|embed(?:ding)?|rerank(?:er)?|guard|moderation|vision|vl|ocr|whisper|tts|nomic|all-minilm)(?:$|[-_:/.])/i; +const LOCAL_MODEL_PARAMETER_COUNT_PATTERN = + /(?:^|[-_:/.])(\d+(?:\.\d+)?)b(?:$|[-_:/.])/i; + +export type TaskModelLookupResult = { + modelId: string; + displayName: string | null; + family: string | null; + metadata: TaskModelMetadata | null; +}; + +export type LocalProviderDiscoveryResult = { + models: TaskModelLookupResult[]; + modelCount: number; + recommendedModels: TaskModelLookupResult[]; + error: string | null; +}; + +function isLocalTaskModelProviderId( + providerId: string, +): providerId is LocalTaskModelProviderId { + return LOCAL_TASK_MODEL_PROVIDER_IDS.includes( + providerId as LocalTaskModelProviderId, + ); +} + +export function getLocalTaskModelProviderIdFromModelId( + modelId: string, +): LocalTaskModelProviderId | null { + const providerId = modelId.split('/')[0]; + return providerId && isLocalTaskModelProviderId(providerId) + ? providerId + : null; +} + +function getLocalProviderConnectionEnv(provider: LocalTaskModelProviderId): { + baseUrl: string; + apiKey?: string; +} { + const descriptor = getSetupModelProvider(provider as SetupModelProviderId); + const baseUrl = descriptor.envVarName; + if (!baseUrl) { + throw new Error(`Endpoint provider ${provider} is missing envVarName`); + } + + const apiKeyField = getSetupModelProviderAdditionalEnvFields(descriptor).find( + (field) => field.secret, + ); + + return { + baseUrl, + ...(apiKeyField ? { apiKey: apiKeyField.envVarName } : {}), + }; +} + +function getLocalModelRecommendationScore(model: TaskModelLookupResult) { + const name = `${model.modelId} ${model.displayName ?? ''}`.toLowerCase(); + + if (UNSUITABLE_LOCAL_MODEL_PATTERN.test(name)) { + return null; + } + + const family = LOCAL_MODEL_RECOMMENDATION_FAMILIES.find(({ pattern }) => + pattern.test(name), + ); + if (!family) { + return null; + } + + const parameterCount = Number( + LOCAL_MODEL_PARAMETER_COUNT_PATTERN.exec(name)?.[1] ?? 0, + ); + if (parameterCount > 0 && parameterCount < 7) { + return null; + } + + const codingBonus = /(?:coder|code)/i.test(name) ? 8 : 0; + return family.score + codingBonus + Math.min(parameterCount, 100) / 100; +} + +/** + * Endpoint providers advertise their installed models rather than a stable + * catalog. Keep the automatic choice deliberately conservative: only known + * general-purpose families are eligible, and small or specialized models are + * left for an operator to enable manually from Models settings. + */ +export function getRecommendedLocalProviderModels( + models: readonly TaskModelLookupResult[], +) { + return models + .flatMap((model) => { + const score = getLocalModelRecommendationScore(model); + return score === null ? [] : [{ model, score }]; + }) + .sort( + (left, right) => + right.score - left.score || + left.model.modelId.localeCompare(right.model.modelId), + ) + .map(({ model }) => model); +} + +function getLocalProviderBaseUrl(baseUrl: string, path: string) { + const normalized = baseUrl.replace(/\/+$/u, ''); + const withoutV1 = normalized.endsWith('/v1') + ? normalized.slice(0, -'/v1'.length) + : normalized; + + return path.startsWith('/v1/') + ? `${normalized.endsWith('/v1') ? normalized : `${normalized}/v1`}${path.slice('/v1'.length)}` + : `${withoutV1}${path}`; +} + +function getLocalProviderError( + provider: LocalTaskModelProviderId, + response: Response, +) { + const label = getModelProviderLabel(provider); + + if (response.status === 401 || response.status === 403) { + return `${label} rejected the API key. Check the saved credentials.`; + } + if (response.status === 404) { + return `${label} did not recognize this endpoint. Check the endpoint URL and API compatibility.`; + } + if (response.status === 429) { + return `${label} is rate limiting requests. Try again shortly.`; + } + if (response.status >= 500) { + return `${label} returned a server error (${response.status}). Check that the provider is healthy.`; + } + return `${label} returned HTTP ${response.status}.`; +} + +function getLocalProviderNetworkError(provider: LocalTaskModelProviderId) { + const label = getModelProviderLabel(provider); + return `Could not reach ${label}. Check the endpoint URL and network access from Roomote.`; +} + +async function getQualificationError( + provider: LocalTaskModelProviderId, + response: Response, +) { + const baseError = getLocalProviderError(provider, response); + if (response.status !== 400 && response.status !== 422) { + return baseError; + } + + try { + const body = (await response.text()).trim(); + if (!body) { + return baseError; + } + const parsed = JSON.parse(body) as { + error?: { message?: unknown }; + detail?: unknown; + }; + const detail = + (typeof parsed.error?.message === 'string' && parsed.error.message) || + (typeof parsed.detail === 'string' && parsed.detail) || + body; + return `${baseError} ${detail.slice(0, 300)}`; + } catch { + return baseError; + } +} + +async function resolveLocalProviderConnection( + provider: LocalTaskModelProviderId, + input?: LocalProviderConnectionInput, +): Promise { + const envNames = getLocalProviderConnectionEnv(provider); + const persisted = await getPersistedEnvironmentVariableValues([ + envNames.baseUrl, + ...(envNames.apiKey ? [envNames.apiKey] : []), + ]); + const baseUrl = + input?.baseUrl?.trim() || + process.env[envNames.baseUrl]?.trim() || + persisted[envNames.baseUrl]?.trim(); + + if (!baseUrl) { + return null; + } + + try { + const url = new URL(baseUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + return null; + } + } catch { + return null; + } + + return { + baseUrl, + apiKey: + input?.apiKey?.trim() || + (envNames.apiKey ? process.env[envNames.apiKey] : null) || + (envNames.apiKey ? persisted[envNames.apiKey] : null) || + null, + }; +} + +function buildLocalProviderHeaders(connection: LocalProviderConnection) { + return connection.apiKey + ? { Authorization: `Bearer ${connection.apiKey}` } + : undefined; +} + +function getNumber(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : null; +} + +function buildLocalProviderModel( + provider: LocalTaskModelProviderId, + slug: string, + displayName?: string, + info?: Record, +): TaskModelLookupResult { + const metadataPatch = { + contextWindow: + getNumber(info?.max_input_tokens) ?? + getNumber(info?.max_tokens) ?? + getNumber(info?.context_window), + inputPricePerToken: getNumber(info?.input_cost_per_token), + outputPricePerToken: getNumber(info?.output_cost_per_token), + }; + const hasMetadata = Object.values(metadataPatch).some( + (value) => value !== null, + ); + const model = buildTaskModelOption({ + id: `${provider}/${slug}`, + displayName: displayName?.trim() || slug, + metadata: hasMetadata ? mergeMetadata(null, metadataPatch) : null, + }); + + return { + modelId: model.id, + displayName: model.displayName, + family: model.family, + metadata: model.metadata ?? null, + }; +} + +export async function fetchLocalProviderModels( + provider: LocalTaskModelProviderId, + connection: LocalProviderConnection, +): Promise { + const paths = + provider === 'ollama' ? ['/api/tags', '/v1/models'] : ['/v1/models']; + let lastError: string | null = null; + + for (const path of paths) { + try { + const response = await fetch( + getLocalProviderBaseUrl(connection.baseUrl, path), + { + headers: buildLocalProviderHeaders(connection), + signal: AbortSignal.timeout(LOCAL_PROVIDER_REQUEST_TIMEOUT_MS), + }, + ); + if (!response.ok) { + lastError = getLocalProviderError(provider, response); + continue; + } + + const payload = (await response.json()) as LocalProviderModelResponse; + let litellmModelInfo = payload.model_info; + if (provider === 'litellm') { + try { + const infoResponse = await fetch( + getLocalProviderBaseUrl(connection.baseUrl, '/model/info'), + { + headers: buildLocalProviderHeaders(connection), + signal: AbortSignal.timeout(LOCAL_PROVIDER_REQUEST_TIMEOUT_MS), + }, + ); + if (infoResponse.ok) { + const infoPayload = + (await infoResponse.json()) as LocalProviderModelResponse; + const infoEntries = Object.fromEntries( + (infoPayload.data ?? []).flatMap((entry) => { + const modelName = entry.model_name ?? entry.id; + return modelName && entry.model_info + ? [[modelName, entry.model_info]] + : []; + }), + ); + litellmModelInfo = { + ...litellmModelInfo, + ...infoPayload.model_info, + ...infoEntries, + }; + } + } catch { + // LiteLLM's metadata endpoint is optional; model discovery still works. + } + } + const entries = path === '/api/tags' ? payload.models : payload.data; + const models = (entries ?? []) + .map((entry) => { + const slug = + entry.id?.trim() || + entry.name?.trim() || + entry.model?.trim() || + entry.model_name?.trim(); + return slug + ? buildLocalProviderModel( + provider, + slug, + entry.model_name ?? entry.name, + entry.model_info ?? litellmModelInfo?.[slug], + ) + : null; + }) + .filter((model): model is TaskModelLookupResult => model !== null) + .sort((left, right) => left.modelId.localeCompare(right.modelId)); + + return { + models, + modelCount: models.length, + recommendedModels: getRecommendedLocalProviderModels(models), + error: null, + }; + } catch { + lastError = getLocalProviderNetworkError(provider); + } + } + + return { + models: [], + modelCount: 0, + recommendedModels: [], + error: lastError ?? getLocalProviderNetworkError(provider), + }; +} + +export async function discoverProviderModels( + input: { provider: LocalTaskModelProviderId } & LocalProviderConnectionInput, +): Promise { + const connection = await resolveLocalProviderConnection( + input.provider, + input, + ); + + if (!connection) { + return { + models: [], + modelCount: 0, + recommendedModels: [], + error: 'Save a valid endpoint URL before discovering models.', + }; + } + + return fetchLocalProviderModels(input.provider, connection); +} + +export async function qualifyProviderModel( + input: { + provider: LocalTaskModelProviderId; + modelId: string; + } & LocalProviderConnectionInput, +): Promise<{ success: true } | { success: false; error: string }> { + const connection = await resolveLocalProviderConnection( + input.provider, + input, + ); + const modelId = input.modelId.trim(); + + if (!connection) { + return { + success: false, + error: 'Save a valid endpoint URL before qualifying a model.', + }; + } + if (!modelId) { + return { success: false, error: 'Choose a model before qualifying it.' }; + } + + try { + const response = await fetch( + getLocalProviderBaseUrl(connection.baseUrl, '/v1/chat/completions'), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildLocalProviderHeaders(connection), + }, + body: JSON.stringify({ + model: modelId.replace(`${input.provider}/`, ''), + messages: [{ role: 'user', content: 'Reply with pong.' }], + stream: true, + tool_choice: { + type: 'function', + function: { name: 'ping' }, + }, + tools: [ + { + type: 'function', + function: { + name: 'ping', + description: 'Returns a short health-check response.', + parameters: { type: 'object', properties: {} }, + }, + }, + ], + }), + signal: AbortSignal.timeout(LOCAL_PROVIDER_REQUEST_TIMEOUT_MS), + }, + ); + if (!response.ok) { + return { + success: false, + error: await getQualificationError(input.provider, response), + }; + } + + if (!response.headers.get('content-type')?.includes('text/event-stream')) { + return { + success: false, + error: + 'The provider returned a non-streaming response. Check OpenAI-compatible streaming support.', + }; + } + + const reader = response.body?.getReader(); + if (!reader) { + return { + success: false, + error: + 'The provider accepted the request but did not return a streaming response body.', + }; + } + + const decoder = new TextDecoder(); + let streamText = ''; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + streamText += decoder.decode(chunk.value, { stream: true }); + } + } finally { + reader.releaseLock(); + } + + if ( + !streamText.includes('"tool_calls"') || + !streamText.includes('"ping"') + ) { + return { + success: false, + error: + 'The model streamed a response but did not call the required tool. Choose a model with OpenAI-compatible tool calling.', + }; + } + return { success: true }; + } catch { + return { + success: false, + error: getLocalProviderNetworkError(input.provider), + }; + } +} From 17f5f44c89f44fead99a3b4ae1f35fec41b487c1 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 18 Jul 2026 12:52:39 +0000 Subject: [PATCH 2/2] refactor: drop unused local discovery exports for knip --- apps/web/src/trpc/commands/task-models/index.ts | 2 -- .../src/trpc/commands/task-models/local-provider-discovery.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/trpc/commands/task-models/index.ts b/apps/web/src/trpc/commands/task-models/index.ts index 699ddc034..da0de5b70 100644 --- a/apps/web/src/trpc/commands/task-models/index.ts +++ b/apps/web/src/trpc/commands/task-models/index.ts @@ -71,8 +71,6 @@ import { } from './local-provider-discovery'; import type { UserAuthSuccess } from '@/types'; -export { getRecommendedLocalProviderModels } from './local-provider-discovery'; - const DEFAULT_DEPLOYMENT_ID = 'default'; const MODEL_METADATA_FETCH_TIMEOUT_MS = 10_000; diff --git a/apps/web/src/trpc/commands/task-models/local-provider-discovery.ts b/apps/web/src/trpc/commands/task-models/local-provider-discovery.ts index dcfae2731..a4ff2825a 100644 --- a/apps/web/src/trpc/commands/task-models/local-provider-discovery.ts +++ b/apps/web/src/trpc/commands/task-models/local-provider-discovery.ts @@ -83,7 +83,7 @@ export type TaskModelLookupResult = { metadata: TaskModelMetadata | null; }; -export type LocalProviderDiscoveryResult = { +type LocalProviderDiscoveryResult = { models: TaskModelLookupResult[]; modelCount: number; recommendedModels: TaskModelLookupResult[]; @@ -319,7 +319,7 @@ function buildLocalProviderModel( }; } -export async function fetchLocalProviderModels( +async function fetchLocalProviderModels( provider: LocalTaskModelProviderId, connection: LocalProviderConnection, ): Promise {