Skip to content
Closed
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 @@ -29,6 +29,7 @@
"kilo:sync": "bun ./packages/core/script/sync-models.ts kilo",
"llmgateway:sync": "bun ./packages/core/script/sync-models.ts llmgateway",
"requesty:sync": "bun ./packages/core/script/sync-models.ts requesty",
"saygm:sync": "bun ./packages/core/script/sync-models.ts saygm",
"merge-gateway:sync": "bun ./packages/core/script/sync-models.ts merge-gateway",
"nano-gpt:sync": "bun ./packages/core/script/sync-models.ts nano-gpt",
"venice:sync": "bun ./packages/core/script/sync-models.ts venice",
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 @@ -30,6 +30,7 @@ import { openrouter } from "./providers/openrouter.js";
import { ovhcloud } from "./providers/ovhcloud.js";
import { pioneer } from "./providers/pioneer.js";
import { requesty } from "./providers/requesty.js";
import { saygm } from "./providers/saygm.js";
import { tinfoil } from "./providers/tinfoil.js";
import { vercel } from "./providers/vercel.js";
import { venice } from "./providers/venice.js";
Expand Down Expand Up @@ -139,6 +140,7 @@ export const providers: {
ovhcloud: SyncProvider<any>;
pioneer: SyncProvider<any>;
requesty: SyncProvider<any>;
saygm: SyncProvider<any>;
tinfoil: SyncProvider<any>;
vercel: SyncProvider<any>;
venice: SyncProvider<any>;
Expand Down Expand Up @@ -170,6 +172,7 @@ export const providers: {
ovhcloud,
pioneer,
requesty,
saygm,
tinfoil,
vercel,
venice,
Expand All @@ -194,7 +197,7 @@ export const groups = {
"vercel",
],
cloudflare: ["cloudflare-workers-ai"],
direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "google", "hyper", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"],
direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "google", "hyper", "openai", "ovhcloud", "pioneer", "saygm", "tinfoil", "venice", "wandb", "xai"],
} as const;

type ProviderID = keyof typeof providers;
Expand Down
157 changes: 157 additions & 0 deletions packages/core/src/sync/providers/saygm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { z } from "zod";

import type { ExistingModel, SyncProvider, SyncedModel } from "../index.js";

const API_ENDPOINT = "https://api.saygm.com/v1/models";
const NDOLLARS_PER_DOLLAR = 1_000_000_000;

const NanoDollars = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);

const PriceDimensions = z.object({
input_per_mtok_ndollars: NanoDollars,
output_per_mtok_ndollars: NanoDollars,
cache_read_per_mtok_ndollars: NanoDollars.optional(),
cache_write_5m_per_mtok_ndollars: NanoDollars.optional(),
cache_write_1h_per_mtok_ndollars: NanoDollars.optional(),
audio_input_per_mtok_ndollars: NanoDollars.optional(),
audio_output_per_mtok_ndollars: NanoDollars.optional(),
long_context_threshold_tokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
long_context_input_per_mtok_ndollars: NanoDollars.optional(),
long_context_output_per_mtok_ndollars: NanoDollars.optional(),
}).passthrough().superRefine((dimensions, context) => {
const longContext = [
dimensions.long_context_threshold_tokens,
dimensions.long_context_input_per_mtok_ndollars,
dimensions.long_context_output_per_mtok_ndollars,
];
const present = longContext.filter((value) => value !== undefined).length;
if (present !== 0 && present !== longContext.length) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "Long-context pricing requires a threshold plus input and output prices",
});
}
});

const RetailCeiling = z.object({
basis: z.literal("published_retail"),
dimensions: PriceDimensions,
}).passthrough();

export const SaygmModel = z.object({
id: z.string().min(1),
object: z.literal("model"),
available: z.boolean(),
api_shapes: z.array(z.string().min(1)),
price_range: z.object({
unit: z.literal("ndollars_per_mtok"),
currency: z.literal("USD"),
ceiling: RetailCeiling,
}).passthrough().optional(),
}).passthrough();

export const SaygmResponse = z.object({
object: z.literal("list"),
data: z.array(SaygmModel),
}).passthrough();

export type SaygmModel = z.infer<typeof SaygmModel>;

