npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@x12i/ai-profiles

v3.4.0

Published

Resolve AI profile names into concrete model choices with bundled defaults and remote JSON refresh

Downloads

4,248

Readme

@x12i/ai-profiles

Resolve AI profile names into concrete model choices. The package ships JSON in src/data/ as the offline fallback. By default, registry data is fetched from public assets, validated, and cached in memory.

v3.2 adds normalizeInvokeModel, resolveAndNormalizeInvokeModel, and resolveAndNormalizeBundledInvokeModel for gateway invoke wire shape. Downstream repos should pin "@x12i/ai-profiles": "^3.2.0".

v3.0 removes legacy resolution inputs: shortcuts, bare profiles, and aliases. Only four resolution forms are accepted — profile/choice, vendor slug, gateway wire, and { provider, modelId }. See CHANGELOG.md.

v2.0 (still relevant): every resolveAIProfile call must pass catalogLane ("text", "image", …). Profile keys deepseek and summarization are gone — use vol and sum.

Why AI profiles

Any system that runs many different AI tasks—classification, architecture review, JSON extraction, tool-using agents—faces the same problem: tasks are not interchangeable. They differ in reasoning quality, cost sensitivity, latency, output shape (prose vs schema vs citations), and which provider traits matter (instruction following, long context, tool use).

Hardcoding one model everywhere leads to one of three failures: overpaying for simple high-volume work, under-serving hard problems with a weak model, or locking the product to a single vendor with no fallback when prices or availability change.

What a profile is

An AI profile is not a model. It is a capability contract: a stable name for what kind of work you need, not which SKU fulfills it.

When a graph node or function asks for deep, it means: high reasoning effort, complex work—resolve that intent to a concrete provider + modelId + runtime defaults today, without the caller caring whether that is Claude, GPT, or Gemini. The registry (models-profiles.json) is the mapping layer; profiles are the interface tasks depend on.

Profiles vs choices

| Layer | Role | Example | |-------|------|---------| | Profile | Semantic intent (cost, speed, reasoning tier, output mode, agentic behavior) | cheap, balanced, json, agentic | | Choice | Implementation option within that profile (provider, model, batch vs sync, cost/quality trade-off) | default, google_floor, anthropic_cheap |

Every profile has a defaultChoice (recommended) and typically two alternatives so operators can tune without changing task code:

  • Provider diversity — Anthropic (writing/reasoning), OpenAI (code/instruction following), Google (cost, long context) within the same profile.
  • Cost flexibility — e.g. balanced + google_balanced vs anthropic_balanced for the same “normal work” intent.
  • Capability variants — e.g. google_deep when the constraint is window size, not reasoning tier alone.
  • Execution mode — e.g. cheap + google_floor with executionMode: batch for background bulk work.
  • Resilience — swap choice or fall back within the same profile when a provider is down or rate-limited.

Tasks should declare requirements as explicit profile/choice keys (e.g. cheap/default), not model names. Use discovery APIs (listAIProfiles, resolveAIProfileByTags) to pick a profile when the caller does not already have a composite key.

Graph and stored model config

Graph JSON, studio defaults, and run overrides store profile slot strings only — never concrete provider model ids. Each task uses a complete triple:

| Slot | Example | Phase | |------|---------|-------| | preActionModel | deep/default | PRE (xynthesis / planning) | | skillModel | balanced/default | MAIN (skill gateway) | | postActionModel | cheap/default | POST (xynthesis / wrap-up) |

Canonical storage form is profile/choice (separator /). Use formatProfileChoiceKey(profile, choice) when writing slots and isKnownProfileChoice(input) (or matchStrictProfileInput) before persist or execute. Always include the choice segment — even for the profile default (cheap/default, not bare cheap).

"modelConfig": {
  "cases": [
    {
      "modelConfig": {
        "preActionModel": "cyber/default",
        "skillModel": "audit/default",
        "postActionModel": "cheap/default"
      }
    }
  ]
}

At execute time, hosts pass the stored alias triple unchanged; each phase resolves independently via resolveAIProfile (or resolveAndNormalizeInvokeModel at the gateway boundary). Do not pre-resolve aliases to concrete ids in graph JSON, runtime.modelConfig, or aliasConfig.

Forbidden in stored config: legacy keys (xynthesisModel, postStrategyModel), legacy aliases (default, strong, weak), bare profile keys (cheap), concrete slugs (openai/gpt-4o-mini), and legacy @ composites (cheap@anthropic_cheap — migrate to cheap/anthropic_cheap on save).

Profile catalog (bundled)

Profiles are grouped by what kind of work they represent—not only by price/quality tier. Several axes are orthogonal (structured output, agents, research, local inference).

| Profile | Category | When to use it | |---------|----------|----------------| | cheap | cost | High-volume, non-critical work: routing, classification, simple transforms. “Cheap” means low-cost selection, not always the absolute cheapest SKU. | | fast | speed | Interactive UX where latency matters more than maximum reasoning depth. | | balanced | standard | Default workhorse: general graph execution, coding, product logic (~80% of tasks). | | deep | reasoning | Hard problems: architecture, planning, analysis, code review—work that needs real reasoning, not pattern matching. | | pro | premium | Critical outputs where quality or a wrong answer has high downstream cost; use sparingly. | | json | output | Strict JSON/schema, extraction, classification—machine-readable pipelines (outputMode: schema, low temperature). | | agentic | agentic | Tool use, planning, multi-step execution (toolPolicy allows writes with approval). | | research | research | Source-heavy reading, synthesis, citations (read-only tools, web/file search). | | cyber | security | Threat analysis, TPRM, contract verification, security log parsing. | | content | creative | Prose, marketing copy, programmatic SEO, brand voice. | | sum | analysis | Document triage, digests, compressing very large corpora. | | vol | agent | Volume/economic agent lane (Flash default, Pro for hard/final). | | vision | vision | Image input; catalogLane: "image". | | vision | multimodal | Image understanding, OCR, charts, scanned PDF structure extraction. | | coding | engineering | Software engineering: implementation, refactors, reviews, debugging. | | extraction | extraction | NER, classification, taxonomy mapping, entity extraction, document labeling. | | routing | routing | Intent classification, record routing, workflow branching (cheapest viable). | | local | local | CPU only — on-device / offline inference (llama.cpp GGUF, Transformers.js). No OpenRouter. |

