Skip to content
Closed
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
15 changes: 15 additions & 0 deletions src/cli/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,21 @@ async function handleCustomRemove(args: string[]): Promise<void> {
// Deliberately admit the whole matched set rather than narrowing to `exact`: an encoded
// selector that spans a real collision must still abort below. Removal stays exact-or-refuse.
const admitted = new Set(rosterMatched?.matched ?? []);
// A complete `<provider>/<modelId>` target can also be the literal native id of a
// self-namespaced row. The shared resolver intentionally prefers that literal reading, but
// removal is destructive: when the provider also has the qualified sibling, refuse both
// interpretations and require the row's UUID instead of silently deleting either one.
const qualifiedModelId = selectedProvider === undefined
? undefined
: target.slice(selectedProvider.length + 1);
if (
qualifiedModelId
&& target !== qualifiedModelId
&& admitted.has(target)
&& existing.some(model => model.provider === selectedProvider && model.modelId === qualifiedModelId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve the full qualified collision class

When the self-namespaced ID contains another slash, this exact equality check misses an encoded qualified sibling. For example, the valid custom rows { provider: "acme", modelId: "acme/a/b" } and { provider: "acme", modelId: "a-b" } make models remove acme/a/b name the first row literally while its provider-qualified reading a/b is equivalent to the second row under resolveSlugSelection; because qualifiedModelId is a/b, this predicate finds no sibling and the command silently deletes the literal row instead of refusing the ambiguous destructive operation. Resolve the qualified reading through the shared resolver and admit its entire matched set rather than checking only exact modelId equality.

Useful? React with 👍 / 👎.

) {
admitted.add(qualifiedModelId);
}
const matchingIndexes = existing.flatMap((model, index) => {
if (selectedProvider === undefined) return model.id === target ? [index] : [];
if (model.provider !== selectedProvider) return [];
Expand Down
18 changes: 5 additions & 13 deletions tests/cli-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,14 +517,7 @@ describe("#2491 the removal selector uses the shared equivalence relation", () =
}
});

/**
* A provider may publish a native id that is itself namespaced under its own name, so
* `acme` owning `acme/turbo` makes the selector `acme/turbo` name that row exactly while
* ALSO reading as the provider-qualified form of a sibling `turbo`. The resolver was called
* once per row with a singleton roster, so each row matched its own reading, the command saw
* two matches and aborted — the exact native spelling could never remove its own row.
*/
test("a self-namespaced selector removes the row it names exactly", () => {
test("a self-namespaced selector refuses a provider-qualified sibling collision", () => {
const { dir } = freshConfig({
customModels: [
{ id: "11111111-1111-4111-8111-111111111111", provider: "acme", modelId: "acme/turbo" },
Expand All @@ -533,12 +526,11 @@ describe("#2491 the removal selector uses the shared equivalence relation", () =
});
try {
const result = runCli(["models", "remove", "acme/turbo", "--yes"], { OPENCODEX_HOME: dir });
expect(result.status).toBe(0);
expect(result.status).toBe(1);
expect(result.stderr).toContain("ambiguous");
expect(result.stderr).toContain("custom model id");
const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
// The sibling survives: the selector named the native row, not the qualified reading.
expect(config.customModels).toEqual([
expect.objectContaining({ provider: "acme", modelId: "turbo" }),
]);
expect(config.customModels).toHaveLength(2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
Expand Down
Loading