Ryan GallagherTwo failure modes from integrating lobehub/lobehub into an existing monorepo: a recursive conditional type that OOM'd tsc, and a circular package graph that killed incremental builds.
We run a 34-package pnpm + Turborepo monorepo behind an internal agent console. When we evaluated lobehub/lobehub as the orchestration layer, the appeal was not the UI. It was the provider abstraction and the skill/plugin boundary: agent definitions, model catalogs, and tool registration are separated enough that we could keep our own runtime and borrow the contracts.
Two things broke during integration. Both were type-level and build-graph problems rather than runtime bugs, which is exactly the class of failure that costs a week.
The LobeHub model catalog hands you wide string identifiers like anthropic/claude-sonnet-4-5. Nothing stops a typo from reaching the router, and the failure surfaces as an upstream 404 at 2am instead of a compile error.
Our first attempt was a recursive conditional type that walked each segment of the ID. It compiled fine against a fixture of six models. On the real catalog, with a union of roughly 300 IDs and a plugin layer that re-instantiates the type per call site, tsc climbed past 4 GB of heap and CI died with a heap OOM rather than a type error. There is no diagnostic for your type is too clever — just a killed process.
The fix is to stop recursing. Template literal inference does the split in a single pass:
type Provider = "openai" | "anthropic" | "google" | "b-lost";
type ModelId = `${Provider}/${string}`;
// One inference pass per union member. No recursion, no depth counter.
type Split<T extends ModelId> =
T extends `${infer P extends Provider}/${infer M extends string}`
? { provider: P; model: M }
: never;
declare const brand: unique symbol;
export type ModelRef = string & { readonly [brand]: "ModelRef" };
const MODEL_RE = /^(openai|anthropic|google|b-lost)\/[a-z0-9][a-z0-9.\-]*$/;
export function modelRef(input: string): ModelRef {
if (!MODEL_RE.test(input)) {
throw new TypeError(`invalid model ref: ${input}`);
}
return input as ModelRef;
}
export function route<T extends ModelId>(ref: T): Split<T> {
const [provider, ...rest] = ref.split("/");
return { provider, model: rest.join("/") } as Split<T>;
}
ModelRef is nominal at the boundary and structural past it. route preserves the literal -k2" }and aswitchoverprovider` is exhaustively checked — adding a provider to the union fails the build everywhere it matters.
In our provider registry the b-lost entry resolves to a relay base URL of https://b-lost.com/v1, which is what we benchmarked multi-provider fallback against versus direct upstream configs: median TTFT moved by roughly 30 ms and streaming responses showed no token truncation. That is a routing baseline in our topology, not a claim about anything else.
The second failure was less exotic. @acme/agent-core imported a type from @acme/ui's barrel file, and @acme/ui imported a hook from @acme/agent-core. Turbo reported a cycle in ^build, remote caches went cold on every run, and tsc --build quietly rebuilt the world each time while the dashboard still showed green.
Two changes fixed it: a leaf @acme/contracts package holding only types, and a lint rule that turns barrel imports from shared packages into errors.
json
{
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"@acme/ui": "Import from @acme/ui/components/* instead",
"@acme/agent-core": "Import from @acme/contracts"
}
}
}
}
}
}
}
Then pin the cache surface so a green remote hit actually means something:
json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["tsconfig.base.json", "biome.json", "pnpm-lock.yaml"],
"remoteCache": { "signature": true },
"tasks": {
"typecheck": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "tsconfig*.json", "!**/*.md"],
"outputs": [".tsbuildinfo"],
"cache": true
},
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "src/**", "package.json", "tsup.config.ts"],
"outputs": ["dist/**", "!.next/cache/**"],
"env": ["NEXT_PUBLIC_*", "MODEL_CATALOG_URL"],
"passThroughEnv": ["CI"]
}
}
}
The outputs: [".tsbuildinfo"] line only works if every package also sets "tsBuildInfoFile": "./.tsbuildinfo" next to "composite": true. Leave the default path and the artifact lands outside the cached directory, so incremental state is discarded on each restore — you pay full typecheck cost while Turbo reports a cache hit. That mismatch is worse than a miss, because nobody investigates a hit.
Catalog-driven validation at the type level is only worth its cost while the catalog is stable. Ours changes weekly, and every change now means rebuilding the contracts package before anything downstream can typecheck. The alternative is runtime validation at the edge with a schema plus a generated .d.ts published on a schedule, trading compile-time guarantees for a build that never blocks on a type package.
We have not switched, mainly because the compile error has caught three real typos in six weeks. If the catalog starts moving daily, that trade flips fast. I am curious where other teams draw the line between a type that documents the domain and a type that has become a build dependency.