@molecule/api-resource-ai-models

API resource · ai-models · API (Node) · v1.16.0 · Apache-2.0

Server-side AI model catalog plus GET /ai/models endpoint

npm install @molecule/api-resource-ai-models

npm · Source on GitHub

How it works

@molecule/api-resource-ai-models is an API resource: the routes, validation and storage for ai-models, built on the database and auth cores so it runs on whichever providers your app has bonded.

import { getModel, MODEL_IDS } from '@molecule/api-resource-ai-models'

// Server-side validation of a client-selected model id:
if (!MODEL_IDS.has(requestedId)) {
  throw new Error('Unknown or retired model')
}
const model = getModel(requestedId)! // full definition (pricing, effort levels)

Works with: @molecule/api-bond

Reference

AI model catalog resource.

Server-side source of truth for available AI models plus an authentication-gated discovery endpoint (GET /ai/models). Server consumers (chat handler, compaction) import MODELS / getModel / MODEL_IDS directly; authenticated clients fetch the filtered projection over HTTP. The list handler enforces the session check itself and fails closed with 401, so the configured-model catalog is never disclosed to an unauthenticated caller even if the route's 'authenticate' middleware is stripped by codegen.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { getModel, MODEL_IDS } from '@molecule/api-resource-ai-models'

// Server-side validation of a client-selected model id:
if (!MODEL_IDS.has(requestedId)) {
  throw new Error('Unknown or retired model')
}
const model = getModel(requestedId)! // full definition (pricing, effort levels)

Type

resource

Installation

npm install @molecule/api-resource-ai-models @molecule/api-bond @molecule/api-i18n @molecule/api-resource

API

Interfaces

ModelTokenRates

The four per-MTok token rates a turn bills at (USD).

interface ModelTokenRates {
  /** Input price per million uncached tokens in USD. */
  inputPricePerMTok: number
  /** Output price per million tokens in USD. */
  outputPricePerMTok: number
  /** Prompt-cache read price per million tokens in USD. */
  cacheReadPricePerMTok: number
  /** Prompt-cache write price per million tokens in USD. */
  cacheWritePricePerMTok: number
}

ModelDefinition

Full server-side metadata for an AI model. Consumed directly by the chat handler, compaction, and any other server-side cost / budget logic.