Typical choices per profile

Each profile’s choices in models-profiles.json follow a consistent pattern: default (recommended), plus variants along cost, provider family, or a specific capability.

| Profile | Choices (bundled) | What the variants optimize | |---------|-------------------|------------------------------| | cheap | default, google_floor, anthropic_cheap, deepseek_cheap, minimax_cheap | Normal cheap vs batch floor vs safer Haiku vs DeepSeek volume floor vs MiniMax M2.5 | | fast | default, google_fast, anthropic_fast, grok_fast, minimax_fast | Interactive default vs lower cost vs stronger quality vs Grok vs MiniMax M2 | | balanced | default, anthropic_balanced, google_balanced, minimax_balanced | General default vs stronger reasoning vs lower cost vs MiniMax M2.5 | | deep | default, openai_deep, google_deep, deepseek_pro, grok_flagship, minimax_deep | High reasoning default vs OpenAI vs long context vs DeepSeek vs Grok vs MiniMax M2.7 | | pro | default, claude_pro, anthropic_pro | Premium default vs Claude premium vs Sonnet-level “pro-like” at lower cost | | json | default, google_json, anthropic_json | Reliable schema output vs volume/cost vs complex extraction needing reasoning | | agentic | default, openai_agentic, google_agentic, minimax_agentic | Agent default vs OpenAI orchestration vs lower-cost moderate agents vs MiniMax | | research | default, openai_research, google_research | Synthesis default vs highest quality vs very large context | | cyber | default, log_analysis, deep_forensics | Policy/contract review vs bulk log JSON vs max forensics | | content | default, pseo_bulk, brand_voice | Natural prose vs batch pSEO schema vs premium copy | | sum | default, mega_context | Fast digest (Haiku) vs multi-hundred-k token compression | | vol | default, flash, pro, final | Volume/economic agent lane (Flash default, Pro for hard/final) | | vision | default, document_ocr | General multimodal vs schema OCR from scans/PDFs | | local | llama_cpp_gguf, transformersjs_default | CPU / offline — GGUF via llama.cpp vs Hugging Face Transformers.js (no cloud LLM) |

Every remote profile choice must resolve to an active OpenRouter catalog id. Profiles without OpenRouter support are not included (use vendor APIs outside this registry, or add catalog coverage first). The only exception is the local profile, which is explicitly CPU / self-hosted.

OpenRouter-only and catalog lanes

Not every profile is OpenRouter-backed: local is CPU/self-hosted only (2 choices, no catalog entry). All other bundled profile choices (59/61) map to an entry in models-catalog.json with an active OpenRouter slug.

| Option | Purpose | |--------|---------| | requireOpenRouterCatalog: true | Fail resolution if the chosen model is not in the catalog with an active OpenRouter id (use for production paths that must not call vendor-direct guesses). | | catalogLane: "text" \| "image" \| … | Only resolve profiles declared for that OpenRouter browser lane. Tier profiles (cheap, cyber, vol, …) are text; vision is image. Lanes without profiles yet (embeddings, rerank, speech, transcription, …) can be added when you introduce lane-specific profiles. | | listAIProfiles({ catalogLane: "image" }) | List only lane-matching profiles. |

await resolveAIProfile("cyber/default", {
  catalogLane: "text",
  requireOpenRouterCatalog: true,
});

await listAIProfiles({ catalogLane: "image" }); // → vision only

Cost tiers (optional)

If you want to restrict model selection by price, you can either set an explicit costCapPer1M, or use a named costTier.

import { resolveAIProfile } from "@x12i/ai-profiles";

// Tier-based (default tiers: tier1=$0.5/1M output, tier2=$3, tier3=$10, tier4=$30)
const cheapTier1 = await resolveAIProfile("balanced/default", {
  catalogLane: "text",
  costTier: "tier1",
});

// Override tiers (per-tier caps can be maxOutput/maxInput/maxBlended)
const customTier = await resolveAIProfile("balanced", {
  catalogLane: "text",
  costTier: "budget",
  costTiers: [{ key: "budget", cap: { maxOutput: 0.5 } }],
});

// Explicit caps always win if both are provided
const explicitCap = await resolveAIProfile("balanced", {
  catalogLane: "text",
  costTier: "tier1",
  costCapPer1M: { maxBlended: 2 },
});

Alternatives by cost tier

When you want a profile’s “same intent, different price points”, use:

import { resolveAIProfileWithCostAlternatives } from "@x12i/ai-profiles";

const { primary, alternativesByCostTier } =
  await resolveAIProfileWithCostAlternatives("deep", {
    catalogLane: "text",
    tiers: [
      { key: "tier1", cap: { maxOutput: 0.5 } },
      { key: "tier2", cap: { maxOutput: 3 } },
      { key: "tier3", cap: { maxOutput: 10 } },
    ],
  });

// alternativesByCostTier: [{ tier, resolved|null, reason? }, ...]

Focus filters (catalog-derived)

You can filter choices (and optionally profile listings) by catalog-derived capabilities.

import { listAIProfiles, resolveAIProfile } from "@x12i/ai-profiles";

// Preset focus filter
const reasoningModel = await resolveAIProfile("deep", {
  catalogLane: "text",
  focus: "reasoning",
});

// Explicit capability filter
const structured = await resolveAIProfile("json", {
  catalogLane: "text",
  capabilityFilter: { structuredOutput: true },
});

// Filter profile discovery to profiles with at least one matching choice
const reasoningProfiles = await listAIProfiles({
  source: "bundled",
  focus: "reasoning",
});

Reasoning model flag (always communicated)

Every resolved primary and secondary includes a stable boolean:

const resolved = await resolveAIProfile("cheap/default", {
  catalogLane: "text",
  includeSecondary: true,
});

resolved.modelSignals.isReasoningModel; // boolean
resolved.secondary?.modelSignals.isReasoningModel; // boolean