export const saygm = {
id: "saygm",
name: "SayGM",
modelsDir: "providers/saygm/models",
// The live inventory can change with miner supply. Until the API publishes
// durable supply depth, only update models that were reviewed into the
// catalog and retain them through temporary outages.
skipCreates: true,
deleteMissing: false,
trackMissingModels: false,
sourceID(model) {
return model.id;
},
skippedNotice(ids) {
if (ids.length === 0) return [];
return [
`${ids.length} SayGM models were not created because this sync is update-only until the public API exposes durable miner depth.`,
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
];
},
missingNotice(paths) {
if (paths.length === 0) return [];
return [
`${paths.length} reviewed SayGM models were retained despite being absent from the current live response.`,
`Retained local models: ${paths.map((item) => `\`${item.replace(/\.toml$/, "")}\``).join(", ")}`,
];
},
async fetchModels() {
return fetchSaygmModels();
},
parseModels(raw) {
return SaygmResponse.parse(raw).data;
},
translateModel(model, context) {
if (context.existing(model.id) === undefined) return undefined;
const authored = context.authored(model.id);
if (authored === undefined) {
throw new Error(`SayGM model ${model.id} has no authored catalog entry`);
}
return {
id: model.id,
model: buildSaygmModel(model, authored),
};
},
} satisfies SyncProvider<SaygmModel>;

export async function fetchSaygmModels(fetcher: typeof fetch = fetch) {
const response = await fetcher(API_ENDPOINT);
if (!response.ok) {
throw new Error(`SayGM models request failed: ${response.status} ${response.statusText}`);
}
return SaygmResponse.parse(await response.json());
}

export function buildSaygmModel(model: SaygmModel, existing: ExistingModel): SyncedModel {
const ceiling = model.price_range?.ceiling;
if (ceiling === undefined) {
// A partial catalogue response must not make the hourly update discard or
// block a reviewed model. Keep its last reviewed price until SayGM
// publishes a complete retail ceiling again.
return existing as SyncedModel;
}

const dimensions = ceiling.dimensions;
const cost: NonNullable<ExistingModel["cost"]> = {
input: usdPerMtok(dimensions.input_per_mtok_ndollars),
output: usdPerMtok(dimensions.output_per_mtok_ndollars),
cache_read: optionalUsdPerMtok(dimensions.cache_read_per_mtok_ndollars),
// models.dev has one cache-write field; its provider catalogs conventionally
// use Anthropic's standard five-minute write rate.
cache_write: optionalUsdPerMtok(dimensions.cache_write_5m_per_mtok_ndollars),
input_audio: optionalUsdPerMtok(dimensions.audio_input_per_mtok_ndollars),
output_audio: optionalUsdPerMtok(dimensions.audio_output_per_mtok_ndollars),
};

if (dimensions.long_context_threshold_tokens !== undefined) {
const longInput = dimensions.long_context_input_per_mtok_ndollars;
const longOutput = dimensions.long_context_output_per_mtok_ndollars;
if (longInput === undefined || longOutput === undefined) {
throw new Error(`SayGM model ${model.id} has incomplete long-context pricing`);
}
cost.tiers = [{
tier: { type: "context", size: dimensions.long_context_threshold_tokens },
input: usdPerMtok(longInput),
output: usdPerMtok(longOutput),
}];
}

return { ...existing, cost } as SyncedModel;
}

export function usdPerMtok(ndollars: number) {
return ndollars / NDOLLARS_PER_DOLLAR;
}

function optionalUsdPerMtok(ndollars: number | undefined) {
return ndollars === undefined ? undefined : usdPerMtok(ndollars);
}
110 changes: 110 additions & 0 deletions packages/core/test/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ import {
import { openai, parseOpenAIModels } from "../src/sync/providers/openai.js";
import { ofox } from "../src/sync/providers/ofox.js";
import { pioneer } from "../src/sync/providers/pioneer.js";
import {
buildSaygmModel,
saygm,
SaygmResponse,
type SaygmModel,
usdPerMtok,
} from "../src/sync/providers/saygm.js";
import { google, shouldTrackGoogleModel } from "../src/sync/providers/google.js";
import { buildTinfoilModel, tinfoil, type TinfoilModel } from "../src/sync/providers/tinfoil.js";
import { resolveVeniceBaseModel } from "../src/sync/providers/venice.js";
Expand Down Expand Up @@ -198,6 +205,109 @@ function readyInceptronModel(overrides: Partial<InceptronModel> = {}): ReadyInce
})[0]!;
}

function saygmModel(overrides: Partial<SaygmModel> = {}): SaygmModel {
return {
id: "gpt-5.4",
object: "model",
available: true,
api_shapes: ["chat.completions", "responses"],
price_range: {
unit: "ndollars_per_mtok",
currency: "USD",
ceiling: {
basis: "published_retail",
dimensions: {
input_per_mtok_ndollars: 2_500_000_000,
output_per_mtok_ndollars: 15_000_000_000,
cache_read_per_mtok_ndollars: 250_000_000,
long_context_threshold_tokens: 272_000,
long_context_input_per_mtok_ndollars: 5_000_000_000,
long_context_output_per_mtok_ndollars: 22_500_000_000,
},
},
},
...overrides,
};
}

test("converts SayGM retail nanodollars into models.dev USD per Mtok costs", () => {
const built = buildSaygmModel(saygmModel(), {
base_model: "openai/gpt-5.4",
reasoning_options: [{ type: "effort", values: ["none", "low", "high"] }],
});

expect(built).toMatchObject({
base_model: "openai/gpt-5.4",
cost: {
input: 2.5,
output: 15,
cache_read: 0.25,
tiers: [{ tier: { type: "context", size: 272_000 }, input: 5, output: 22.5 }],
},
});
expect(usdPerMtok(1)).toBe(0.000000001);
});

test("uses SayGM's five-minute cache-write rate for models.dev", () => {
const built = buildSaygmModel(saygmModel({
id: "claude-haiku-4-5",
api_shapes: ["messages"],
price_range: {
unit: "ndollars_per_mtok",
currency: "USD",
ceiling: {
basis: "published_retail",
dimensions: {
input_per_mtok_ndollars: 1_000_000_000,
output_per_mtok_ndollars: 5_000_000_000,
cache_read_per_mtok_ndollars: 100_000_000,
cache_write_5m_per_mtok_ndollars: 1_250_000_000,
cache_write_1h_per_mtok_ndollars: 2_000_000_000,
},
},
},
}), { base_model: "anthropic/claude-haiku-4-5" });

expect(built).toMatchObject({
cost: { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 },
});
});

test("retains reviewed SayGM pricing when the live ceiling is unavailable", () => {
const existing = {
base_model: "openai/gpt-5.4",
cost: { input: 2.5, output: 15, cache_read: 0.25 },
};

const built = buildSaygmModel(saygmModel({ price_range: undefined }), existing);

expect(built).toEqual(existing);
});

test("rejects incomplete or non-retail SayGM pricing", () => {
const valid = saygmModel();
const missingLongOutput = structuredClone(valid);
delete missingLongOutput.price_range!.ceiling.dimensions.long_context_output_per_mtok_ndollars;
expect(() => SaygmResponse.parse({ object: "list", data: [missingLongOutput] })).toThrow(
"Long-context pricing requires a threshold plus input and output prices",
);

const floorOnly = {
...valid,
price_range: {
...valid.price_range!,
ceiling: { ...valid.price_range!.ceiling, basis: "cheapest_eligible_offer" },
},
};
expect(() => SaygmResponse.parse({ object: "list", data: [floorOnly] })).toThrow();
});

test("keeps SayGM model lifecycle changes review-only", () => {
expect(saygm.skipCreates).toBe(true);
expect(saygm.deleteMissing).toBe(false);
expect(saygm.trackMissingModels).toBe(false);
});

test("builds current Inceptron models from explicit base metadata", () => {
const models = parseInceptronModels({
object: "list",
Expand Down
6 changes: 6 additions & 0 deletions providers/saygm/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions providers/saygm/models/claude-fable-5.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
base_model = "anthropic/claude-fable-5"

[[reasoning_options]]
type = "effort"
values = ["low", "medium", "high", "xhigh", "max"]

[cost]
input = 10
output = 50
cache_read = 1
cache_write = 12.5

[provider]
npm = "@ai-sdk/anthropic"
api = "https://api.saygm.com/v1"
15 changes: 15 additions & 0 deletions providers/saygm/models/claude-haiku-4-5.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
base_model = "anthropic/claude-haiku-4-5"

[[reasoning_options]]
type = "budget_tokens"
min = 1_024

[cost]
input = 1
output = 5
cache_read = 0.1
cache_write = 1.25

[provider]
npm = "@ai-sdk/anthropic"
api = "https://api.saygm.com/v1"
15 changes: 15 additions & 0 deletions providers/saygm/models/claude-opus-4-7.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
base_model = "anthropic/claude-opus-4-7"

[[reasoning_options]]
type = "effort"
values = ["low", "medium", "high", "xhigh", "max"]

[cost]
input = 5
output = 25
cache_read = 0.5
cache_write = 6.25

[provider]
npm = "@ai-sdk/anthropic"
api = "https://api.saygm.com/v1"
15 changes: 15 additions & 0 deletions providers/saygm/models/claude-opus-4-8.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
base_model = "anthropic/claude-opus-4-8"

[[reasoning_options]]
type = "effort"
values = ["low", "medium", "high", "xhigh", "max"]

[cost]
input = 5
output = 25
cache_read = 0.5
cache_write = 6.25

[provider]
npm = "@ai-sdk/anthropic"
api = "https://api.saygm.com/v1"
Loading
Loading