interface ModelDefinition {
  /** API model ID (e.g. `'claude-sonnet-4-6'`, `'gpt-5.4'`). */
  id: string
  /** Which AI provider serves this model. */
  provider: AIProviderID
  /** Human-readable label (e.g. `'Claude Sonnet 4.6'`). */
  label: string
  /** Short description for UI display. */
  description: string
  /** Maximum input context window in tokens. */
  contextWindow: number
  /** Maximum output tokens per response. */
  maxOutputTokens: number
  /** Whether the model supports extended thinking / chain-of-thought. */
  supportsThinking: boolean
  /** Default thinking budget in tokens (only relevant when `supportsThinking` is true). */
  thinkingBudgetTokens: number
  /**
   * Whether the thinking budget can be controlled via API params.
   * When false, the model always reasons but does not accept a thinking / reasoning_effort param.
   */
  thinkingConfigurable: boolean
  /**
   * The model's OWN reasoning-effort levels, ordered ascending (least → most
   * effort) — the exact values a user picks, that get persisted, and that the
   * `/effort` command offers. There is NO abstract scale: these are the model's
   * real levels.
   *
   * - **Native-effort models** (Anthropic `output_config.effort`, OpenAI
   *   `reasoning_effort`, Gemini `thinking_level`, …) list their provider values
   *   verbatim, e.g. `['low', 'high', 'xhigh', 'max']` — each value is sent as
   *   the provider's effort param as-is.
   * - **Budget-configurable models** (a raw thinking-token budget, no native
   *   level names — e.g. Claude Haiku 4.5, Qwen3.7) list scaled-budget LABELS,
   *   e.g. `['4K', '8K', '16K', '32K']`, with `effortBudgetTokens` mapping
   *   each label to the actual token budget sent.
   * - **Fixed-reasoning models** (DeepSeek executors, Kimi, …) omit this field
   *   entirely — reasoning depth can't be tuned, so there is nothing to pick.
   *
   * A persisted value outside the active model's set degrades to the nearest one
   * (`model-selection.ts` `resolveEffortForModel`). Absent → no effort choice.
   */
  supportedEffortLevels?: EffortLevel[]
  /**
   * The model's default effort value — the one used when the user hasn't chosen.
   * MUST be a member of `supportedEffortLevels`. Absent only when the model
   * has no effort levels (fixed reasoning).
   */
  defaultEffortLevel?: EffortLevel
  /**
   * For budget-configurable models ONLY: maps each label in
   * `supportedEffortLevels` to the thinking-token budget it sends
   * (e.g. `{ '4K': 4000, '8K': 8000, '16K': 16000, '32K': 32000 }`). Its
   * presence is what marks a model as budget-driven rather than native-effort:
   * a model WITH this map sends `budget_tokens`; a model WITHOUT it sends its
   * chosen level as the provider's native effort param.
   *
   * CRITICAL for Anthropic 4.6+ models (Fable 5, Opus 4.8/4.6, Sonnet 5 / 4.6):
   * these must NOT carry this map — they are native-effort models, and sending
   * `budget_tokens` returns a 400 on Fable 5 / Opus 4.8 / Sonnet 5.
   */
  effortBudgetTokens?: Record<string, number>
  /** Whether the model supports vision (images, documents, etc.). */
  supportsVision: boolean
  /** Whether the model supports prompt caching. */
  supportsPromptCaching: boolean
  /** Whether the model supports tool use / function calling. */
  supportsTools: boolean
  /**
   * Tool-less utility calls (commit messages) run this model with reasoning
   * explicitly off (`effort: 'none'`); tool-carrying turns use the turn's
   * reasoning effort like any other model. Set for the OpenAI gpt-5.4+ entries
   * that accept `'none'`. (A temperature on those that reject one is handled
   * by `rejectsTemperature`, not by this.)
   *
   * The name is historical: on /v1/chat/completions these models also rejected
   * function tools combined with reasoning, so tool-carrying calls were pinned
   * off too. The OpenAI bond calls /v1/responses, where tools and reasoning
   * combine, and that pin was lifted — reasoning off with web search made the
   * model search again and repeat its tool call on every turn. Kept under this
   * name because renaming a published field breaks consumers.
   */
  toolsRequireReasoningOff?: boolean
  /**
   * The provider rejects a FORCED tool choice for this model — Anthropic
   * `tool_choice` `any` / `tool` answer 400 on claude-fable-5-1 and
   * claude-opus-5-5, and Moonshot rejects `required` while thinking is on,
   * which it always is on kimi-k2.7-code (all probed live 2026-09-23). `auto`
   * works and the model still calls the tool. Callers that would force a tool call (Synthase
   * discovery, starting-point selection) must send `auto` for these models;
   * molecule-dev resolves that in one place (`toolChoiceParam` in
   * request-shape.ts) and its live dispatch check probes the forced shape for
   * every model, so a new model with this restriction fails the check instead
   * of every discovery turn.
   */
  rejectsForcedToolChoice?: boolean
  /**
   * The provider rejects a caller-chosen `temperature` for this model —
   * Anthropic answers 400 "`temperature` is deprecated for this model" on the
   * Claude 5 family and Opus 4.7/4.8 (Opus 4.6, Sonnet 4.6 and Haiku 4.5 still
   * accept it), and Moonshot's native host allows only 1 on kimi-k3 and
   * kimi-k2.7-code (all probed live 2026-09-23). Omitting it is always safe. Callers that set a temperature (commit
   * messages, starting-point selection) must omit it for these models;
   * molecule-dev resolves that in one place (`temperatureParam` in
   * request-shape.ts), and its live dispatch check sends the tool-less,
   * temperature-0 shape to every model.
   */
  rejectsTemperature?: boolean
  /**
   * Provider-specific server tool type for web search (e.g. `'web_search_20250305'`).
   * When set, the chat handler sends this as a ServerTool alongside custom tools.
   * Omit if the model / provider does not support native web search.
   *
   * Set it only when a search actually runs on a Synthase-shaped request
   * (function tools present, streaming) — some providers accept the tool and
   * silently never search in that shape. Requires `webSearchPricePer1k`.
   */
  webSearchToolType?: string
  /**
   * USD per 1,000 web searches the provider bills on top of tokens, applied to
   * `TokenUsage.webSearchRequests`. Required whenever `webSearchToolType` is
   * set (enforced by the catalog tests) — a search tool with no price is
   * unmetered spend. Search-result tokens are billed as ordinary input tokens
   * and need nothing here. Free monthly allowances are account-wide, not
   * per-user, so they are not modeled: every search is metered at list price.
   */
  webSearchPricePer1k?: number
  /**
   * Provider-specific server tool type for code execution (e.g. `'code_execution_20250825'`).
   * Omit if the model / provider does not support native code execution.
   */
  codeExecutionToolType?: string
  /**
   * Provider-specific server tool type for web fetch / URL context (e.g. `'web_fetch_20260209'`).
   * Omit if the model / provider does not support native web fetch.
   */
  webFetchToolType?: string
  /** Whether this model is available on the free tier (only one model should be true). */
  freeTier?: boolean
  /**
   * Regions in which this model is free-tier selectable even though the model
   * as a whole is not `freeTier` — for models whose regional hosts price very
   * differently (e.g. a cheap native host powering the free planner while its
   * ~3× re-host stays paid-only). Ignored when `freeTier` is true (all regions
   * free); omitted → no free-tier access outside `freeTier`.
   */
  freeTierRegions?: string[]
  /**
   * Processing regions this model can run in, as arbitrary region codes; the
   * FIRST entry is the model's default region. Omit for `['us']` (the platform
   * default — a single-region US model). A single-entry list pins the model to
// … truncated — the full declaration is in node_modules/@molecule/api-resource-ai-models/dist/types.d.ts

ModeModelDefaults

The model ids the SERVER falls back to per mode/job when the user hasn't picked one — already resolved for the requester's tier (a free-tier caller sees the free-tier clamp ids, a paid caller the paid defaults). Lets the client label an unset per-mode picker "Default (<model>)" instead of a vague "default" the user can't decode.

interface ModeModelDefaults {
  /** Model id used in plan mode when nothing is configured. */
  plan: string
  /** Model id used in execute mode when nothing is configured. */
  execute: string
  /** Model id used for commit-message generation when nothing is configured. */
  commit: string
  /** Model id used for conversation compaction when nothing is configured. */
  compact: string
}