Registry fields on each choice—runtime (reasoningEffort, toolPolicy, outputMode, instructionTier, backend), modelStatus (verified | predicted), api (vendor + OpenRouter slugs), and reason—document why that model backs the choice. Pricing and capabilities come from models-catalog.json at resolve time. Profile-level requiredCapabilities describes intent for future dynamic selection (Optimixer). Consumers use resolveAIProfile to materialize the merged result.

Install

npm install @x12i/ai-profiles

Public API

Profiles and registry loading

| Export | Sync? | Purpose | |--------|-------|---------| | listAIProfiles(options?) | No (async) | Profile summaries: primary profile key, tags, choices, defaults | | getAIRegistryStatistics(options?) | No (async) | Counts: profiles, choices, providers, catalog models, linkage health | | formatRegistryStatistics(stats) | Yes | Human-readable summary for logs / CLI | | loadAIProfilesRegistry(options?) | No (async) | Raw profiles registry (auto / remote / bundled, 24h memory cache) | | loadAIProfilesRegistryWithWarnings(options?) | No (async) | Same as above, plus warnings[] (stale cache, bundled fallback, etc.) | | loadModelsCatalog(options?) | No (async) | Unified models catalog (auto / remote / bundled, same cache pattern as profiles) | | loadBundledModelsCatalog() | Yes | Package-shipped models-catalog.json only | | loadBundledModelsRegistry() | Yes | Package-shipped models-registry.json (linkage index) | | listAIProfileChoices(options?) | No (async) | List explicit profile/choice keys for strict pickers and stored config | | formatProfileChoiceKey(profile, choice) | Yes | Build canonical slot key (cheap/default) | | parseProfileChoiceKey(input) | Yes | Parse profile/choice (accepts \ as separator on input) | | matchStrictProfileInput(registry, input) | Yes | Resolve input to profile+choice or invalidChoice | | isKnownProfileChoice(input) | Yes (bundled only) | Whether input is a known valid profile/choice key | | isKnownProfileChoiceInput(registry, input) | Yes | Same as above with an explicit registry | | getBundledRegistry() | Yes | Sync bundled profiles registry (no network) | | resolveBundledAIProfile(input, options) | Yes | Strict profile/choice → model, pricing, runtime (source: "bundled") | | resolveBundledInput(input, options) | Yes | Unified resolver: profile/choice → registry; vendor slug / gateway wire / { provider, modelId } → catalog | | resolveBundledCatalogModel(input, options?) | Yes | Sync strict catalog resolution (exact/high confidence only) | | ensureConcreteModel(input, options?) | Yes | String normalizer: profile/choice (strict) or concrete model (catalog best-effort) → invoke-time { provider, modelId } | | normalizeInvokeModel(input, options?) | Yes | Idempotent gateway invoke tuple: strips openrouter/ display slugs; rejects unresolved profile/choice aliases | | resolveAndNormalizeInvokeModel(alias, options) | No (async) | resolveAIProfilenormalizeInvokeModel for MAIN / handoff paths | | resolveAndNormalizeBundledInvokeModel(alias, options) | Yes | Bundled resolveAIProfilenormalizeInvokeModel (no network) | | calculateInvokeCost(input, options?) | No (async) | Token → USD from catalog pricing (costStatus: "priced" \| "unpriced") | | calculateInvokeCostSync(input, options?) | Yes | Bundled offline cost calculator | | calculateInvokeCostFromRecord(record, options?) | No (async) | Activix snake_case usage record → USD | | buildInvokePricingRecord(input) | Yes | Build Activix-compatible invoke billing record | | lookupBundledCatalogCaps({ provider, modelId }) | Yes | Sync quick caps + pricing summary from bundled catalog | | lookupBundledCatalogModel(slug, options?) | Yes | Sync rich catalog snapshot for a slug (pricing, reasoning, modalities); returns undefined on miss | | resolveBundledCatalogModelDetails(slug, options?) | Yes | Same as above but throws UNKNOWN_MODEL on miss | | resolveCatalogModelDetails(slug, options?) | No (async) | Rich catalog snapshot with optional remote catalog refresh | | isReasoningModel(input, options?) | Yes | true/false for supported inputs (throws UNKNOWN_MODEL on unknown concrete models) | | resolveAIProfile(input, options?) | No (async) | Resolve an explicit profile/choice key → model, pricing, runtime. Pass includeSecondary: true to attach secondary; includeCatalog: true to attach full catalogModel. | | resolveSecondaryModel(input, options?) | No (async) | Resolve primary choice + optional companion secondary from profile secondary refs | | getSecondaryForResolvedProfile(resolved, registry, catalog?) | Yes | Secondary for an already-resolved primary (no extra I/O when catalog is passed) | | listProfileSecondaries(profileKey, registry) | Yes | List per-choice secondary declarations on a profile | | resolveAIProfileByTags(tags, options?) | No (async) | Pick best profile by tag overlap (partial match OK), then resolve | | scoreProfileTags / rankProfilesByTags | Yes | Tag overlap scoring for discovery and custom UIs | | AIProfilesError / AIProfilesErrorCode | — | Typed errors (UNKNOWN_PROFILE, INVOKE_MODEL_ALIAS_AT_GATEWAY, INVOKE_MODEL_SHAPE_INVALID, …) |

Model identity (vendor + model strings)

Use these when callers pass raw model ids or OpenRouter slugs, not profile names.

| Export | Sync? | Purpose | |--------|-------|---------| | resolveCatalogModel(input, options?) | No (async) | Resolve a model SKU or OpenRouter slug via models-catalog.json (strict; no profile intent) | | resolveModelIdentity(input, options?) | Yes | Given a bare id or vendor/slug, return { provider, modelId, invocation? } via bundled registry + catalog fallback | | buildModelInvocation(provider, modelId) | Yes | { direct, openrouter } endpoints for a provider hint + model string | | buildDirectInvocation(provider, modelId) | Yes | Vendor-direct { provider, modelId }; splits prefixed slugs for all vendors | | buildOpenRouterModelId(provider, modelId) | Yes | Build an OpenRouter slug (openai/gpt-5.4) from registry fields | | parseDirectFromOpenRouterSlug(slug, family?) | Yes | Split vendor/model → direct slice (custom + bare id when vendor is not a first-class provider) | | normalizeAnthropicVersionSlug(slug) | Yes | Dotted minor versions for Anthropic (4-64.6) | | buildRegistryKey(provider, modelId) | Yes | Stable registry key (vendor:…) used in models-registry.json | | matchOpenRouterCatalog(provider, modelId, index) | Yes | Match a choice against a catalog index | | lookupInvocationForChoice(registry, provider, modelId) | Yes | Lookup prebuilt invocation from models-registry.json |

