openclaw/extensions/telegram/src/model-buttons.test.ts

415 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
buildModelSelectionCallbackData,
buildModelsKeyboard,
buildBrowseProvidersButton,
buildProviderKeyboard,
calculateTotalPages,
getModelsPageSize,
parseModelCallbackData,
resolveModelSelection,
type ProviderInfo,
} from "./model-buttons.js";
describe("parseModelCallbackData", () => {
it("parses supported callback variants", () => {
const cases = [
["mdl_prov", { type: "providers" }],
["mdl_back", { type: "back" }],
["mdl_list_anthropic_2", { type: "list", provider: "anthropic", page: 2 }],
["mdl_list_open-ai_1", { type: "list", provider: "open-ai", page: 1 }],
[
"mdl_sel_anthropic/claude-sonnet-4-5",
{ type: "select", provider: "anthropic", model: "claude-sonnet-4-5" },
],
["mdl_sel_openai/gpt-4/turbo", { type: "select", provider: "openai", model: "gpt-4/turbo" }],
[
"mdl_sel/us.anthropic.claude-3-5-sonnet-20240620-v1:0",
{ type: "select", model: "us.anthropic.claude-3-5-sonnet-20240620-v1:0" },
],
[
"mdl_sel/anthropic/claude-3-7-sonnet",
{ type: "select", model: "anthropic/claude-3-7-sonnet" },
],
[" mdl_prov ", { type: "providers" }],
] as const;
for (const [input, expected] of cases) {
expect(parseModelCallbackData(input), input).toEqual(expected);
}
});
it("returns null for unsupported callback variants", () => {
const invalid = [
"commands_page_1",
"other_callback",
"",
"mdl_invalid",
"mdl_list_",
"mdl_sel_noslash",
"mdl_sel/",
];
for (const input of invalid) {
expect(parseModelCallbackData(input), input).toBeNull();
}
});
});
describe("resolveModelSelection", () => {
it("returns explicit provider selections unchanged", () => {
const result = resolveModelSelection({
callback: { type: "select", provider: "openai", model: "gpt-4.1" },
providers: ["openai", "anthropic"],
byProvider: new Map([
["openai", new Set(["gpt-4.1"])],
["anthropic", new Set(["claude-sonnet-4-5"])],
]),
});
expect(result).toEqual({ kind: "resolved", provider: "openai", model: "gpt-4.1" });
});
it("resolves compact callbacks when exactly one provider matches", () => {
const result = resolveModelSelection({
callback: { type: "select", model: "shared" },
providers: ["openai", "anthropic"],
byProvider: new Map([
["openai", new Set(["shared"])],
["anthropic", new Set(["other"])],
]),
});
expect(result).toEqual({ kind: "resolved", provider: "openai", model: "shared" });
});
it("returns ambiguous result when zero or multiple providers match", () => {
const sharedByBoth = resolveModelSelection({
callback: { type: "select", model: "shared" },
providers: ["openai", "anthropic"],
byProvider: new Map([
["openai", new Set(["shared"])],
["anthropic", new Set(["shared"])],
]),
});
expect(sharedByBoth).toEqual({
kind: "ambiguous",
model: "shared",
matchingProviders: ["openai", "anthropic"],
});
const missingEverywhere = resolveModelSelection({
callback: { type: "select", model: "missing" },
providers: ["openai", "anthropic"],
byProvider: new Map([
["openai", new Set(["gpt-4.1"])],
["anthropic", new Set(["claude-sonnet-4-5"])],
]),
});
expect(missingEverywhere).toEqual({
kind: "ambiguous",
model: "missing",
matchingProviders: [],
});
});
});
describe("buildModelSelectionCallbackData", () => {
it("uses standard callback when under limit and compact callback when needed", () => {
expect(buildModelSelectionCallbackData({ provider: "openai", model: "gpt-4.1" })).toBe(
"mdl_sel_openai/gpt-4.1",
);
const longModel = "us.anthropic.claude-3-5-sonnet-20240620-v1:0";
expect(buildModelSelectionCallbackData({ provider: "amazon-bedrock", model: longModel })).toBe(
`mdl_sel/${longModel}`,
);
});
it("returns null when even compact callback exceeds Telegram limit", () => {
const tooLongModel = "x".repeat(80);
expect(buildModelSelectionCallbackData({ provider: "openai", model: tooLongModel })).toBeNull();
});
});
describe("buildProviderKeyboard", () => {
it("lays out providers in two-column rows", () => {
const cases = [
{
name: "empty input",
input: [],
expected: [],
},
{
name: "single provider",
input: [{ id: "anthropic", count: 5 }],
expected: [[{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" }]],
},
{
name: "exactly one full row",
input: [
{ id: "anthropic", count: 5 },
{ id: "openai", count: 8 },
],
expected: [
[
{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" },
{ text: "openai (8)", callback_data: "mdl_list_openai_1" },
],
],
},
{
name: "wraps overflow to second row",
input: [
{ id: "anthropic", count: 5 },
{ id: "openai", count: 8 },
{ id: "google", count: 3 },
],
expected: [
[
{ text: "anthropic (5)", callback_data: "mdl_list_anthropic_1" },
{ text: "openai (8)", callback_data: "mdl_list_openai_1" },
],
[{ text: "google (3)", callback_data: "mdl_list_google_1" }],
],
},
] as const satisfies Array<{
name: string;
input: ProviderInfo[];
expected: ReturnType<typeof buildProviderKeyboard>;
}>;
for (const testCase of cases) {
expect(buildProviderKeyboard(testCase.input), testCase.name).toEqual(testCase.expected);
}
});
});
describe("buildModelsKeyboard", () => {
it("shows back button for empty models", () => {
const result = buildModelsKeyboard({
provider: "anthropic",
models: [],
currentPage: 1,
totalPages: 1,
});
expect(result).toHaveLength(1);
expect(result[0]?.[0]?.text).toBe("<< Back");
expect(result[0]?.[0]?.callback_data).toBe("mdl_back");
});
it("renders model rows and optional current-model indicator", () => {
const cases = [
{
name: "no current model",
currentModel: undefined,
firstText: "claude-sonnet-4",
},
{
name: "current model marked",
currentModel: "anthropic/claude-sonnet-4",
firstText: "claude-sonnet-4 ✓",
},
] as const;
for (const testCase of cases) {
const result = buildModelsKeyboard({
provider: "anthropic",
models: ["claude-sonnet-4", "claude-opus-4"],
currentModel: testCase.currentModel,
currentPage: 1,
totalPages: 1,
});
// 2 model rows + back button
expect(result, testCase.name).toHaveLength(3);
expect(result[0]?.[0]?.text).toBe(testCase.firstText);
expect(result[0]?.[0]?.callback_data).toBe("mdl_sel_anthropic/claude-sonnet-4");
expect(result[1]?.[0]?.text).toBe("claude-opus-4");
expect(result[2]?.[0]?.text).toBe("<< Back");
}
});
it("renders pagination controls for first, middle, and last pages", () => {
const cases = [
{
name: "first page",
params: { currentPage: 1, models: ["model1", "model2"] },
expectedPagination: ["1/3", "Next ▶"],
},
{
name: "middle page",
params: {
currentPage: 2,
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
},
expectedPagination: ["◀ Prev", "2/3", "Next ▶"],
},
{
name: "last page",
params: {
currentPage: 3,
models: ["model1", "model2", "model3", "model4", "model5", "model6"],
},
expectedPagination: ["◀ Prev", "3/3"],
},
] as const;
for (const testCase of cases) {
const result = buildModelsKeyboard({
provider: "anthropic",
models: [...testCase.params.models],
currentPage: testCase.params.currentPage,
totalPages: 3,
pageSize: 2,
});
// 2 model rows + pagination row + back button
expect(result, testCase.name).toHaveLength(4);
expect(result[2]?.map((button) => button.text)).toEqual(testCase.expectedPagination);
}
});
it("keeps short display IDs untouched and truncates overly long IDs", () => {
const cases = [
{
name: "max-length display",
provider: "anthropic",
model: "claude-3-5-sonnet-20241022-with-suffix",
expected: "claude-3-5-sonnet-20241022-with-suffix",
},
{
name: "overly long display",
provider: "a",
model: "this-model-name-is-long-enough-to-need-truncation-abcd",
startsWith: "…",
maxLength: 38,
},
] as const;
for (const testCase of cases) {
const result = buildModelsKeyboard({
provider: testCase.provider,
models: [testCase.model],
currentPage: 1,
totalPages: 1,
});
const text = result[0]?.[0]?.text;
if ("expected" in testCase) {
expect(text, testCase.name).toBe(testCase.expected);
} else {
expect(text?.startsWith(testCase.startsWith), testCase.name).toBe(true);
expect(text?.length, testCase.name).toBeLessThanOrEqual(testCase.maxLength);
}
}
});
it("uses compact selection callback when provider/model callback exceeds 64 bytes", () => {
const model = "us.anthropic.claude-3-5-sonnet-20240620-v1:0";
const result = buildModelsKeyboard({
provider: "amazon-bedrock",
models: [model],
currentPage: 1,
totalPages: 1,
});
expect(result[0]?.[0]?.callback_data).toBe(`mdl_sel/${model}`);
});
});
describe("buildBrowseProvidersButton", () => {
it("returns browse providers button", () => {
const result = buildBrowseProvidersButton();
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0]?.[0]?.text).toBe("Browse providers");
expect(result[0]?.[0]?.callback_data).toBe("mdl_prov");
});
});
describe("getModelsPageSize", () => {
it("returns default page size", () => {
expect(getModelsPageSize()).toBe(8);
});
});
describe("calculateTotalPages", () => {
it("calculates pages correctly", () => {
expect(calculateTotalPages(0)).toBe(0);
expect(calculateTotalPages(1)).toBe(1);
expect(calculateTotalPages(8)).toBe(1);
expect(calculateTotalPages(9)).toBe(2);
expect(calculateTotalPages(16)).toBe(2);
expect(calculateTotalPages(17)).toBe(3);
});
it("uses custom page size", () => {
expect(calculateTotalPages(10, 5)).toBe(2);
expect(calculateTotalPages(11, 5)).toBe(3);
});
});
describe("large model lists (OpenRouter-scale)", () => {
it("handles 100+ models with pagination", () => {
const models = Array.from({ length: 150 }, (_, i) => `model-${i}`);
const totalPages = calculateTotalPages(models.length);
expect(totalPages).toBe(19); // 150 / 8 = 18.75 -> 19 pages
// Test first page
const firstPage = buildModelsKeyboard({
provider: "openrouter",
models,
currentPage: 1,
totalPages,
});
expect(firstPage.length).toBe(10); // 8 models + pagination + back
expect(firstPage[0]?.[0]?.text).toBe("model-0");
expect(firstPage[7]?.[0]?.text).toBe("model-7");
// Test last page
const lastPage = buildModelsKeyboard({
provider: "openrouter",
models,
currentPage: 19,
totalPages,
});
// Last page has 150 - (18 * 8) = 6 models
expect(lastPage.length).toBe(8); // 6 models + pagination + back
expect(lastPage[0]?.[0]?.text).toBe("model-144");
});
it("all callback_data stays within 64-byte limit", () => {
// Realistic OpenRouter model IDs
const models = [
"anthropic/claude-3-5-sonnet-20241022",
"google/gemini-2.0-flash-thinking-exp:free",
"deepseek/deepseek-r1-distill-llama-70b",
"meta-llama/llama-3.3-70b-instruct:nitro",
"nousresearch/hermes-3-llama-3.1-405b:extended",
];
const result = buildModelsKeyboard({
provider: "openrouter",
models,
currentPage: 1,
totalPages: 1,
});
for (const row of result) {
for (const button of row) {
const bytes = Buffer.byteLength(button.callback_data, "utf8");
expect(bytes).toBeLessThanOrEqual(64);
}
}
});
it("skips models that would exceed callback_data limit", () => {
const models = [
"short-model",
"this-is-an-extremely-long-model-name-that-definitely-exceeds-the-sixty-four-byte-limit",
"another-short",
];
const result = buildModelsKeyboard({
provider: "openrouter",
models,
currentPage: 1,
totalPages: 1,
});
// Should have 2 model buttons (skipping the long one) + back
const modelButtons = result.filter((row) => !row[0]?.callback_data.startsWith("mdl_back"));
expect(modelButtons.length).toBe(2);
expect(modelButtons[0]?.[0]?.text).toBe("short-model");
expect(modelButtons[1]?.[0]?.text).toBe("another-short");
});
});