ListModelsResponse

Response shape returned by GET /ai/models.

interface ListModelsResponse {
  models: ModelDefinition[]
  /**
   * Per-mode server default model ids for the requester's tier. Optional —
   * servers that don't compute tier-aware defaults omit it, and clients fall
   * back to generic "default" labeling.
   */
  defaults?: ModeModelDefaults
}

Types

AIProviderID

AI provider identifier.

Maps to the bond category used at runtime (e.g. bond('ai', 'anthropic', anthropicProvider)). Adding a new provider here means a corresponding AI bond package must exist.

type AIProviderID =
  | 'anthropic'
  | 'openai'
  | 'google'
  | 'xai'
  | 'deepseek'
  | 'meta'
  | 'moonshot'
  | 'minimax'
  | 'alibaba'
  | 'zhipu'
  /**
   * A model served by a USER-configured endpoint + key (bring-your-own AI)
   * rather than a platform bond. Never appears in the static catalog — hosts
   * synthesize these definitions at runtime from per-project provider config,
   * with all prices 0 (the user pays their own provider directly).
   */
  | 'custom'

EffortLevel

A reasoning-effort value — a model's OWN native effort level.

There is no abstract cross-model scale: the value stored on a project and sent to the provider IS the model's real level (e.g. 'high' / 'xhigh' / 'max' on current Claude models, 'medium' on Grok, or a scaled thinking-budget label like '16K' on budget-configurable models). Each model declares its own ordered ModelDefinition.supportedEffortLevels; a value that isn't in the active model's set degrades to the nearest one (see model-selection.ts). Mirrored by the client-side EffortLevel in @molecule/app-ai-models; keep the two in sync. Re-declared (rather than imported) by the ide-react and molecule-dev consumers per the cross-stack rule, but this catalog is the canonical home.

