diff --git a/package.json b/package.json index a1de7f5bb8e..3e651db9481 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "requesty:sync": "bun ./packages/core/script/sync-models.ts requesty", "merge-gateway:sync": "bun ./packages/core/script/sync-models.ts merge-gateway", "nano-gpt:sync": "bun ./packages/core/script/sync-models.ts nano-gpt", + "nearai:sync": "bun ./packages/core/script/sync-models.ts nearai", "venice:sync": "bun ./packages/core/script/sync-models.ts venice", "tinfoil:sync": "bun ./packages/core/script/sync-models.ts tinfoil", "vercel:generate": "bun ./packages/core/script/sync-models.ts vercel", diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 9156827c689..bf754bb81f0 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -30,6 +30,7 @@ import { llmgateway, llmgatewayProviders } from "./providers/llmgateway.js"; import { mergeGateway } from "./providers/merge-gateway.js"; import { meta } from "./providers/meta.js"; import { nanoGpt } from "./providers/nano-gpt.js"; +import { nearai } from "./providers/nearai.js"; import { ollamaCloud } from "./providers/ollama-cloud.js"; import { openai } from "./providers/openai.js"; import { ofox } from "./providers/ofox.js"; @@ -164,6 +165,7 @@ export const providers: { "merge-gateway": SyncProvider; meta: SyncProvider; "nano-gpt": SyncProvider; + nearai: SyncProvider; ofox: SyncProvider; "ollama-cloud": SyncProvider; openai: SyncProvider; @@ -202,6 +204,7 @@ export const providers: { "merge-gateway": mergeGateway, meta, "nano-gpt": nanoGpt, + nearai, ofox, "ollama-cloud": ollamaCloud, openai, @@ -234,7 +237,7 @@ export const groups = { "vercel", ], cloudflare: ["cloudflare-ai-gateway", "cloudflare-workers-ai"], - direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], + direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "nearai", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/nearai.ts b/packages/core/src/sync/providers/nearai.ts new file mode 100644 index 00000000000..6021ad683e3 --- /dev/null +++ b/packages/core/src/sync/providers/nearai.ts @@ -0,0 +1,146 @@ +import { z } from "zod"; + +import type { ExistingModel, SyncedFullModel, SyncedModel, SyncProvider } from "../index.js"; +import { factorBaseModel } from "./openrouter.js"; + +const API_ENDPOINT = "https://cloud-api.near.ai/v1/models"; + +const TOKENS_PER_PRICING_UNIT = 1_000_000; + +const HOSTED_BY_NEAR_AI = "nearai"; + +const NearAIPricing = z.object({ + input: z.number().nonnegative(), + output: z.number().nonnegative(), + input_cache_read: z.string().optional(), +}).passthrough(); + +export const NearAIModel = z.object({ + id: z.string().min(1), + object: z.literal("model"), + created: z.number().int().nonnegative(), + owned_by: z.string(), + name: z.string().min(1), + pricing: NearAIPricing, + context_length: z.number().int().positive(), + max_output_length: z.number().int().positive().optional(), + input_modalities: z.array(z.string()), + output_modalities: z.array(z.string()), + supported_features: z.array(z.string()), +}).passthrough(); + +export const NearAIResponse = z.object({ + object: z.literal("list"), + data: z.array(NearAIModel), +}).passthrough(); + +export type NearAIModel = z.infer; + +export const nearai = { + id: "nearai", + name: "NEAR AI Cloud", + modelsDir: "providers/nearai/models", + skipCreates: true, + // Much of the catalog has no local entry. Those are reported in the sync + // notice rather than filed as an issue each. + trackMissingModels: false, + // A truncated or degraded catalog response is indistinguishable from a + // genuine removal, so absence never proposes a delete. + deleteMissing: false, + sourceID(model) { + return model.id; + }, + skippedNotice(ids) { + if (ids.length === 0) return []; + return [ + `${ids.length} NEAR AI models were not synced, either because they have no` + + ` local entry (the catalog exposes no release date, knowledge cutoff or` + + ` reasoning controls, so those are authored by hand) or because the local` + + ` entry resolves to no cost, which the catalog cannot supply on its own.`, + `Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`, + ]; + }, + async fetchModels() { + return fetchNearAIModels(); + }, + parseModels(raw) { + return NearAIResponse.parse(raw).data; + }, + translateModel(model, context) { + const existing = context.existing(model.id); + // The runner rethrows anything but a missing-reasoning error, so one unpriced + // entry would abort the run for every other model. Skip it into the notice + // instead: the catalog cannot supply a cost the local entry does not resolve. + if (existing === undefined || existing.cost === undefined) return undefined; + return { + id: model.id, + model: buildNearAIModel(model, existing), + }; + }, +} satisfies SyncProvider; + +export async function fetchNearAIModels(fetcher: typeof fetch = fetch) { + const response = await fetcher(API_ENDPOINT); + if (!response.ok) { + throw new Error(`NEAR AI models request failed: ${response.status} ${response.statusText}`); + } + return NearAIResponse.parse(await response.json()); +} + +function atMost(current: number | undefined, reported: number | undefined): number | undefined { + if (current === undefined) return reported; + if (reported === undefined) return current; + return Math.min(current, reported); +} + +// The catalog returns artifacts like 1.4000000000000001, and scaling per-token +// strings introduces its own, so every published price is rounded. +function price(value: number): number { + return Number(value.toFixed(6)); +} + +// Per-token strings, unlike the per-million `input` and `output` numbers. +function perMillion(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? price(parsed * TOKENS_PER_PRICING_UNIT) : undefined; +} + +export function buildNearAIModel( + model: NearAIModel, + existing: ExistingModel, +): SyncedModel { + if (existing.cost === undefined) { + throw new Error(`NEAR AI model ${model.id} has incomplete local pricing required for sync`); + } + + const { base_model: baseModel, base_model_omit: baseModelOmit, ...current } = existing; + + const cost = { + ...existing.cost, + input: price(model.pricing.input), + output: price(model.pricing.output), + cache_read: perMillion(model.pricing.input_cache_read) ?? existing.cost.cache_read, + }; + + // `context_length` is the serving `max_model_len` only for models NEAR AI hosts + // itself. On relayed routes it is whatever the upstream aggregator reported and + // is often rounded below the lab figure, so it would publish a cap the host does + // not impose. `max_output_length` is advisory even on hosted models: requests + // above it succeed, and only exceeding the context window is rejected. So output + // is never synced, and context only for hosted models, capped downward. + const limit = model.owned_by === HOSTED_BY_NEAR_AI + ? { ...existing.limit, context: atMost(existing.limit?.context, model.context_length) } + : existing.limit; + + // Only price and serving limits come from the catalog. Its capability fields are + // wrong in both directions: `supported_features` lists reasoning for relayed + // routes that return no reasoning content, and `input_modalities` claims image + // for routes that reject it. Capabilities, modalities and reasoning controls + // therefore stay hand-authored. + const values = { ...current, cost, limit } as SyncedFullModel; + + return baseModel === undefined + ? values + : factorBaseModel(baseModel, values, limit, baseModelOmit); +} diff --git a/packages/core/test/nearai.test.ts b/packages/core/test/nearai.test.ts new file mode 100644 index 00000000000..cd7c0bd45fb --- /dev/null +++ b/packages/core/test/nearai.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "bun:test"; + +import type { ExistingModel } from "../src/sync/index.js"; +import { + buildNearAIModel, + fetchNearAIModels, + nearai, + type NearAIModel, +} from "../src/sync/providers/nearai.js"; + +function context(entries: Record) { + return { existing: (id: string) => entries[id], authored: (id: string) => entries[id] }; +} + +function nearAIModel(overrides: Partial = {}): NearAIModel { + return { + id: "zai-org/GLM-5.1-FP8", + object: "model", + created: 1_759_104_000, + owned_by: "nearai", + name: "GLM 5.1 FP8", + pricing: { input: 1.4, output: 4.4, input_cache_read: "0.00000026" }, + context_length: 202_752, + max_output_length: 16_384, + input_modalities: ["text"], + output_modalities: ["text"], + supported_features: ["tools", "structured_outputs", "reasoning"], + ...overrides, + }; +} + +// A full provider definition rather than a base_model overlay, so these assert +// the mapping itself instead of how factorBaseModel diffs against a lab file. +function authored(overrides: Record = {}): ExistingModel { + return { + name: "GLM 5.1 FP8", + reasoning: true, + reasoning_options: [{ type: "toggle" }], + tool_call: true, + structured_output: true, + open_weights: true, + cost: { input: 1.4, output: 4.4, cache_write: 0.5 }, + limit: { context: 202_752, output: 64_000 }, + modalities: { input: ["text", "pdf"], output: ["text"] }, + ...overrides, + } as ExistingModel; +} + +test("keeps hand-authored reasoning when the catalog omits the feature", () => { + const built = buildNearAIModel( + nearAIModel({ supported_features: ["tools"] }), + authored(), + ); + + expect(built).toMatchObject({ + reasoning: true, + reasoning_options: [{ type: "toggle" }], + }); +}); + +test("converts the per-token cache price to dollars per million tokens", () => { + const built = buildNearAIModel(nearAIModel(), authored()); + + expect(built).toMatchObject({ cost: { cache_read: 0.26 } }); +}); + +test("rounds away the catalog's floating point artifacts", () => { + const built = buildNearAIModel( + nearAIModel({ pricing: { input: 1.4000000000000001, output: 4.4 } }), + authored(), + ); + + expect(built).toMatchObject({ cost: { input: 1.4, output: 4.4 } }); +}); + +test("preserves a locally authored cost the catalog does not publish", () => { + const built = buildNearAIModel(nearAIModel(), authored()); + + expect(built).toMatchObject({ cost: { cache_write: 0.5 } }); +}); + +test("caps context at the lower of local and gateway, and never raises it", () => { + const built = buildNearAIModel( + nearAIModel({ owned_by: "nearai", context_length: 1_000_000 }), + authored({ limit: { context: 202_752, output: 131_072 } }), + ); + + expect(built).toMatchObject({ limit: { context: 202_752 } }); +}); + +test("ignores the context a relayed route reports, which can round below the lab", () => { + const built = buildNearAIModel( + nearAIModel({ owned_by: "openai", context_length: 1_000_000 }), + authored({ limit: { context: 1_047_576, output: 32_768 } }), + ); + + expect(built).toMatchObject({ limit: { context: 1_047_576 } }); +}); + +test("leaves the output limit authored, since max_output_length is not enforced", () => { + const built = buildNearAIModel( + nearAIModel({ max_output_length: 16_384 }), + authored({ limit: { context: 202_752, output: 131_072 } }), + ); + + expect(built).toMatchObject({ limit: { output: 131_072 } }); +}); + +test("retains a modality the gateway does not advertise", () => { + const built = buildNearAIModel(nearAIModel(), authored()); + + expect(built).toMatchObject({ modalities: { input: ["text", "pdf"] } }); +}); + +test("does not widen a hand-narrowed modality the gateway over-reports", () => { + const built = buildNearAIModel( + nearAIModel({ input_modalities: ["text", "image"] }), + authored({ modalities: { input: ["text"], output: ["text"] } }), + ); + + expect(built).toMatchObject({ modalities: { input: ["text"] } }); +}); + +test("ignores an output modality the catalog schema cannot express", () => { + const built = buildNearAIModel( + nearAIModel({ output_modalities: ["embedding"] }), + authored(), + ); + + expect(built).toMatchObject({ modalities: { output: ["text"] } }); +}); + +test("takes no capability from supported_features, which misreports both ways", () => { + const built = buildNearAIModel( + nearAIModel({ supported_features: ["tools", "structured_outputs", "reasoning"] }), + authored({ tool_call: false, structured_output: false, reasoning: false }), + ); + + expect(built).toMatchObject({ + tool_call: false, + structured_output: false, + reasoning: false, + }); +}); + +test("leaves attachment as authored when the gateway claims an image route", () => { + const built = buildNearAIModel( + nearAIModel({ input_modalities: ["text", "image"] }), + authored({ attachment: false, modalities: { input: ["text"], output: ["text"] } }), + ); + + expect(built).toMatchObject({ attachment: false }); +}); + +test("routes an overlay through the base model rather than inlining it", () => { + const built = buildNearAIModel( + nearAIModel({ id: "anthropic/claude-sonnet-4-5" }), + authored({ base_model: "anthropic/claude-sonnet-4-5" }), + ); + + expect(built).toMatchObject({ + base_model: "anthropic/claude-sonnet-4-5", + cost: { input: 1.4, output: 4.4 }, + }); +}); + +test("refuses to sync a model with no locally authored pricing", () => { + expect(() => buildNearAIModel(nearAIModel(), authored({ cost: undefined }))) + .toThrow(/incomplete local pricing/); +}); + +test("keeps the authored cache price when the catalog publishes an unparseable one", () => { + const built = buildNearAIModel( + nearAIModel({ pricing: { input: 1.4, output: 4.4, input_cache_read: "n/a" } }), + authored({ cost: { input: 1.4, output: 4.4, cache_read: 0.26 } }), + ); + + expect(built).toMatchObject({ cost: { cache_read: 0.26 } }); +}); + +test("skips an unpriced local entry rather than aborting the whole run", () => { + const model = nearAIModel(); + const entries = { [model.id]: authored({ cost: undefined }) }; + + expect(nearai.translateModel(model, context(entries))).toBeUndefined(); +}); + +test("reports both reasons a model can be skipped", () => { + const notice = nearai.skippedNotice(["openai/privacy-filter"]); + + expect(notice[0]).toContain("no local entry"); + expect(notice[0]).toContain("no cost"); + expect(notice[1]).toContain("`openai/privacy-filter`"); +}); + +test("fails the run rather than syncing from a degraded catalog response", async () => { + const unavailable = () => + Promise.resolve(new Response("", { status: 503, statusText: "Service Unavailable" })); + + await expect(fetchNearAIModels(unavailable as unknown as typeof fetch)) + .rejects.toThrow(/503 Service Unavailable/); +}); diff --git a/sync.md b/sync.md index d2c8bc3520c..3b3dfaef9d2 100644 --- a/sync.md +++ b/sync.md @@ -23,6 +23,7 @@ The grouped sync targets are available for local convenience, but CI syncs each - `bun models:sync ollama-cloud` syncs Ollama Cloud catalog availability. - `bun models:sync github-copilot` syncs only GitHub Copilot pricing. - `bun models:sync tinfoil` syncs only Tinfoil. +- `bun models:sync nearai` syncs only NEAR AI Cloud. - `bun models:sync aggregators --dry-run` prints changes without writing model files. - `bun models:sync aggregators --new-only` creates new model files but skips updates and removals. - `bun models:sync --open-issues` opens GitHub issues for missing models (on by default only when `GITHUB_ACTIONS=true`). @@ -265,6 +266,21 @@ xAI is implemented in `packages/core/src/sync/providers/xai.ts`. - New token-priced chat, safety, and embedding models are not created automatically (`skipCreates`); each missing ID opens a deduped GitHub issue for hand-authored metadata. - Per-request tool, TTS, transcription, realtime, and document-processing services are ignored because their pricing cannot be represented by the token-cost schema. +## NEAR AI Cloud Notes + +- NEAR AI Cloud is implemented in `packages/core/src/sync/providers/nearai.ts`. +- Source endpoint: `https://cloud-api.near.ai/v1/models`. +- No authentication is required; the catalog is public, so the sync needs no repository secret. +- Existing models are updated from API-authoritative input, output and cached-input pricing, and context windows. Nothing else is taken from the endpoint. +- `pricing.input` and `pricing.output` are already dollars per million tokens, while `pricing.input_cache_read` is per token and is scaled. Every published price is rounded because the endpoint returns artifacts such as `1.4000000000000001`. +- `context` is synced only for models NEAR AI hosts itself (`owned_by = "nearai"`), where `context_length` is the serving `max_model_len`. On relayed routes the figure comes from the upstream aggregator and is often rounded below the lab entry, so syncing it would publish a cap the host does not impose. Where it is synced it is taken as the lower of the two values, so a smaller verified cap survives. +- `limit.output` is never synced. `max_output_length` is advisory rather than enforced: a request above it is accepted, and only exceeding the context window is rejected. +- No capability is taken from the endpoint, because its capability fields are wrong in both directions. `supported_features` lists `reasoning` for relayed routes that accept every documented reasoning parameter and still return no reasoning content, and omits it for others that plainly reason. `input_modalities` advertises image input for routes that reject it, and also reports an `embedding` modality the schema has no value for. +- `reasoning`, `reasoning_options`, `tool_call`, `structured_output`, `modalities`, `attachment`, `interleaved` and lifecycle `status` are all hand-authored. +- New models are not created automatically (`skipCreates`) because the endpoint exposes no release date or knowledge cutoff, and most NEAR AI models reason and so need hand-authored controls. +- Remote-only models are listed in the sync notice rather than filed as issues (`trackMissingModels: false`), because a substantial part of the catalog has no local entry. +- Absence never removes a model (`deleteMissing: false`): a truncated response would be indistinguishable from a genuine withdrawal. + ## OpenAI Notes - OpenAI is implemented in `packages/core/src/sync/providers/openai.ts`.