UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

525 lines (467 loc) • 30.7 kB
--- summary: "Live model discovery, catalog helpers, and provider-owned pricing normalization" read_when: - You are opting a provider into shared live model discovery - You need the liveModelDiscovery contract, cache, and failure rules - You are normalizing a provider-owned pricing feed - You want the narrower single-provider plugin entry helper title: "Provider model catalogs" sidebarTitle: "Model catalogs" --- Catalog reference for provider plugins: shared live model discovery, catalog helpers, pricing normalization, and the narrower single-provider entry point. Part of the [Building provider plugins](/plugins/sdk-provider-plugins) guide. For lightweight model-reference normalization, use `openclaw/plugin-sdk/model-ref-parse`. Its `normalizeGooglePreviewModelId` and `normalizeAntigravityPreviewModelId` exports share the catalog's alias rules without loading provider replay or transport helpers. Use `splitTrailingAuthProfile` to separate a trailing auth profile while preserving model-version and local quantization suffixes. ## Live model discovery If your provider exposes an OpenAI-compatible `/models` API, opt the single-provider helper into shared discovery: ```typescript catalog: { buildProvider: () => ({ api: "openai-completions", baseUrl: "https://api.acme-ai.com/v1", models: [...STATIC_MODELS], }), buildStaticProvider: () => ({ api: "openai-completions", baseUrl: "https://api.acme-ai.com/v1", models: [...STATIC_MODELS], }), liveModelDiscovery: true, }, ``` `liveModelDiscovery: true` is a public Plugin SDK contract with these behaviors: | Area | Contract | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Credentials | Discovery uses the catalog's resolved provider credential, preferring `discoveryApiKey` when auth supplies one. Secret-reference markers are never sent as tokens. The default request uses `Authorization: Bearer <token>`; use `buildRequestHeaders` for another vendor auth scheme. | | Endpoint | The default URL is `models` relative to the effective provider `baseUrl`, including an operator override when `allowExplicitBaseUrl` is enabled. Use `endpointPath` for another relative path. Use `endpointUrl: { url, requireBaseUrl }` only for a fixed vendor URL; discovery is skipped unless the effective base URL still equals `requireBaseUrl`, so a custom proxy credential is not sent to the vendor. | | Network limits | Fetches use OpenClaw's SSRF guard, one 5-second timeout budget across pagination, a 4 MiB response limit per page, and a 50-page limit. Cross-origin pagination links are rejected; credentials are removed after a cross-origin redirect. | | Cache | Successful, non-empty catalogs are cached for 60 seconds by provider, endpoint, and resolved credential. Empty or unusable results are not cached. | | Filtering | The listing decides which models exist. Exact live IDs keep their trusted static metadata; every other listed chat ID becomes a conservative text/chat row, so new models appear without an OpenClaw release. Disabled, archived, deprecated, past-`shutdown_date`, explicitly non-chat, embedding, reranking, moderation, speech, transcription, realtime, audio, image, video, and legacy completions rows are excluded, including static IDs. Use `readRows` only to select rows from a nonstandard response envelope; provider-specific model semantics still belong in a custom catalog. | | Admission | Optional. Set `acceptUnknownModel: ({ id, record }) => boolean` when your request shaping is model-version specific, so discovery cannot publish a model you cannot yet build a valid request for. It is called only for IDs your static catalog does not already publish; known IDs bypass it and keep their published metadata. Return `false` to drop the row. Providers that omit it keep the previous behavior unchanged. Prefer comparing the vendor's advertised capabilities against your own contract checks over a hand-maintained model list, and fail closed when the row carries no capability data. | | Failure | Live discovery is advisory. Auth, network, timeout, pagination, parsing, empty-catalog, and filtering failures return the provider-owned static seed instead of removing the provider. | Relative catalog cache TTLs start when a successful load completes. Cache hits preserve that deadline, and explicit absolute provider deadlines remain unchanged. Pending loads retain their initial expiry so stalled work can be replaced. An explicit refresh through the prepared catalog owner bypasses completed response-cache entries only for the keys acquired by that request. Repeated reads within the same acquisition share the refreshed value; concurrent callers share pending work. Ordinary acquisition keeps its existing cache lifetime. Bundled providers set `discoveryMode: "strict"` in their catalog options. This code option keeps successful empty results empty and reports failed acquisition through `ProviderCatalogResult.outcomes`, rather than returning seed models as a successful refresh. HTTP 401/403 produces a catalog-scoped `auth-rejected` outcome; other acquisition failures produce `unavailable`. Neither a static catalog nor skipped discovery produces a live outcome. Each outcome carries the profile selected for the actual request, when one supplied its credential. Family providers report each sibling independently. An explicit `ready` outcome may include `modelOrder: string[]` to rank its already discovered models in the picker. This does not add models or grant access; absent models are ignored, and outcomes without `modelOrder` retain the manifest order. Provider-scoped refreshes preserve explicit outcomes reported under a registered alias of the selected provider; unrelated sibling outcomes remain excluded. With a positive cache lifetime, validated empty results use the same successful-observation lifetime as nonempty results. After expiry, ordinary catalog reads return retained rows while the existing inventory owner refreshes the provider in the background. `ttlMs: 0` still disables response caching and does not record an expiry for this renewal path. A successful authenticated outcome may include private `modelServiceTiers` observations: `{ modelId, runtimeId, api, baseUrl, serviceTiers }` rows bound to that outcome's `profileId`. Supply only tier IDs explicitly advertised by the account's successful response; never copy them from static seeds or native fallback catalogs. The host matches the selected profile, model, route, and runtime before projecting `serviceTiers` on a public model choice. Account tier maps never appear in public `providerOutcomes`. Missing, failed, stale, or mismatched observations leave support unknown. This metadata does not authorize execution or guarantee upstream fulfillment. A `ready` outcome from an account-scoped subscription listing may include private `listedModelIds: string[]` provenance: every model id the response returned, including hidden rows. It never appears in public `providerOutcomes`. When present, the picker treats a model's subscription route as not entitled for that account if the id is absent, unless another usable credential class serves the model. An empty array is authoritative. The listing applies only to the credential that made it: the outcome's `profileId`, or non-profile auth when omitted. Another account's listing leaves entitlement unknown. Omit the field when the listing fails or does not describe account entitlement. Public metadata requests declare `authentication: "none"` in discovery options. The prepared request then has no credential or profile identity; its cache key is independent of the configured inference credential. The returned provider configuration still retains its inference credential. External calls that omit `discoveryMode` retain the advisory contract above. The public Chutes, Hugging Face, KiloCode, and Vercel AI Gateway discovery functions and builders also retain that default. Their bundled catalog hooks pass `{ discoveryMode: "strict" }` explicitly; Hugging Face discovery accepts this options object after its existing timeout argument. The Chutes public default retains its anonymous retry after HTTP 401; strict calls never retry without the selected credential. The strict and advisory paths share the same guarded transport and cache, with separate cache identities. Advisory calls still retain only nonempty results. Custom live builders can use `runLiveProviderCatalog` at their catalog hook to report successful acquisition and convert acquisition errors into outcomes. Returning provider configuration alone does not establish a live discovery outcome. Catalog hooks should omit `contextWindow` when the provider does not report it; keep a reported `contextTokens` prompt limit separately. Cached catalogs preserve that omission. Runtime construction supplies an estimate and marks it with `contextWindowSource: "synthetic"`; dynamic runtime resolvers may mark their own unknown-model estimates the same way. This marker is runtime-only, never config or persisted catalog metadata. Accepted account discovery can replace that estimate for the same provider, exact model ID, and transport API (and endpoint, when the fallback binds one). Do not mark curated static limits or authored caps as synthetic. Failed discovery keeps the estimate unless the catalog owner can retain the same account's inventory. For compatibility, nonempty rows returned by a legacy catalog hook without an outcome survive provider-wide failures under the same credentials. This does not establish a successful discovery origin or retain unrelated configured and supplemental rows. Empty legacy catalogs and profile-specific failures do not use that fallback; a successful replacement clears the previous row provenance. Keep metadata-feed fallback separate from account discovery; do not retry a rejected account request anonymously or substitute seed rows inside a strict builder. Custom catalog hooks may receive optional `mode` metadata from `ctx.resolveProviderApiKey()`: `api_key`, `oauth`, or `token`. When present, it describes that lookup's selected credential. Use it when choosing a vendor authentication scheme; a separate `resolveProviderAuth()` call may select a different profile. Omitted mode metadata does not change existing callback behavior. `ctx.resolveProviderAuth()` may set `preparationFailed: true` when OAuth preparation exhausted its candidates. Do not treat that flag as absent configuration or restart resolution of the same profiles. A hook may still choose another credential source. Its returned provider configuration or explicit outcome remains authoritative; otherwise the catalog owner reports the consumed preparation failure with the attempted profile identities. For a non-Bearer or nonstandard list endpoint, pass options instead of `true`: ```typescript liveModelDiscovery: { endpointPath: "model-catalog", buildRequestHeaders: ({ apiKey, discoveryApiKey }) => ({ "vendor-version": "2026-01-01", "x-api-key": discoveryApiKey ?? apiKey ?? "", }), readRows: (body) => body && typeof body === "object" && Array.isArray((body as { models?: unknown }).models) ? (body as { models: unknown[] }).models : [], }, ``` Do not use `endpointUrl` as an unconditional alternate host. Its `requireBaseUrl` check is the credential-isolation boundary for providers whose model-list host differs from their inference host. If the provider needs custom model semantics rather than the conservative OpenAI-compatible projection, keep only that projection in the plugin. Pass it as `projectRows`; the shared runtime still owns guarded fetches, provider-auth headers, cache admission, and static fallback. Use `buildLiveModelProviderConfig` when the plugin builds the provider config itself. Without `projectRows`, the listing decides which models exist: every listed chat model is published, static `models` rows only enrich matching IDs, and non-chat rows are filtered by the same rules as `liveModelDiscovery`. To restrict the list, pass `readModelId(row)` and return `undefined` for rows the provider does not support; the returned string also becomes the model ID. Pass `projectRows` when the live API publishes richer model metadata: ```typescript index.ts import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { buildLiveModelProviderConfig, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; const STATIC_MODELS = [ { id: "acme-large", name: "Acme Large", reasoning: true, input: ["text", "image"], cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, contextWindow: 200000, maxTokens: 32768, }, { id: "acme-small", name: "Acme Small", reasoning: false, input: ["text"], cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, contextWindow: 128000, maxTokens: 8192, }, ] as const; async function buildAcmeLiveProvider(params: { apiKey: string; discoveryApiKey?: string; fetchGuard?: LiveModelCatalogFetchGuard; }) { return await buildLiveModelProviderConfig({ providerId: "acme-ai", endpoint: "https://api.acme-ai.com/v1/models", providerConfig: { baseUrl: "https://api.acme-ai.com/v1", api: "openai-completions", }, models: STATIC_MODELS, apiKey: params.apiKey, discoveryApiKey: params.discoveryApiKey, fetchGuard: params.fetchGuard, ttlMs: 60_000, auditContext: "acme-ai-model-discovery", projectRows: (rows, fallback) => rows.flatMap((row) => { const model = projectAcmeModel(row, fallback); return model ? [model] : []; }), }); } export default definePluginEntry({ id: "acme-ai", name: "Acme AI", register(api) { api.registerProvider({ id: "acme-ai", label: "Acme AI", catalog: { order: "simple", run: async (ctx) => { const auth = ctx.resolveProviderAuth("acme-ai"); const apiKey = auth.apiKey ?? ctx.resolveProviderApiKey("acme-ai").apiKey; if (!apiKey) return null; return { provider: await buildAcmeLiveProvider({ apiKey, discoveryApiKey: auth.discoveryApiKey, }), }; }, }, staticCatalog: { order: "simple", run: async () => ({ provider: { baseUrl: "https://api.acme-ai.com/v1", api: "openai-completions", models: [...STATIC_MODELS], }, }), }, }); }, }); ``` `run` should stay auth-gated and return `null` when no usable credential is available. Keep an offline `staticRun` or static fallback so setup, docs, tests, and picker surfaces do not depend on live network access. Use a TTL appropriate for model-list freshness, avoid request-time filesystem polling, and pass a provider-specific `readRows` only when the upstream response is not an OpenAI-compatible `{ data: [{ id, object }] }` shape. Pass `readModelId` when rows carry their ID elsewhere or the provider must reject some listed rows. During model-runtime preparation, `staticCatalog.run` and `prepareSyntheticAuth` receive an optional `signal`. Shutdown and plugin/config replacement abort it. Stop awaited acquisition when it aborts and finish resource cleanup before the hook settles. OpenClaw discards cancelled results and joins cleanup before a replacement can acquire the same agent resources. For a separate authoritative metadata feed, the same `provider-catalog-live-runtime` subpath exposes `ProviderCatalogSnapshot`: each entry pairs a runtime model with its lifecycle status. `projectUpstreamProviderCatalogSnapshot` rebuilds that snapshot from a trusted seed and accepted upstream rows, dropping withdrawn upstream-only models. `projectProviderCatalogSnapshotRows` intersects advertised IDs with active snapshot entries, deduplicating in endpoint order; `listProviderCatalogSnapshotEntries` projects the same lifecycle facts for catalog consumers. Keep seed lifecycle policy and model-specific decoration in the owning plugin. Derive static fallback eligibility after refreshing metadata so the first failed or fully filtered discovery uses current status. Public metadata never establishes account entitlement or expands the credential scope of discovery. The private `createUpstreamProviderCatalog` helper keeps this snapshot lifecycle in one prepared owner. Supply the trusted seed, provider routes, metadata and model-list endpoints, discovery and starter-model audit labels, static-entry eligibility, and any model decoration. An optional `upstreamSeed` controls which seed lifecycle facts survive an upstream refresh. The owner exposes `getSnapshot`, `refreshMetadata`, `buildStaticProvider`, and `buildLiveProvider`; credentials belong to each build call. Live builds refresh metadata before deriving static eligibility and intersecting advertised IDs. Metadata acquisition failure retains the previous snapshot; model-list failures and empty results remain strict. `refreshMetadata` returns `undefined` when the feed lacks the provider, so explicit model preparation cannot mistake retained metadata for a successful refresh. Plugin policy still owns which models may resolve directly from the seed or current snapshot. `resolveStarterModel` checks the preferred provider/model reference against a fresh account model-list response. It returns that reference only when its exact model ID is advertised, without refreshing or extending the metadata snapshot. Upstream reasoning metadata preserves omitted controls as unspecified and an empty options or effort list as no effort control. A native `null` effort maps to `none`; provider-native effort names retain their casing. These facts remain separate from whether a model performs reasoning internally. Official plugins use the private, pure `openclaw/plugin-sdk/model-catalog-pricing` runtime subpath. It exposes `normalizeModelPricingCatalog(rows, normalizePricing, options?)` for provider-owned pricing feeds. It returns a map of complete costs: absent prices are omitted, while malformed declared prices, invalid or duplicate model IDs, and a feed with no usable prices return `undefined`. Supply the provider's unit conversion. Options can select `readModelId(model)` (default `model.id`), `readPricing(model)` (default `model.pricing`), and `isSupportedPricing(rawPricing)` (default `true`). Declared prices are normalized and validated before unsupported schedules are omitted; duplicate IDs are rejected even on unpriced or unsupported rows. Non-token domains can return `undefined` from `readPricing`. No auth, discovery, or runtime loader is imported. DeepInfra's `pricing-api.ts` uses these selectors for its native array and `model_name` identities. Release plugins using the options contract (including DeepInfra and Venice) with a matching host, and coordinate their plugin API and minimum-host floors at release time. The private subpath is not an independently versioned third-party compatibility API. This subpath also exposes `normalizeOpenRouterModelPricing(pricing)` for native OpenRouter pricing objects. It converts per-token rates and static prompt-length overrides into a complete per-million cost schedule, without network access or prices from another source. Overrides apply strictly above `min_prompt_tokens`, counting uncached input, cache reads, and cache writes. Matching entries apply in source order: later entries win per price key, including at equal thresholds; omitted keys inherit the native base or an earlier matching entry. Cache rates absent from the base default to zero. Invalid effective token rates return `undefined`. Entries with time-based or unknown conditions are skipped; other known charge dimensions are ignored. ## Selecting catalog augmentation hooks `augmentModelCatalogWithProviderPlugins` is exported from `openclaw/plugin-sdk/provider-catalog-runtime`. Its optional top-level `providerIds` selects which registered `augmentModelCatalog` hooks run: - Omit `providerIds` to keep the unscoped behavior. - Pass `[]` to run no augmentation hooks. - Pass provider IDs or registered aliases to select matching hooks. Matching normalizes IDs and aliases, including hook aliases used by provider families. This selector does **not** filter the rows returned by a selected hook. A family hook may return rows for several providers; the caller owns any row filtering. The helper returns supplemental rows, not the input `context.entries`. The shipped v2026.9.4 export has no `providerIds` selector and may ignore that option at runtime. Plugins that depend on scoped hook selection must require a host release containing the selector in `openclaw.compat.pluginApi`. Omitting the option retains the existing behavior on both older and newer hosts. This top-level selector is separate from the `catalog.run` callback context. When `ctx.providerIds` is present, it contains the normalized provider identities selected for that catalog owner. Return `null` before resolving credentials or making network requests when the hook serves none of them; OpenClaw also filters returned identities to that scope. An absent scope means the caller requested the full catalog. If the upstream provider uses different control tokens than OpenClaw, add a small bidirectional text transform instead of replacing the stream path: ```typescript api.registerTextTransforms({ input: [ { from: /red basket/g, to: "blue basket" }, { from: /paper ticket/g, to: "digital ticket" }, { from: /left shelf/g, to: "right shelf" }, ], output: [ { from: /blue basket/g, to: "red basket" }, { from: /digital ticket/g, to: "paper ticket" }, { from: /right shelf/g, to: "left shelf" }, ], }); ``` `input` rewrites the final system prompt and text message content before transport. `output` rewrites assistant text deltas and final text before OpenClaw parses its own control markers or channel delivery. For bundled providers that only register one text provider with API-key auth plus a single catalog-backed runtime, prefer the narrower `defineSingleProviderPluginEntry(...)` helper: ```typescript import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; export default defineSingleProviderPluginEntry({ id: "acme-ai", name: "Acme AI", description: "Acme AI model provider", provider: { label: "Acme AI", docsPath: "/providers/acme-ai", auth: [ { methodId: "api-key", label: "Acme AI API key", hint: "API key from your Acme AI dashboard", optionKey: "acmeAiApiKey", flagName: "--acme-ai-api-key", envVar: "ACME_AI_API_KEY", promptMessage: "Enter your Acme AI API key", defaultModel: "acme-ai/acme-large", }, ], catalog: { buildProvider: () => ({ api: "openai-completions", baseUrl: "https://api.acme-ai.com/v1", models: [{ id: "acme-large", name: "Acme Large" }], }), buildStaticProvider: () => ({ api: "openai-completions", baseUrl: "https://api.acme-ai.com/v1", models: [{ id: "acme-large", name: "Acme Large" }], }), }, }, }); ``` `buildProvider` is the live catalog path used when OpenClaw can resolve real provider auth. It may perform provider-specific discovery. Use `buildStaticProvider` only for offline rows that are safe to show before auth is configured; it must not require credentials or make network requests. OpenClaw's `models list --all` display currently executes static catalogs only for bundled provider plugins, with an empty config, empty env, and no agent/workspace paths. If your auth flow also needs to patch `models.providers.*`, aliases, and the agent default model during onboarding, use the preset helpers from `openclaw/plugin-sdk/provider-onboard`. For registered connection-only setup, use `createProviderConnectionPresetAppliers(...)` with a lazy `catalogModels` supplier. Ordinary setup writes connection facts, aliases, and a missing primary without copying the built-in catalog into saved configuration. Explicit `models.mode: "replace"` evaluates the supplier and merges generated rows behind authored rows. Each setup result owns its generated model data. Use `createDefaultModelsConnectionPresetAppliers(...)` when replace mode must retain the existing required-default rule: add the supplied `defaultModels` only when the configured provider lacks the selected `defaultModelId`. `applyProviderConnectionConfig(...)` provides the catalog variant for auth flows that resolve their endpoint or primary per invocation. These helpers preserve authored rows, existing aliases and fallbacks. The auth flow still owns any explicit default selection. The existing public `createDefaultModelPresetAppliers(...)`, `createDefaultModelsPresetAppliers(...)`, and `createModelCatalogPresetAppliers(...)` retain their catalog-seeding behavior in ordinary mode. The latter two also accept lazy model suppliers. A provider with published config helpers can share one preset descriptor between its existing helper and its new registered setup helper; migrate the registration without silently changing the published helper's contract. Independently published plugins must require the host release containing these helpers in `openclaw.compat.pluginApi`. The core release version-sync tool updates that range with the paired release; these imports do not work on an older host that lacks the helpers. When a provider's native endpoint supports streamed usage blocks on the normal `openai-completions` transport, prefer the shared catalog helpers in `openclaw/plugin-sdk/provider-catalog-shared` instead of hardcoding provider-id checks. `supportsNativeStreamingUsageCompat(...)` and `applyProviderNativeStreamingUsageCompat(...)` detect support from the endpoint capability map, so native Moonshot/DashScope-style endpoints still opt in even when a plugin is using a custom provider id. The live discovery examples above cover `/models`-style provider APIs. Keep that discovery inside `catalog.run`, gated on usable auth, and keep `staticRun` network-free for offline catalog generation. Runtime model preparation also runs `staticRun` when configured model refs need its rows, and then sets `ctx.providerIds` to the selected provider ids. Only in that case may the hook await provider-owned caches that its synchronous hooks read, such as model capabilities used for thinking levels. Catalog generation, `models list --all`, and doctor validation leave `ctx.providerIds` unset; keep those runs network-free. Official provider plugins that share credentials can use `resolveFirstProviderCatalogAuth(ctx.resolveProviderApiKey, providerIds)` from the private runtime `openclaw/plugin-sdk/provider-catalog-shared` subpath. Keep provider precedence in the caller's ordered IDs. The helper stops at the first result with an `apiKey` or `discoveryApiKey` and returns that whole result, preserving its profile and auth mode. An unresolved SecretRef marker takes precedence over another provider's live key; fields are never mixed across accounts. It returns `undefined` when no provider has auth and propagates lookup failures. Official plugin releases using this host export must require a host version that provides it in their `compat.pluginApi`. `findNormalizedProviderKey(entries, providerId)` from the private-local `openclaw/plugin-sdk/provider-model-metadata` surface returns the first configured key whose trimmed, lowercase spelling matches the provider ID. Callers that prefer an exact authored key must check that key first.