diff --git a/.changeset/fal-openapi-queue.md b/.changeset/fal-openapi-queue.md new file mode 100644 index 0000000..dc543ac --- /dev/null +++ b/.changeset/fal-openapi-queue.md @@ -0,0 +1,5 @@ +--- +'@modelschemas/client': minor +--- + +`GET /v1/openapi/fal` with no selector is one queue document: `POST /{endpoint_id}` and `GET /{endpoint_id}/requests/{request_id}`, with every live model in the `endpoint_id` enum and untyped bodies. `?activity=` narrows the enum and inlines each model's own body schema. Adds `GET /v1/providers/{provider}`: one provider's status, counts, and live model counts per activity. diff --git a/openapi.json b/openapi.json index 6bd5615..6672f36 100644 --- a/openapi.json +++ b/openapi.json @@ -75,6 +75,46 @@ } } }, + "/v1/providers/{provider}": { + "get": { + "operationId": "getProvider", + "summary": "One provider: status, counts, live models per activity", + "description": "The same row as GET /v1/providers plus activities: live model counts keyed by activity, the values GET /v1/openapi/{provider}?activity= and GET /v1/models?activity= accept.", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "description": "Provider slug (see GET /v1/providers).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Provider detail.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + } + } + }, + "404": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/v1/providers/{provider}/models": { "get": { "operationId": "listProviderModels", @@ -500,7 +540,7 @@ "get": { "operationId": "getProviderOpenApi", "summary": "Generation-only OpenAPI document for one provider", - "description": "Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). Not a raw upstream-spec mirror. Model-grained providers (today: FAL) require ?model=. A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints.", + "description": "Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). Not a raw upstream-spec mirror. FAL (model-grained, queue) is one document: POST /{endpoint_id} and GET /{endpoint_id}/requests/{request_id}. endpoint_id is the model raw id (x-modelschemas-encode raw: do not percent-encode slashes). With no selector the bodies are untyped; ?activity= narrows the enum and inlines each model's own body schema (x-modelschemas-model), up to 1024 models; ?model= is that one model's paths. A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints.", "parameters": [ { "name": "provider", @@ -514,7 +554,7 @@ { "name": "model", "in": "query", - "description": "Pin to one model (raw id or slug). Required for model-grained providers.", + "description": "Pin to one model (raw id or slug).", "schema": { "type": "string" } @@ -2453,6 +2493,106 @@ } } }, + "Provider": { + "type": "object", + "required": [ + "id", + "displayName", + "status", + "counts", + "spec", + "activities", + "_links" + ], + "description": "One row of GET /v1/providers plus activities. Model counts are live rows; counts.deprecated holds the rest.", + "properties": { + "id": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "lastPolledAt": { + "type": [ + "integer", + "null" + ] + }, + "lastSyncedAt": { + "type": [ + "integer", + "null" + ] + }, + "counts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "spec": { + "type": "object", + "required": [ + "grain", + "endpointCount" + ], + "properties": { + "grain": { + "type": "string", + "enum": [ + "provider", + "model" + ] + }, + "endpointCount": { + "type": "integer" + } + } + }, + "activities": { + "type": "object", + "description": "Live model count per activity. A key is present only when the provider has a live model with that activity.", + "propertyNames": { + "enum": [ + "chat", + "image", + "video", + "audio", + "embeddings", + "moderation" + ] + }, + "additionalProperties": { + "type": "integer" + } + }, + "_links": { + "type": "object", + "required": [ + "self", + "models", + "schemas" + ], + "properties": { + "self": { + "$ref": "#/components/schemas/HalLink" + }, + "models": { + "$ref": "#/components/schemas/HalLink" + }, + "schemas": { + "$ref": "#/components/schemas/HalLink" + }, + "openapi": { + "$ref": "#/components/schemas/HalLink" + } + } + } + } + }, "ModelLinks": { "type": "object", "required": [ diff --git a/packages/client/src/generated/index.ts b/packages/client/src/generated/index.ts index 88865ef..b83e113 100644 --- a/packages/client/src/generated/index.ts +++ b/packages/client/src/generated/index.ts @@ -6,6 +6,7 @@ export { extractFalPricing, getActivitySchemas, getModel, + getProvider, getProviderOpenApi, getSchema, getServiceIndex, @@ -62,11 +63,16 @@ export type { GetModelErrors, GetModelResponse, GetModelResponses, + GetProviderData, + GetProviderError, + GetProviderErrors, GetProviderOpenApiData, GetProviderOpenApiError, GetProviderOpenApiErrors, GetProviderOpenApiResponse, GetProviderOpenApiResponses, + GetProviderResponse, + GetProviderResponses, GetSchemaData, GetSchemaError, GetSchemaErrors, @@ -109,6 +115,7 @@ export type { ModelReasoning, PriceBounds, PricingRange, + Provider, ProviderModelList, RateCard, RateCardBooleanInput, diff --git a/packages/client/src/generated/sdk.gen.ts b/packages/client/src/generated/sdk.gen.ts index 7f78dac..4113f90 100644 --- a/packages/client/src/generated/sdk.gen.ts +++ b/packages/client/src/generated/sdk.gen.ts @@ -24,9 +24,12 @@ import type { GetModelData, GetModelErrors, GetModelResponses, + GetProviderData, + GetProviderErrors, GetProviderOpenApiData, GetProviderOpenApiErrors, GetProviderOpenApiResponses, + GetProviderResponses, GetSchemaData, GetSchemaErrors, GetSchemaResponses, @@ -109,6 +112,20 @@ export const listProviders = ( ThrowOnError >({ url: '/v1/providers', ...options }) +/** + * One provider: status, counts, live models per activity + * + * The same row as GET /v1/providers plus activities: live model counts keyed by activity, the values GET /v1/openapi/{provider}?activity= and GET /v1/models?activity= accept. + */ +export const getProvider = ( + options: Options, +): RequestResult => + (options.client ?? client).get< + GetProviderResponses, + GetProviderErrors, + ThrowOnError + >({ url: '/v1/providers/{provider}', ...options }) + /** * List models for one provider */ @@ -211,7 +228,7 @@ export const getSchema = ( /** * Generation-only OpenAPI document for one provider * - * Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). Not a raw upstream-spec mirror. Model-grained providers (today: FAL) require ?model=. A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints. + * Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). Not a raw upstream-spec mirror. FAL (model-grained, queue) is one document: POST /{endpoint_id} and GET /{endpoint_id}/requests/{request_id}. endpoint_id is the model raw id (x-modelschemas-encode raw: do not percent-encode slashes). With no selector the bodies are untyped; ?activity= narrows the enum and inlines each model's own body schema (x-modelschemas-model), up to 1024 models; ?model= is that one model's paths. A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints. */ export const getProviderOpenApi = ( options: Options, diff --git a/packages/client/src/generated/types.gen.ts b/packages/client/src/generated/types.gen.ts index 7cd2f1e..120b1ef 100644 --- a/packages/client/src/generated/types.gen.ts +++ b/packages/client/src/generated/types.gen.ts @@ -617,6 +617,36 @@ export type HalLink = { } } +/** + * One row of GET /v1/providers plus activities. Model counts are live rows; counts.deprecated holds the rest. + */ +export type Provider = { + id: string + displayName: string + status: string + lastPolledAt?: number | null + lastSyncedAt?: number | null + counts: { + [key: string]: number + } + spec: { + grain: 'provider' | 'model' + endpointCount: number + } + /** + * Live model count per activity. A key is present only when the provider has a live model with that activity. + */ + activities: { + [key: string]: number + } + _links: { + self: HalLink + models: HalLink + schemas: HalLink + openapi?: HalLink + } +} + export type ModelLinks = { provider: HalLink schemas: HalLink @@ -1126,6 +1156,37 @@ export type ListProvidersResponses = { export type ListProvidersResponse = ListProvidersResponses[keyof ListProvidersResponses] +export type GetProviderData = { + body?: never + path: { + /** + * Provider slug (see GET /v1/providers). + */ + provider: string + } + query?: never + url: '/v1/providers/{provider}' +} + +export type GetProviderErrors = { + /** + * Error + */ + 404: Error +} + +export type GetProviderError = GetProviderErrors[keyof GetProviderErrors] + +export type GetProviderResponses = { + /** + * Provider detail. + */ + 200: Provider +} + +export type GetProviderResponse = + GetProviderResponses[keyof GetProviderResponses] + export type ListProviderModelsData = { body?: never path: { @@ -1400,7 +1461,7 @@ export type GetProviderOpenApiData = { } query?: { /** - * Pin to one model (raw id or slug). Required for model-grained providers. + * Pin to one model (raw id or slug). */ model?: string /** diff --git a/skill/modelschemas/SKILL.md b/skill/modelschemas/SKILL.md index 8e66568..90f420d 100644 --- a/skill/modelschemas/SKILL.md +++ b/skill/modelschemas/SKILL.md @@ -34,8 +34,13 @@ availability and schema questions. self-contained JSON Schema (URL-encode slashes in endpoint ids: `chat%2Fcompletions`). A listed model rawId also works as `{endpointId}` on grain=provider catalogs. - - `GET /v1/openapi/{provider}` — connectable generation OpenAPI - (FAL, currently the only model-grained provider, requires `?model=`) + - `GET /v1/providers/{provider}` — one provider's status, counts, and + live models per activity (the `?activity=` values). + - `GET /v1/openapi/{provider}` — connectable generation OpenAPI. + FAL is one queue document: the model id is the `endpoint_id` path + parameter (`x-modelschemas-encode: raw`, so do not percent-encode + slashes). Bare, the bodies are untyped; `?activity=` inlines each + model's own body schema; `?model=` is that one model's paths. 3. **Authenticate for more** (5k req/h + webhooks). Easiest: the CLI — `modelschemas login` (agent-auth, autonomous mode, keys stored locally) or `modelschemas login --api-key`. Raw HTTP alternative: @@ -140,6 +145,8 @@ spec syncs daily. Responses are JSON unless noted (`text/typescript`, 1. GET /v1 — endpoint index. 2. GET /v1/models?activity=chat — cross-provider catalog of chat models. + GET /v1/providers/{provider} — one provider's status, counts, and live + models per activity. 3. GET /v1/schemas — system-wide index: every provider's activities and endpoint ids in one response (all schemas on the system). 4. GET /v1/schemas/anthropic — activities + endpoint ids for one provider. @@ -148,7 +155,11 @@ spec syncs daily. Responses are JSON unless noted (`text/typescript`, Grain=provider models also accept the raw id: `/v1/schemas/grok/image/grok-imagine-image-2.0?kind=input`. 6. GET /v1/openapi/{provider} — generation-only OpenAPI (servers, auth, - paths). FAL requires ?model=. A filter that matches none: 400 + paths). FAL is one queue document: the model id is the endpoint_id path + parameter (x-modelschemas-encode: raw — do not percent-encode slashes). + Bare, the bodies are untyped; ?activity= inlines each model's own body + schema (up to 1024 models); ?model= is that one model's paths. A filter + that matches none: 400 spec_requires_selector. HTTP only (no MCP tool). 7. POST /v1/validate {"provider","endpointId","payload"} — check a payload. 8. POST /v1/estimate {"provider","model","request"?,"usage"?} — {amount, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 28683d2..c3d2add 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -43,6 +43,7 @@ import { Route as V1AgentsRegisterKeyRouteImport } from './routes/v1/agents/regi import { Route as V1ModelsIndexRouteImport } from './routes/v1/models/index' import { Route as V1OpenapiProviderRouteImport } from './routes/v1/openapi/$provider' import { Route as V1ProvidersIndexRouteImport } from './routes/v1/providers/index' +import { Route as V1ProvidersProviderRouteImport } from './routes/v1/providers/$provider' import { Route as V1SchemasIndexRouteImport } from './routes/v1/schemas/index' import { Route as V1SubscriptionsIndexRouteImport } from './routes/v1/subscriptions/index' import { Route as V1SubscriptionsIdRouteImport } from './routes/v1/subscriptions/$id' @@ -231,6 +232,11 @@ const V1ProvidersIndexRoute = V1ProvidersIndexRouteImport.update({ path: '/v1/providers/', getParentRoute: () => rootRouteImport, } as any) +const V1ProvidersProviderRoute = V1ProvidersProviderRouteImport.update({ + id: '/v1/providers/$provider', + path: '/v1/providers/$provider', + getParentRoute: () => rootRouteImport, +} as any) const V1SchemasIndexRoute = V1SchemasIndexRouteImport.update({ id: '/v1/schemas/', path: '/v1/schemas/', @@ -275,9 +281,9 @@ const V1ModelsProviderModelIdRoute = V1ModelsProviderModelIdRouteImport.update({ } as any) const V1ProvidersProviderModelsRoute = V1ProvidersProviderModelsRouteImport.update({ - id: '/v1/providers/$provider/models', - path: '/v1/providers/$provider/models', - getParentRoute: () => rootRouteImport, + id: '/models', + path: '/models', + getParentRoute: () => V1ProvidersProviderRoute, } as any) const V1SchemasProviderIndexRoute = V1SchemasProviderIndexRouteImport.update({ id: '/v1/schemas/$provider/', @@ -330,6 +336,7 @@ export interface FileRoutesByFullPath { '/v1/agents/me': typeof V1AgentsMeRoute '/v1/agents/register-key': typeof V1AgentsRegisterKeyRoute '/v1/openapi/$provider': typeof V1OpenapiProviderRoute + '/v1/providers/$provider': typeof V1ProvidersProviderRouteWithChildren '/v1/subscriptions/$id': typeof V1SubscriptionsIdRoute '/v1/models/': typeof V1ModelsIndexRoute '/v1/providers/': typeof V1ProvidersIndexRoute @@ -378,6 +385,7 @@ export interface FileRoutesByTo { '/v1/agents/me': typeof V1AgentsMeRoute '/v1/agents/register-key': typeof V1AgentsRegisterKeyRoute '/v1/openapi/$provider': typeof V1OpenapiProviderRoute + '/v1/providers/$provider': typeof V1ProvidersProviderRouteWithChildren '/v1/subscriptions/$id': typeof V1SubscriptionsIdRoute '/v1/models': typeof V1ModelsIndexRoute '/v1/providers': typeof V1ProvidersIndexRoute @@ -427,6 +435,7 @@ export interface FileRoutesById { '/v1/agents/me': typeof V1AgentsMeRoute '/v1/agents/register-key': typeof V1AgentsRegisterKeyRoute '/v1/openapi/$provider': typeof V1OpenapiProviderRoute + '/v1/providers/$provider': typeof V1ProvidersProviderRouteWithChildren '/v1/subscriptions/$id': typeof V1SubscriptionsIdRoute '/v1/models/': typeof V1ModelsIndexRoute '/v1/providers/': typeof V1ProvidersIndexRoute @@ -477,6 +486,7 @@ export interface FileRouteTypes { | '/v1/agents/me' | '/v1/agents/register-key' | '/v1/openapi/$provider' + | '/v1/providers/$provider' | '/v1/subscriptions/$id' | '/v1/models/' | '/v1/providers/' @@ -525,6 +535,7 @@ export interface FileRouteTypes { | '/v1/agents/me' | '/v1/agents/register-key' | '/v1/openapi/$provider' + | '/v1/providers/$provider' | '/v1/subscriptions/$id' | '/v1/models' | '/v1/providers' @@ -573,6 +584,7 @@ export interface FileRouteTypes { | '/v1/agents/me' | '/v1/agents/register-key' | '/v1/openapi/$provider' + | '/v1/providers/$provider' | '/v1/subscriptions/$id' | '/v1/models/' | '/v1/providers/' @@ -622,6 +634,7 @@ export interface RootRouteChildren { V1AgentsMeRoute: typeof V1AgentsMeRoute V1AgentsRegisterKeyRoute: typeof V1AgentsRegisterKeyRoute V1OpenapiProviderRoute: typeof V1OpenapiProviderRoute + V1ProvidersProviderRoute: typeof V1ProvidersProviderRouteWithChildren V1SubscriptionsIdRoute: typeof V1SubscriptionsIdRoute V1ModelsIndexRoute: typeof V1ModelsIndexRoute V1ProvidersIndexRoute: typeof V1ProvidersIndexRoute @@ -632,7 +645,6 @@ export interface RootRouteChildren { V1AdminExtractFalRoute: typeof V1AdminExtractFalRoute V1AdminSyncProviderRoute: typeof V1AdminSyncProviderRoute V1ModelsProviderModelIdRoute: typeof V1ModelsProviderModelIdRoute - V1ProvidersProviderModelsRoute: typeof V1ProvidersProviderModelsRoute V1SchemasProviderIndexRoute: typeof V1SchemasProviderIndexRoute V1SchemasProviderActivitySplatRoute: typeof V1SchemasProviderActivitySplatRoute V1SchemasProviderActivityIndexRoute: typeof V1SchemasProviderActivityIndexRoute @@ -878,6 +890,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof V1ProvidersIndexRouteImport parentRoute: typeof rootRouteImport } + '/v1/providers/$provider': { + id: '/v1/providers/$provider' + path: '/v1/providers/$provider' + fullPath: '/v1/providers/$provider' + preLoaderRoute: typeof V1ProvidersProviderRouteImport + parentRoute: typeof rootRouteImport + } '/v1/schemas/': { id: '/v1/schemas/' path: '/v1/schemas' @@ -936,10 +955,10 @@ declare module '@tanstack/react-router' { } '/v1/providers/$provider/models': { id: '/v1/providers/$provider/models' - path: '/v1/providers/$provider/models' + path: '/models' fullPath: '/v1/providers/$provider/models' preLoaderRoute: typeof V1ProvidersProviderModelsRouteImport - parentRoute: typeof rootRouteImport + parentRoute: typeof V1ProvidersProviderRoute } '/v1/schemas/$provider/': { id: '/v1/schemas/$provider/' @@ -965,6 +984,17 @@ declare module '@tanstack/react-router' { } } +interface V1ProvidersProviderRouteChildren { + V1ProvidersProviderModelsRoute: typeof V1ProvidersProviderModelsRoute +} + +const V1ProvidersProviderRouteChildren: V1ProvidersProviderRouteChildren = { + V1ProvidersProviderModelsRoute: V1ProvidersProviderModelsRoute, +} + +const V1ProvidersProviderRouteWithChildren = + V1ProvidersProviderRoute._addFileChildren(V1ProvidersProviderRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AccountRoute: AccountRoute, @@ -1001,6 +1031,7 @@ const rootRouteChildren: RootRouteChildren = { V1AgentsMeRoute: V1AgentsMeRoute, V1AgentsRegisterKeyRoute: V1AgentsRegisterKeyRoute, V1OpenapiProviderRoute: V1OpenapiProviderRoute, + V1ProvidersProviderRoute: V1ProvidersProviderRouteWithChildren, V1SubscriptionsIdRoute: V1SubscriptionsIdRoute, V1ModelsIndexRoute: V1ModelsIndexRoute, V1ProvidersIndexRoute: V1ProvidersIndexRoute, @@ -1011,7 +1042,6 @@ const rootRouteChildren: RootRouteChildren = { V1AdminExtractFalRoute: V1AdminExtractFalRoute, V1AdminSyncProviderRoute: V1AdminSyncProviderRoute, V1ModelsProviderModelIdRoute: V1ModelsProviderModelIdRoute, - V1ProvidersProviderModelsRoute: V1ProvidersProviderModelsRoute, V1SchemasProviderIndexRoute: V1SchemasProviderIndexRoute, V1SchemasProviderActivitySplatRoute: V1SchemasProviderActivitySplatRoute, V1SchemasProviderActivityIndexRoute: V1SchemasProviderActivityIndexRoute, diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index ba30777..6c5406c 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -14,6 +14,9 @@ export const serviceIndex = { self: halGet('/v1'), status: halGet('/v1/status'), providers: halGet('/v1/providers'), + provider: halGet('/v1/providers/{provider}', { + example: '/v1/providers/anthropic', + }), models: halGet( '/v1/models{?activity,provider,capability,q,provenance,pricing}', { diff --git a/src/routes/v1/providers/$provider.ts b/src/routes/v1/providers/$provider.ts new file mode 100644 index 0000000..4b43d8d --- /dev/null +++ b/src/routes/v1/providers/$provider.ts @@ -0,0 +1,27 @@ +import { createFileRoute } from '@tanstack/react-router' +import { env } from 'cloudflare:workers' + +import { getDb } from '#/db/index.ts' +import { jsonError } from '#/server/admin.ts' +import { getProviderCatalog, knownProviderIds } from '#/server/catalog.ts' +import { cachedJson, catalogCache } from '#/server/http-cache.ts' + +export const Route = createFileRoute('/v1/providers/$provider')({ + server: { + handlers: { + GET: async ({ request, params }) => { + const db = getDb(env, { replica: true }) + const body = await getProviderCatalog(db, params.provider) + if (!body) { + const valid = (await knownProviderIds(db)).join(', ') + return jsonError( + 404, + 'unknown_provider', + `Unknown provider '${params.provider}'. Valid providers: ${valid}.`, + ) + } + return cachedJson(request, body, catalogCache()) + }, + }, + }, +}) diff --git a/src/server/catalog.ts b/src/server/catalog.ts index 073f24d..8c8c1cd 100644 --- a/src/server/catalog.ts +++ b/src/server/catalog.ts @@ -64,7 +64,7 @@ function openApiLink(providerId: string) { if (!config?.connect) return undefined const grain = resolveSpecGrain(config) if (grain === 'model') { - return halGet(`/v1/openapi/${providerId}{?model}`, { + return halGet(`/v1/openapi/${providerId}{?model,activity}`, { contentType: OPENAPI_CONTENT_TYPE, example: `/v1/openapi/${providerId}?model=fal-ai/flux/dev`, }) @@ -222,26 +222,55 @@ function toApiModel( export type ApiModel = ReturnType +function providerLinks(providerId: string) { + const openapi = openApiLink(providerId) + return { + self: halGet(`/v1/providers/${providerId}`), + models: halGet(`/v1/providers/${providerId}/models`), + schemas: halGet(`/v1/schemas/${providerId}`), + ...(openapi ? { openapi } : {}), + } +} + /** GET /v1/providers — status, counts, spec grain, and links. */ export async function listProvidersCatalog(db: Db) { const status = await getServiceStatus(db) return { - providers: status.providers.map((p) => { - const openapi = openApiLink(p.id) - return { - ...p, - spec: specDescriptor(p.id, p.counts.endpoints), - _links: { - models: halGet(`/v1/providers/${p.id}/models`), - schemas: halGet(`/v1/schemas/${p.id}`), - ...(openapi ? { openapi } : {}), - }, - } - }), + providers: status.providers.map((p) => ({ + ...p, + spec: specDescriptor(p.id, p.counts.endpoints), + _links: providerLinks(p.id), + })), _links: { self: halGet('/v1/providers'), catalog: halGet('/v1/models') }, } } +/** + * GET /v1/providers/{provider} — one provider's status row plus live model + * counts per activity: the `?activity=` values `GET /v1/openapi/{provider}` + * and `GET /v1/models` accept. Null for an unknown provider. + */ +export async function getProviderCatalog(db: Db, providerId: string) { + const status = await getServiceStatus(db) + const provider = status.providers.find((p) => p.id === providerId) + if (!provider) return null + const rows = await db + .select({ activity: models.activity, count: sql`count(*)` }) + .from(models) + .where(and(eq(models.providerId, providerId), isNull(models.deprecatedAt))) + .groupBy(models.activity) + const activities: Partial> = {} + for (const row of rows) { + if (row.activity) activities[row.activity] = row.count + } + return { + ...provider, + spec: specDescriptor(providerId, provider.counts.endpoints), + activities, + _links: providerLinks(providerId), + } +} + /** GET /v1/models — the cross-provider, filterable catalog. */ export async function listModelsCatalog(db: Db, filters: ModelFilters = {}) { const conditions: Array = [] diff --git a/src/server/catalog.worker.test.ts b/src/server/catalog.worker.test.ts index 88fbf3d..aa6307b 100644 --- a/src/server/catalog.worker.test.ts +++ b/src/server/catalog.worker.test.ts @@ -10,6 +10,7 @@ import { GPT_4O } from '../../packages/rate-card/src/fixtures/gpt-4o.ts' import { NANO_BANANA_2 } from '../../packages/rate-card/src/fixtures/nano-banana-2.ts' import { getModelDetail, + getProviderCatalog, listModelsCatalog, listProviderModels, listProvidersCatalog, @@ -169,7 +170,7 @@ describe('listModelsCatalog filters', () => { ) expect(fal?.spec.grain).toBe('model') expect(fal?._links.openapi).toMatchObject({ - href: '/v1/openapi/fal{?model}', + href: '/v1/openapi/fal{?model,activity}', contentType: 'application/openapi+json', templated: true, }) @@ -313,7 +314,17 @@ describe('provider-scoped queries', () => { const result = await listProvidersCatalog(db) const alpha = result.providers.find((p) => p.id === 'cat-alpha') expect(alpha?._links.models.href).toBe('/v1/providers/cat-alpha/models') + expect(alpha?._links.self.href).toBe('/v1/providers/cat-alpha') expect(alpha?.counts.models).toBe(2) + expect(alpha).not.toHaveProperty('activities') + }) + + it('serves one provider with live model counts per activity', async () => { + const alpha = await getProviderCatalog(db, 'cat-alpha') + expect(alpha?.counts.models).toBe(2) + expect(alpha?.activities).toEqual({ chat: 1, image: 1 }) + expect(alpha?._links.self.href).toBe('/v1/providers/cat-alpha') + expect(await getProviderCatalog(db, 'nope')).toBeNull() }) }) diff --git a/src/server/hal.worker.test.ts b/src/server/hal.worker.test.ts index 6670a5f..e30719e 100644 --- a/src/server/hal.worker.test.ts +++ b/src/server/hal.worker.test.ts @@ -8,6 +8,7 @@ import { serviceIndex } from '../routes/v1/index.ts' import { listChanges } from './changes-api.ts' import { getModelDetail, + getProviderCatalog, listModelsCatalog, listProvidersCatalog, } from './catalog.ts' @@ -115,11 +116,17 @@ async function resolve(target: string | Record) { case 'status': expect((await getServiceStatus(db)).providers.length).toBeGreaterThan(0) return - case 'providers': - expect((await listProvidersCatalog(db)).providers.length).toBeGreaterThan( - 0, - ) + case 'providers': { + if (rest.length === 0) { + expect( + (await listProvidersCatalog(db)).providers.length, + ).toBeGreaterThan(0) + return + } + const [provider] = rest + expect(await getProviderCatalog(db, provider ?? '')).not.toBeNull() return + } case 'models': { if (rest.length === 0) { const activity = url.searchParams.get('activity') diff --git a/src/server/llms-txt.ts b/src/server/llms-txt.ts index a678b95..007246f 100644 --- a/src/server/llms-txt.ts +++ b/src/server/llms-txt.ts @@ -48,6 +48,8 @@ spec syncs daily. Responses are JSON unless noted (\`text/typescript\`, 1. GET /v1 — endpoint index. 2. GET /v1/models?activity=chat — cross-provider catalog of chat models. + GET /v1/providers/{provider} — one provider's status, counts, and live + models per activity. 3. GET /v1/schemas — system-wide index: every provider's activities and endpoint ids in one response (all schemas on the system). 4. GET /v1/schemas/anthropic — activities + endpoint ids for one provider. @@ -56,7 +58,11 @@ spec syncs daily. Responses are JSON unless noted (\`text/typescript\`, Grain=provider models also accept the raw id: \`/v1/schemas/grok/image/grok-imagine-image-2.0?kind=input\`. 6. GET /v1/openapi/{provider} — generation-only OpenAPI (servers, auth, - paths). FAL requires ?model=. A filter that matches none: 400 + paths). FAL is one queue document: the model id is the endpoint_id path + parameter (x-modelschemas-encode: raw — do not percent-encode slashes). + Bare, the bodies are untyped; ?activity= inlines each model's own body + schema (up to 1024 models); ?model= is that one model's paths. A filter + that matches none: 400 spec_requires_selector. HTTP only (no MCP tool). 7. POST /v1/validate {"provider","endpointId","payload"} — check a payload. 8. POST /v1/estimate {"provider","model","request"?,"usage"?} — {amount, diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 908185d..14e7b8c 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -348,6 +348,26 @@ export const openApiDocument = { }, }, }, + '/v1/providers/{provider}': { + get: { + operationId: 'getProvider', + summary: 'One provider: status, counts, live models per activity', + description: + 'The same row as GET /v1/providers plus activities: live model counts keyed by activity, the values GET /v1/openapi/{provider}?activity= and GET /v1/models?activity= accept.', + parameters: [providerParam], + responses: { + '200': { + description: 'Provider detail.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Provider' }, + }, + }, + }, + '404': errorResponse, + }, + }, + }, '/v1/providers/{provider}/models': { get: { operationId: 'listProviderModels', @@ -564,7 +584,8 @@ export const openApiDocument = { summary: 'Generation-only OpenAPI document for one provider', description: 'Assembled from classified endpoints plus the provider connect profile (servers, auth, required headers). ' + - 'Not a raw upstream-spec mirror. Model-grained providers (today: FAL) require ?model=. ' + + 'Not a raw upstream-spec mirror. FAL (model-grained, queue) is one document: POST /{endpoint_id} and GET /{endpoint_id}/requests/{request_id}. endpoint_id is the model raw id (x-modelschemas-encode raw: do not percent-encode slashes). ' + + "With no selector the bodies are untyped; ?activity= narrows the enum and inlines each model's own body schema (x-modelschemas-model), up to 1024 models; ?model= is that one model's paths. " + 'A filter that matches no endpoint returns 400 spec_requires_selector; there is no cap on the number of paths in a provider-grained document. ' + 'An unsynced provider returns 404 no_endpoints. ?model= pins the live model enum on provider-grained specs; it does not drop endpoints.', parameters: [ @@ -572,8 +593,7 @@ export const openApiDocument = { { name: 'model', in: 'query', - description: - 'Pin to one model (raw id or slug). Required for model-grained providers.', + description: 'Pin to one model (raw id or slug).', schema: { type: 'string' }, }, { @@ -1203,6 +1223,53 @@ export const openApiDocument = { }, }, }, + Provider: { + type: 'object', + required: [ + 'id', + 'displayName', + 'status', + 'counts', + 'spec', + 'activities', + '_links', + ], + description: + 'One row of GET /v1/providers plus activities. Model counts are live rows; counts.deprecated holds the rest.', + properties: { + id: { type: 'string' }, + displayName: { type: 'string' }, + status: { type: 'string' }, + lastPolledAt: { type: ['integer', 'null'] }, + lastSyncedAt: { type: ['integer', 'null'] }, + counts: { type: 'object', additionalProperties: { type: 'integer' } }, + spec: { + type: 'object', + required: ['grain', 'endpointCount'], + properties: { + grain: { type: 'string', enum: ['provider', 'model'] }, + endpointCount: { type: 'integer' }, + }, + }, + activities: { + type: 'object', + description: + 'Live model count per activity. A key is present only when the provider has a live model with that activity.', + propertyNames: { enum: activityEnum }, + additionalProperties: { type: 'integer' }, + }, + _links: { + type: 'object', + required: ['self', 'models', 'schemas'], + properties: { + self: { $ref: '#/components/schemas/HalLink' }, + models: { $ref: '#/components/schemas/HalLink' }, + schemas: { $ref: '#/components/schemas/HalLink' }, + openapi: { $ref: '#/components/schemas/HalLink' }, + }, + }, + }, + }, ModelLinks: { type: 'object', required: ['provider', 'schemas'], diff --git a/src/server/provider-openapi.test.ts b/src/server/provider-openapi.test.ts index e22cd3c..64adeb4 100644 --- a/src/server/provider-openapi.test.ts +++ b/src/server/provider-openapi.test.ts @@ -13,7 +13,11 @@ import { import { falProvider } from './providers/fal.ts' import { anthropicProvider } from './providers/anthropic.ts' import { getProvider } from './providers/index.ts' -import { resolveConnect, resolveSpecGrain } from './providers/connect.ts' +import { + MAX_QUEUE_SPEC_PATHS, + resolveConnect, + resolveSpecGrain, +} from './providers/connect.ts' describe('path + header helpers', () => { it('extracts braced path params', () => { @@ -215,6 +219,7 @@ describe('connect profiles', () => { it('declares FAL as model-grained and Anthropic as provider-grained', () => { expect(resolveSpecGrain(falProvider)).toBe('model') expect(resolveSpecGrain(anthropicProvider)).toBe('provider') + expect(MAX_QUEUE_SPEC_PATHS).toBe(1024) }) it('carries Anthropic version + x-api-key and FAL sibling GET', () => { diff --git a/src/server/provider-openapi.ts b/src/server/provider-openapi.ts index 73b05d8..5abf279 100644 --- a/src/server/provider-openapi.ts +++ b/src/server/provider-openapi.ts @@ -5,10 +5,11 @@ * the live or pinned `model` enum, and moves output onto the sibling GET * when `connect.siblingGet` is set. */ -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' +import type { SQL } from 'drizzle-orm' import type { Db } from '#/db/index.ts' -import { activities, endpoints, schemaVersions } from '#/db/schema.ts' +import { activities, endpoints, models, schemaVersions } from '#/db/schema.ts' import type { Activity } from '#/db/schema.ts' import { getModelDetail, @@ -18,6 +19,7 @@ import { import { contentHash } from '#/server/kv.ts' import { getProvider } from '#/server/providers/index.ts' import { + MAX_QUEUE_SPEC_PATHS, requiredHeaderParameters, resolveSpecGrain, } from '#/server/providers/connect.ts' @@ -56,8 +58,12 @@ interface LoadedEndpoint { */ responseContentTypes: Array | null specRevision: string | null + /** Set when the endpoint was selected for a queue document. */ + modelRawId?: string } +const QUEUE_PATH = '/{endpoint_id}' + function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -237,12 +243,12 @@ function refuseModelGrained( count: number, ): AssembleOpenApiResult { const example = `GET /v1/openapi/${providerId}?model=` - const models = `GET /v1/providers/${providerId}/models` + const modelList = `GET /v1/providers/${providerId}/models` return { ok: false, status: 400, code: 'spec_requires_selector', - message: `Provider '${providerId}' is model-grained (${String(count)} endpoints) — there is no combined OpenAPI document. Pass ?model={rawId}, e.g. ${example}. List models: ${models}.`, + message: `Provider '${providerId}' is model-grained (${String(count)} endpoints) — there is no combined OpenAPI document. Pass ?model={rawId}, e.g. ${example}. List models: ${modelList}.`, } } @@ -268,10 +274,16 @@ function applyConnectProfile( } } +/** + * `bodies: false` fetches presence only: a stored schema arrives as `{}`, + * so `input !== undefined` still holds without shipping and parsing every + * body. The lean queue document emits none of them. + */ async function loadEndpoints( db: Db, providerId: string, activity: Activity | undefined, + bodies = true, ): Promise> { const rows = await db .select({ @@ -283,7 +295,11 @@ async function loadEndpoints( requestContentType: endpoints.requestContentType, responseContentTypes: endpoints.responseContentTypes, kind: schemaVersions.kind, - schema: schemaVersions.schema, + schema: bodies + ? schemaVersions.schema + : sql< + string | null + >`CASE WHEN ${schemaVersions.schema} IS NULL THEN NULL ELSE '{}' END`, specRevision: schemaVersions.specRevision, }) .from(endpoints) @@ -432,6 +448,226 @@ function buildSiblingGet( } } +interface QueueModel { + rawId: string + capabilities: unknown +} + +/** Live rows for a queue document, narrowed to one activity when given. */ +async function loadQueueModels( + db: Db, + providerId: string, + activity: Activity | undefined, +): Promise> { + const conditions: Array = [ + eq(models.providerId, providerId), + isNull(models.deprecatedAt), + ] + if (activity) conditions.push(eq(models.activity, activity)) + return db + .select({ + rawId: models.rawId, + capabilities: models.capabilities, + }) + .from(models) + .where(and(...conditions)) + .orderBy(models.rawId) +} + +/** + * One endpoint per queue model. The raw id is the queue path value + * (`https://queue.fal.run/{rawId}`), which is not the same string as the + * stored path (`/{rawId}`). A model with no stored endpoint is left out. + */ +function selectQueueEndpoints( + loaded: Array, + rawIds: Array, +): Array { + // Every key endpointMatchesModel accepts, so the match is one lookup. + const byKey = new Map() + for (const rawId of rawIds) { + byKey.set(rawId, rawId) + byKey.set(`/${rawId}`, rawId) + } + const pending = new Set(rawIds) + const selected: Array = [] + for (const endpoint of loaded) { + const matched = byKey.get(endpoint.id) ?? byKey.get(endpoint.path) + if (matched === undefined || !pending.has(matched)) continue + pending.delete(matched) + selected.push({ ...endpoint, modelRawId: matched }) + } + selected.sort((a, b) => + (a.modelRawId ?? '').localeCompare(b.modelRawId ?? ''), + ) + return selected +} + +/** Component key. OpenAPI names are letters, digits, `.`, `_`, and `-`. */ +function schemaComponentKey( + kind: 'input' | 'output', + rawId: string, + used: Set, +): string { + const slug = rawId.replace(/[^A-Za-z0-9._-]+/g, '_') + let key = `${kind}_${slug}` + if (!/^[A-Za-z_]/.test(key)) key = `m_${key}` + key = key.slice(0, 240) + let unique = key + let n = 2 + while (used.has(unique)) { + unique = `${key.slice(0, 220)}_${String(n)}` + n += 1 + } + used.add(unique) + return unique +} + +function withModelAnnotation(schema: unknown, rawId: string): unknown { + if (isObject(schema)) return { ...schema, 'x-modelschemas-model': rawId } + return { 'x-modelschemas-model': rawId, allOf: [schema] } +} + +function schemaUnion( + refs: Array>, +): Record | undefined { + if (refs.length === 1) return refs[0] + // anyOf: FAL inputs overlap (prompt, image_url, …), so a valid body for + // one model often satisfies several schemas. oneOf would reject it. + if (refs.length > 1) return { anyOf: refs } + return undefined +} + +/** + * OpenAPI path templating matches one segment and percent-encodes + * reserved characters. FAL ids contain slashes, so generators must append + * the raw id. `x-modelschemas-encode: raw` is that contract. + */ +function queueEndpointParameter( + rawIds: Array, +): Record { + return { + name: 'endpoint_id', + in: 'path', + required: true, + 'x-modelschemas-encode': 'raw', + description: + 'Model raw id. Slashes are path segments: fal-ai/flux/dev is https://queue.fal.run/fal-ai/flux/dev, not fal-ai%2Fflux%2Fdev. x-modelschemas-encode is raw — do not percent-encode this value.', + schema: { type: 'string', enum: rawIds }, + } +} + +/** + * One queue client: `POST /{endpoint_id}` and + * `GET /{endpoint_id}/requests/{request_id}`. A model is in `endpoint_id` + * only when it has an input schema. The request `anyOf` is those schemas, + * in enum order. The result schema is included only when every included + * model has an output schema, in the same order. A partial set is omitted + * rather than filled with another model's schema. + * + * `lean` (no `?activity=`: every live model) keeps the enum and leaves the + * bodies untyped — a thousand inlined schemas is not a client. Each + * model's own schema stays at `GET /v1/schemas/{provider}/...`. + */ +function buildQueueDocument( + providerId: string, + selected: Array, + connect: ProviderConnect, + lean: boolean, +): + | { + paths: Record> + schemas: Record + } + | { empty: true } { + const typed = selected.filter( + ( + endpoint, + ): endpoint is LoadedEndpoint & { modelRawId: string; input: unknown } => + endpoint.modelRawId !== undefined && endpoint.input !== undefined, + ) + if (typed.length === 0) return { empty: true } + const used = new Set() + const schemas: Record = {} + const inputRefs: Array> = [] + const outputRefs: Array> = [] + const rawIds = typed.map((endpoint) => endpoint.modelRawId) + const outputsComplete = typed.every( + (endpoint) => endpoint.output !== undefined, + ) + for (const endpoint of typed) { + if (lean) break + const rawId = endpoint.modelRawId + const inputName = schemaComponentKey('input', rawId, used) + schemas[inputName] = withModelAnnotation(endpoint.input, rawId) + inputRefs.push({ $ref: `#/components/schemas/${inputName}` }) + if (!outputsComplete || endpoint.output === undefined) continue + const outputName = schemaComponentKey('output', rawId, used) + schemas[outputName] = withModelAnnotation(endpoint.output, rawId) + outputRefs.push({ $ref: `#/components/schemas/${outputName}` }) + } + const activitiesPresent = new Set(typed.map((endpoint) => endpoint.activity)) + const onlyActivity = + activitiesPresent.size === 1 ? typed[0]?.activity : undefined + const endpointParam = queueEndpointParameter(rawIds) + const input = lean + ? { + type: 'object', + description: `The model's own input schema: GET /v1/schemas/${providerId}/{activity}/{endpoint_id}?kind=input. Pass ?activity= on this document to inline them.`, + } + : schemaUnion(inputRefs) + const output = !lean && outputsComplete ? schemaUnion(outputRefs) : undefined + const post: Record = { + operationId: operationId(providerId, 'post', QUEUE_PATH), + summary: onlyActivity + ? `Queue a ${onlyActivity} generation` + : 'Queue a generation', + ...(onlyActivity ? { 'x-modelschemas-activity': onlyActivity } : {}), + parameters: [endpointParam, ...headerParams(connect.requiredHeaders)], + ...(input + ? { requestBody: { required: true, ...requestMedia(input, null) } } + : {}), + responses: { + '200': { + description: 'Success', + ...(connect.siblingGet ? {} : (responseMedia(output, null) ?? {})), + }, + }, + } + const paths: Record> = { + [QUEUE_PATH]: { post }, + } + if (connect.siblingGet) { + const requestPath = siblingRequestPath(QUEUE_PATH) + paths[requestPath] = { + get: { + operationId: operationId(providerId, 'get', requestPath), + summary: 'Fetch a queued request result', + ...(onlyActivity ? { 'x-modelschemas-activity': onlyActivity } : {}), + parameters: [ + endpointParam, + { + name: 'request_id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: + lean || outputsComplete + ? 'Queued result' + : `Queued result. Untyped: ${String(typed.filter((endpoint) => endpoint.output === undefined).length)} of ${String(typed.length)} models have no stored output schema, so no result union is published. GET /v1/schemas/${providerId}/{activity}/{endpoint_id}?kind=output for one model.`, + ...(output ? responseMedia(output, null) : {}), + }, + }, + }, + } + } + return { paths, schemas } +} + export function operationsFor( providerId: string, endpoint: LoadedEndpoint, @@ -493,6 +729,10 @@ export function operationsFor( * Assemble GET /v1/openapi/{provider}. Refuses combined documents for * model-grained providers; a provider-grained document is never capped by * path count — client generation needs the whole generation surface (#560). + * A model-grained queue provider (FAL) with no `?model=` is one queue + * document: `endpoint_id` enumerates every live model with an input schema; + * `?activity=` narrows the enum and inlines each model's own body schema + * (up to {@link MAX_QUEUE_SPEC_PATHS}). */ export async function assembleProviderOpenApi( db: Db, @@ -553,7 +793,31 @@ export async function assembleProviderOpenApi( } } - const loaded = await loadEndpoints(db, providerId, activity) + // A model-grained provider whose POST answers with a queue ack is one + // queue document when no model is pinned; ?activity= narrows it. + const queue = + grain === 'model' && + pinnedModel === undefined && + config?.connect?.siblingGet === true + const lean = queue && activity === undefined + let queueRawIds: Array | undefined + if (queue) { + const listed = await loadQueueModels(db, providerId, activity) + const httpModels = listed.filter( + (model) => !hasAsyncApiFlag(model.capabilities), + ) + if (listed.length > 0 && httpModels.length === 0) { + return { + ok: false, + status: 400, + code: 'spec_requires_selector', + message: `Activity '${activity ?? ''}' on '${providerId}' has no HTTP OpenAPI models (the listed rows publish AsyncAPI). Use GET /v1/schemas/${providerId}.`, + } + } + queueRawIds = httpModels.map((model) => model.rawId) + } + + const loaded = await loadEndpoints(db, providerId, activity, !lean) let selected = loaded if (pinnedModel !== undefined) { const matching = loaded.filter((e) => @@ -568,9 +832,11 @@ export async function assembleProviderOpenApi( message: `Model '${pinnedModel}' is listed for '${providerId}' but has no classified generation endpoint (no path matching that raw id). List endpoints: GET /v1/schemas/${providerId}. If the model is new, wait for spec sync or POST /v1/admin/sync/${providerId}. Catalog: GET /v1/providers/${providerId}/models.`, } } + } else if (queueRawIds !== undefined) { + selected = selectQueueEndpoints(loaded, queueRawIds) } - if (grain === 'model' && pinnedModel === undefined) { + if (grain === 'model' && pinnedModel === undefined && !queue) { return refuseModelGrained(providerId, loaded.length) } @@ -590,6 +856,15 @@ export async function assembleProviderOpenApi( } } + if (queue && !lean && selected.length > MAX_QUEUE_SPEC_PATHS) { + return { + ok: false, + status: 400, + code: 'spec_requires_selector', + message: `Activity '${activity ?? ''}' matches ${String(selected.length)} endpoints (cap ${String(MAX_QUEUE_SPEC_PATHS)}). Pin ?model={rawId}, or drop ?activity= for the whole-catalog document with untyped bodies. Counts per activity: GET /v1/providers/${providerId}.`, + } + } + if (!connect || connect.servers.length === 0) { return { ok: false, @@ -599,45 +874,67 @@ export async function assembleProviderOpenApi( } } - const modelIds = pinnedModel - ? [pinnedModel] - : ( - await listModelsCatalog(db, { - provider: providerId, - activity, - }) - ).models.map((m) => m.rawId) + let queueSchemas: Record | undefined const paths: Record> = {} - for (const endpoint of selected) { - for (const op of operationsFor( - providerId, - endpoint, - connect, - modelIds, - pinnedModel, - )) { - const item = paths[op.path] ?? {} - item[op.method] = op.operation - paths[op.path] = item + if (queue) { + const built = buildQueueDocument(providerId, selected, connect, lean) + if ('empty' in built) { + return { + ok: false, + status: 400, + code: 'spec_requires_selector', + message: `No input schemas to publish for '${providerId}'${activity ? ` activity '${activity}'` : ''}. List endpoints: GET /v1/schemas/${providerId}.`, + } + } + Object.assign(paths, built.paths) + queueSchemas = built.schemas + } else { + const modelIds = pinnedModel + ? [pinnedModel] + : ( + await listModelsCatalog(db, { + provider: providerId, + activity, + }) + ).models.map((m) => m.rawId) + for (const endpoint of selected) { + for (const op of operationsFor( + providerId, + endpoint, + connect, + modelIds, + pinnedModel, + )) { + const item = paths[op.path] ?? {} + item[op.method] = op.operation + paths[op.path] = item + } } } const revision = selected.find((e) => e.specRevision)?.specRevision ?? 'current' + const components: Record = {} + if (Object.keys(connect.securitySchemes).length > 0) { + components.securitySchemes = connect.securitySchemes + } + if (queueSchemas && Object.keys(queueSchemas).length > 0) { + components.schemas = queueSchemas + } const document: Record = { openapi: '3.1.0', info: { title: `${config?.displayName ?? providerId} (modelschemas)`, version: revision, - description: `Generation-only OpenAPI assembled by modelschemas from live endpoint schemas. Call this provider directly with your own key — this is not a proxy.`, + description: queue + ? `Generation-only OpenAPI assembled by modelschemas from live endpoint schemas. Call this provider directly with your own key — this is not a proxy. One queue client: endpoint_id is the model raw id${lean ? "; bodies are untyped here, pass ?activity= to inline each model's own schema" : ", and each component schema is that model's own body (x-modelschemas-model)"}.` + : `Generation-only OpenAPI assembled by modelschemas from live endpoint schemas. Call this provider directly with your own key — this is not a proxy.`, }, servers: connect.servers, ...(Object.keys(connect.securitySchemes).length > 0 - ? { - security: connect.security, - components: { securitySchemes: connect.securitySchemes }, - } + ? { security: connect.security } : {}), + ...(Object.keys(components).length > 0 ? { components } : {}), paths, } diff --git a/src/server/provider-openapi.worker.test.ts b/src/server/provider-openapi.worker.test.ts index e07fff8..8c2afbd 100644 --- a/src/server/provider-openapi.worker.test.ts +++ b/src/server/provider-openapi.worker.test.ts @@ -3,6 +3,8 @@ import { env } from 'cloudflare:test' import { getDb } from '#/db/index.ts' import type { Db } from '#/db/index.ts' +import { eq } from 'drizzle-orm' + import { endpoints, models, providers, schemaVersions } from '#/db/schema.ts' import { assembleProviderOpenApi } from './provider-openapi.ts' @@ -58,6 +60,13 @@ beforeAll(async () => { method: 'POST', path: '/fal-ai/other', }, + { + id: 'fal/fal-ai/retired', + providerId: 'fal', + activity: 'image', + method: 'POST', + path: '/fal-ai/retired', + }, ]) .onConflictDoNothing() await db @@ -116,6 +125,26 @@ beforeAll(async () => { providerId: 'fal', rawId: 'fal-ai/flux/dev', activity: 'image', + providerMetadata: { category: 'text-to-image' }, + firstSeenAt: NOW, + lastSeenAt: NOW, + }, + { + id: 'fal-fal-ai-other', + providerId: 'fal', + rawId: 'fal-ai/other', + activity: 'image', + providerMetadata: { category: 'text-to-image' }, + firstSeenAt: NOW, + lastSeenAt: NOW, + }, + { + id: 'fal-retired', + providerId: 'fal', + rawId: 'fal-ai/retired', + activity: 'image', + providerMetadata: { category: 'text-to-image' }, + deprecatedAt: NOW, firstSeenAt: NOW, lastSeenAt: NOW, }, @@ -187,14 +216,40 @@ describe('assembleProviderOpenApi', () => { ).toEqual(['claude-sonnet-4-5']) }) - it('refuses a combined FAL spec and serves one model with sibling GET', async () => { + it('serves FAL with no selector as one untyped queue document', async () => { const bare = await assembleProviderOpenApi(db, 'fal') - expect(bare).toMatchObject({ - ok: false, - status: 400, - code: 'spec_requires_selector', - }) + expect(bare.ok).toBe(true) + if (!bare.ok) return + const barePaths = bare.document.paths as Record< + string, + { + post: { + parameters: Array<{ name: string; schema?: { enum?: Array } }> + requestBody: { + content: { 'application/json': { schema: Record } } + } + } + } + > + expect(Object.keys(barePaths).sort()).toEqual([ + '/{endpoint_id}', + '/{endpoint_id}/requests/{request_id}', + ]) + const bareEnum = barePaths['/{endpoint_id}']?.post.parameters.find( + (parameter) => parameter.name === 'endpoint_id', + )?.schema?.enum + expect(bareEnum).toContain('fal-ai/flux/dev') + expect(bareEnum).not.toContain('fal-ai/retired') + expect( + barePaths['/{endpoint_id}']?.post.requestBody.content['application/json'] + .schema, + ).toMatchObject({ type: 'object' }) + expect( + (bare.document.components as { schemas?: unknown }).schemas, + ).toBeUndefined() + }) + it('serves one model with sibling GET', async () => { const pinned = await assembleProviderOpenApi(db, 'fal', { model: 'fal-ai/flux/dev', }) @@ -233,13 +288,401 @@ describe('assembleProviderOpenApi', () => { ).toBeDefined() expect(paths['/fal-ai/other']).toBeUndefined() - expect( - await assembleProviderOpenApi(db, 'fal', { activity: 'image' }), - ).toMatchObject({ + const pinnedActivity = await assembleProviderOpenApi(db, 'fal', { + model: 'fal-ai/flux/dev', + activity: 'image', + }) + expect(pinnedActivity.ok).toBe(true) + if (!pinnedActivity.ok) return + expect(pinnedActivity.document.paths).toHaveProperty('/fal-ai/flux/dev') + expect(pinnedActivity.document.paths).not.toHaveProperty('/{endpoint_id}') + }) + + it('serves one FAL queue document for an activity', async () => { + const empty = await assembleProviderOpenApi(db, 'fal', { + activity: 'chat', + }) + expect(empty).toMatchObject({ ok: false, status: 400, code: 'spec_requires_selector', }) + + const result = await assembleProviderOpenApi(db, 'fal', { + activity: 'image', + }) + expect(result.ok).toBe(true) + if (!result.ok) return + const paths = result.document.paths as Record< + string, + { + post?: { + parameters?: Array<{ + name: string + schema?: { enum?: Array } + }> + requestBody?: { + content: { + 'application/json': { + schema: { anyOf?: Array<{ $ref: string }> } + } + } + } + } + get?: { + responses?: { + '200'?: { + content?: { + 'application/json': { schema: { $ref?: string } } + } + } + } + } + } + > + expect(Object.keys(paths).sort()).toEqual([ + '/{endpoint_id}', + '/{endpoint_id}/requests/{request_id}', + ]) + const endpointParam = paths['/{endpoint_id}']?.post?.parameters?.find( + (parameter) => parameter.name === 'endpoint_id', + ) + expect(endpointParam).toMatchObject({ 'x-modelschemas-encode': 'raw' }) + expect(endpointParam?.schema?.enum).toEqual([ + 'fal-ai/flux/dev', + 'fal-ai/other', + ]) + const body = paths['/{endpoint_id}']?.post?.requestBody + expect(body?.content['application/json'].schema.anyOf).toEqual([ + { $ref: '#/components/schemas/input_fal-ai_flux_dev' }, + { $ref: '#/components/schemas/input_fal-ai_other' }, + ]) + // fal-ai/other has no output schema, so the result stays untyped and + // the response says why. + const queued = ( + paths['/{endpoint_id}/requests/{request_id}'] as { + get?: { responses?: { '200'?: { description?: string } } } + } + ).get?.responses?.['200'] + expect(queued?.description).toContain('1 of 2 models') + expect(queued).not.toHaveProperty('content') + const schemas = ( + result.document.components as { + schemas: Record< + string, + { 'x-modelschemas-model'?: string; properties?: unknown } + > + } + ).schemas + const fluxInput = schemas['input_fal-ai_flux_dev'] + const otherInput = schemas['input_fal-ai_other'] + expect(fluxInput?.['x-modelschemas-model']).toBe('fal-ai/flux/dev') + expect(fluxInput?.properties).toEqual({ prompt: { type: 'string' } }) + expect(otherInput?.['x-modelschemas-model']).toBe('fal-ai/other') + // fal-ai/other has no output schema, so the result body is omitted + // rather than described as Flux's output. + expect( + paths['/{endpoint_id}/requests/{request_id}']?.get?.responses?.['200'] + ?.content, + ).toBeUndefined() + expect(schemas['output_fal-ai_flux_dev']).toBeUndefined() + expect(schemas['input_fal-ai_retired']).toBeUndefined() + const info = result.document.info as { description?: string } + expect(info.description).toContain('x-modelschemas-model') + }) + + it('leaves a model with no generation endpoint out of the enum', async () => { + await db + .insert(models) + .values({ + id: 'fal-orphan', + providerId: 'fal', + rawId: 'fal-ai/orphan', + activity: 'image', + providerMetadata: { category: 'text-to-image' }, + firstSeenAt: NOW, + lastSeenAt: NOW, + }) + .onConflictDoNothing() + const result = await assembleProviderOpenApi(db, 'fal', { + activity: 'image', + }) + expect(result.ok).toBe(true) + if (!result.ok) return + const post = ( + result.document.paths as Record< + string, + { post: { parameters: Array<{ schema?: { enum?: Array } }> } } + > + )['/{endpoint_id}']?.post + expect(post?.parameters[0]?.schema?.enum).not.toContain('fal-ai/orphan') + await db.delete(models).where(eq(models.id, 'fal-orphan')) + }) + + it('pairs each result schema with the same endpoint id', async () => { + await db + .insert(endpoints) + .values([ + { + id: 'fal/fal-ai/pair/a', + providerId: 'fal', + activity: 'chat', + method: 'POST', + path: '/fal-ai/pair/a', + }, + { + id: 'fal/fal-ai/pair/b', + providerId: 'fal', + activity: 'chat', + method: 'POST', + path: '/fal-ai/pair/b', + }, + { + id: 'fal/fal-ai/pair/no-input', + providerId: 'fal', + activity: 'chat', + method: 'POST', + path: '/fal-ai/pair/no-input', + }, + ]) + .onConflictDoNothing() + await db + .insert(models) + .values([ + { + id: 'fal-pair-a', + providerId: 'fal', + rawId: 'fal-ai/pair/a', + activity: 'chat', + providerMetadata: { category: 'paired-image' }, + firstSeenAt: NOW, + lastSeenAt: NOW, + }, + { + id: 'fal-pair-b', + providerId: 'fal', + rawId: 'fal-ai/pair/b', + activity: 'chat', + providerMetadata: { category: 'paired-image' }, + firstSeenAt: NOW, + lastSeenAt: NOW, + }, + { + id: 'fal-pair-no-input', + providerId: 'fal', + rawId: 'fal-ai/pair/no-input', + activity: 'chat', + providerMetadata: { category: 'paired-image' }, + firstSeenAt: NOW, + lastSeenAt: NOW, + }, + ]) + .onConflictDoNothing() + await db + .insert(schemaVersions) + .values([ + { + id: 'fal/fal-ai/pair/a:input', + endpointId: 'fal/fal-ai/pair/a', + kind: 'input', + contentHash: `a1${'1'.repeat(62)}`, + schema: JSON.stringify({ + type: 'object', + properties: { prompt: { const: 'a' } }, + }), + createdAt: NOW, + }, + { + id: 'fal/fal-ai/pair/a:output', + endpointId: 'fal/fal-ai/pair/a', + kind: 'output', + contentHash: `a2${'2'.repeat(62)}`, + schema: JSON.stringify({ + type: 'object', + properties: { image: { const: 'a' } }, + }), + createdAt: NOW, + }, + { + id: 'fal/fal-ai/pair/b:input', + endpointId: 'fal/fal-ai/pair/b', + kind: 'input', + contentHash: `b1${'3'.repeat(62)}`, + schema: JSON.stringify({ + type: 'object', + properties: { prompt: { const: 'b' } }, + }), + createdAt: NOW, + }, + { + id: 'fal/fal-ai/pair/b:output', + endpointId: 'fal/fal-ai/pair/b', + kind: 'output', + contentHash: `b2${'4'.repeat(62)}`, + schema: JSON.stringify({ + type: 'object', + properties: { image: { const: 'b' } }, + }), + createdAt: NOW, + }, + ]) + .onConflictDoNothing() + + const result = await assembleProviderOpenApi(db, 'fal', { + activity: 'chat', + }) + expect(result.ok).toBe(true) + if (!result.ok) return + const post = ( + result.document.paths as Record< + string, + { + post?: { + parameters?: Array<{ schema?: { enum?: Array } }> + requestBody?: { + content: { + 'application/json': { + schema: { anyOf?: Array<{ $ref: string }> } + } + } + } + } + get?: { + responses?: { + '200'?: { + content?: { + 'application/json': { + schema: { anyOf?: Array<{ $ref: string }> } + } + } + } + } + } + } + > + )['/{endpoint_id}']?.post + const get = ( + result.document.paths as Record< + string, + { + get?: { + parameters?: Array<{ schema?: { enum?: Array } }> + responses?: { + '200'?: { + content?: { + 'application/json': { + schema: { anyOf?: Array<{ $ref: string }> } + } + } + } + } + } + } + > + )['/{endpoint_id}/requests/{request_id}']?.get + expect(post?.parameters?.[0]?.schema?.enum).toEqual([ + 'fal-ai/pair/a', + 'fal-ai/pair/b', + ]) + expect(get?.parameters?.[0]?.schema?.enum).toEqual([ + 'fal-ai/pair/a', + 'fal-ai/pair/b', + ]) + expect(post?.requestBody?.content['application/json'].schema.anyOf).toEqual( + [ + { $ref: '#/components/schemas/input_fal-ai_pair_a' }, + { $ref: '#/components/schemas/input_fal-ai_pair_b' }, + ], + ) + expect( + get?.responses?.['200']?.content?.['application/json'].schema.anyOf, + ).toEqual([ + { $ref: '#/components/schemas/output_fal-ai_pair_a' }, + { $ref: '#/components/schemas/output_fal-ai_pair_b' }, + ]) + const schemas = ( + result.document.components as { + schemas: Record + } + ).schemas + expect(schemas['input_fal-ai_pair_a']?.['x-modelschemas-model']).toBe( + 'fal-ai/pair/a', + ) + expect(schemas['output_fal-ai_pair_b']?.['x-modelschemas-model']).toBe( + 'fal-ai/pair/b', + ) + expect(schemas['input_fal-ai_pair_no-input']).toBeUndefined() + }) + + it('serves an activity larger than the 40-endpoint cap as one queue document', async () => { + const count = 41 + const endpointRows = Array.from({ length: count }, (_, i) => ({ + id: `fal/bulk/m${String(i)}`, + providerId: 'fal', + activity: 'audio' as const, + method: 'POST', + path: `/bulk/m${String(i)}`, + })) + const modelRows = Array.from({ length: count }, (_, i) => ({ + id: `fal-bulk-m${String(i)}`, + providerId: 'fal', + rawId: `bulk/m${String(i)}`, + activity: 'audio' as const, + providerMetadata: { category: 'bulk-image' }, + firstSeenAt: NOW, + lastSeenAt: NOW, + })) + const schemaRows = Array.from({ length: count }, (_, i) => ({ + id: `fal/bulk/m${String(i)}:input`, + endpointId: `fal/bulk/m${String(i)}`, + kind: 'input' as const, + contentHash: `${String(i).padStart(2, '0')}${'f'.repeat(62)}`, + schema: JSON.stringify({ + type: 'object', + properties: { n: { const: i } }, + }), + createdAt: NOW, + })) + for (let i = 0; i < count; i += 10) { + await db + .insert(endpoints) + .values(endpointRows.slice(i, i + 10)) + .onConflictDoNothing() + await db + .insert(models) + .values(modelRows.slice(i, i + 10)) + .onConflictDoNothing() + await db + .insert(schemaVersions) + .values(schemaRows.slice(i, i + 10)) + .onConflictDoNothing() + } + const result = await assembleProviderOpenApi(db, 'fal', { + activity: 'audio', + }) + expect(result.ok).toBe(true) + if (!result.ok) return + const paths = result.document.paths as Record + expect(Object.keys(paths).sort()).toEqual([ + '/{endpoint_id}', + '/{endpoint_id}/requests/{request_id}', + ]) + const post = ( + paths['/{endpoint_id}'] as { + post: { + parameters: Array<{ schema?: { enum?: Array } }> + requestBody: { + content: { + 'application/json': { schema: { anyOf: Array } } + } + } + } + } + ).post + expect(post.parameters[0]?.schema?.enum).toHaveLength(count) + expect( + post.requestBody.content['application/json'].schema.anyOf, + ).toHaveLength(count) }) it('refuses empty paths instead of returning 200', async () => { @@ -420,6 +863,16 @@ describe('assembleProviderOpenApi', () => { code: 'unknown_model', }) if (!result.ok) expect(result.message).toContain('AsyncAPI') + + const byActivity = await assembleProviderOpenApi(db, 'fal', { + activity: 'video', + }) + expect(byActivity).toMatchObject({ + ok: false, + status: 400, + code: 'spec_requires_selector', + }) + if (!byActivity.ok) expect(byActivity.message).toContain('AsyncAPI') }) it('404s unknown provider and unknown model', async () => { diff --git a/src/server/providers/connect.ts b/src/server/providers/connect.ts index f556804..f121107 100644 --- a/src/server/providers/connect.ts +++ b/src/server/providers/connect.ts @@ -8,6 +8,13 @@ import type { SpecGrain, } from './types.ts' +/** + * Queue documents narrowed with `?activity=`: FAL `video` was 653 models + * and `image` 638 (2026-11-04), each inlined as its own schema. The + * unnarrowed queue document is uncapped because it inlines none. + */ +export const MAX_QUEUE_SPEC_PATHS = 1024 + export function bearerConnect( serverUrl: string, description?: string, diff --git a/src/server/skill.ts b/src/server/skill.ts index 0901619..b3480ca 100644 --- a/src/server/skill.ts +++ b/src/server/skill.ts @@ -42,8 +42,13 @@ availability and schema questions. self-contained JSON Schema (URL-encode slashes in endpoint ids: \`chat%2Fcompletions\`). A listed model rawId also works as \`{endpointId}\` on grain=provider catalogs. - - \`GET /v1/openapi/{provider}\` — connectable generation OpenAPI - (FAL, currently the only model-grained provider, requires \`?model=\`) + - \`GET /v1/providers/{provider}\` — one provider's status, counts, and + live models per activity (the \`?activity=\` values). + - \`GET /v1/openapi/{provider}\` — connectable generation OpenAPI. + FAL is one queue document: the model id is the \`endpoint_id\` path + parameter (\`x-modelschemas-encode: raw\`, so do not percent-encode + slashes). Bare, the bodies are untyped; \`?activity=\` inlines each + model's own body schema; \`?model=\` is that one model's paths. 3. **Authenticate for more** (5k req/h + webhooks). Easiest: the CLI — \`modelschemas login\` (agent-auth, autonomous mode, keys stored locally) or \`modelschemas login --api-key\`. Raw HTTP alternative: