Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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";
Expand Down Expand Up @@ -162,6 +163,7 @@ export const providers: {
"merge-gateway": SyncProvider<any>;
meta: SyncProvider<any>;
"nano-gpt": SyncProvider<any>;
nearai: SyncProvider<any>;
ofox: SyncProvider<any>;
"ollama-cloud": SyncProvider<any>;
openai: SyncProvider<any>;
Expand Down Expand Up @@ -199,6 +201,7 @@ export const providers: {
"merge-gateway": mergeGateway,
meta,
"nano-gpt": nanoGpt,
nearai,
ofox,
"ollama-cloud": ollamaCloud,
openai,
Expand Down Expand Up @@ -231,7 +234,7 @@ export const groups = {
"vercel",
],
cloudflare: ["cloudflare-ai-gateway", "cloudflare-workers-ai"],
direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "friendli", "github-copilot", "google", "hyper", "meta", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"],
direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "friendli", "github-copilot", "google", "hyper", "meta", "nearai", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"],
} as const;

type ProviderID = keyof typeof providers;
Expand Down
146 changes: 146 additions & 0 deletions packages/core/src/sync/providers/nearai.ts
Original file line number Diff line number Diff line change
@@ -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<typeof NearAIModel>;

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<NearAIModel>;

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);
}
202 changes: 202 additions & 0 deletions packages/core/test/nearai.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ExistingModel>) {
return { existing: (id: string) => entries[id], authored: (id: string) => entries[id] };
}

function nearAIModel(overrides: Partial<NearAIModel> = {}): 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<string, unknown> = {}): 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/);
});
Loading
Loading