Prefixed slugs: buildDirectInvocation("meta-llama", "meta-llama/llama-3.1-8b-instruct") yields { provider: "meta-llama", modelId: "llama-3.1-8b-instruct" }. Host paths keep the nested id when the provider hint differs (e.g. Together: together + meta-llama/Llama-3.3-70b-instruct). Bare and prefixed Anthropic inputs normalize to the same direct and OpenRouter ids.

Routing, validation, and maintainer tooling

| Export | Sync? | Purpose | |--------|-------|---------| | resolvePreferOpenRouter(explicit?) | Yes | Resolve OpenRouter vs vendor-direct routing (option → env → default) | | applyModelRouting(choice, backend, preferOpenRouter, catalog?) | Yes | Turn a registry choice into invoke-time { provider, modelId, routing } (same rules as resolveAIProfile) | | normalizeGatewayWireId(input) | Yes | openrouter/openai/gpt-5.5{ provider: "openrouter", modelId: "openai/gpt-5.5" } (profile keys like cheap/default are not gateway wires) | | validateBundledProfileVendorCoverage(options?) | Yes | Advisory report of leading-vendor coverage per profile | | validateBundledProfileModalityCoverage(options?) | Yes | Advisory report of profile choice vs required input/output media | | isProfileVendorTrimReady(profile, allowedProviders) | Yes | Whether auto-pick can satisfy a vendor filter | | isProfileModalityCoverageReady(profileKey) | Yes | Whether a profile has at least one modality-compatible choice | | listFaultyProfileChoices() | Yes | Profile choices with faulty OpenRouter catalog linkage | | buildModelsRegistry, verifyOpenRouterAlignment, … | Yes | Catalog/registry build and alignment (see scripts below) |

Registry JSON lives in four bundled assets:

| File | Schema | Role | |------|--------|------| | models-profiles.json | x12i.ai-profiles.model-registry.v3 | Profile intent, choices, requiredCapabilities, modelStatusno embedded pricing | | models-catalog.json | x12i.ai-profiles.models-catalog | Per-model pricing, context window, capabilities (OpenRouter-normalized) | | models-registry.json | x12i.ai-profiles.models-registry | Built linkage index: vendor keys, OpenRouter slugs, invocation pairs |

v3 contract: Each profile choice stores only provider + vendor-native modelId as a pointer into models-catalog.json, which owns the direct and openrouter listings. Never embed api, defaultTransport, or OpenRouter slugs on profile choices. resolveAIProfile picks transport via env/options and merges catalog pricing, limits, and capabilities — the client-facing shape is unchanged. Run npm run registry:finish to strip legacy fields, verify linkage, and rebuild models-registry.json.

Validate with src/data/models-profiles.schema.json. Maintainer scripts: npm run migrate:profiles-v3, npm run registry:finish (catalog supplement + verified defaults + linkage rebuild).

Sync bundled resolution (predict / invoke)

For consumers that need no network and a single entry point:

import {
  ensureConcreteModel,
  isKnownProfileChoice,
  isReasoningModel,
  resolveBundledInput,
  resolveBundledAIProfile,
  applyModelRouting,
  normalizeGatewayWireId,
  lookupBundledCatalogCaps,
  getBundledRegistry,
} from "@x12i/ai-profiles";

// Four supported resolution forms:
// 1. profile/choice  → registry (strict)
// 2. vendor slug     → catalog (strict in resolveBundledCatalogModel; best-effort in ensureConcreteModel)
// 3. gateway wire    → catalog
// 4. { provider, modelId } → catalog

ensureConcreteModel("cheap/default");
// → { provider: "openrouter", modelId: "google/gemini-2.5-flash-lite", profileChoice: { profile: "cheap", choice: "default" } }

ensureConcreteModel("google/gemini-2.5-flash-lite");
// → { provider: "openrouter", modelId: "google/gemini-2.5-flash-lite", matchConfidence: "exact" }

ensureConcreteModel("openrouter/openai/gpt-5.5");
// → { provider: "openrouter", modelId: "openai/gpt-5.5" }

if (isKnownProfileChoice("cheap/default")) {
  const resolved = resolveBundledInput("cheap/default", { catalogLane: "text" });
}

const wire = normalizeGatewayWireId("openrouter/openai/gpt-5.5");
// → { provider: "openrouter", modelId: "openai/gpt-5.5" }

isReasoningModel("deep/default"); // true
isReasoningModel("cheap/default"); // true (catalog marks gemini-2.5-flash-lite reasoning-capable)
isReasoningModel("google/gemini-2.5-flash-lite"); // false

const caps = lookupBundledCatalogCaps({
  provider: "openrouter",
  modelId: "google/gemini-2.5-flash-lite",
});

const registry = getBundledRegistry();
const routed = applyModelRouting(
  registry.profiles.cheap.choices.default,
  "openrouter",
  true,
);

isKnownProfileChoice returns false for bare profiles (cheap), legacy shortcuts (cheapest), and concrete slugs (google/gemini-2.5-flash-lite).

Basic usage

import {
  listAIProfiles,
  listAIProfileChoices,
  isKnownProfileChoice,
  resolveAIProfile,
  resolveCatalogModel,
} from "@x12i/ai-profiles";

const profiles = await listAIProfiles();
const choices = await listAIProfileChoices();

// Strict lookup key: profile/choice
const model = await resolveAIProfile("cheap/default", { catalogLane: "text" });

console.log(model.profile); // canonical primary key: cheap
console.log(model.provider); // "openrouter" by default for remote profiles
console.log(model.modelId); // "google/gemini-2.5-flash-lite"
console.log(model.routing); // "openrouter" | "direct"
console.log(model.pricing); // from models-catalog.json
console.log(model.modelStatus); // verified | predicted
console.log(model.requiredCapabilities); // profile intent
console.log(model.capabilities); // contextWindow, supportsTools, …
console.log(model.instructionTier);
console.log(model.backend);

