Skip to content
Merged
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
62 changes: 62 additions & 0 deletions tests/web-model-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,68 @@ test("non-glob patterns fall back to partial id and name containment", () => {
).toBe(true);
});

test("character classes match one member, like minimatch", () => {
expect(
modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.2" }),
).toBe(true);
expect(
modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.3" }),
).toBe(true);
expect(
modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.4" }),
).toBe(false);
expect(
modelMatchesScopePattern("zai/glm-5.[2-3]", "zai", { id: "glm-5.3" }),
).toBe(true);
expect(
modelMatchesScopePattern("zai/glm-5.[2-3]", "zai", { id: "glm-5.4" }),
).toBe(false);
});

test("negated character classes exclude members and the path separator", () => {
expect(
modelMatchesScopePattern("zai/glm-5.[!23]", "zai", { id: "glm-5.4" }),
).toBe(true);
expect(
modelMatchesScopePattern("zai/glm-5.[!23]", "zai", { id: "glm-5.2" }),
).toBe(false);
expect(
modelMatchesScopePattern("zai/glm-5.[^23]", "zai", { id: "glm-5.x" }),
).toBe(true);
expect(
modelMatchesScopePattern("zai/glm-5.[^23]", "zai", { id: "glm-5.2" }),
).toBe(false);
// A negated class never matches "/", so it cannot span the provider boundary.
expect(modelMatchesScopePattern("zai[!z]*", "zai", { id: "glm-5.4" })).toBe(
false,
);
// A class whose only member is "/" can never match anything.
expect(modelMatchesScopePattern("zai[/]*", "zai", { id: "glm-5.4" })).toBe(
false,
);
});

test("unterminated character classes fall back to a literal bracket", () => {
expect(
modelMatchesScopePattern("zai/glm-5.[2", "zai", { id: "glm-5.2" }),
).toBe(false);
expect(
modelMatchesScopePattern("zai/glm-5.[2", "zai", { id: "glm-5.[2" }),
).toBe(true);
});

test("a leading bracket inside a class is a literal member", () => {
expect(
modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.]" }),
).toBe(true);
expect(
modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.2" }),
).toBe(true);
expect(
modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.4" }),
).toBe(false);
});

test("readEnabledModelPatterns reads enabledModels from a settings file", async () => {
directory = await mkdtemp(join(tmpdir(), "pi-model-scope-"));
const settingsPath = join(directory, "settings.json");
Expand Down
69 changes: 67 additions & 2 deletions web/server/modelScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ export async function readEnabledModelPatterns(
/**
* Convert a glob to a RegExp with minimatch's default semantics for the
* characters we support: `*` and `?` never cross `/`, and `[...]` character
* classes pass through. Everything else is literal.
* classes (sets, ranges, `!`/`^` negation, a leading `]` as a literal member,
* `\`-escaped members, and unterminated `[` treated as a literal) translate
* directly. Everything else is literal.
*/
function globToRegExp(pattern: string): RegExp {
let source = "";
Expand All @@ -56,11 +58,74 @@ function globToRegExp(pattern: string): RegExp {
if (char === "*") source += "[^/]*";
else if (char === "?") source += "[^/]";
else if (char === "\\") source += "\\\\";
else source += char.replace(/[.+^${}()|[\]]/g, "\\$&");
else if (char === "[") {
const charClass = parseCharClass(pattern, index);
if (charClass) {
source += charClass.source;
index = charClass.nextIndex - 1;
} else {
// Unterminated class: minimatch treats the `[` as a literal.
source += "\\[";
}
} else source += char.replace(/[.+^${}()|[\]]/g, "\\$&");
}
return new RegExp(`^${source}$`, "i");
}

/** Escape a character so it is a literal member of a RegExp character class. */
function escapeClassMember(char: string): string {
return char === "\\" || char === "]" || char === "^" ? `\\${char}` : char;
}

/**
* Translate the `[...]` character class starting at `start` into a RegExp
* class source with minimatch's semantics, returning the source and the index
* just past the closing `]`. `undefined` when the class is unterminated.
*/
function parseCharClass(
pattern: string,
start: number,
): { source: string; nextIndex: number } | undefined {
let index = start + 1;
let negate = false;
if (pattern[index] === "!" || pattern[index] === "^") {
negate = true;
index += 1;
}
const members: string[] = [];
// A `]` immediately after `[` (or the negation) is a literal member.
if (pattern[index] === "]") {
members.push("\\]");
index += 1;
}
let closed = false;
while (index < pattern.length) {
const char = pattern[index];
if (char === "\\") {
const next = pattern[index + 1];
if (next === undefined) return undefined;
members.push(escapeClassMember(next));
index += 2;
continue;
}
if (char === "]") {
closed = true;
index += 1;
break;
}
members.push(escapeClassMember(char));
index += 1;
}
if (!closed) return undefined;
// Minimatch classes never match the path separator: a `/` member can never
// match (dropped), and a negated class excludes `/` alongside its members.
const literalMembers = members.filter((member) => member !== "/");
return {
source: `[${negate ? "^/" : ""}${literalMembers.join("")}]`,
nextIndex: index,
};
}

function hasGlobCharacter(pattern: string): boolean {
return /[*?[]/.test(pattern);
}
Expand Down