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 @@ -18,6 +18,7 @@
"test": "bun test",
"validate": "bun ./packages/core/script/validate.ts",
"compare:migrations": "bun ./packages/core/script/compare-model-migrations.ts",
"aimlapi:sync": "bun ./packages/core/script/sync-models.ts aimlapi",
"anthropic:sync": "bun ./packages/core/script/sync-models.ts anthropic",
"baseten:sync": "bun ./packages/core/script/sync-models.ts baseten",
"deepinfra:sync": "bun ./packages/core/script/sync-models.ts deepinfra",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { z } from "zod";

import { AuthoredModel, AuthoredModelShape, ModelMetadata } from "../schema.js";
import { openMissingModelIssues } from "./missing-issues.js";
import { aimlapi } from "./providers/aimlapi.js";
import { ambient } from "./providers/ambient.js";
import { anthropic } from "./providers/anthropic.js";
import { baseten } from "./providers/baseten.js";
Expand Down Expand Up @@ -128,6 +129,7 @@ export interface SyncResult {
}

export const providers: {
aimlapi: SyncProvider<any>;
ambient: SyncProvider<any>;
anthropic: SyncProvider<any>;
baseten: SyncProvider<any>;
Expand Down Expand Up @@ -162,6 +164,7 @@ export const providers: {
wandb: SyncProvider<any>;
xai: SyncProvider<any>;
} = {
aimlapi,
ambient,
anthropic,
baseten,
Expand Down Expand Up @@ -199,6 +202,7 @@ export const providers: {

export const groups = {
aggregators: [
"aimlapi",
"crossmodel",
"edenai",
"empiriolabs",
Expand Down
293 changes: 293 additions & 0 deletions packages/core/src/sync/providers/aimlapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import { z } from "zod";

import type { SyncProvider } from "../index.js";
import { factorBaseModel, modelMetadata, resolveModelMetadataBaseModel } from "./openrouter.js";

// The public catalog needs no key, and `include` is what turns on the pricing
// and modality blocks this sync depends on.
const API_ENDPOINT = "https://api.aimlapi.com/v1/models?include=pricing,modalities";

// Per-model request schema. It is the only place the API states which reasoning
// controls a model actually accepts, so reasoning_options is read from here
// rather than assumed.
const DOCS_ENDPOINT = "https://api.aimlapi.com/docs-json";

// AI/ML API serves one id under several endpoint types — a model can be both a
// chat model and, say, an image model. Only the chat surface belongs here.
const CHAT_COMPLETIONS_TYPE = "openai/chat-completions";

// Values this schema accepts for an "effort" reasoning control. Anything the
// API documents outside this set is dropped rather than coerced.
const EFFORT_VALUES = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"]);

const DOCS_CONCURRENCY = 8;

const PricingUnit = z
.object({
name: z.string().nullish(),
content: z.string().nullish(),
origin: z.string().nullish(),
price: z.number().nullish(),
per: z.number().nullish(),
})
.passthrough();

const Info = z
.object({
contextLength: z.number().int().nonnegative().nullish(),
outputMax: z.number().int().nonnegative().nullish(),
})
.passthrough();

export const AimlapiModel = z
.object({
id: z.string().min(1),
type: z.string().nullish(),
info: Info.nullish(),
modalities: z
.object({
input: z.array(z.string()).nullish(),
output: z.array(z.string()).nullish(),
})
.passthrough()
.nullish(),
pricing: z
.object({
units: z.array(PricingUnit).nullish(),
})
.passthrough()
.nullish(),
/** Attached by fetchModels; not part of the upstream payload. */
reasoningEffort: z.array(z.string()).nullish(),
})
.passthrough();

export const AimlapiResponse = z
.object({
data: z.array(AimlapiModel).min(1),
})
.passthrough();

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

type Modality = "text" | "audio" | "image" | "video" | "pdf";

const MODALITIES = new Set<string>(["text", "audio", "image", "video", "pdf"]);

function normalizeModalities(values: readonly string[] | null | undefined): Modality[] {
const seen = new Set<Modality>();
for (const value of values ?? []) {
const normalized = value.toLowerCase();
if (MODALITIES.has(normalized)) seen.add(normalized as Modality);
}
if (seen.size === 0) seen.add("text");
return [...seen];
}

function isChatTextModel(model: AimlapiModel): boolean {
if (model.type !== CHAT_COMPLETIONS_TYPE) return false;
const output = normalizeModalities(model.modalities?.output);
// A chat model whose output is not purely text is a media model riding the
// chat protocol, and does not belong in a chat catalog.
return output.length === 1 && output[0] === "text";
}

/**
* Lab entry this id is a host for. AI/ML API is an aggregator and authors none
* of these models, so every entry has to point at the lab file rather than
* restate it.
*/
function baseModelFor(id: string): string | undefined {
return resolveModelMetadataBaseModel(id);
}

function baseReasoning(baseModelID: string): boolean {
try {
return modelMetadata(baseModelID).reasoning === true;
} catch {
return false;
}
}

/**
* Prices are quoted as `price` per `per` tokens; models.dev stores dollars per
* million. The unit discriminator is `origin`, not `measure`: provided is
* input, generated is output, cached is a cache read. Only text token charges
* are taken — a model's image or audio units are a different surface.
*/
function perMillion(units: readonly z.infer<typeof PricingUnit>[], origin: string): number | undefined {
const unit = units.find(
(candidate) => candidate.name === "token" && candidate.content === "text" && candidate.origin === origin,
);
if (!unit || unit.price == null || !unit.per) return undefined;
return (unit.price / unit.per) * 1_000_000;
}

function positive(value: number | null | undefined): number | undefined {
return value != null && value > 0 ? value : undefined;
}

/**
* Reads the documented `reasoning_effort` enum for one model. Returns undefined
* when the docs do not describe the control, which is treated as "cannot state
* it" rather than "the model has none".
*/
async function fetchReasoningEffort(id: string): Promise<string[] | undefined> {
const url = `${DOCS_ENDPOINT}?model=${encodeURIComponent(id)}&endpoint=${encodeURIComponent(CHAT_COMPLETIONS_TYPE)}`;
let payload: unknown;
try {
const response = await fetch(url);
if (!response.ok) return undefined;
payload = await response.json();
} catch {
return undefined;
}

const found = findReasoningEffortEnum(payload);
if (found === undefined) return undefined;

const values = found.filter((value) => EFFORT_VALUES.has(value));
return values.length > 0 ? values : undefined;
}

function findReasoningEffortEnum(node: unknown): string[] | undefined {
if (Array.isArray(node)) {
for (const item of node) {
const found = findReasoningEffortEnum(item);
if (found !== undefined) return found;
}
return undefined;
}
if (node === null || typeof node !== "object") return undefined;

const record = node as Record<string, unknown>;
const effort = record["reasoning_effort"];
if (effort !== null && typeof effort === "object") {
const values = (effort as Record<string, unknown>)["enum"];
if (Array.isArray(values) && values.every((value) => typeof value === "string")) {
return values as string[];
}
}

for (const value of Object.values(record)) {
const found = findReasoningEffortEnum(value);
if (found !== undefined) return found;
}
return undefined;
}

async function attachReasoningEffort(models: AimlapiModel[]): Promise<void> {
// Only models whose lab entry says they reason need the control documented,
// and only those are worth a request.
const pending = models.filter((model) => {
if (!isChatTextModel(model)) return false;
const base = baseModelFor(model.id);
return base !== undefined && baseReasoning(base);
});

let cursor = 0;
const workers = Array.from({ length: Math.min(DOCS_CONCURRENCY, pending.length) }, async () => {
while (cursor < pending.length) {
const model = pending[cursor++];
if (model === undefined) return;
model.reasoningEffort = await fetchReasoningEffort(model.id);
}
});
await Promise.all(workers);
}

export const aimlapi = {
id: "aimlapi",
name: "AI/ML API",
modelsDir: "providers/aimlapi/models",
// The catalog turns over quickly and lists far more than the chat surface, so
// a local model missing from one response is not proof that it is gone.
deleteMissing: false,
sourceID(model) {
return isChatTextModel(model) ? model.id : undefined;
},
skippedNotice(ids) {
if (ids.length === 0) return [];
return [
`${ids.length} AI/ML API chat models were skipped because this repository has no lab entry to point \`base_model\` at, or because the API does not document the reasoning control a reasoning model requires.`,
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
];
},
missingNotice(paths) {
if (paths.length === 0) return [];
return [
`${paths.length} local AI/ML API models were absent from the catalog and were retained for manual lifecycle review.`,
`Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`,
];
},
async fetchModels() {
const response = await fetch(API_ENDPOINT);
if (!response.ok) {
throw new Error(`AI/ML API request failed: ${response.status} ${response.statusText}`);
}
const raw = await response.json();
const parsed = AimlapiResponse.parse(raw);
await attachReasoningEffort(parsed.data);
return parsed;
},
parseModels(raw) {
return AimlapiResponse.parse(raw).data;
},
translateModel(model, context) {
if (!isChatTextModel(model)) return undefined;

const existing = context.existing(model.id);

// AI/ML API hosts other people's models, so the entry must reference the
// lab file instead of duplicating it. Without a lab entry to point at there
// is nothing correct to write: inlining the metadata is what this schema
// forbids, and authoring the lab file would mean sourcing capability data
// the catalog does not publish.
const base = existing?.base_model ?? baseModelFor(model.id);
if (base === undefined) return undefined;

// Required whenever the base model reasons. Only the API's own request
// schema can say which values it takes, so a model whose docs stay silent
// is skipped rather than given an invented control.
let reasoningOptions: Array<{ type: "effort"; values: string[] }> | undefined;
if (baseReasoning(base)) {
const values = model.reasoningEffort ?? undefined;
if (values === undefined || values.length === 0) return undefined;
reasoningOptions = [{ type: "effort", values }];
}

const units = model.pricing?.units ?? [];
const info = model.info ?? {};
const contextLimit = positive(info.contextLength);
const outputLimit = positive(info.outputMax);
// Only what the catalog actually publishes. It reports a context window and
// an output cap but no input cap, and equating the input cap with the whole
// context would overwrite the lab's correct split (e.g. 272k in + 128k out
// within a 400k window) with a wrong number.
const limit =
contextLimit === undefined && outputLimit === undefined
? undefined
: { context: contextLimit, output: outputLimit };

// Everything else — the capability flags, description, dates, modalities —
// is the lab's to state and is inherited. factorBaseModel drops whatever
// matches the base, so the file carries only what is genuinely ours.
return {
id: model.id,
model: factorBaseModel(
base,
{
cost: {
input: perMillion(units, "provided") ?? existing?.cost?.input,
output: perMillion(units, "generated") ?? existing?.cost?.output,
cache_read: perMillion(units, "cached") ?? existing?.cost?.cache_read,
},
reasoning_options: reasoningOptions,
limit,
},
limit,
existing?.base_model === base ? existing?.base_model_omit : undefined,
),
};
},
} satisfies SyncProvider<AimlapiModel>;
3 changes: 3 additions & 0 deletions providers/aimlapi/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions providers/aimlapi/models/alibaba/qwen-max.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
base_model = "alibaba/qwen-max"

[cost]
input = 2.08
output = 8.32
cache_read = 0.416

[limit]
context = 32_000
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
base_model = "alibaba/qwen3-coder-480b-a35b-instruct"

[cost]
input = 1.95
output = 9.75

[limit]
context = 262_000
6 changes: 6 additions & 0 deletions providers/aimlapi/models/alibaba/qwen3-max.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
base_model = "alibaba/qwen3-max"

[cost]
input = 1.56
output = 7.8
cache_read = 0.312
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
base_model = "alibaba/qwen3-next-80b-a3b-instruct"

[cost]
input = 0.195
output = 1.56

[limit]
context = 129_024
output = 16_384
9 changes: 9 additions & 0 deletions providers/aimlapi/models/alibaba/qwen3.5-flash.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
base_model = "alibaba/qwen3.5-flash"

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

[cost]
input = 0.13
output = 0.52
13 changes: 13 additions & 0 deletions providers/aimlapi/models/alibaba/qwen3.6-27b.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
base_model = "alibaba/qwen3.6-27b"

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

[cost]
input = 0.78
output = 4.68

[limit]
context = 256_000
output = 252_000
Loading
Loading