type EffortLevel = string

Functions

list(_req, res)

Returns models whose provider has a bond registered under the 'ai' category. When no AI providers are bonded the response is { models: [] }, which signals a misconfigured server rather than masking the issue.

Fails closed with 401 when there is no authenticated session, so the model catalog is never disclosed to an unauthenticated caller even if the route's 'authenticate' middleware is dropped by codegen.

function list(_req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • _req — The request object (unused).
  • res — The response object.

isSelectableModel(model)

Whether a model may be offered for selection: not disabled (retired upstream) and not supersededBy a newer generation of its own family. Both kinds stay in the catalog for pricing — this predicate is the single place that decides exposure, so every listing/validation surface agrees.

function isSelectableModel(model: Pick<ModelDefinition, 'disabled' | 'supersededBy'>): boolean
  • model — The model definition (or the two flags from one).

Returns: True when the model may be listed and chosen.

resolveSelectableModelId(id)

Resolve a model id FORWARD to the selectable model that replaces it, following the ModelDefinition.supersededBy chain (a saved qwen3.7-max → qwen3.8-max). Lets a persisted selection keep the user's intent — the same tier from the same provider — instead of falling back to the platform default once the older generation stops being offered.

function resolveSelectableModelId(id: string): string | undefined
  • id — The persisted model id.

Returns: The selectable successor's id, the id itself when it is already selectable, or undefined for an unknown or disabled model (nothing to forward to).

getModel(id)

Look up a model definition by ID.

Returns disabled and supersededBy models too: a saved selection or a historical usage row may reference a since-retired or since-superseded model, and it must stay priceable. Use MODEL_IDS / getAvailableModels (or isSelectableModel) to decide what is selectable.

function getModel(id: string): ModelDefinition | undefined
  • id — The API model ID.

Returns: The model definition, or undefined if not found.

getModelsByProvider(provider)

Get all models for a specific provider.

function getModelsByProvider(provider: AIProviderID): readonly ModelDefinition[]
  • provider — The provider ID.

Returns: Array of model definitions for that provider.

getAvailableModels(availableProviders)

Get models that are currently usable — filtered to only providers that are available.

The caller passes in which provider IDs are active (i.e. have a bond wired). Models that are not isSelectableModel — disabled or superseded by a newer generation — are excluded; they are never offered for selection.

function getAvailableModels(
  availableProviders: ReadonlySet<AIProviderID> | readonly AIProviderID[],
): readonly ModelDefinition[]
  • availableProviders — Set or array of provider IDs that have active bonds.

Returns: Selectable models whose provider is in the available set.

effectiveBaseRates(modelDef, at)

A model's BASE token rates in effect at a given instant — the staged ModelDefinition.scheduledPricing rates once their effectiveFrom has passed, else the base fields.

These are the native provider's rates. A regionPricing override is a different host's rate card and is resolved separately by modelRegionRates.

function effectiveBaseRates(modelDef: ModelDefinition, at?: Date): ModelTokenRates
  • modelDef — The model definition.
  • at — The instant to price at (defaults to now).

Returns: The base rates in effect at that instant.

effectivePeakPricing(modelDef, at)

A model's peak-hour pricing in effect at a given instant: the staged ModelDefinition.scheduledPricing peakPricing once its effectiveFrom has passed (when that entry declares one — an omitted one leaves the existing windows in force), else the model's own peakPricing.

function effectivePeakPricing(
  modelDef: ModelDefinition,
  at?: Date,
):
  | {
      windows: { startMinuteUtc: number; endMinuteUtc: number; daysOfWeekUtc?: number[] }[]
      multiplier: number
      excludedDatesUtc?: string[]
      rule?: { url: string; text: string }
    }
  | undefined
  • modelDef — The model definition.
  • at — The instant to evaluate (defaults to now).

Returns: The peak-pricing config in effect, or undefined when none is.

effectiveLongContextPricing(modelDef, at)

A model's long-context band in effect at a given instant: the staged ModelDefinition.scheduledPricing longContextPricing once its effectiveFrom has passed (when that entry declares one — an omitted one leaves the model's existing band in force), else the model's own ModelDefinition.longContextPricing.

