Skip to content

Commit dbc8a77

Browse files
authored
feat: expose SDK model registry exports (#134)
Expose SDK-owned model registry metadata so consumers can rely on canonical model names, alias normalization, and filtered model listings instead of duplicating model literals. This keeps existing SDK model factories working while adding stable helpers for registry consumers and preserving `*-latest` as server-resolved moving targets. ```ts import { isCanonicalModel, listModels, resolveCanonicalModelAlias } from "@decartai/sdk"; const canonical = resolveCanonicalModelAlias("lucy-pro-v2v"); // "lucy-clip" const videoModels = listModels({ kind: "video", canonicalOnly: true }); const isCanonical = canonical !== undefined && isCanonicalModel(canonical); ``` Validated with unit tests, typecheck, and build. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Expands the SDK’s public surface area around model identifiers and adds new alias/listing logic; low runtime risk but moderate compatibility risk for consumers depending on prior exports/types. > > **Overview** > Adds **public model-registry exports** from the package root (`CanonicalModel`, `modelAliases`, `isModel`/`isCanonicalModel`, `resolveModelAlias`/`resolveCanonicalModelAlias`, and `listModels`) so consumers can normalize model inputs and enumerate available models by `kind`. > > Introduces explicit **canonical model name schemas** (excluding `*-latest` and deprecated aliases) while keeping existing model factories and broader model validation accepting legacy/latest names; unit tests are expanded to cover the new helpers, listing behavior, and to assert raw zod schemas are not exported from the root. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a3973ff. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent eee4878 commit dbc8a77

3 files changed

Lines changed: 274 additions & 5 deletions

File tree

packages/sdk/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,25 @@ export type {
4747
export type { ConnectionState } from "./realtime/types";
4848
export type { WebRTCStats } from "./realtime/webrtc-stats";
4949
export {
50+
type CanonicalModel,
5051
type CustomModelDefinition,
5152
type ImageModelDefinition,
5253
type ImageModels,
54+
isCanonicalModel,
5355
isImageModel,
56+
isModel,
5457
isRealtimeModel,
5558
isVideoModel,
59+
type ListedModelDefinition,
60+
listModels,
5661
type Model,
5762
type ModelDefinition,
63+
type ModelKind,
64+
modelAliases,
5865
models,
5966
type RealTimeModels,
67+
resolveCanonicalModelAlias,
68+
resolveModelAlias,
6069
type VideoModelDefinition,
6170
type VideoModels,
6271
} from "./shared/model";

packages/sdk/src/shared/model.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,43 @@
11
import { z } from "zod";
22
import { createModelNotFoundError } from "../utils/errors";
33

4+
const CANONICAL_MODEL_NAMES = [
5+
"lucy-2.1",
6+
"lucy-2.1-vton",
7+
"lucy-vton-2",
8+
"lucy-restyle-2",
9+
"lucy-clip",
10+
"lucy-image-2",
11+
] as const;
12+
13+
const CANONICAL_REALTIME_MODEL_NAMES = ["lucy-2.1", "lucy-2.1-vton", "lucy-vton-2", "lucy-restyle-2"] as const;
14+
const CANONICAL_VIDEO_MODEL_NAMES = [
15+
"lucy-clip",
16+
"lucy-2.1",
17+
"lucy-2.1-vton",
18+
"lucy-vton-2",
19+
"lucy-restyle-2",
20+
] as const;
21+
const CANONICAL_IMAGE_MODEL_NAMES = ["lucy-image-2"] as const;
22+
23+
export const canonicalRealtimeModels = z.enum(CANONICAL_REALTIME_MODEL_NAMES);
24+
export const canonicalVideoModels = z.enum(CANONICAL_VIDEO_MODEL_NAMES);
25+
export const canonicalImageModels = z.enum(CANONICAL_IMAGE_MODEL_NAMES);
26+
export const canonicalModelSchema = z.enum(CANONICAL_MODEL_NAMES);
27+
export type CanonicalModel = z.infer<typeof canonicalModelSchema>;
28+
429
/**
530
* Map of deprecated model names to their canonical replacements.
631
* Old names still work but will log a deprecation warning.
732
*/
8-
const MODEL_ALIASES: Record<string, string> = {
33+
export const modelAliases = {
934
mirage_v2: "lucy-restyle-2",
1035
"lucy-vton": "lucy-2.1-vton",
1136
"lucy-2.1-vton-2": "lucy-vton-2",
1237
"lucy-pro-v2v": "lucy-clip",
1338
"lucy-restyle-v2v": "lucy-restyle-2",
1439
"lucy-pro-i2i": "lucy-image-2",
15-
};
40+
} as const satisfies Record<string, CanonicalModel>;
1641

1742
const _warnedAliases = new Set<string>();
1843

@@ -22,7 +47,7 @@ export function _resetDeprecationWarnings(): void {
2247
}
2348

2449
function warnDeprecated(model: string): void {
25-
const canonical = MODEL_ALIASES[model];
50+
const canonical = modelAliases[model as keyof typeof modelAliases];
2651
if (canonical && !_warnedAliases.has(model)) {
2752
_warnedAliases.add(model);
2853
console.warn(
@@ -92,6 +117,44 @@ export function isImageModel(model: string): model is ImageModels {
92117
return imageModels.safeParse(model).success;
93118
}
94119

120+
export function isModel(model: string): model is Model {
121+
return modelSchema.safeParse(model).success;
122+
}
123+
124+
export function isCanonicalModel(model: string): model is CanonicalModel {
125+
return canonicalModelSchema.safeParse(model).success;
126+
}
127+
128+
/**
129+
* Resolve deprecated aliases to canonical model names and pass accepted model names through unchanged.
130+
* Latest aliases pass through unchanged because they are server-side moving targets. This is a pure normalization helper
131+
* and does not emit deprecation warnings.
132+
*/
133+
export function resolveModelAlias(model: string): Model | undefined {
134+
const canonical = modelAliases[model as keyof typeof modelAliases];
135+
if (canonical) {
136+
return canonical;
137+
}
138+
139+
const parsedModel = modelSchema.safeParse(model);
140+
return parsedModel.success ? parsedModel.data : undefined;
141+
}
142+
143+
/**
144+
* Resolve deprecated aliases and canonical inputs to stable canonical model names.
145+
* Latest aliases are server-side moving targets, so they intentionally return undefined. This is a pure normalization
146+
* helper and does not emit deprecation warnings.
147+
*/
148+
export function resolveCanonicalModelAlias(model: string): CanonicalModel | undefined {
149+
const canonical = modelAliases[model as keyof typeof modelAliases];
150+
if (canonical) {
151+
return canonical;
152+
}
153+
154+
const parsedModel = canonicalModelSchema.safeParse(model);
155+
return parsedModel.success ? parsedModel.data : undefined;
156+
}
157+
95158
const fileInputSchema = z.union([
96159
z.instanceof(File),
97160
z.instanceof(Blob),
@@ -497,6 +560,34 @@ const _models = {
497560
},
498561
} as const;
499562

563+
export type ModelKind = "realtime" | "video" | "image";
564+
export type ListedModelDefinition = ModelDefinition & { kind: ModelKind };
565+
566+
const modelKinds = ["realtime", "video", "image"] as const satisfies readonly ModelKind[];
567+
const canonicalSchemasByKind = {
568+
realtime: canonicalRealtimeModels,
569+
video: canonicalVideoModels,
570+
image: canonicalImageModels,
571+
} as const;
572+
573+
/**
574+
* List SDK model definitions by kind.
575+
* When canonicalOnly is true, deprecated and latest aliases are excluded per kind. Models available in multiple kinds
576+
* are returned once per kind with the same name and different kind values.
577+
*/
578+
export function listModels(options: { kind?: ModelKind; canonicalOnly?: boolean } = {}): ListedModelDefinition[] {
579+
const kinds = options.kind ? [options.kind] : modelKinds;
580+
581+
return kinds.flatMap((kind) => {
582+
return Object.values(_models[kind])
583+
.filter(
584+
(modelDefinition) =>
585+
!options.canonicalOnly || canonicalSchemasByKind[kind].safeParse(modelDefinition.name).success,
586+
)
587+
.map((modelDefinition) => ({ ...modelDefinition, kind }) as ListedModelDefinition);
588+
});
589+
}
590+
500591
export const models = {
501592
/**
502593
* Get a realtime streaming model identifier.

packages/sdk/tests/unit.test.ts

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,33 @@
11
import { HttpResponse, http } from "msw";
22
import { setupServer } from "msw/node";
33
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4-
import { createDecartClient, isRealtimeModel, isVideoModel, models } from "../src/index.js";
5-
import { _resetDeprecationWarnings } from "../src/shared/model.js";
4+
import * as publicSdk from "../src/index.js";
5+
import {
6+
type CanonicalModel,
7+
createDecartClient,
8+
isCanonicalModel,
9+
isModel,
10+
isRealtimeModel,
11+
isVideoModel,
12+
type ListedModelDefinition,
13+
listModels,
14+
type ModelKind,
15+
modelAliases,
16+
models,
17+
resolveCanonicalModelAlias,
18+
resolveModelAlias,
19+
} from "../src/index.js";
20+
import {
21+
_resetDeprecationWarnings,
22+
canonicalImageModels,
23+
canonicalModelSchema,
24+
canonicalRealtimeModels,
25+
canonicalVideoModels,
26+
imageModels,
27+
modelSchema,
28+
realtimeModels,
29+
videoModels,
30+
} from "../src/shared/model.js";
631

732
const MOCK_RESPONSE_DATA = new Uint8Array([0x00, 0x01, 0x02]).buffer;
833
const TEST_API_KEY = "test-api-key";
@@ -3267,6 +3292,150 @@ describe("CustomModelDefinition", () => {
32673292
});
32683293

32693294
describe("Canonical Model Names", () => {
3295+
describe("Public model registry exports", () => {
3296+
const latestAliases = [
3297+
"lucy-latest",
3298+
"lucy-vton-latest",
3299+
"lucy-restyle-latest",
3300+
"lucy-clip-latest",
3301+
"lucy-image-latest",
3302+
];
3303+
const deprecatedAliases = [
3304+
"mirage_v2",
3305+
"lucy-vton",
3306+
"lucy-2.1-vton-2",
3307+
"lucy-pro-v2v",
3308+
"lucy-restyle-v2v",
3309+
"lucy-pro-i2i",
3310+
];
3311+
3312+
it("canonical schemas exclude deprecated and latest aliases", () => {
3313+
for (const alias of [...latestAliases, ...deprecatedAliases]) {
3314+
expect(canonicalModelSchema.safeParse(alias).success).toBe(false);
3315+
}
3316+
3317+
expect(canonicalRealtimeModels.options).toEqual(["lucy-2.1", "lucy-2.1-vton", "lucy-vton-2", "lucy-restyle-2"]);
3318+
expect(canonicalVideoModels.options).toEqual([
3319+
"lucy-clip",
3320+
"lucy-2.1",
3321+
"lucy-2.1-vton",
3322+
"lucy-vton-2",
3323+
"lucy-restyle-2",
3324+
]);
3325+
expect(canonicalImageModels.options).toEqual(["lucy-image-2"]);
3326+
expect(canonicalRealtimeModels.safeParse("lucy-2.1").success).toBe(true);
3327+
expect(canonicalRealtimeModels.safeParse("lucy-latest").success).toBe(false);
3328+
expect(canonicalVideoModels.safeParse("lucy-clip").success).toBe(true);
3329+
expect(canonicalVideoModels.safeParse("lucy-pro-v2v").success).toBe(false);
3330+
expect(canonicalImageModels.safeParse("lucy-image-2").success).toBe(true);
3331+
expect(canonicalImageModels.safeParse("lucy-image-latest").success).toBe(false);
3332+
});
3333+
3334+
it("public model schemas still accept deprecated and latest aliases", () => {
3335+
for (const model of [...latestAliases, ...deprecatedAliases]) {
3336+
expect(modelSchema.safeParse(model).success).toBe(true);
3337+
}
3338+
3339+
expect(realtimeModels.safeParse("lucy-latest").success).toBe(true);
3340+
expect(realtimeModels.safeParse("mirage_v2").success).toBe(true);
3341+
expect(videoModels.safeParse("lucy-clip-latest").success).toBe(true);
3342+
expect(videoModels.safeParse("lucy-pro-v2v").success).toBe(true);
3343+
expect(imageModels.safeParse("lucy-image-latest").success).toBe(true);
3344+
expect(imageModels.safeParse("lucy-pro-i2i").success).toBe(true);
3345+
});
3346+
3347+
it("resolves model aliases while preserving accepted latest aliases", () => {
3348+
expect(modelAliases["lucy-pro-v2v"]).toBe("lucy-clip");
3349+
expect(resolveModelAlias("lucy-pro-v2v")).toBe("lucy-clip");
3350+
expect(resolveModelAlias("lucy-clip")).toBe("lucy-clip");
3351+
expect(resolveModelAlias("lucy-latest")).toBe("lucy-latest");
3352+
expect(resolveModelAlias("unknown-model")).toBeUndefined();
3353+
});
3354+
3355+
it("resolves only stable canonical names for canonical alias resolution", () => {
3356+
expect(resolveCanonicalModelAlias("lucy-pro-v2v")).toBe("lucy-clip");
3357+
expect(resolveCanonicalModelAlias("lucy-clip")).toBe("lucy-clip");
3358+
expect(resolveCanonicalModelAlias("lucy-latest")).toBeUndefined();
3359+
expect(resolveCanonicalModelAlias("unknown-model")).toBeUndefined();
3360+
});
3361+
3362+
it("validates models through public helper functions instead of root zod exports", () => {
3363+
expect(isModel("lucy-latest")).toBe(true);
3364+
expect(isModel("unknown-model")).toBe(false);
3365+
expect(isCanonicalModel("lucy-clip")).toBe(true);
3366+
expect(isCanonicalModel("lucy-latest")).toBe(false);
3367+
});
3368+
3369+
it("does not emit deprecation warnings from alias resolution helpers", () => {
3370+
_resetDeprecationWarnings();
3371+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
3372+
3373+
expect(resolveModelAlias("lucy-pro-v2v")).toBe("lucy-clip");
3374+
expect(resolveCanonicalModelAlias("lucy-pro-v2v")).toBe("lucy-clip");
3375+
expect(warnSpy).not.toHaveBeenCalled();
3376+
3377+
warnSpy.mockRestore();
3378+
});
3379+
3380+
it("lists all models when called without options", () => {
3381+
const listedModels = listModels();
3382+
3383+
expect(listedModels).toHaveLength(26);
3384+
expect(listedModels.some((model) => model.kind === "realtime" && model.name === "lucy-2.1")).toBe(true);
3385+
expect(listedModels.some((model) => model.kind === "video" && model.name === "lucy-clip")).toBe(true);
3386+
expect(listedModels.some((model) => model.kind === "image" && model.name === "lucy-image-2")).toBe(true);
3387+
});
3388+
3389+
it("filters by kind without excluding latest or deprecated aliases", () => {
3390+
const realtimeModels = listModels({ kind: "realtime" });
3391+
const realtimeNames = realtimeModels.map((model) => model.name);
3392+
3393+
expect(realtimeModels.every((model) => model.kind === "realtime")).toBe(true);
3394+
expect(realtimeNames).toContain("lucy-latest");
3395+
expect(realtimeNames).toContain("mirage_v2");
3396+
});
3397+
3398+
it("lists canonical model definitions without latest or deprecated aliases", () => {
3399+
const listedModels = listModels({ canonicalOnly: true });
3400+
const listedNames = listedModels.map((model) => model.name);
3401+
3402+
for (const alias of [...latestAliases, ...deprecatedAliases]) {
3403+
expect(listedNames).not.toContain(alias);
3404+
}
3405+
expect(listedModels.every((model) => canonicalModelSchema.safeParse(model.name).success)).toBe(true);
3406+
});
3407+
3408+
it("preserves model kind for dual-kind canonical names", () => {
3409+
const lucyModels = listModels({ canonicalOnly: true }).filter((model) => model.name === "lucy-2.1");
3410+
3411+
expect(lucyModels).toHaveLength(2);
3412+
expect(lucyModels.map((model) => model.kind).sort()).toEqual(["realtime", "video"]);
3413+
});
3414+
3415+
it("supports consumer-style imports from the package root", () => {
3416+
const kind: ModelKind = "video";
3417+
const canonicalModel: CanonicalModel = resolveCanonicalModelAlias("lucy-pro-v2v") ?? "lucy-clip";
3418+
const listedModels: ListedModelDefinition[] = listModels({ kind, canonicalOnly: true });
3419+
3420+
expect(canonicalModel).toBe("lucy-clip");
3421+
expect(isCanonicalModel(canonicalModel)).toBe(true);
3422+
expect(listedModels.every((model) => model.kind === kind)).toBe(true);
3423+
});
3424+
3425+
it("does not expose raw zod schemas from the package root", () => {
3426+
expect("canonicalModelSchema" in publicSdk).toBe(false);
3427+
expect("canonicalRealtimeModels" in publicSdk).toBe(false);
3428+
expect("canonicalVideoModels" in publicSdk).toBe(false);
3429+
expect("canonicalImageModels" in publicSdk).toBe(false);
3430+
expect("modelSchema" in publicSdk).toBe(false);
3431+
expect("realtimeModels" in publicSdk).toBe(false);
3432+
expect("videoModels" in publicSdk).toBe(false);
3433+
expect("imageModels" in publicSdk).toBe(false);
3434+
expect("modelInputSchemas" in publicSdk).toBe(false);
3435+
expect("modelDefinitionSchema" in publicSdk).toBe(false);
3436+
});
3437+
});
3438+
32703439
describe("Realtime canonical models", () => {
32713440
it("lucy-2.1 canonical name works", () => {
32723441
const model = models.realtime("lucy-2.1");

0 commit comments

Comments
 (0)