// SKU lookup (catalog), not a profile
const sku = await resolveCatalogModel("google/gemini-2.5-flash-lite", {
  source: "bundled",
  preferOpenRouter: false,
});

console.log(isKnownProfileChoice("cheap/default")); // true
console.log(isKnownProfileChoice("cheap")); // false

Registry statistics

Use getAIRegistryStatistics() for discovery dashboards, CI summaries, and operator tooling. It loads profiles and the models catalog with the same source / refresh options as resolveAIProfile, then returns structured counts.

import {
  formatRegistryStatistics,
  getAIRegistryStatistics,
} from "@x12i/ai-profiles";

const { statistics, warnings } = await getAIRegistryStatistics({
  source: "bundled", // or "auto" | "remote"
  includeCatalog: true,
  includeLinkage: true,
});

console.log(statistics.profiles.total);
console.log(statistics.profiles.providers);
console.log(statistics.catalog?.totalModels);
console.log(statistics.linkage?.faulty);

console.log(formatRegistryStatistics(statistics));

CLI (after build): npm run stats (text) or npm run stats:json.

Supported resolution inputs

Resolution accepts exactly four input forms:

| Form | Example | Resolver | |------|---------|----------| | Profile + choice | cheap/default | Registry (resolveAIProfile, resolveBundledAIProfile) | | Vendor slug | google/gemini-2.5-flash-lite | Catalog (resolveCatalogModel, resolveBundledCatalogModel) | | Gateway wire | openrouter/openai/gpt-5.5 | Catalog (gateway normalization) | | Object | { provider: "google", modelId: "gemini-2.5-flash-lite" } | Catalog |

Not supported: shortcuts (cheapest), bare profiles (cheap), aliases (cost-sensitive). Use discovery to pick a profile, then resolve an explicit profile/choice.

Tags are short (2–4 chars), many per profile (cyb, eco, ds, …). Use listAIProfiles({ tags }) or resolveAIProfileByTags(['cyb', 'eco']) for discovery — partial overlap is OK; the profile with the most matching tags wins, then an explicit choice is resolved.

Input is normalized (trimmed, lowercased) before matching. Composite keys accept / or \ as the separator (cyber\defaultcyber/default). Profile and choice segment names treat - and _ as equivalent (cheap/google-floorcheap/google_floor).

Examples

// Composite key
await resolveAIProfile("balanced/default", { catalogLane: "text" });

// Tag-based pick (partial overlap OK) → explicit profile/choice
await resolveAIProfileByTags(["cyb", "eco", "ds"], { catalogLane: "text" });

// Model SKU → catalog, not a profile
await resolveCatalogModel("google/gemini-2.5-flash-lite", { source: "bundled" });

// String normalizer for invoke-time routing
ensureConcreteModel("cheap/default");
ensureConcreteModel("google/gemini-2.5-flash-lite");

listAIProfiles() returns profile keys, tags, and choices for pickers. listAIProfileChoices() lists every valid profile/choice composite key.

Invoke wire shape (gateway / HTTP)

Downstream packages (ai-tasks, ai-skills, ai-gateway, xynthesis, optimixer) must agree on what is sent on the wire after semantic resolution. Stored graph config uses profile/choice aliases; gateway / HTTP uses the invoke tuple below.

Display slug

A display slug is a single string with an openrouter/ prefix for human-readable UI and logs, e.g. openrouter/deepseek/deepseek-v4-pro. Studio pickers, plan previews, and Activix run logs may show this form. It is not the shape passed to ai-gateway catalog lookup or OpenRouter HTTP — normalize before those boundaries.

Invoke tuple

An invoke tuple is { provider, model, routing } where provider is "openrouter" (or a vendor for direct routing) and model is the bare vendor/model slug without a gateway prefix, e.g. { provider: "openrouter", model: "deepseek/deepseek-v4-pro", routing: "openrouter" }. This is what ai-gateway uses for catalog lookup and what OpenRouter HTTP expects.

Call normalizeInvokeModel at every gateway boundary (idempotent). Profile intent still goes through resolveAIProfile first, or use resolveAndNormalizeInvokeModel / resolveAndNormalizeBundledInvokeModel for the combined path.

| Export | Sync? | Purpose | |--------|-------|---------| | normalizeInvokeModel(input, options?) | Yes | Display slug or invoke object → canonical invoke tuple; rejects unresolved profile/choice | | resolveAndNormalizeInvokeModel(alias, options) | No (async) | resolveAIProfilenormalizeInvokeModel | | resolveAndNormalizeBundledInvokeModel(alias, options) | Yes | Bundled resolveAIProfilenormalizeInvokeModel (no network) |

import {
  normalizeInvokeModel,
  resolveAndNormalizeInvokeModel,
  resolveAndNormalizeBundledInvokeModel,
} from "@x12i/ai-profiles";

// Display slug → invoke tuple (string or { model } object)
normalizeInvokeModel("openrouter/deepseek/deepseek-v4-pro");
normalizeInvokeModel({ model: "openrouter/deepseek/deepseek-v4-pro" });
// → { provider: "openrouter", model: "deepseek/deepseek-v4-pro", routing: "openrouter", displaySlug: "..." }

// Already-correct tuple → unchanged (idempotent)
normalizeInvokeModel({ provider: "openrouter", model: "deepseek/deepseek-v4-pro" });

// Unresolved profile/choice at gateway → INVOKE_MODEL_ALIAS_AT_GATEWAY
normalizeInvokeModel("cheap/default");
normalizeInvokeModel({ model: "cheap/default" }); // same error

// Profile/choice → resolve, then normalize (gateway catalog-lookup tuple)
await resolveAndNormalizeInvokeModel("cyber/deep_forensics", {
  catalogLane: "text",
  preferOpenRouter: true,
});
// → { provider: "openrouter", model: "deepseek/deepseek-v4-pro", routing: "openrouter", ... }

// Sync bundled variant (tests, predict paths)
resolveAndNormalizeBundledInvokeModel("cyber/deep_forensics", {
  catalogLane: "text",
  preferOpenRouter: true,
});

