From 2541e6c9b14f17d82755cfe1eab2eab334b63d25 Mon Sep 17 00:00:00 2001 From: pranav718 Date: Mon, 31 Aug 2026 22:47:25 +0530 Subject: [PATCH 1/3] fix(web): disambiguate MCP language model selection for ask_codebase (#1137) --- CHANGELOG.md | 3 + .../web/src/ee/features/mcp/askCodebase.ts | 28 +--- .../selectConfiguredLanguageModel.test.ts | 145 ++++++++++++++++++ .../chat/selectConfiguredLanguageModel.ts | 109 +++++++++++++ 4 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts create mode 100644 packages/web/src/features/chat/selectConfiguredLanguageModel.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f20fd50c..902f072cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a live repository indexing job runtime to the syncing badge. [#1623](https://github.com/sourcebot-dev/sourcebot/pull/1623) +### Fixed +- Fixed MCP `ask_codebase` rejecting explicit language model selection by resolving matching model configurations when `displayName` is omitted and providing clear disambiguation errors for multi-config models. [#1622](https://github.com/sourcebot-dev/sourcebot/pull/1622) + ## [5.1.10] - 2026-08-27 ### Fixed diff --git a/packages/web/src/ee/features/mcp/askCodebase.ts b/packages/web/src/ee/features/mcp/askCodebase.ts index 35337d29f..4bf681203 100644 --- a/packages/web/src/ee/features/mcp/askCodebase.ts +++ b/packages/web/src/ee/features/mcp/askCodebase.ts @@ -4,7 +4,8 @@ import { generateChatNameFromMessage } from "@/ee/features/chat/llm.server"; import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server"; import { resolveContextWindow } from "@/features/chat/modelContextWindow.server"; import { LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types"; -import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils"; +import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage } from "@/features/chat/utils"; +import { selectConfiguredLanguageModel } from "@/features/chat/selectConfiguredLanguageModel"; import { resolveModelCapabilities } from "@/features/chat/modelCapabilities.server"; import { ErrorCode } from "@/lib/errorCodes"; import { ServiceError, ServiceErrorException } from "@/lib/serviceError"; @@ -61,28 +62,11 @@ export const askCodebase = (params: AskCodebaseParams): Promise getLanguageModelKey(m) === getLanguageModelKey(requestedLanguageModel) - ); - if (!matchingModel) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `Language model '${requestedLanguageModel.provider}/${requestedLanguageModel.model}' is not configured.`, - } satisfies ServiceError; - } - languageModelConfig = matchingModel; + const modelSelection = selectConfiguredLanguageModel(configuredModels, requestedLanguageModel); + if (!modelSelection.success) { + return modelSelection.error; } + const languageModelConfig = modelSelection.model; const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig); const modelName = languageModelConfig.displayName ?? languageModelConfig.model; diff --git a/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts b/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts new file mode 100644 index 000000000..7da05ff3d --- /dev/null +++ b/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "vitest"; +import { selectConfiguredLanguageModel } from "./selectConfiguredLanguageModel"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; + +describe("selectConfiguredLanguageModel", () => { + const model1 = { + provider: "anthropic", + model: "claude-sonnet-4-6", + displayName: "Claude Sonnet 4.6", + }; + + const model2 = { + provider: "anthropic", + model: "claude-opus-4-7", + displayName: "Claude Opus 4.7 (Fast)", + }; + + const model3 = { + provider: "anthropic", + model: "claude-opus-4-7", + displayName: "Claude Opus 4.7 (Thinking)", + }; + + const model4 = { + provider: "openai", + model: "gpt-4o", + }; + + const configuredModels = [model1, model2, model3, model4]; + + test("returns error when no models are configured", () => { + const result = selectConfiguredLanguageModel([], { + provider: "anthropic", + model: "claude-sonnet-4-6", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(result.error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY); + expect(result.error.message).toContain("No language models are configured"); + } + }); + + test("defaults to first configured model when no requested model is provided", () => { + const result = selectConfiguredLanguageModel(configuredModels); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.model).toEqual(model1); + } + }); + + test("defaults to first configured model when empty object is provided", () => { + const result = selectConfiguredLanguageModel(configuredModels, {}); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.model).toEqual(model1); + } + }); + + test("matches model uniquely by provider and model when displayName is omitted", () => { + const result = selectConfiguredLanguageModel(configuredModels, { + provider: "anthropic", + model: "claude-sonnet-4-6", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.model).toEqual(model1); + } + }); + + test("matches model uniquely when configured model has no displayName and request omits it", () => { + const result = selectConfiguredLanguageModel(configuredModels, { + provider: "openai", + model: "gpt-4o", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.model).toEqual(model4); + } + }); + + test("matches model strictly by exact provider, model, and displayName", () => { + const result = selectConfiguredLanguageModel(configuredModels, { + provider: "anthropic", + model: "claude-opus-4-7", + displayName: "Claude Opus 4.7 (Thinking)", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.model).toEqual(model3); + } + }); + + test("returns 400 with disambiguation message when multiple models match provider/model and displayName is omitted", () => { + const result = selectConfiguredLanguageModel(configuredModels, { + provider: "anthropic", + model: "claude-opus-4-7", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(result.error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY); + expect(result.error.message).toContain("Multiple configurations found for language model 'anthropic/claude-opus-4-7'"); + expect(result.error.message).toContain("'Claude Opus 4.7 (Fast)'"); + expect(result.error.message).toContain("'Claude Opus 4.7 (Thinking)'"); + } + }); + + test("returns 400 when model is not configured", () => { + const result = selectConfiguredLanguageModel(configuredModels, { + provider: "google", + model: "gemini-2.0-flash", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(result.error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY); + expect(result.error.message).toBe("Language model 'google/gemini-2.0-flash' is not configured."); + } + }); + + test("returns 400 when provider/model matches but displayName does not match any config", () => { + const result = selectConfiguredLanguageModel(configuredModels, { + provider: "anthropic", + model: "claude-sonnet-4-6", + displayName: "Nonexistent Display Name", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(result.error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY); + expect(result.error.message).toBe("Language model 'anthropic/claude-sonnet-4-6' ('Nonexistent Display Name') is not configured."); + } + }); +}); diff --git a/packages/web/src/features/chat/selectConfiguredLanguageModel.ts b/packages/web/src/features/chat/selectConfiguredLanguageModel.ts new file mode 100644 index 000000000..67bf12246 --- /dev/null +++ b/packages/web/src/features/chat/selectConfiguredLanguageModel.ts @@ -0,0 +1,109 @@ +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; +import { ServiceError } from "@/lib/serviceError"; + +export type SelectConfiguredLanguageModelResult = + | { success: true; model: T } + | { success: false; error: ServiceError }; + +type MatchableModel = { + provider: string; + model: string; + displayName?: string; +}; + +/** + * Selects a configured language model from a list of configured models + * based on a requested language model specifier. + * + * - If no language model is requested, defaults to the first configured model. + * - If `displayName` is provided in the request, matches strictly on `(provider, model, displayName)`. + * - If `displayName` is omitted in the request: + * - If exactly one model matches `(provider, model)`, that model is selected. + * - If multiple models match `(provider, model)`, returns a 400 error requiring `displayName` disambiguation. + * - If no models match, returns a 400 error indicating the model is not configured. + */ +export const selectConfiguredLanguageModel = ( + configuredModels: T[], + requestedLanguageModel?: Partial +): SelectConfiguredLanguageModelResult => { + if (configuredModels.length === 0) { + return { + success: false, + error: { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "No language models are configured. Please configure at least one language model. See: https://docs.sourcebot.dev/docs/configuration/language-model-providers", + }, + }; + } + + if (!requestedLanguageModel || (!requestedLanguageModel.provider && !requestedLanguageModel.model)) { + return { + success: true, + model: configuredModels[0], + }; + } + + const { provider, model, displayName } = requestedLanguageModel; + + // If displayName is explicitly provided, match on exact (provider, model, displayName) + if (displayName) { + const exactMatch = configuredModels.find((m) => { + return m.provider === provider && m.model === model && m.displayName === displayName; + }); + + if (exactMatch) { + return { + success: true, + model: exactMatch, + }; + } + + return { + success: false, + error: { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `Language model '${provider}/${model}' ('${displayName}') is not configured.`, + }, + }; + } + + // If displayName is omitted, find all configs matching (provider, model) + const matchingModels = configuredModels.filter((m) => { + return m.provider === provider && m.model === model; + }); + + if (matchingModels.length === 1) { + return { + success: true, + model: matchingModels[0], + }; + } + + if (matchingModels.length > 1) { + const displayNames = matchingModels + .map((m) => m.displayName || "(default)") + .map((name) => `'${name}'`) + .join(', '); + + return { + success: false, + error: { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `Multiple configurations found for language model '${provider}/${model}'. Please specify a displayName (${displayNames}) to disambiguate.`, + }, + }; + } + + return { + success: false, + error: { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `Language model '${provider}/${model}' is not configured.`, + }, + }; +}; From d30d3b6e3228d3835e81961a88fe664c0bc8c5fe Mon Sep 17 00:00:00 2001 From: pranav718 Date: Mon, 31 Aug 2026 23:20:43 +0530 Subject: [PATCH 2/3] fix(web): address review comments for model disambiguation and changelog link --- .../selectConfiguredLanguageModel.test.ts | 37 +++++++++++++++++++ .../chat/selectConfiguredLanguageModel.ts | 29 +++++---------- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts b/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts index 7da05ff3d..cde15754c 100644 --- a/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts +++ b/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts @@ -111,6 +111,26 @@ describe("selectConfiguredLanguageModel", () => { expect(result.error.message).toContain("Multiple configurations found for language model 'anthropic/claude-opus-4-7'"); expect(result.error.message).toContain("'Claude Opus 4.7 (Fast)'"); expect(result.error.message).toContain("'Claude Opus 4.7 (Thinking)'"); + expect(result.error.message).not.toContain("(default)"); + } + }); + + test("handles multiple models with identical provider/model when none have displayName", () => { + const duplicateUnnamed = [ + { provider: "ollama", model: "llama3" }, + { provider: "ollama", model: "llama3" }, + ]; + + const result = selectConfiguredLanguageModel(duplicateUnnamed, { + provider: "ollama", + model: "llama3", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(result.error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY); + expect(result.error.message).toContain("Please configure distinct displayNames in your configuration"); } }); @@ -142,4 +162,21 @@ describe("selectConfiguredLanguageModel", () => { expect(result.error.message).toBe("Language model 'anthropic/claude-sonnet-4-6' ('Nonexistent Display Name') is not configured."); } }); + + test("matches when displayName is empty string if configured with empty string", () => { + const modelsWithEmpty = [ + { provider: "openai", model: "gpt-4o", displayName: "" }, + ]; + + const result = selectConfiguredLanguageModel(modelsWithEmpty, { + provider: "openai", + model: "gpt-4o", + displayName: "", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.model.displayName).toBe(""); + } + }); }); diff --git a/packages/web/src/features/chat/selectConfiguredLanguageModel.ts b/packages/web/src/features/chat/selectConfiguredLanguageModel.ts index 67bf12246..bbe96f442 100644 --- a/packages/web/src/features/chat/selectConfiguredLanguageModel.ts +++ b/packages/web/src/features/chat/selectConfiguredLanguageModel.ts @@ -12,17 +12,6 @@ type MatchableModel = { displayName?: string; }; -/** - * Selects a configured language model from a list of configured models - * based on a requested language model specifier. - * - * - If no language model is requested, defaults to the first configured model. - * - If `displayName` is provided in the request, matches strictly on `(provider, model, displayName)`. - * - If `displayName` is omitted in the request: - * - If exactly one model matches `(provider, model)`, that model is selected. - * - If multiple models match `(provider, model)`, returns a 400 error requiring `displayName` disambiguation. - * - If no models match, returns a 400 error indicating the model is not configured. - */ export const selectConfiguredLanguageModel = ( configuredModels: T[], requestedLanguageModel?: Partial @@ -47,8 +36,7 @@ export const selectConfiguredLanguageModel = ( const { provider, model, displayName } = requestedLanguageModel; - // If displayName is explicitly provided, match on exact (provider, model, displayName) - if (displayName) { + if (displayName !== undefined) { const exactMatch = configuredModels.find((m) => { return m.provider === provider && m.model === model && m.displayName === displayName; }); @@ -70,7 +58,6 @@ export const selectConfiguredLanguageModel = ( }; } - // If displayName is omitted, find all configs matching (provider, model) const matchingModels = configuredModels.filter((m) => { return m.provider === provider && m.model === model; }); @@ -83,17 +70,21 @@ export const selectConfiguredLanguageModel = ( } if (matchingModels.length > 1) { - const displayNames = matchingModels - .map((m) => m.displayName || "(default)") - .map((name) => `'${name}'`) - .join(', '); + const availableNames = matchingModels + .map((m) => m.displayName) + .filter((name): name is string => typeof name === "string" && name.length > 0) + .map((name) => `'${name}'`); + + const hint = availableNames.length > 0 + ? `Please specify a displayName (${availableNames.join(', ')}) to disambiguate.` + : `Please configure distinct displayNames in your configuration to disambiguate.`; return { success: false, error: { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: `Multiple configurations found for language model '${provider}/${model}'. Please specify a displayName (${displayNames}) to disambiguate.`, + message: `Multiple configurations found for language model '${provider}/${model}'. ${hint}`, }, }; } From 687fee1386855a86eeb878023112425b90d49d09 Mon Sep 17 00:00:00 2001 From: pranav718 Date: Tue, 1 Sep 2026 19:31:35 +0530 Subject: [PATCH 3/3] fix(web): preserve empty displayName and prompt for unnamed configs in disambiguation --- .../selectConfiguredLanguageModel.test.ts | 38 +++++++++++++++++++ .../chat/selectConfiguredLanguageModel.ts | 17 ++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts b/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts index cde15754c..f2d23e196 100644 --- a/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts +++ b/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts @@ -134,6 +134,44 @@ describe("selectConfiguredLanguageModel", () => { } }); + test("handles mixed named and unnamed models sharing provider and model", () => { + const mixed = [ + { provider: "anthropic", model: "claude-opus-4-7" }, + { provider: "anthropic", model: "claude-opus-4-7", displayName: "Thinking Mode" }, + ]; + + const result = selectConfiguredLanguageModel(mixed, { + provider: "anthropic", + model: "claude-opus-4-7", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(result.error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY); + expect(result.error.message).toContain("Please specify a displayName from ['Thinking Mode'] or configure distinct displayNames in your configuration for unnamed models."); + } + }); + + test("preserves empty-string displayName in disambiguation message", () => { + const withEmptyString = [ + { provider: "openai", model: "gpt-4o", displayName: "" }, + { provider: "openai", model: "gpt-4o", displayName: "Named" }, + ]; + + const result = selectConfiguredLanguageModel(withEmptyString, { + provider: "openai", + model: "gpt-4o", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(result.error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY); + expect(result.error.message).toContain("('', 'Named')"); + } + }); + test("returns 400 when model is not configured", () => { const result = selectConfiguredLanguageModel(configuredModels, { provider: "google", diff --git a/packages/web/src/features/chat/selectConfiguredLanguageModel.ts b/packages/web/src/features/chat/selectConfiguredLanguageModel.ts index bbe96f442..2c38e73a0 100644 --- a/packages/web/src/features/chat/selectConfiguredLanguageModel.ts +++ b/packages/web/src/features/chat/selectConfiguredLanguageModel.ts @@ -70,14 +70,19 @@ export const selectConfiguredLanguageModel = ( } if (matchingModels.length > 1) { - const availableNames = matchingModels + const hasUnnamed = matchingModels.some((m) => m.displayName === undefined); + const namedConfigs = matchingModels .map((m) => m.displayName) - .filter((name): name is string => typeof name === "string" && name.length > 0) - .map((name) => `'${name}'`); + .filter((name): name is string => typeof name === "string"); - const hint = availableNames.length > 0 - ? `Please specify a displayName (${availableNames.join(', ')}) to disambiguate.` - : `Please configure distinct displayNames in your configuration to disambiguate.`; + let hint: string; + if (hasUnnamed) { + hint = namedConfigs.length > 0 + ? `Please specify a displayName from [${namedConfigs.map((n) => `'${n}'`).join(', ')}] or configure distinct displayNames in your configuration for unnamed models.` + : `Please configure distinct displayNames in your configuration to disambiguate.`; + } else { + hint = `Please specify a displayName (${namedConfigs.map((n) => `'${n}'`).join(', ')}) to disambiguate.`; + } return { success: false,