diff --git a/skills-manifest.json b/skills-manifest.json index cb128dc75..c9fdc9a00 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "1fc64413fe651bde", + "hash": "7e329ade41b1c1ba", "files": 152 }, "motion-graphics": { diff --git a/skills/media-use/scripts/lib/registry.mjs b/skills/media-use/scripts/lib/registry.mjs index 0f5468f11..8faa91920 100644 --- a/skills/media-use/scripts/lib/registry.mjs +++ b/skills/media-use/scripts/lib/registry.mjs @@ -127,6 +127,44 @@ export function providerNamesFor(type) { return listFor(type).map((p) => p.name); } +/** + * name -> cost tier ("local" | "network_free" | "network_paid") over a collection + * of ordered provider lists, i.e. the A / N / P distinction the constructors above + * already declare. Exported so the conflict rule below is testable against a + * fixture; production reads the REGISTRY-wide index built from it. + * + * A name declared under two media types must carry the same tier in both. If it + * didn't, "did this resolve cost credit" would depend on which type happened to + * serve it, and the telemetry property would mean nothing — so this throws at + * import rather than silently picking one. + */ +export function buildProviderTierIndex(providerLists) { + const tiers = new Map(); + for (const list of providerLists) { + for (const p of list) { + const tier = p.paid ? "network_paid" : p.network ? "network_free" : "local"; + const prior = tiers.get(p.name); + if (prior && prior !== tier) + throw new Error( + `provider "${p.name}" is declared ${prior} under one media type and ${tier} under another`, + ); + tiers.set(p.name, tier); + } + } + return tiers; +} + +const PROVIDER_TIERS = buildProviderTierIndex(Object.values(REGISTRY)); + +/** + * Cost tier of a provider by name, or undefined for a name the registry doesn't + * declare. The registry stays the single owner of "does this cost credit", so + * dashboards and callers never re-derive it from provider-name string matching. + */ +export function providerTierFor(name) { + return PROVIDER_TIERS.get(name); +} + /** * Does an override token (full name like "codex.image_gen" or a prefix like * "codex") match any provider declared for the type? Same match rule as diff --git a/skills/media-use/scripts/lib/registry.test.mjs b/skills/media-use/scripts/lib/registry.test.mjs index 771a78e85..08f30351c 100644 --- a/skills/media-use/scripts/lib/registry.test.mjs +++ b/skills/media-use/scripts/lib/registry.test.mjs @@ -8,6 +8,8 @@ import { providerNamesFor, runProviders, runCapability, + providerTierFor, + buildProviderTierIndex, } from "./registry.mjs"; // --- registry shape ------------------------------------------------------- @@ -185,6 +187,60 @@ test("runCapability('bgm','process') is null — process slot is graceful when u assert.equal(await runCapability("bgm", "process", "x", {}), null); }); +// --- provider cost tier (telemetry) --------------------------------------- + +test("providerTierFor reports the registry's own A/N/P declaration", () => { + assert.equal(providerTierFor("heygen.tts"), "network_paid"); + assert.equal(providerTierFor("heygen.video"), "network_paid"); + assert.equal(providerTierFor("heygen.audio.sounds"), "network_free"); + assert.equal(providerTierFor("heygen.asset.search"), "network_free"); + assert.equal(providerTierFor("codex.image_gen"), "network_free"); + assert.equal(providerTierFor("bundled.sfx"), "local"); + assert.equal(providerTierFor("kokoro.local"), "local"); +}); + +test("providerTierFor agrees across every type that declares the same name", () => { + // heygen.audio.sounds serves both bgm and sfx; heygen.asset.search serves both + // image and icon. A name whose tier depended on the media type would make the + // telemetry property meaningless. + const byName = new Map(); + for (const type of listTypes()) { + for (const name of providerNamesFor(type)) { + const tier = providerTierFor(name); + assert.ok(tier, `every declared provider has a tier (${type}/${name})`); + const prior = byName.get(name); + if (prior) assert.equal(tier, prior, `${name} must carry one tier across types`); + byName.set(name, tier); + } + } +}); + +test("providerTierFor returns undefined for a name the registry does not declare", () => { + assert.equal(providerTierFor("does.not.exist"), undefined); + assert.equal(providerTierFor(undefined), undefined); + assert.equal(providerTierFor(null), undefined); + assert.equal(providerTierFor(""), undefined); +}); + +test("buildProviderTierIndex throws when one name carries two tiers", () => { + assert.throws( + () => + buildProviderTierIndex([ + [{ name: "dual", network: true }], + [{ name: "dual", network: true, paid: true }], + ]), + /declared network_free under one media type and network_paid under another/, + ); +}); + +test("buildProviderTierIndex accepts the same name repeated at the same tier", () => { + const index = buildProviderTierIndex([ + [{ name: "same", network: true }], + [{ name: "same", network: true }], + ]); + assert.equal(index.get("same"), "network_free"); +}); + test("--local-only skips every network provider (even free remote ones)", async () => { let remoteRan = false; const providers = [ diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index fc1f33224..3e6c36f02 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -14,7 +14,13 @@ import { } from "./lib/manifest.mjs"; import { regenerateIndex } from "./lib/index-gen.mjs"; import { cacheGet, cacheGetByEntity, importFromCache, cachePut } from "./lib/cache.mjs"; -import { runCapability, listTypes, providerMatches, providerNamesFor } from "./lib/registry.mjs"; +import { + runCapability, + listTypes, + providerMatches, + providerNamesFor, + providerTierFor, +} from "./lib/registry.mjs"; import { freezeUrl, freezeLocalFile, isDirectMediaUrl } from "./lib/freeze.mjs"; import { findExistingAsset } from "./lib/adopt.mjs"; import { track } from "./lib/telemetry.mjs"; @@ -1206,6 +1212,11 @@ async function result(record, source) { // signal about the fetch that actually consumed a heygen credit, not // about the (free, no-credential) act of copying a cached file. auth_method: record.provenance?.authMethod, + // "local" / "network_free" / "network_paid", straight from the registry's own + // A/N/P declaration — so a dashboard can separate free lookups from calls that + // spend credit without hardcoding provider names. Sparse: absent when the + // record carries no provider (cache and reuse hits) or the name is unknown. + provider_tier: providerTierFor(record.provenance?.provider), local_only: !!args["local-only"], provider_override: !!args.provider, }); diff --git a/skills/media-use/scripts/resolve.test.mjs b/skills/media-use/scripts/resolve.test.mjs index 5468ee19b..028270b08 100644 --- a/skills/media-use/scripts/resolve.test.mjs +++ b/skills/media-use/scripts/resolve.test.mjs @@ -962,8 +962,11 @@ test("identical grade resolve hits the project cache without re-freezing", () => // resolve that reaches track("media_use_resolve", ...) with tracking allowed // posts to a local HTTP server instead of production, and the server actually // receives it (not just "nothing happened because nothing was listening"). -test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real interception", async () => { - setup(); +// Spawns a real resolve that hits the manifest for `provider`, intercepts the +// telemetry POST it makes, and hands back the media_use_resolve event actually +// sent. Nothing is stubbed: the CLI runs as its own process, telemetry.mjs builds +// the URL, and a local server reads the payload off the wire. +async function captureResolveEvent({ provider, type = "bgm", intent }) { const received = []; const server = createServer((req, res) => { let body = ""; @@ -972,7 +975,7 @@ test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real intercept try { received.push(JSON.parse(body)); } catch { - // ignore malformed body; assertions below fail on empty `received` + // ignore malformed body; callers assert on empty `received` } res.writeHead(200, { "Content-Type": "application/json" }); res.end("{}"); @@ -983,8 +986,14 @@ test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real intercept const sandboxHome = mkdtempSync(join(tmpdir(), "mu-resolve-telemetry-home-")); try { + // The record's type must match the --type below, otherwise the manifest + // never matches, the cascade calls a live provider, and the run fails for + // reasons that have nothing to do with the tier. const record = makeRecord({ - provenance: { prompt: "telemetry seam test", provider: "test" }, + id: `${type}_tier_001`, + type, + path: `.media/audio/${type}/${type}_tier_001.wav`, + provenance: { prompt: intent, provider }, }); appendRecord(tmp, record); const filePath = join(tmp, record.path); @@ -1000,7 +1009,7 @@ test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real intercept // their real email into this test's local-server payload despite HOME // being sandboxed (HEYGEN_CONFIG_DIR, not HOME, resolves the credentials // path). Every other test in this file keeps its untouched default env. - runResolve(["--type", "bgm", "--intent", "telemetry seam test", "--project", tmp, "--json"], { + runResolve(["--type", type, "--intent", intent, "--project", tmp, "--json"], { env: { DO_NOT_TRACK: "0", HYPERFRAMES_NO_TELEMETRY: "0", @@ -1013,7 +1022,7 @@ test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real intercept }); // runResolve blocks synchronously (execFileSync) until the child exits, which - // pauses this process's own event loop for that whole span — the child's + // pauses this process's own event loop for that whole span -- the child's // request to our local server sits accepted-but-unprocessed in the kernel // backlog until control returns here. Poll briefly to let the event loop // drain it rather than asserting before the server has had a turn to run. @@ -1023,16 +1032,70 @@ test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real intercept } finally { await new Promise((resolve) => server.close(resolve)); rmSync(sandboxHome, { recursive: true, force: true }); - cleanup(); } assert.ok(received.length > 0, "expected the local telemetry server to receive a POST"); - const resolveEvent = received[0].batch.find((event) => event.event === "media_use_resolve"); - assert.ok(resolveEvent, "expected a media_use_resolve event in the intercepted batch"); - assert.equal(resolveEvent.properties.provider, "test"); - assert.equal(resolveEvent.properties.type, "bgm"); + const event = received[0].batch.find((e) => e.event === "media_use_resolve"); + assert.ok(event, "expected a media_use_resolve event in the intercepted batch"); + return event; +} + +test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real interception", async () => { + setup(); + try { + const event = await captureResolveEvent({ provider: "test", intent: "telemetry seam test" }); + assert.equal(event.properties.provider, "test"); + assert.equal(event.properties.type, "bgm"); + // "test" is not a declared registry provider, so the tier is absent rather + // than guessed, the same sparseness rule auth_method follows. + assert.equal( + "provider_tier" in event.properties && event.properties.provider_tier !== undefined, + false, + "an undeclared provider must not be assigned a cost tier", + ); + } finally { + cleanup(); + } }); +// The registry-derived tier has to survive the whole path -- registry lookup, +// result(), track(), JSON body -- not just a unit call to providerTierFor. Each +// case names a provider the registry declares at a different tier and asserts the +// tier that actually reaches the wire. +for (const [provider, type, expected] of [ + ["heygen.tts", "voice", "network_paid"], + ["heygen.audio.sounds", "bgm", "network_free"], + ["bundled.sfx", "sfx", "local"], +]) { + test(`a resolve won by ${provider} sends provider_tier: ${expected}`, async () => { + setup(); + try { + const event = await captureResolveEvent({ + provider, + type, + intent: `tier seam ${provider}`, + }); + assert.equal(event.properties.provider, provider); + assert.equal( + event.properties.provider_tier, + expected, + `${provider} must reach the wire as ${expected}`, + ); + // The tier is derived from the registry and the auth method from the + // credential state; they must not become entangled. A non-heygen provider + // carries a tier and no auth method, whatever credentials exist locally. + if (!provider.startsWith("heygen.")) + assert.equal( + event.properties.auth_method, + undefined, + "a non-heygen provider must carry a tier without an auth method", + ); + } finally { + cleanup(); + } + }); +} + // --- run --- async function main() {