mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
feat(media-use): derive provider cost tier from the registry (#3155)
The provider registry already declares whether a provider is local, free over the network, or paid over the network via its A/N/P constructors, but nothing downstream could read that, so anything needing to know whether resolving through a provider can spend the user's credit had to re-derive it by string-matching provider names. Expose it as providerTierFor(name) over the same table and carry the derived value on the resolve event alongside the provider it came from. Sparse: absent when the record carries no provider or the name is not declared. A name declared under two media types must carry one tier; the index throws at import rather than silently picking one. Covered by unit tests on the lookup and by end-to-end cases that spawn the real CLI and read the value off the payload a local server receives, one per tier.
This commit is contained in:
@@ -46,7 +46,7 @@
|
|||||||
"files": 10
|
"files": 10
|
||||||
},
|
},
|
||||||
"media-use": {
|
"media-use": {
|
||||||
"hash": "1fc64413fe651bde",
|
"hash": "7e329ade41b1c1ba",
|
||||||
"files": 152
|
"files": 152
|
||||||
},
|
},
|
||||||
"motion-graphics": {
|
"motion-graphics": {
|
||||||
|
|||||||
@@ -127,6 +127,44 @@ export function providerNamesFor(type) {
|
|||||||
return listFor(type).map((p) => p.name);
|
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
|
* 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
|
* "codex") match any provider declared for the type? Same match rule as
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
providerNamesFor,
|
providerNamesFor,
|
||||||
runProviders,
|
runProviders,
|
||||||
runCapability,
|
runCapability,
|
||||||
|
providerTierFor,
|
||||||
|
buildProviderTierIndex,
|
||||||
} from "./registry.mjs";
|
} from "./registry.mjs";
|
||||||
|
|
||||||
// --- registry shape -------------------------------------------------------
|
// --- 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);
|
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 () => {
|
test("--local-only skips every network provider (even free remote ones)", async () => {
|
||||||
let remoteRan = false;
|
let remoteRan = false;
|
||||||
const providers = [
|
const providers = [
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import {
|
|||||||
} from "./lib/manifest.mjs";
|
} from "./lib/manifest.mjs";
|
||||||
import { regenerateIndex } from "./lib/index-gen.mjs";
|
import { regenerateIndex } from "./lib/index-gen.mjs";
|
||||||
import { cacheGet, cacheGetByEntity, importFromCache, cachePut } from "./lib/cache.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 { freezeUrl, freezeLocalFile, isDirectMediaUrl } from "./lib/freeze.mjs";
|
||||||
import { findExistingAsset } from "./lib/adopt.mjs";
|
import { findExistingAsset } from "./lib/adopt.mjs";
|
||||||
import { track } from "./lib/telemetry.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
|
// signal about the fetch that actually consumed a heygen credit, not
|
||||||
// about the (free, no-credential) act of copying a cached file.
|
// about the (free, no-credential) act of copying a cached file.
|
||||||
auth_method: record.provenance?.authMethod,
|
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"],
|
local_only: !!args["local-only"],
|
||||||
provider_override: !!args.provider,
|
provider_override: !!args.provider,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
// resolve that reaches track("media_use_resolve", ...) with tracking allowed
|
||||||
// posts to a local HTTP server instead of production, and the server actually
|
// posts to a local HTTP server instead of production, and the server actually
|
||||||
// receives it (not just "nothing happened because nothing was listening").
|
// receives it (not just "nothing happened because nothing was listening").
|
||||||
test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real interception", async () => {
|
// Spawns a real resolve that hits the manifest for `provider`, intercepts the
|
||||||
setup();
|
// 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 received = [];
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
let body = "";
|
let body = "";
|
||||||
@@ -972,7 +975,7 @@ test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real intercept
|
|||||||
try {
|
try {
|
||||||
received.push(JSON.parse(body));
|
received.push(JSON.parse(body));
|
||||||
} catch {
|
} 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.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end("{}");
|
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-"));
|
const sandboxHome = mkdtempSync(join(tmpdir(), "mu-resolve-telemetry-home-"));
|
||||||
|
|
||||||
try {
|
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({
|
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);
|
appendRecord(tmp, record);
|
||||||
const filePath = join(tmp, record.path);
|
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
|
// their real email into this test's local-server payload despite HOME
|
||||||
// being sandboxed (HEYGEN_CONFIG_DIR, not HOME, resolves the credentials
|
// being sandboxed (HEYGEN_CONFIG_DIR, not HOME, resolves the credentials
|
||||||
// path). Every other test in this file keeps its untouched default env.
|
// 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: {
|
env: {
|
||||||
DO_NOT_TRACK: "0",
|
DO_NOT_TRACK: "0",
|
||||||
HYPERFRAMES_NO_TELEMETRY: "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
|
// 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
|
// request to our local server sits accepted-but-unprocessed in the kernel
|
||||||
// backlog until control returns here. Poll briefly to let the event loop
|
// 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.
|
// 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 {
|
} finally {
|
||||||
await new Promise((resolve) => server.close(resolve));
|
await new Promise((resolve) => server.close(resolve));
|
||||||
rmSync(sandboxHome, { recursive: true, force: true });
|
rmSync(sandboxHome, { recursive: true, force: true });
|
||||||
cleanup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.ok(received.length > 0, "expected the local telemetry server to receive a POST");
|
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");
|
const event = received[0].batch.find((e) => e.event === "media_use_resolve");
|
||||||
assert.ok(resolveEvent, "expected a media_use_resolve event in the intercepted batch");
|
assert.ok(event, "expected a media_use_resolve event in the intercepted batch");
|
||||||
assert.equal(resolveEvent.properties.provider, "test");
|
return event;
|
||||||
assert.equal(resolveEvent.properties.type, "bgm");
|
}
|
||||||
|
|
||||||
|
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 ---
|
// --- run ---
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user