This is what lets a banded model's price change move BOTH cards at once: resolving the static band against landed scheduled base rates would leave

threshold prompts on a stale card (possibly cheaper than the new base — silently under-metering the most expensive traffic).

function effectiveLongContextPricing(
  modelDef: ModelDefinition,
  at?: Date,
):
  | {
      aboveInputTokens: number
      inputPricePerMTok: number
      outputPricePerMTok: number
      cacheReadPricePerMTok: number
      cacheWritePricePerMTok: number
    }
  | undefined
  • modelDef — The model definition.
  • at — The instant to evaluate (defaults to now).

Returns: The long-context band in effect, or undefined when none is.

withEffectivePricing(modelDef, at)

A model projected onto the pricing in effect at a given instant: the staged ModelDefinition.scheduledPricing rates folded into the base fields (and its peak windows into peakPricing) once effective, with the staged entry stripped.

This is what the GET /ai/models handler serves, so a client renders the rates that are actually billing right now without needing to resolve a schedule against its own clock — the server's clock is the only one that decides when a price change lands.

function withEffectivePricing(modelDef: ModelDefinition, at?: Date): ModelDefinition
  • modelDef — The model definition.
  • at — The instant to project at (defaults to now).

Returns: The model with effective pricing and no scheduledPricing.

priceMultiplierAt(modelDef, at, region)

The price multiplier in effect for a model at a given instant, in a region.

Consults the model's ModelDefinition.peakPricing windows (UTC, half-open, may wrap midnight, optionally restricted to certain UTC weekdays via daysOfWeekUtc). Metering MUST call this with each request's own timestamp so peak-hour usage bills at the provider's real rate — pricing everything at the flat rate silently under-meters peak traffic.

Peak windows belong to the NATIVE provider, so they apply only where the base rates do. A region with a ModelDefinition.regionPricing override is a different host billing its own complete rate card, including whether it has time-of-day pricing at all — and re-hosts generally do not. Applying the native provider's surcharge on top of a re-host's flat rates would over-bill every turn in its windows (DeepSeek's 2× Beijing-hours pricing charged against DeepInfra, which has no peak pricing).

function priceMultiplierAt(modelDef: ModelDefinition | undefined, at: Date, region?: string): number
  • modelDef — The model definition (or undefined).
  • at — The instant the request was made.
  • region — The user's per-model region choice, if any (omitted → the model's default region).

Returns: The multiplier (1 outside peak windows, when none are declared, or in a region that prices off its own override).

effectiveModelRegion(modelDef, requested)

Resolve a model's effective processing region: the requested region when the model's ModelDefinition.regions list offers it, else the model's DEFAULT region (the first listed; 'us' when the catalog omits regions, which also covers unknown model ids). A single-entry regions list pins the model regardless of the request (e.g. a model with no US re-host).

function effectiveModelRegion(modelDef: ModelDefinition | undefined, requested?: string): string
  • modelDef — The model definition (or undefined for unknown ids).
  • requested — The user's per-model region choice, if any.

Returns: The effective region code.

modelRegionRates(modelDef, requested, at, promptTokens)

The token rates for a model in a given processing region: the model's ModelDefinition.regionPricing override for the region when one exists, else the base rates in effect at at (the native provider's list prices, including any staged ModelDefinition.scheduledPricing change that has landed). Omitted cache fields in an override fall back to the override's input price (hosts with no cache discount / no write premium). The region is resolved via effectiveModelRegion, so callers may pass the raw user choice.

at defaults to NOW rather than being required, so an existing caller cannot keep billing a superseded rate by omitting it — metering should still pass each request's own timestamp, the same way it must for priceMultiplierAt.

