diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c363645..8f461e1f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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..cde15754c --- /dev/null +++ b/packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts @@ -0,0 +1,182 @@ +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)'"); + 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"); + } + }); + + 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."); + } + }); + + 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 new file mode 100644 index 000000000..bbe96f442 --- /dev/null +++ b/packages/web/src/features/chat/selectConfiguredLanguageModel.ts @@ -0,0 +1,100 @@ +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; +}; + +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 !== undefined) { + 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.`, + }, + }; + } + + 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 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}'. ${hint}`, + }, + }; + } + + return { + success: false, + error: { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `Language model '${provider}/${model}' is not configured.`, + }, + }; +};