Error codes (on AIProfilesError.code):

| Code | When | |------|------| | INVOKE_MODEL_ALIAS_AT_GATEWAY | profile/choice reached normalizeInvokeModel without prior resolveAIProfile | | INVOKE_MODEL_SHAPE_INVALID | Empty model, missing provider, or unparseable composite after normalization |

allowAlias: true exists for rare internal paths; gateway and HTTP callers should not use it — resolve aliases first.

Sync validation (bundled registry)

Use isKnownProfileChoice(input) before run/execute or on first paint when the remote registry may not be loaded yet. It reads the bundled JSON only (no network, no in-memory cache race).

const canRun =
  isKnownProfileChoice(aiProfileKey) &&
  isKnownProfileChoice(fallbackKey);

Accepts valid profile/choice keys only. Unknown strings return false.

After validation, call resolveAIProfile (usually source: "auto" or "bundled") to get the canonical resolved.profile and the concrete model details.

UI guidance

  • Profile picker: await listAIProfiles() → use each row’s profile as the value; show displayName as the label.
  • Choice picker: await listAIProfileChoices({ profile: "cheap" }) or list choices from the profile summary.
  • Stored config: store profile/choice composite keys (e.g. cheap/default).

Pick a specific model implementation (choice)

const model = await resolveAIProfile("cheap/anthropic_cheap", {
  catalogLane: "text",
});

OpenRouter vs vendor-direct routing

Remote profiles (anything that is not local / on-device) can be resolved for OpenRouter or for a vendor API directly. Local CPU profiles are always returned as-is.

Resolution priority

  1. options.preferOpenRouter when passed to resolveAIProfile
  2. PREFER_OPENROUTER in .env / process env
  3. Default: true (OpenRouter)

Supported today: OpenRouter and vendor-direct. Azure and AWS routing are not supported yet.

OpenRouter (default)

const resolved = await resolveAIProfile("cheap/default", { catalogLane: "text" });
// or explicitly:
await resolveAIProfile("cheap/default", { catalogLane: "text", preferOpenRouter: true });

// resolved.provider === "openrouter"
// resolved.modelId === "google/gemini-3.1-flash-lite"
// resolved.routing === "openrouter"
// resolved.backend === "openrouter"

Registry choices store vendor-native ids (e.g. gpt-5.4, gemini-2.5-flash-lite). When routing via OpenRouter, this package rewrites them to full slugs: {vendor}/{model} (e.g. openai/gpt-5.4, anthropic/claude-sonnet-4.6). Anthropic version segments are normalized to dotted form (4.6, not 4-6).

This applies to any profile/choice input that resolves to a remote profile choice.

Avoid hardcoded OpenRouter slugs (especially dated previews)

Do not copy OpenRouter slugs into graph JSON or app config (preActionModel, skillModel, postActionModel, env vars, etc.) and do not use canonicalSlug from the catalog for API calls. OpenRouter retires dated preview ids (e.g. google/gemini-2.5-flash-lite-preview-06-17) while stable ids remain (google/gemini-2.5-flash-lite).

// Good — store profile/choice; resolve at execute time
preActionModel: "cheap/default";
skillModel: "balanced/default";
postActionModel: "cheap/default";

const pre = await resolveAIProfile("cheap/default", { catalogLane: "text" });
// pre.modelId → "google/gemini-2.5-flash-lite" (concrete id only after resolution)

// Bad — concrete slug in stored config (stale when catalog moves)
preActionModel: "google/gemini-2.5-flash-lite-preview-06-17";

Use assertStableOpenRouterSlug(slug) before outbound OpenRouter calls when you must accept external slug strings. Run npm run verify:openrouter:live in CI to catch catalog drift.

Vendor-direct

const resolved = await resolveAIProfile("cheap/default", {
  catalogLane: "text",
  preferOpenRouter: false,
});

// resolved.provider === "google"
// resolved.modelId === "gemini-2.5-flash-lite"
// resolved.routing === "direct"
// resolved.backend === "custom"

Use this when calling OpenAI, Anthropic, Google, etc. APIs directly instead of through OpenRouter.

Rich catalog data (pricing, reasoning, modalities)

Use the right entry point for your input shape:

| Input | Function | Returns | |-------|----------|---------| | OpenRouter / vendor slug | resolveCatalogModelDetails(slug) or sync lookupBundledCatalogModel(slug) | ResolvedCatalogModel | | profile/choice | resolveAIProfile(key, { catalogLane, includeCatalog: true }) | ResolvedAIProfile + catalogModel | | Post-resolve slug check | requireCatalogModel(slug, { includeDetails: true }) | presence + optional catalogModel |

// Slug → full catalog enrichment
const model = await resolveCatalogModelDetails("google/gemini-2.5-flash-lite", {
  source: "bundled",
});
model.pricing?.input;                    // flattened per-1M rates
model.catalogPricing?.imageInput;        // raw OpenRouter pricing slice
model.contextWindow;                     // e.g. 1048576
model.maxCompletionTokens;               // e.g. 65536 (catalog maxCompletionTokens)
model.limits?.maxOutputTokens;           // same as maxCompletionTokens
model.modelSignals.isReasoningModel;     // true
model.modelSignals.maxOutputTokens;      // same as maxCompletionTokens
model.catalogCapabilities?.toolCalling;  // true
model.invocation.openrouter?.modelId;    // "google/gemini-2.5-flash-lite"
model.capabilities?.supportsOpenRouterReasoningParam; // gateway reasoning.effort

Invoke cost (Activix / FuncX)

import { calculateInvokeCost, buildInvokePricingRecord } from "@x12i/ai-profiles";

const cost = await calculateInvokeCost(
  {
    model: "cheap/default",
    tokens: { prompt: 1000, completion: 500 },
  },
  { catalogLane: "text", bundledOnly: true, includeBreakdown: true },
);
// cost.costUsd, cost.costStatus: "priced", cost.breakdown

// Profile/choice catalog validation
const required = await requireCatalogModel("cheap/default", {
  catalogLane: "text",
  resolveProfileKeys: true,
  bundledOnly: true,
});
// required.found, required.modelId, required.profileChoice

