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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"huggingface:sync": "bun ./packages/core/script/sync-models.ts huggingface",
"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",
"venice:sync": "bun ./packages/core/script/sync-models.ts venice",
"vercel:generate": "bun ./packages/core/script/sync-models.ts vercel",
"wandb:generate": "bun ./packages/core/script/sync-models.ts wandb",
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 @@ -23,6 +23,7 @@ import { openai } from "./providers/openai.js";
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 { vercel } from "./providers/vercel.js";
import { venice } from "./providers/venice.js";
import { wandb } from "./providers/wandb.js";
Expand Down Expand Up @@ -123,6 +124,7 @@ export const providers: {
openrouter: SyncProvider<any>;
ovhcloud: SyncProvider<any>;
pioneer: SyncProvider<any>;
requesty: SyncProvider<any>;
vercel: SyncProvider<any>;
venice: SyncProvider<any>;
wandb: SyncProvider<any>;
Expand All @@ -146,14 +148,15 @@ export const providers: {
openrouter,
ovhcloud,
pioneer,
requesty,
vercel,
venice,
wandb,
xai,
};

export const groups = {
aggregators: ["crossmodel", "empiriolabs", "huggingface", "kilo", "llmgateway", "openrouter", "vercel"],
aggregators: ["crossmodel", "empiriolabs", "huggingface", "kilo", "llmgateway", "openrouter", "requesty", "vercel"],
cloudflare: ["cloudflare-workers-ai"],
direct: ["ambient", "anthropic", "baseten", "chutes", "deepinfra", "digitalocean", "google", "hyper", "openai", "ovhcloud", "pioneer", "venice", "wandb", "xai"],
} as const;
Expand Down
219 changes: 219 additions & 0 deletions packages/core/src/sync/providers/requesty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";

import { z } from "zod";

import type {
ExistingModel,
SyncProvider,
SyncedBaseModel,
SyncedFullModel,
SyncedModel,
} from "../index.js";
import { buildOpenRouterModel, type OpenRouterModel } from "./openrouter.js";

const API_ENDPOINT = "https://router.requesty.ai/v1/models";
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
const TOKENS_PER_MILLION = 1_000_000;
const PRICE_DECIMALS = 1_000_000;
const REASONING_EFFORTS = ["none", "low", "medium", "high", "max"] as const;

const PricingBand = z
.object({
prompt_tokens_threshold: z.number(),
input_price: z.number().optional(),
output_price: z.number().optional(),
cached_price: z.number().optional(),
caching_price: z.number().optional(),
})
.passthrough();

export const RequestyModel = z
.object({
id: z.string().min(1),
created: z.number(),
context_window: z.number(),
max_output_tokens: z.number(),
input_price: z.number(),
output_price: z.number(),
cached_price: z.number().optional(),
caching_price: z.number().optional(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should use the other Devin PR (which introduced pricing to have an array here actually showing all the bands we have, so we can potentially map it to different tiers

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wired up: the schema takes the optional pricing array from requestyai/backend#2349 (prompt_tokens_threshold, input_price, output_price, cached_price, caching_price) and maps every band above threshold 0 to a cost.tiers entry, with the threshold-0 band staying the flat cost. The live endpoint doesn't return pricing yet, so today's run keeps the hand-authored bands (e.g. the 200K Anthropic tier); the moment anomalyco#2349 ships they come from the API instead.

pricing: z.array(PricingBand).optional(),
supports_vision: z.boolean().optional(),
supports_reasoning: z.boolean().optional(),
supports_tool_calling: z.boolean().optional(),
supports_output_json_schema: z.boolean().optional(),
})
.passthrough();

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

const BaseMetadata = z
.object({
reasoning: z.boolean().optional(),
temperature: z.boolean().optional(),
tool_call: z.boolean().optional(),
structured_output: z.boolean().optional(),
open_weights: z.boolean().optional(),
limit: z.object({ context: z.number(), output: z.number().optional() }).passthrough(),
modalities: z
.object({ input: z.array(z.string()), output: z.array(z.string()) })
.passthrough(),
})
.passthrough();

export type RequestyModel = z.infer<typeof RequestyModel>;
type BaseMetadata = z.infer<typeof BaseMetadata>;

export const requesty = {
id: "requesty",
name: "Requesty",
modelsDir: "providers/requesty/models",
sourceID: (model) => model.id,
skippedNotice: (ids) => [
`${ids.length} Requesty routes have no \`models/\` metadata entry yet: ${ids.join(", ")}`,
],
async fetchModels() {
const response = await fetch(API_ENDPOINT);
if (!response.ok) {
throw new Error(`Requesty request failed: ${response.status} ${response.statusText}`);
}
return response.json();
},
parseModels(raw) {
return RequestyResponse.parse(raw).data;
},
translateModel(model, context) {
const baseModel = resolveRequestyBaseModel(model.id);
// A route with no metadata to inherit keeps whatever is authored for it.
if (baseModel === undefined) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this mean?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, that comment was doing too much work. It's the routes we have no models/<lab>/<slug>.toml entry for (95 of them today: novita/qwen/qwen3-235b-a22b-fp8, xai/grok-4, …). There's nothing to inherit name / release_date / open_weights from, and the API doesn't give them, so the route can't be written from scratch. If someone already hand-wrote the file, it's left alone (otherwise deleteMissing would delete it); otherwise it's listed in the skipped notice. Comment is one line now: "A route with no metadata to inherit keeps whatever is authored for it."

const authored = context.authored(model.id);
return authored === undefined ? undefined : { id: model.id, model: authored as SyncedModel };
}
return {
id: model.id,
model: buildRequestyModel(model, baseModel, context.existing(model.id)),
};
},
} satisfies SyncProvider<RequestyModel>;

export function buildRequestyModel(
model: RequestyModel,
baseModel: string,
existing: ExistingModel | undefined,
): SyncedModel {
const base = baseMetadata(baseModel);
const built = buildOpenRouterModel(
toOpenRouterModel(model, base),
existing,
baseModel,
) as SyncedBaseModel;
// Name, description, and family are provider-agnostic facts of the base model.
const { name: _name, description: _description, family: _family, ...factored } = built;
const tiers = pricingTiers(model) ?? existing?.cost?.tiers;
return tiers === undefined ? factored : { ...factored, cost: { ...factored.cost, tiers } };
}

function toOpenRouterModel(model: RequestyModel, base: BaseMetadata): OpenRouterModel {
const context = model.context_window > 0 ? model.context_window : base.limit.context;
const reasoning = model.supports_reasoning === true || base.reasoning === true;
return {
id: model.id,
name: "",
created: model.created,
hugging_face_id: base.open_weights === true ? model.id : null,
knowledge_cutoff: null,
context_length: context,
architecture: {
input_modalities: base.modalities.input,
output_modalities: base.modalities.output,
},
pricing: {
prompt: String(model.input_price),
completion: String(model.output_price),
input_cache_read: chargedPerTokenPrice(model.cached_price),
input_cache_write: chargedPerTokenPrice(model.caching_price),
},
top_provider: {
context_length: context,
max_completion_tokens: model.max_output_tokens > 0
? model.max_output_tokens
: base.limit.output ?? null,
},
supported_parameters: [
...(base.temperature === false ? [] : ["temperature"]),
...(reasoning ? ["reasoning"] : []),
...(model.supports_tool_calling === true || base.tool_call === true ? ["tools"] : []),
...(model.supports_output_json_schema === true || base.structured_output === true
? ["structured_outputs"]
: []),
],
// Requesty translates a single `reasoning_effort` into each vendor's native
// reasoning control: https://docs.requesty.ai/features/reasoning
reasoning: reasoning ? { mandatory: false, supported_efforts: [...REASONING_EFFORTS] } : undefined,
};
}

/** Context-length pricing bands. The first band is the flat `cost` of the model. */
function pricingTiers(model: RequestyModel): NonNullable<SyncedFullModel["cost"]>["tiers"] {
const tiers = (model.pricing ?? [])
.slice(1)
.map((band) => ({
tier: { type: "context" as const, size: band.prompt_tokens_threshold },
input: pricePerMillion(band.input_price ?? model.input_price),
output: pricePerMillion(band.output_price ?? model.output_price),
cache_read: chargedPricePerMillion(band.cached_price),
cache_write: chargedPricePerMillion(band.caching_price),
}));
return tiers.length > 0 ? tiers : undefined;
}

/** Requesty prices are USD per token; zero means the route does not charge for it. */
function chargedPerTokenPrice(price: number | undefined): string | undefined {
return price === undefined || price <= 0 ? undefined : String(price);
}

function chargedPricePerMillion(price: number | undefined): number | undefined {
return price === undefined || price <= 0 ? undefined : pricePerMillion(price);
}

function pricePerMillion(price: number): number {
return Math.round(price * TOKENS_PER_MILLION * PRICE_DECIMALS) / PRICE_DECIMALS;
}

const metadataBySlug = new Map<string, string | undefined>();
const metadataByID = new Map<string, BaseMetadata>();

/** `vertex/claude-opus-4@us-east5` and `anthropic/claude-opus-4` are the same model. */
export function resolveRequestyBaseModel(modelID: string): string | undefined {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we care?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the route prefix is the host, not the lab: vertex/claude-opus-4-5@us-east5, bedrock/claude-opus-4-5, and anthropic/claude-opus-4-5 all need to inherit models/anthropic/claude-opus-4-5.toml, so the lookup is on the slug with @region/:tier stripped rather than on the prefix. That's the only reason the function exists — docstring is one line now.

if (metadataBySlug.size === 0) indexMetadata();
const slug = modelID.split("/").at(-1)?.split(/[@:]/)[0];
return slug === undefined ? undefined : metadataBySlug.get(slug.toLowerCase());
}

function indexMetadata() {
for (const provider of readdirSync(MODELS_DIR)) {
for (const file of readdirSync(path.join(MODELS_DIR, provider))) {
if (!file.endsWith(".toml")) continue;
const modelID = file.slice(0, -".toml".length);
const slug = modelID.toLowerCase();
// An ambiguous slug cannot be attributed to one lab from the route alone.
metadataBySlug.set(slug, metadataBySlug.has(slug) ? undefined : `${provider}/${modelID}`);
}
}
}

function baseMetadata(modelID: string): BaseMetadata {
let metadata = metadataByID.get(modelID);
if (metadata === undefined) {
const file = readFileSync(path.join(MODELS_DIR, `${modelID}.toml`), "utf8");
metadata = BaseMetadata.parse(Bun.TOML.parse(file));
metadataByID.set(modelID, metadata);
}
return metadata;
}
89 changes: 89 additions & 0 deletions packages/core/test/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import {
import { buildLLMGatewayModel, type LLMGatewayModel } from "../src/sync/providers/llmgateway.js";
import { openai, parseOpenAIModels } from "../src/sync/providers/openai.js";
import { pioneer } from "../src/sync/providers/pioneer.js";
import {
buildRequestyModel,
resolveRequestyBaseModel,
type RequestyModel,
} from "../src/sync/providers/requesty.js";
import { google, shouldTrackGoogleModel } from "../src/sync/providers/google.js";
import { resolveVeniceBaseModel } from "../src/sync/providers/venice.js";
import { buildVercelModel, vercel } from "../src/sync/providers/vercel.js";
Expand Down Expand Up @@ -1535,6 +1540,90 @@ test("maps EmpirioLabs aliases to canonical model metadata", () => {
expect(resolveEmpiriolabsBaseModel("step-3-5-flash")).toBe("stepfun/step-3.5-flash");
});

test("factors Requesty routes against canonical metadata", () => {
expect(buildRequestyModel(requestyModel(), "anthropic/claude-sonnet-5", undefined)).toEqual({
base_model: "anthropic/claude-sonnet-5",
base_model_omit: undefined,
reasoning_options: [{ type: "effort", values: ["none", "low", "medium", "high", "max"] }],
structured_output: true,
cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 },
});
});

test("keeps Requesty capabilities the catalog under-reports", () => {
expect(buildRequestyModel(
requestyModel({
id: "xai/grok-4.5",
supports_reasoning: false,
supports_vision: false,
max_output_tokens: 0,
cached_price: 0,
caching_price: 0,
}),
"xai/grok-4.5",
undefined,
)).toEqual({
base_model: "xai/grok-4.5",
base_model_omit: undefined,
reasoning_options: [{ type: "effort", values: ["none", "low", "medium", "high", "max"] }],
limit: { context: 1_000_000 },
cost: { input: 3, output: 15 },
});
});

test("maps Requesty context pricing bands to cost tiers", () => {
expect(buildRequestyModel(
requestyModel({
pricing: [
{ prompt_tokens_threshold: 0, input_price: 0.000003, output_price: 0.000015 },
{
prompt_tokens_threshold: 200_000,
input_price: 0.000006,
output_price: 0.0000225,
cached_price: 0.0000006,
caching_price: 0.0000075,
},
],
}),
"anthropic/claude-sonnet-5",
undefined,
).cost?.tiers).toEqual([
{
tier: { type: "context", size: 200_000 },
input: 6,
output: 22.5,
cache_read: 0.6,
cache_write: 7.5,
},
]);
});

test("resolves Requesty hosting, region, and service-tier routes to metadata", () => {
expect(resolveRequestyBaseModel("zai/glm-5.2")).toBe("zhipuai/glm-5.2");
expect(resolveRequestyBaseModel("moonshot/kimi-k3")).toBe("moonshotai/kimi-k3");
expect(resolveRequestyBaseModel("openai/gpt-5.4:flex")).toBe("openai/gpt-5.4");
expect(resolveRequestyBaseModel("vertex/claude-sonnet-5@us-east5")).toBe("anthropic/claude-sonnet-5");
expect(resolveRequestyBaseModel("novita/qwen/qwen3-235b-a22b-fp8")).toBeUndefined();
});

function requestyModel(overrides: Partial<RequestyModel> = {}): RequestyModel {
return {
id: "anthropic/claude-sonnet-5",
created: 1_782_777_600,
context_window: 1_000_000,
max_output_tokens: 128_000,
input_price: 0.000003,
output_price: 0.000015,
cached_price: 0.0000003,
caching_price: 0.00000375,
supports_vision: true,
supports_reasoning: true,
supports_tool_calling: true,
supports_output_json_schema: true,
...overrides,
};
}

function unavailableStub(): OpenRouterModel {
return openRouterModel({
id: "~anthropic/claude-fable-latest",
Expand Down
8 changes: 8 additions & 0 deletions providers/requesty/models/alibaba/qwen-max.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
base_model = "alibaba/qwen-max"
structured_output = true

[cost]
input = 1.6
output = 6.4
cache_read = 1.6
cache_write = 1.6
15 changes: 15 additions & 0 deletions providers/requesty/models/alibaba/qwen-plus.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
base_model = "alibaba/qwen-plus"
structured_output = true

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

[cost]
input = 0.4
output = 1.2
cache_read = 0.4
cache_write = 0.4

[limit]
context = 131_072
Loading