Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Maintained the sidebar scroll position when navigating between chats instead of resetting to the top. [#1411](https://github.com/sourcebot-dev/sourcebot/pull/1411)
- Upgraded `nodemailer` to `^9.0.1`. [#1356](https://github.com/sourcebot-dev/sourcebot/pull/1356)
- Upgraded `@opentelemetry/core` to `^2.8.0`. [#1413](https://github.com/sourcebot-dev/sourcebot/pull/1413)
- [EE] Fixed `ask_codebase` language model selection to match configured models by `provider` and `model`, only requiring `displayName` when multiple configurations share the same pair. [#1414](https://github.com/sourcebot-dev/sourcebot/pull/1414)
- [EE] Fixed connector setup dialogs to add scrolling when connector setup content goes out of view.
- Fixed Gitea sync failing with `ERR_STREAM_PREMATURE_CLOSE` by forcing identity encoding on the Gitea API fetch and guarding against null repository responses. [#1405](https://github.com/sourcebot-dev/sourcebot/pull/1405)

Expand Down
102 changes: 102 additions & 0 deletions packages/web/src/ee/features/mcp/askCodebase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { ErrorCode } from "@/lib/errorCodes";
import { StatusCodes } from "http-status-codes";
import { describe, expect, it } from "vitest";
import { selectConfiguredLanguageModel } from "./selectConfiguredLanguageModel";

type ConfiguredLanguageModel = Parameters<typeof selectConfiguredLanguageModel>[0][number];

const createConfiguredModel = (overrides: Partial<{
provider: string;
model: string;
displayName?: string;
}> = {}) => ({
provider: 'anthropic',
model: 'claude-opus-4-7',
displayName: 'Claude Opus 4.7',
...overrides,
}) as ConfiguredLanguageModel;

describe("selectConfiguredLanguageModel", () => {
it("matches a configured model by provider and model when displayName is omitted", () => {
const configuredModel = createConfiguredModel();

const result = selectConfiguredLanguageModel(
[configuredModel],
{ provider: configuredModel.provider, model: configuredModel.model }
);

expect(result).toEqual({ languageModelConfig: configuredModel });
});

it("uses displayName to disambiguate duplicate provider/model entries", () => {
const firstModel = createConfiguredModel({ displayName: 'Claude Opus 4.7 (slow)' });
const secondModel = createConfiguredModel({ displayName: 'Claude Opus 4.7 (fast)' });

const result = selectConfiguredLanguageModel(
[firstModel, secondModel],
{
provider: firstModel.provider,
model: firstModel.model,
displayName: secondModel.displayName,
}
);

expect(result).toEqual({ languageModelConfig: secondModel });
});

it("returns an error when no configured model matches provider and model", () => {
const configuredModel = createConfiguredModel();

const result = selectConfiguredLanguageModel(
[configuredModel],
{ provider: 'openai', model: 'gpt-5' }
);

expect(result).toEqual({
error: {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "Language model 'openai/gpt-5' is not configured.",
},
});
});

it("returns a disambiguation error when multiple configured models match", () => {
const firstModel = createConfiguredModel({ displayName: 'Claude Opus 4.7 (slow)' });
const secondModel = createConfiguredModel({ displayName: 'Claude Opus 4.7 (fast)' });

const result = selectConfiguredLanguageModel(
[firstModel, secondModel],
{ provider: firstModel.provider, model: firstModel.model }
);

expect(result).toEqual({
error: {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "Language model 'anthropic/claude-opus-4-7' matches multiple configured models. Pass displayName to disambiguate. Available matches: 'Claude Opus 4.7 (slow)', 'Claude Opus 4.7 (fast)'.",
},
});
});

it("returns an error when displayName does not match any configured model", () => {
const configuredModel = createConfiguredModel();

const result = selectConfiguredLanguageModel(
[configuredModel],
{
provider: configuredModel.provider,
model: configuredModel.model,
displayName: 'Claude Sonnet 4.6',
}
);

expect(result).toEqual({
error: {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "Language model 'anthropic/claude-opus-4-7' is configured, but not with displayName 'Claude Sonnet 4.6'. Available matches: 'Claude Opus 4.7'.",
},
});
});
});
18 changes: 8 additions & 10 deletions packages/web/src/ee/features/mcp/askCodebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { resolveModelCapabilities } from "@/features/chat/modelCapabilities.server";
import { ErrorCode } from "@/lib/errorCodes";
import { ServiceError, ServiceErrorException } from "@/lib/serviceError";
Expand All @@ -18,6 +18,7 @@ import { captureEvent } from "@/lib/posthog";
import { createAudit } from "@/ee/features/audit/audit";
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { selectConfiguredLanguageModel } from "@/ee/features/mcp/selectConfiguredLanguageModel";

const logger = createLogger('ask-codebase-api');

Expand Down Expand Up @@ -71,17 +72,14 @@ export const askCodebase = (params: AskCodebaseParams): Promise<AskCodebaseResul

let languageModelConfig = configuredModels[0];
if (requestedLanguageModel) {
const matchingModel = configuredModels.find(
(m) => getLanguageModelKey(m) === getLanguageModelKey(requestedLanguageModel)
const result = selectConfiguredLanguageModel(
configuredModels,
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;
if (result.error) {
return result.error;
}
languageModelConfig = matchingModel;
languageModelConfig = result.languageModelConfig;
}

const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);
Expand Down
62 changes: 62 additions & 0 deletions packages/web/src/ee/features/mcp/selectConfiguredLanguageModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { LanguageModelInfo } from "@/features/chat/types";
import { ErrorCode } from "@/lib/errorCodes";
import { ServiceError } from "@/lib/serviceError";
import { StatusCodes } from "http-status-codes";

const formatLanguageModelName = (model: Pick<LanguageModelInfo, 'provider' | 'model'>) =>
`${model.provider}/${model.model}`;

const formatConfiguredLanguageModelLabel = (model: Pick<LanguageModelInfo, 'provider' | 'model' | 'displayName'>) =>
model.displayName ? `'${model.displayName}'` : formatLanguageModelName(model);

export const selectConfiguredLanguageModel = <T extends Pick<LanguageModelInfo, 'provider' | 'model' | 'displayName'>>(
configuredModels: T[],
requestedLanguageModel: Pick<LanguageModelInfo, 'provider' | 'model' | 'displayName'>
): (
| { languageModelConfig: T; error?: never }
| { languageModelConfig?: never; error: ServiceError }
) => {
const candidateModels = configuredModels.filter(
(model) => model.provider === requestedLanguageModel.provider && model.model === requestedLanguageModel.model
);
if (candidateModels.length === 0) {
return {
error: {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model '${formatLanguageModelName(requestedLanguageModel)}' is not configured.`,
} satisfies ServiceError,
};
}

if (requestedLanguageModel.displayName) {
const matchingModel = candidateModels.find(
(model) => model.displayName === requestedLanguageModel.displayName
);
if (!matchingModel) {
const availableDisplayNames = candidateModels.map(formatConfiguredLanguageModelLabel).join(', ');
return {
error: {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model '${formatLanguageModelName(requestedLanguageModel)}' is configured, but not with displayName '${requestedLanguageModel.displayName}'. Available matches: ${availableDisplayNames}.`,
} satisfies ServiceError,
};
}

return { languageModelConfig: matchingModel };
}

if (candidateModels.length > 1) {
const availableDisplayNames = candidateModels.map(formatConfiguredLanguageModelLabel).join(', ');
return {
error: {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model '${formatLanguageModelName(requestedLanguageModel)}' matches multiple configured models. Pass displayName to disambiguate. Available matches: ${availableDisplayNames}.`,
} satisfies ServiceError,
};
}

return { languageModelConfig: candidateModels[0] };
};