A model priced by prompt length (ModelDefinition.longContextPricing) bills a request whose promptTokens EXCEED the threshold at the long-context rates (all of its tokens, not the excess). Pass ONE provider call's prompt size (fresh input + cache read + cache write); omit it and the base rates apply. The band itself is resolved at at via effectiveLongContextPricing — a landed ModelDefinition.scheduledPricing change that declares its own band replaces the static one from the same instant (an omitted one carries the static band through).

function modelRegionRates(
  modelDef: ModelDefinition,
  requested?: string,
  at?: Date,
  promptTokens?: number,
): ModelTokenRates
  • modelDef — The model definition.
  • requested — The user's per-model region choice, if any.
  • at — The instant to price at (defaults to now).
  • promptTokens — The request's prompt size in tokens, when the caller knows it.

Returns: The region-effective rates.

Constants

MODEL_IDS

Set of selectable model IDs for fast validation.

Excludes disabled models so a retired model (e.g. grok-code-fast-1) can never be chosen for a new chat, and supersededBy models so an older generation of a family (e.g. qwen3.7-max next to qwen3.8-max) is never offered — while getModel still resolves both for historical pricing.

const MODEL_IDS: ReadonlySet<string>

MODELS

All available AI models, grouped by provider, ordered from most to least capable.

To add or remove a model, edit this array. Both the server-side validation and the public discovery endpoint will update automatically.

EVERY entry field that shapes the outbound request (webSearchToolType, supportedEffortLevels/defaultEffortLevel/effortBudgetTokens, maxOutputTokens, regions) must hold for the model's ACTUAL serving host(s) in every region — not just look right in a docs table. The pre-commit hook enforces this with a LIVE Synthase-shaped probe of each added/changed entry (molecule-dev verify:model-dispatch --staged-catalog); a value the host rejects fails the whole request for every user (glm-5.3-flash 2026-08-28: a webSearchToolType both zhipu hosts reject made every Synthase turn 400/422 while the model itself worked fine).

Effort is each model's OWN native value — there is no abstract scale (see ModelDefinition.supportedEffortLevels):

  • A model driven by a provider-native effort/level param lists its provider values verbatim in supportedEffortLevels (ascending), with defaultEffortLevel = the provider's default/recommended level for agentic coding. NO effortBudgetTokens.
  • A model with a controllable token budget but no native level names (e.g. Claude Haiku 4.5's budget_tokens, Qwen's thinking_budget) lists scaled-budget labels (['4K', '8K', '16K', '32K']) with effortBudgetTokens

(description truncated — read the full text in node_modules/@molecule/api-resource-ai-models/dist/models.d.ts.)

const MODELS: readonly ModelDefinition[]

requestHandlerMap

Map of request handlers for the AI model catalog routes.

const requestHandlerMap: { readonly list: typeof list }

routes

Route array for the AI model catalog: GET list of available models.

const routes: readonly [
  {
    readonly method: 'get'
    readonly path: '/ai/models'
    readonly handler: 'list'
    readonly middlewares: readonly ['authenticate']
  },
]

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-bond ^1.0.1
  • @molecule/api-i18n ^1.0.1
  • @molecule/api-resource ^1.0.1

Runtime Dependencies

  • @molecule/api-bond

  • @molecule/api-i18n

  • @molecule/api-resource

  • No database, no migration — the catalog is code. Add/retire models by editing models.ts; validation (MODEL_IDS) and the discovery endpoint update automatically.

  • GET /ai/models only lists models whose provider is BONDED. The handler intersects MODELS with the names registered under the 'ai' bond category, so bond('ai', '<name>', provider) names must equal ModelDefinition.provider ids. An empty { models: [] } response means no AI bond is wired — fix the wiring; never hardcode a model list client-side.

  • Disabled models stay resolvable on purpose. getModel(id) returns retired models so historical usage still prices correctly; gate what a user may SELECT with MODEL_IDS / getAvailableModels(), never with getModel().

  • The list handler enforces authentication in-handler (fails closed 401) — if you fork it, keep that check; route middleware alone can be stripped by codegen.