Sign inStart creating

Model Router

Implementation: src/server/router/. Registry: src/server/models/registry.ts.

The absolute rule

The creation engine is permanently separated from model providers. The app requests capabilities; the Router picks the model. New model = new adapter, never a rebuild.

Enforced by the type system: nothing above the Router can express "use Kling". A GenerationJob carries a CapabilityRequest, never a model id.

The one exception is arena mode, which passes pinnedModelId — because comparing models is the feature. It is commented as a deliberate opening of the abstraction so it does not look like a leak.

Proof this matters

Sora 2's API shuts down 24 Sept 2026. It is excluded from the lineup. Removing a flagship model from this architecture costs one deleted registry row.

Job schema

interface GenerationJob {
  kind: 'image' | 'video' | 'audio_tts' | 'audio_music' | 'audio_sfx' | 'utility';
  prompt: string;
  negativePrompt?: string;
  refs: GenerationRef[];        // role-tagged, provenance-linked
  capabilities: CapabilityRequest;
  params: { durationSec, width, height, fps, seed, aspectRatio, postPass, shotType };
  qualityMode: 'economy' | 'balanced' | 'cinema';
  intent: 'draft' | 'final';
  costCeiling?: number;
  latencyBudgetSec?: number;
}

refs carry sourceType/sourceId back to the graph node they came from, so provenance survives into the asset record.

Scoring

Hard gates first — a model failing any of these is disqualified with a stated reason, and that reason is shown in /studio/models:

  1. Credentials present (requiresEnv)
  2. Compliance gate (below)
  3. Provider health — persisted, so failover survives a restart
  4. Duration within maxDurationSec, or extension support
  5. Every requested capability present
  6. Resolution floor
  7. Cost ceiling

Then soft scoring: quality-mode fit, character-lock strength vs requirement, reference capacity, native audio bonus, latency budget, cost pressure, and learned benchmark quality + win rate.

Character lock is weighted hardest (−120 × shortfall). For a film it is the axis that decides whether the output is usable at all, and two locked characters in frame raises the requirement from 0.7 to 0.85.

Compliance gate

Part 9.3 is enforced in code, not documentation:

if (model.id === 'minimax-h3' && opts.selfHostPath) {
  return { ok: false, reason: 'hosted-API-only under its Community License' };
}

Its Community License prohibits local deployment in the US/EU/UK/South Korea, and the vendor is in active studio copyright litigation. A comment would not stop a future contributor flipping ROUTING_PREFER_SELF_HOST; a hard block does.

Failover

executeWithFailover walks [decision.modelId, ...fallbacks]. Provider-down errors (5xx, 429, timeout) advance the chain and mark health degraded. A 4xx breaks immediately — the request itself is wrong, so retrying elsewhere just wastes money. The mock adapter is the floor: a creator never loses work to a provider outage.

Regeneration ladder

Attempt 0 uses the project mode; attempts 1–2 drop to Economy; attempt 3+ does one final pass at the project mode. This is what makes the ≥5:1 draft-to-final guardrail economically real — lock composition cheap, finish expensive.

Arena mode

routeArena returns the top 2–4 eligible models. The creator's pick calls pickArenaWinner, which updates ModelBenchmark.winRate for every entrant. That preference data is the Production Intelligence moat — it accumulates per model per shot-type and feeds back into scoring.

Quality scoring — read this before trusting a number

scoreGeneration returns six dimensions and an overall. With no vision model configured it is a deterministic heuristic derived from the model's tier and benchmark profile, seeded from the generation seed so reruns reproduce.

It is genuinely useful for ranking and for driving the Improve loop (weakDimensions picks what to regenerate). It is not perceptual assessment, and the UI labels it "estimated". Do not build a customer-facing quality guarantee on it until a real scorer is wired.

Adapters

ModelAdapter is three methods: isAvailable(), estimate(), execute().

  • mock — permanent, always available, fully implemented. Produces real animated SVG and synthesized WAV. Not a stub to delete; it is the fixture every test routes through and the reason the app works with zero keys.
  • REST adapters — built by createRestAdapter from a per-vendor config: payload builder, poll target, output parser, auth header. Adding a vendor is one config object.

Integration honesty: the REST configs are written from documented API conventions and have not been executed against live endpoints in this build. Fields needing confirmation carry VERIFY: comments. They activate only with keys, and failures degrade to the next model.

Adding a model

  1. Add a ModelDef to MODEL_REGISTRY with honest capability flags — the Router trusts them.
  2. If the vendor is new, add an adapter config to providers.ts.
  3. Reseed (npm run db:seed) to sync ModelRegistryEntry.

That is the whole cost.