See .docs/AI-PROFILES-ACTIVIX-COST-CONTRACT.md for Activix migration wire.

Catalog SOT: do not use ai-tools' small OpenRouter index for invoke gating — use requireCatalogModel / validateBundledProfileCatalogLinkage on this package (npm run validate:linkage).

// Profile → same enrichment on catalogModel
const resolved = await resolveAIProfile("cheap/default", {
  catalogLane: "text",
  includeCatalog: true,
  includeCatalogEntry: true, // attach full models-catalog.json row
});
resolved.catalogModel?.pricing;
resolved.catalogModel?.catalogEntry;

Env fallback

Set in .env or the process environment:

PREFER_OPENROUTER=1    # or true, yes, on  → OpenRouter
PREFER_OPENROUTER=0    # or false, no, off → vendor-direct

When preferOpenRouter is omitted from resolveAIProfile, the env value is used. An explicit option always wins over env.

process.env.PREFER_OPENROUTER = "0";
await resolveAIProfile("cheap/default", { catalogLane: "text" }); // vendor-direct

await resolveAIProfile("cheap/default", { catalogLane: "text", preferOpenRouter: true }); // still OpenRouter

Helper exports:

import {
  resolvePreferOpenRouter,
  buildOpenRouterModelId,
  buildModelInvocation,
  resolveModelIdentity,
} from "@x12i/ai-profiles";

resolvePreferOpenRouter(); // true by default
resolvePreferOpenRouter(false); // explicit override

buildOpenRouterModelId("openai", "gpt-5.4"); // "openai/gpt-5.4"

// Same direct + OpenRouter shape whether input is bare or prefixed:
buildModelInvocation("anthropic", "claude-3-5-sonnet-20241022");
buildModelInvocation("anthropic", "anthropic/claude-3-5-sonnet-20241022");

// Bare model string → vendor + model (no profile name required):
resolveModelIdentity("google/gemini-2.5-flash-lite");
// { provider: "google", modelId: "gemini-2.5-flash-lite", invocation: { … } }

resolveModelIdentity("google/gemini-2.5-flash-lite", { asOpenRouter: true });
// { provider: "openrouter", modelId: "google/gemini-2.5-flash-lite", … }

Tests: test/resolveModelRouting.test.ts, test/modelIdentity.test.ts, test/resolveModelIdentity.test.ts.

Use bundled only

const model = await resolveAIProfile("balanced/default", {
  catalogLane: "text",
  source: "bundled",
});

Force refresh

const model = await resolveAIProfile("balanced/default", {
  catalogLane: "text",
  refresh: true,
});

Runtime behavior

Default (source: "auto"):

  1. Fetch models-profiles.json from the assets URL (see below).
  2. Validate the response; if valid, use it and cache it in process memory for 24 hours.
  3. If fetch or validation fails, use the JSON shipped inside this npm package (bundled fallback).
  4. After TTL, the next call tries fetch again; if that fails but an earlier fetch was cached, reuse the stale cache with a warning.

source: "bundled" — package JSON only, no network.

source: "remote" — fetch every time (still cached after success); errors propagate, no bundled fallback.

No database. No disk cache in v1.

The version field in the JSON files (and registryVersion on resolved output) is an optional label for humans or external tooling. This package does not compare versions, enforce semver, or pick assets by version number.

Remote assets

  • https://open-assets.x12i.com/models-profiles.json

Integration example

import { resolveAIProfile, resolveAndNormalizeInvokeModel } from "@x12i/ai-profiles";

// Graph JSON stores aliases only (merged job defaults + node overrides per task)
const slots = {
  preActionModel: node.taskConfiguration.modelConfig?.preActionModel ?? "deep/default",
  skillModel: node.taskConfiguration.modelConfig?.skillModel ?? "balanced/default",
  postActionModel: node.taskConfiguration.modelConfig?.postActionModel ?? "cheap/default",
};

// Resolve each phase independently at execute time
const pre = await resolveAIProfile(slots.preActionModel, { catalogLane: "text" });
const main = await resolveAIProfile(slots.skillModel, { catalogLane: "text" });
const post = await resolveAIProfile(slots.postActionModel, { catalogLane: "text" });

// Gateway handoff: resolve + normalize invoke tuple
const mainInvoke = await resolveAndNormalizeInvokeModel(slots.skillModel, {
  catalogLane: "text",
  preferOpenRouter: true,
});
// mainInvoke → { provider: "openrouter", model: "openai/gpt-5.4", routing: "openrouter", ... }

Profile matrix (FuncX contract)

Default choice per profile (vendor-native ids in registry; OpenRouter slugs when preferOpenRouter is true):

| Profile | Inference | Instruction tier | Default choice | Example model | |---------|-----------|------------------|----------------|---------------| | cheap | Remote | default | default | gemini-2.5-flash-lite | | fast | Remote | default | default | gpt-5.4-mini | | balanced | Remote | default | default | gpt-5.4 | | deep | Remote | reasoning | default | claude-sonnet-4.6 | | pro | Remote | default | default | gpt-5.5 | | json | Remote | default | default | gpt-5.4-mini (schema output) | | agentic | Remote | default | default | claude-sonnet-4.6 | | research | Remote | reasoning | default | claude-sonnet-4.6 | | cyber | Remote | reasoning | default | claude-sonnet-4.6 | | content | Remote | default | default | claude-sonnet-4.6 | | sum | Remote | default | default | claude-haiku-4.5 | | vision | Remote | default | default | gpt-5.4 | | local | CPU (no OpenRouter) | cpu | llama_cpp_gguf | funcx:llama-cpp:gguf | | local | CPU | cpu | transformersjs_default | Xenova/distilbart-cnn-6-6 |

Local CPU choices expose FuncX env keys in resolved.metadata (e.g. LLAMA_CPP_MODEL_PATH, TRANSFORMERS_JS_MODEL_ID).

OpenRouter model ids

Remote profile choices in models-profiles.json use vendor-native modelId values (e.g. gpt-5.4, gemini-2.5-flash-lite). By default, resolveAIProfile returns OpenRouter-ready output:

  • provider: "openrouter"
  • modelId: full slug such as openai/gpt-5.4
  • routing: "openrouter"

