# @x12i/ai-tools

TypeScript package for AI model catalogs, runtime cost calculation, and LLM utilities.

**Catalog source of truth:** [`@x12i/ai-profiles`](https://www.npmjs.com/package/@x12i/ai-profiles) **^4.2.1** — profile registry + unified models catalog (direct vendor + OpenRouter slices on each entry). Remote asset: [models-catalog.v2.json](https://open-assets.x12i.com/models-catalog.v2.json).

### Resolution order

1. **Known profile input** → `@x12i/ai-profiles` (`resolveInput` / `isKnownProfileInput`)
2. **Concrete model** → ai-profiles identity + unified catalog
3. **Fuzzy match** → `ModelNameResolver` over the ai-profiles-loaded catalog map
4. Pattern last-resort (vendor inference) only when nothing above hits

## v5.0 breaking changes

- Removed ai-tools-owned dual catalogs (`src/data/*.json`, legacy open-assets URLs).
- Profile-first resolve (see above). Do not pass `profile/choice` through vendor-slug parsers.

## v3.0 breaking changes

Aligned with [**@x12i/ai-profiles**](https://www.npmjs.com/package/@x12i/ai-profiles) **v3+** (current dependency `^4.2.1`).

### Accepted model strings

Only two forms are supported everywhere (`model`, `modelUsed`, `usedModel`, CLI `--model`, invoke config):

1. **`profile/choice`** — e.g. `cheap/default`, `balanced/default`, `deep/google_deep`
2. **Concrete model id** — e.g. `deepseek-v3.2`, `deepseek/deepseek-v3.2`, `gpt-5.5`, `openai/gpt-5.5`, `gemini-2.5-flash-lite`

### Removed (v3)

| Removed | Replacement |
|---------|-------------|
| ai-profiles shortcuts (`standard`, `cheapest`, `economy`, …) | Full `profile/choice` keys (`balanced/default`, `cheap/default`, …) |
| Bare profile names (`cheap`, `balanced`) | `cheap/default` or `{ profile: "cheap", choice: "default" }` |
| Project-local aliases (`@x12i/ai-tools/aliases`, `ai-tools alias` CLI, `AliasRegistry`) | Store `profile/choice` or concrete model ids in config |
| Legacy model shorthands (`deepseekv32`, `claude-sonnet`, …) | Catalog ids (`deepseek-v3.2`, `deepseek/deepseek-v3.2`) |
| `listAIShortcuts` | `listAIProfiles` / `listAIProfileChoices` |
| `applyOpenRouterInvokePolicy` | `applyOpenRouterInvokeRouting` |
| `ModelProfileUnroutableError.profileAlias` | `profileKey` |

### Migration examples

| Before (v2.x) | After (v3) |
|---------------|------------|
| `standard`, `cheapest` | `balanced/default`, `cheap/default` |
| `cheap` (bare) | `cheap/default` |
| `ai-tools alias set best …` | Store concrete id or `profile/choice` in config |
| `deepseekv32` | `deepseek-v3.2` or `deepseek/deepseek-v3.2` |
| `applyOpenRouterInvokePolicy(...)` | `applyOpenRouterInvokeRouting(...)` |

**Dependency:** `@x12i/ai-profiles` `^4.2.1`.

## Invoke model resolution

Host-agnostic pipeline for **AI engines** that call `LLMProviderRouter.invoke()`. Pass `{ provider?, model }`; get router-ready `{ provider, model, allowOpenRouterProxy?, providerProxy? }` plus structured diagnostics.

```ts
import { resolveInvokeModel } from "@x12i/ai-tools";
// or: import { resolveInvokeModel } from "@x12i/ai-tools/invoke";

const { router, diagnostics, resolution } = await resolveInvokeModel(
  { provider: invokeConfig.provider, model: invokeConfig.model },
  {
    preferOpenRouter: true,
    openRouterApiKey: process.env.OPENROUTER_API_KEY,
    defaultProvider: "openai",
    modelsOnly: false, // set true to reject profile/choice inputs
  },
);

Object.assign(invokeConfig, router);
// diagnostics.profileKey / invokedModelId for logging when routedViaOpenRouter
```

| Export | Role |
|--------|------|
| `resolveInvokeModel` | Full pipeline (profile → catalog → vendor map → OR routing) |
| `buildInvokeModelResolverOptions` | Build `ModelResolverOptions` with `routeViaOpenRouter` when preferred |
| `mapResolutionToRouterConfig` | Map `ModelResolutionSuccess` → `{ provider, model }` |
| `applyOpenRouterInvokeRouting` | Set `allowOpenRouterProxy` / `providerProxy` on router config |
| `normalizeInvokeModel` | Idempotent gateway invoke wire shape (from ai-profiles v3.2) |
| `resolveAndNormalizeInvokeModel` | `resolveAIProfile` → `normalizeInvokeModel` |
| `createAiToolsInvokeClient` | Shared catalog + cost calculator + routing env |
| `ModelProfileUnroutableError` | Profile/choice input that failed to route |

**Responsibility split:** ai-tools owns model strings, catalogs, OpenRouter vs direct transport, and cost. The AI engine owns sampling defaults, `maxTokens`, message templates, router registration, and activity tracking.

Set `resolveModels: false` for passthrough (no catalog lookup, no resolution errors).

## AI profiles (`@x12i/ai-profiles` v3)

Profile resolution follows the same strict contract as `@x12i/ai-profiles` — **inside and outside** this repo (configs, graph nodes, AI engines, any service that calls `resolveAIProfile`).

### Two kinds of model strings

| Intent | What you pass | How ai-tools resolves it |
|--------|---------------|---------------------------|
| **Profile** (capability tier) | `profile/choice` only | `@x12i/ai-profiles` → concrete `provider` + `modelId` |
| **Concrete model** (vendor SKU) | `gemini-2.5-flash-lite`, `deepseek/deepseek-v3.2`, … | Catalog / profile-choice **index** |

```mermaid
flowchart TD
  input["model / modelUsed / usedModel string"]
  input --> composite{"profile/choice?"}
  composite -->|yes| profiles["@x12i/ai-profiles\nresolveAIProfile"]
  composite -->|no| concrete["catalog + registry index\n(concrete model SKU)"]
  profiles --> out["provider + modelId + pricing"]
  concrete --> out
```

### Profile keys: `profile/choice` (required)

Canonical resolve key: **`{profile}/{choice}`** with a **forward slash** `/`.

```ts
"cheap/default"
"balanced/default"
"deep/google_deep"
"local/llama_cpp_gguf"
```

- **Profile** = capability contract (`cheap`, `balanced`, `deep`, `json`, …) — not a model name.
- **Choice** = implementation variant within that profile (`default`, `google_floor`, …).

**Bare profile names are not accepted** (`cheap`, `balanced`, `deep` alone). ai-tools does not infer `default` for you.

| Valid | Invalid |
|-------|---------|
| `cheap/default` | `cheap` |
| `deep/default` | `standard`, `cheapest` (removed shortcuts) |
| `balanced/default` | `balanced` (without `/choice`) |

### `resolveProfileForAsk` — split fields allowed

For ask-node / FuncX config, pass either the combined key or explicit fields (still requires a choice):

```ts
import { resolveProfileForAsk, TEXT_CATALOG_LANE } from "@x12i/ai-tools";

await resolveProfileForAsk({
  profile: "cheap/default",
  catalogLane: TEXT_CATALOG_LANE,
});

await resolveProfileForAsk({
  profile: "cheap",
  choice: "default",
  catalogLane: TEXT_CATALOG_LANE,
});
```

`catalogLane` is required (`"text"` for most tiers; `"image"` for `vision`).

### Concrete model names

When the string is a **vendor or OpenRouter model id**, resolution uses the catalog and the profile registry index:

```ts
import { CostCalculator, AiModelsCatalogClient } from "@x12i/ai-tools";

const calculator = new CostCalculator(catalog);

await calculator.calculate({
  tokens: { prompt: 1_000_000, completion: 0, total: 1_000_000 },
  provider: "google",
  modelUsed: "gemini-2.5-flash-lite",
});

await calculator.calculate({
  tokens: { prompt: 1_000_000, completion: 0, total: 1_000_000 },
  provider: "google",
  modelUsed: "cheap/default",
});
```

```ts
const catalog = new AiModelsCatalogClient();

await catalog.resolveModel({ model: "gemini-2.5-flash-lite", provider: "google" });
await catalog.resolveModel({ model: "cheap/default" });
// await catalog.resolveModel({ model: "cheap" }); // not found
// await catalog.resolveModel({ model: "standard" }); // not found (shortcut removed)
```

### Errors and helpers

```ts
import {
  requireProfileResolveKey,
  profileResolveKey,
  ProfileResolveKeyRequiredError,
  formatProfileChoiceKey,
  isKnownProfileInput,
  isKnownProfileChoice,
  ensureConcreteModel,
} from "@x12i/ai-tools";

await requireProfileResolveKey("cheap", { choice: undefined }); // throws

await profileResolveKey("gemini-2.5-flash-lite"); // null — concrete SKU
await profileResolveKey("cheap/default"); // "cheap/default"

isKnownProfileInput("cheap/default"); // true
isKnownProfileInput("google/gemma-3-4b-it"); // false
isKnownProfileChoice("standard"); // false
```

### What to put in configs

| Stored value | Use when |
|--------------|----------|
| `cheap/default`, `deep/default` | Task / graph tier (profile intent) |
| `gemini-2.5-flash-lite`, `deepseek/deepseek-v3.2`, `openai/gpt-5.5` | Explicit model SKU |
| ~~`cheap`~~, ~~`standard`~~, ~~`deepseekv32`~~ | **Do not use** — rejected in v3 |

### Re-exports

```ts
import {
  resolveAIProfile,
  listAIProfiles,
  listAIProfileChoices,
  resolveAIProfileByTags,
  resolvePreferOpenRouter,
  readPreferOpenRouterFromEnv,
  ensureConcreteModel,
  resolveProfileForAsk,
  TEXT_CATALOG_LANE,
} from "@x12i/ai-tools";
// or
import { resolveProfileForAsk, requireProfileResolveKey } from "@x12i/ai-tools/profiles";
```

## Install

```bash
npm install @x12i/ai-tools
```

Requires **Node.js 20+**.

Optional peers: `@x12i/helpers`, `openai`, `better-sqlite3` (toolbox SQLite storage).

## Quick start

```ts
import { AiModelsCatalogClient, CostCalculator } from "@x12i/ai-tools";

const catalog = new AiModelsCatalogClient();
const calculator = new CostCalculator(catalog);

const result = await calculator.calculate({
  tokens: { prompt: 1000, completion: 500, total: 1500 },
  provider: "openai",
  usedModel: "gpt-5.5-2026-04-23",
});

console.log(result.cost);
console.log(result.resolvedModelId);
console.log(result.usedModel);
console.log(result.usage);
```

From activity or invoke records:

```ts
const result = await calculator.calculateFromRecord(activityDocument);
```

### Catalog loading and cache

Catalogs load via `@x12i/ai-profiles` (`loadModelsCatalog`) on first use and are cached in memory for **24 hours**. Override TTL with `AI_TOOLS_CACHE_TTL_MS`. Use `bundledOnly: true` / `--bundled-only` to skip remote and use the ai-profiles bundled catalog.

```bash
npx ai-tools catalog refresh
npx ai-tools catalog verify --json
npx ai-tools catalog refresh --bundled-only
```

## Cost calculation

```ts
await calculator.calculate({
  tokens: { prompt: 1000, completion: 500, total: 1500 },
  provider: "openrouter",
  usedModel: "deepseek/deepseek-v3.2",
});
```

Runtime model priority: `usedModel` → `modelUsed` → `model`.

**Profile vs model in `usedModel`:** pass `cheap/default` for tier-based pricing; pass `gemini-2.5-flash-lite` (or `vendor/slug`) for a concrete SKU.

## Smart model resolution

`ModelNameResolver` normalises `provider` + `model` input against the cached catalog. Catalog record aliases (metadata in JSON, e.g. `deepseek-v3.2` → `deepseek/deepseek-v3.2`) are supported; shorthand tokens (`deepseekv32`, etc.) are not.

```ts
import { AiModelsCatalogClient, ModelNameResolver } from "@x12i/ai-tools";

const catalog = new AiModelsCatalogClient();
const models = await catalog.getAllModels();
const resolver = new ModelNameResolver(models);

const result = await catalog.resolveModel({
  provider: "openrouter",
  model: "deepseek-v3.2",
});
```

| Input | Typical resolution |
|-------|-------------------|
| `openrouter` + `deepseek-v3.2` | `deepseek/deepseek-v3.2` |
| `cheap/default` | ai-profiles → e.g. `deepseek/deepseek-v4-flash` |
| `standard`, `cheapest` | **not found** (use `balanced/default`, `cheap/default`) |
| `deepseekv32` | ai-profiles alias → `deepseek-v3.2` (use `deepseek/deepseek-v3.2` for explicit SKU) |
| `gemma-3-4b-it` | registry index / catalog |
| `ollama` + `llama3:8b` | local passthrough |

### OpenRouter vs direct routing

| Condition | Routes via OpenRouter |
|-----------|----------------------|
| `PREFER_OPENROUTER` true (default) and `OPENROUTER_API_KEY` set | Yes |
| `OPENROUTER_API_KEY` set, vendor `{VENDOR}_API_KEY` missing | Yes |
| Vendor key present, `PREFER_OPENROUTER=false` or `0` | Direct |

```ts
import {
  loadOpenRouterRoutingEnv,
  shouldDefaultRouteViaOpenRouter,
  resolvePreferOpenRouter,
} from "@x12i/ai-tools";
```

## CLI

```bash
npx ai-tools catalog refresh
npx ai-tools catalog verify --json
npx ai-tools models list --provider openai
npx ai-tools models resolve --model deepseek-v3.2 --provider openrouter --verbose
npx ai-tools cost --model openai/gpt-5.5 --prompt-tokens 1000 --completion-tokens 500 --provider openai
npx ai-tools cost --model cheap/default --prompt-tokens 1000000 --completion-tokens 0
npx ai-tools models resolve --model cheap/default --verbose
```

## Environment variables

Copy `.env.example` to `.env` in your app.

| Variable | Purpose |
|----------|---------|
| `OPENROUTER_API_KEY` | OpenRouter routing defaults; enables invoke proxy routing when set |
| `PREFER_OPENROUTER` | `true` / `1` / `false` / `0` — prefer OpenRouter when OR + vendor keys both set (default `true` when unset; parsed via `@x12i/ai-profiles`) |
| `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, … | Direct vendor keys (`{PROVIDER_ID}_API_KEY`) |
| `AI_TOOLS_CACHE_TTL_MS` | Catalog in-memory cache TTL (default: `86400000` = 24h) |

## Package exports

| Subpath | Contents |
|---------|----------|
| `@x12i/ai-tools` | Catalog client, cost calculator, resolver, profiles, invoke |
| `@x12i/ai-tools/cost` | Cost types, `extractUsageInput`, `CostCalculator` |
| `@x12i/ai-tools/catalog` | `loadCatalogSources`, refresh/verify |
| `@x12i/ai-tools/models` | Model listing and filters |
| `@x12i/ai-tools/sync` | `ModelNameResolver`, OpenRouter fetch helper |
| `@x12i/ai-tools/profiles` | `resolveProfileForAsk`, ai-profiles re-exports |
| `@x12i/ai-tools/invoke` | `resolveInvokeModel`, OpenRouter invoke routing helpers |
| `@x12i/ai-tools/toolbox` | Tracker, router, guard |

**Dependencies:** `@x12i/ai-profiles` `^4.2.1`, `@x12i/env` `^4.0.3`.

## Runtime targets

`@x12i/ai-tools` targets **Node ≥20** (CLI, catalog HTTP fetch, optional Mongo-shaped usage extraction). Subpaths such as `./cost` or pure resolver helpers may run in other runtimes when you avoid Node-only APIs — but profile resolution pulls in `@x12i/ai-profiles`, which depends on `@x12i/logxer@^5.1.0` for Node-side registry tooling.

This package does **not** import `@x12i/logxer` directly.

### `@x12i/logxer@5.1.0` — ESM/CJS and import boundaries

`@x12i/logxer@4.x` was CJS-only (`"type": "commonjs"`, single `"default"` export). Vite and other ESM hosts that did `import { createLogxer } from '@x12i/logxer'` hit CJS/ESM interop failure even though `createLogxer` existed on the CJS export object.

**`@x12i/logxer@5.1.0` fixes this** with dual ESM + CJS builds (real named exports in both), a browser-safe **`@x12i/logxer/package-levels`** subpath, and a **node-only stub** on the main entry so accidental browser imports fail with a clear message.

When your app also uses logxer alongside ai-tools, follow subpath rules so Vite client bundles never pull the Node logger:

| Need | Import from | Runs in |
|------|-------------|---------|
| Full logger factory, Mongo persistence, sessions, fetch | `@x12i/logxer` | **Node only** (BFF, execute harness, SSR with Node externals) |
| Env key naming / parse package level strings | `@x12i/logxer/package-levels` | **Browser + Node** |
| Diagnostic evidence helpers, catalog types | `@x12i/logxer/diagnostics` | Browser-safe for pure helpers; check each symbol |
| Agent instruction docs | `@x12i/logxer/agent-instructions` | Node / tooling |

**Never** import `@x12i/logxer` (main) from Vite client bundles or any file that lands in the browser graph.

```ts
// Browser / Vite client — env key naming only
import { packageLogsLevelEnvKey } from "@x12i/logxer/package-levels";

// Node / SSR / BFF — full logger (requires @x12i/logxer@^5.1.0 for ESM named exports)
import { createLogxer } from "@x12i/logxer";
```

**Upgrade:** `npm install @x12i/logxer@^5.1.0` (pulled transitively via `@x12i/ai-profiles@^3.4.1` when you install ai-tools).

## Tests

```bash
npm test
npm run build
npm run test:live    # optional OpenRouter API live test
```

## License

MIT
