fix(ui): fall back to /models for the Playground model dropdown

The Playground (Chat & Compare) populates its model dropdown from
/model_group/info, which returns an empty list for non-admin UI sessions,
so internal_user saw "No models available" even though /v1/models and the
Models+Endpoints page list everything their key can call.

When /model_group/info comes back empty or fails, fall back to /models —
the canonical user-facing list — so the dropdown always mirrors what the
selected credentials can actually call. Capability fields (reasoning
efforts) are still preferred from /model_group/info when it has data.
This commit is contained in:
pengzh1 2026-08-28 01:20:48 +08:00
parent 166694948f
commit a86cf0f619
2 changed files with 59 additions and 10 deletions

View file

@ -50,14 +50,46 @@ describe("fetchAvailableModels", () => {
{ model_group: "plain", mode: "chat" },
{ model_group: "smart", mode: "chat", supports_reasoning: true, supported_reasoning_efforts: ["low", "high"] },
]);
expect(modelAvailableCallMock).not.toHaveBeenCalled();
});
it.each([
["an error payload in place of the list", { data: { error: "no access" } }],
["a missing data key", {}],
["no body at all", undefined],
])("returns an empty list on %s rather than throwing", async (_label, response) => {
modelHubCallMock.mockResolvedValue(response);
])("falls back to /models on %s so non-admin sessions still get a dropdown", async (_label, hubResponse) => {
modelHubCallMock.mockResolvedValue(hubResponse);
modelAvailableCallMock.mockResolvedValue({
data: [
{ id: "llama", mode: "chat" },
{ id: "gpt-4o", mode: "chat" },
],
});
expect(await fetchAvailableModels("token")).toEqual([
{ model_group: "gpt-4o", mode: "chat" },
{ model_group: "llama", mode: "chat" },
]);
expect(modelAvailableCallMock).toHaveBeenCalledWith("token", "", "");
});
it("falls back to /models when the model hub call fails", async () => {
modelHubCallMock.mockRejectedValue(new Error("network down"));
modelAvailableCallMock.mockResolvedValue({ data: [{ id: "gpt-4o", mode: "chat" }] });
expect(await fetchAvailableModels("token")).toEqual([{ model_group: "gpt-4o", mode: "chat" }]);
});
it("deduplicates the fallback list by model group", async () => {
modelHubCallMock.mockResolvedValue({ data: [] });
modelAvailableCallMock.mockResolvedValue({ data: [{ id: "gpt-4o" }, { id: "gpt-4o" }] });
expect(await fetchAvailableModels("token")).toEqual([{ model_group: "gpt-4o" }]);
});
it("returns an empty list when both routes come back empty", async () => {
modelHubCallMock.mockResolvedValue({ data: [] });
modelAvailableCallMock.mockResolvedValue({ data: [] });
expect(await fetchAvailableModels("token")).toEqual([]);
});

View file

@ -29,6 +29,15 @@ const toModelGroup = (item: AvailableModel): ModelGroup => {
};
};
const toModelGroups = (fetchedData: unknown): ModelGroup[] =>
(Array.isArray(fetchedData) ? fetchedData : [])
.map(toModelGroup)
.filter((model: ModelGroup) => model.model_group !== "")
.sort((a: ModelGroup, b: ModelGroup) => a.model_group.localeCompare(b.model_group));
const dedupeByGroup = (models: ModelGroup[]): ModelGroup[] =>
Array.from(new Map(models.map((model) => [model.model_group, model])).values());
export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: string): Promise<ModelGroup[]> => {
const response = await modelAvailableCall(accessToken, "", "", false, teamId);
const modelNames: string[] = (response?.data ?? []).map((model: { id: string }) => model.id);
@ -39,19 +48,27 @@ export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: s
};
/**
* Fetches available models using modelHubCall and formats them for the selection dropdown.
* Fetches the models the given credentials can call, for the selection dropdown.
*
* Primary source is /model_group/info (modelHubCall), which carries capability
* fields like supported_reasoning_efforts. That route returns an empty list for
* non-admin UI sessions (#38534), so when it comes back empty or fails we fall
* back to /models the canonical list (same route as GET /v1/models) that
* works for both UI session tokens and virtual keys.
*/
export const fetchAvailableModels = async (accessToken: string): Promise<ModelGroup[]> => {
let models: ModelGroup[] = [];
try {
const fetchedModels = await modelHubCall(accessToken);
const fetchedData: unknown = fetchedModels?.data;
const models: ModelGroup[] = (Array.isArray(fetchedData) ? fetchedData : [])
.map(toModelGroup)
.filter((model: ModelGroup) => model.model_group !== "")
.sort((a: ModelGroup, b: ModelGroup) => a.model_group.localeCompare(b.model_group));
return Array.from(new Map(models.map((model) => [model.model_group, model])).values());
models = toModelGroups(fetchedModels?.data);
} catch (error) {
console.error("Error fetching model info:", error);
throw error;
}
if (models.length > 0) {
return dedupeByGroup(models);
}
const response = await modelAvailableCall(accessToken, "", "");
return dedupeByGroup(toModelGroups(response?.data));
};