Pass preferOpenRouter: false (or PREFER_OPENROUTER=0) to get vendor-native provider + modelId for direct API calls.

Every resolved profile includes invocation with both call shapes:

resolved.invocation.direct    // { provider: "openai", modelId: "gpt-5.4" }
resolved.invocation.openrouter // { provider: "openrouter", modelId: "openai/gpt-5.4" } | null

Top-level provider / modelId / routing remain the active path selected by preferOpenRouter.

Vendor trimming

Restrict resolution to specific vendors (auto-picks within the profile when the default is outside the set). Leading vendors (openai, anthropic, google, deepseek, minimax, xai) are preferred during auto-pick, not forced — explicit choice still honors together and other providers.

await resolveAIProfile("balanced/default", {
  catalogLane: "text",
  allowedProviders: ["google", "openai"],
  preferLeadingVendors: true, // default
});

Modality filtering (input / output media)

Every profile declares required input and output media in requiredCapabilities:

  • Most profiles: inputModalities: ["text"], outputModalities: ["text"]
  • vision: inputModalities: ["text", "image"], outputModalities: ["text"]

Supported values: text, image, audio, video, file (aligned with models-catalog.json).

During resolution, choices whose catalog modalities do not satisfy the contract are excluded. A text-only model cannot be picked for vision; a vision-capable model can still serve text profiles.

const resolved = await resolveAIProfile("vision/default", { catalogLane: "image" });
// resolved.requiredModalities.input includes "image"
// resolved.modalities reflects the picked model from catalog

await resolveAIProfile("cheap/default", {
  catalogLane: "text",
  inputModalities: ["text", "image"], // override for this call
  outputModalities: ["text"],
});

Override at resolve time with inputModalities / outputModalities. Incompatible explicit choice values throw NO_MODALITY_MATCH.

Registry maintenance:

import { validateBundledProfileModalityCoverage } from "@x12i/ai-profiles";

validateBundledProfileModalityCoverage(); // { ok, profiles[] } with mismatches

npm run catalog:build writes MODALITY_MISMATCH rows to reports/fault-report.json.

Cost cap filtering

Restrict auto-pick to choices whose catalog pricing stays under a USD-per-1M-token ceiling:

// Both input and output rates must be <= $0.20 / 1M tokens
await resolveAIProfile("balanced/default", { catalogLane: "text", costCapPer1M: 0.2 });

// Finer control
await resolveAIProfile("cheap/minimax_cheap", {
  catalogLane: "text",
  costCapPer1M: { maxInput: 0.2, maxOutput: 2 },
});

When the default choice exceeds the cap, resolution auto-picks the next compatible alternative (respecting fallback order and vendor preferences). Explicit choice values over the cap throw NO_COST_CAP_MATCH. Local/self-hosted choices are treated as zero cost.

Keeping models aligned over time

Bundled linkage lives in src/data/models-registry.json, built from models-profiles.json and src/data/models-catalog.json.

Each catalog model has two explicit call shapes (never provider: "openrouter" with modelId: "qwen/..." as the only view):

  • direct — vendor-native ({ provider: "google", modelId: "gemini-3.5-flash" })
  • openrouter — transport ({ provider: "openrouter", modelId: "google/gemini-3.5-flash" })

Legacy open-assets files named openrouter-models-catalog.json are normalized into this shape on catalog:pull.

When OpenRouter deprecates or renames a model slug:

  1. Update src/data/models-profiles.json to a current vendor-native id (preferred).
  2. Refresh src/data/models-catalog.json via npm run catalog:pull (fetched from open-assets, normalized locally).
  3. Run alignment checks before publishing:
npm run registry:finish         # merge profile catalog supplement, verified defaults, rebuild linkage
npm run catalog:sync            # pull catalog, rebuild models-registry.json, verify
npm run catalog:pull            # fetch open-assets catalog into src/data/
npm run catalog:build           # rebuild models-registry.json + reports/fault-report.json
npm run verify:openrouter       # bundled catalog + resolved slug policy (offline-safe)
npm run verify:openrouter:live  # above + live OpenRouter API id check (needs network; retries 3x)
npm run catalog:verify          # registry build / fault report from sync-models-registry

With the seed catalog (<50 models), choices not yet listed are marked catalog-miss in the registry (warnings in reports/fault-report.json, not CI failures). A full catalog promotes true faulty rows for slug mismatches.

Unit tests in test/openrouterAlignment.test.ts, test/modelIdentity.test.ts, test/resolveModelIdentity.test.ts, and test/resolveModelRouting.test.ts guard resolution rules offline.

Vendor-direct-only choices (not routed via OpenRouter) can opt out:

"metadata": { "openRouter": "vendor-direct" }

Tests

npm test              # build + full suite
npm run test:unit     # source tests only (skip integration/exports)
npm run test:integration  # integration + dist export smoke tests
npm run pack:check    # dry-run npm tarball contents

Publishing to npm

This package is published as a public scoped module: @x12i/ai-profiles on npmjs.com.

Prerequisites

  1. npm account with access to the @x12i organization/scope
  2. Authenticated CLI: npm login (or set NPM_TOKEN — see .npmrc.example)
  3. Git remote: github.com/x12i/ai-profiles

Publish locally

npm run pack:check   # verify tarball includes dist/ + JSON data
npm publish --access public

prepublishOnly runs the full test suite; prepack rebuilds dist/ before packing.

Publish via GitHub Actions

Workflow YAML is in docs/github-workflows/. Copy into .github/workflows/ and push using a GitHub token with the workflow scope (see docs/github-workflows/README.md).

  1. Add repository secret NPM_TOKEN (npm automation token with publish rights for @x12i)
  2. Create a GitHub Release (tag e.g. v1.0.0) — triggers publish-npm.yml
  3. Or run Publish to npm workflow manually from the Actions tab

After publish

Consumers install with:

npm install @x12i/ai-profiles@^3.2.0

Peer dependency for downstream repos (graphs-studio, graph-engine, ai-tasks):

"@x12i/ai-profiles": "^3.2.0"

License

MIT — see LICENSE.