Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 6 additions & 22 deletions packages/web/src/ee/features/mcp/askCodebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -61,28 +62,11 @@ export const askCodebase = (params: AskCodebaseParams): Promise<AskCodebaseResul
const { query, repos = [], languageModel: requestedLanguageModel, visibility: requestedVisibility, source } = params;

const configuredModels = await getConfiguredLanguageModels();
if (configuredModels.length === 0) {
return {
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",
} satisfies ServiceError;
}

let languageModelConfig = configuredModels[0];
if (requestedLanguageModel) {
const matchingModel = configuredModels.find(
(m) => 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;
Expand Down
182 changes: 182 additions & 0 deletions packages/web/src/features/chat/selectConfiguredLanguageModel.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
}
});
});
100 changes: 100 additions & 0 deletions packages/web/src/features/chat/selectConfiguredLanguageModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
import { ServiceError } from "@/lib/serviceError";

export type SelectConfiguredLanguageModelResult<T> =
| { success: true; model: T }
| { success: false; error: ServiceError };

type MatchableModel = {
provider: string;
model: string;
displayName?: string;
};

export const selectConfiguredLanguageModel = <T extends MatchableModel>(
configuredModels: T[],
requestedLanguageModel?: Partial<MatchableModel>
): SelectConfiguredLanguageModelResult<T> => {
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

@cubic-dev-ai cubic-dev-ai Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When matching configurations include an unnamed or empty-name entry alongside named entries, this hint hides that entry and tells callers to specify only the listed names. Preserve empty-string names and explicitly tell callers to configure a distinct name whenever any matching configuration has no displayName.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/web/src/features/chat/selectConfiguredLanguageModel.ts, line 73:

<comment>When matching configurations include an unnamed or empty-name entry alongside named entries, this hint hides that entry and tells callers to specify only the listed names. Preserve empty-string names and explicitly tell callers to configure a distinct name whenever any matching configuration has no `displayName`.</comment>

<file context>
@@ -83,17 +70,21 @@ export const selectConfiguredLanguageModel = <T extends MatchableModel>(
-            .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)
</file context>
Fix with cubic

.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.`,
},
};
};