diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 7f88aacb5bb..b6755b85b07 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { AuthoredModel, AuthoredModelShape, ModelMetadata } from "../schema.js"; import { openMissingModelIssues } from "./missing-issues.js"; import { MissingReasoningOptionsError } from "./missing-reasoning-options.js"; +import { aihubmix } from "./providers/aihubmix.js"; import { ambient } from "./providers/ambient.js"; import { anthropic } from "./providers/anthropic.js"; import { baseten } from "./providers/baseten.js"; @@ -80,7 +81,12 @@ export interface SyncProvider { * deduped GitHub issue per missing model ID. */ skipCreates?: boolean; - /** Report remote-only models skipped by skipCreates as GitHub issues. */ + /** + * Open one deduped GitHub issue per model the provider skipped. Implied by + * skipCreates, and settable on its own by a provider that creates models but + * still skips the ones it cannot write — without it those skips produce a + * notice nobody acts on. + */ trackMissingModels?: boolean; deleteMissing?: boolean; preserveSymlinks?: boolean; @@ -103,6 +109,12 @@ export interface SyncProvider { context: { existing(id: string): ExistingModel | undefined; authored(id: string): ExistingModel | undefined; + /** + * The leading comment block already on the file, so a provider that owns + * its header (authoritativeHeaders) can refresh the part it generates + * without discarding notes a human wrote around it. + */ + header?(id: string): string | undefined; }, ): { id: string; @@ -130,6 +142,7 @@ export interface SyncResult { } export const providers: { + aihubmix: SyncProvider; ambient: SyncProvider; anthropic: SyncProvider; baseten: SyncProvider; @@ -165,6 +178,7 @@ export const providers: { wandb: SyncProvider; xai: SyncProvider; } = { + aihubmix, ambient, anthropic, baseten, @@ -203,6 +217,7 @@ export const providers: { export const groups = { aggregators: [ + "aihubmix", "crossmodel", "edenai", "empiriolabs", @@ -264,6 +279,9 @@ export async function syncProvider( authored(id) { return existing.get(`${id}.toml`)?.authored; }, + header(id) { + return existing.get(`${id}.toml`)?.header || undefined; + }, }); } catch (error) { if (!(error instanceof MissingReasoningOptionsError)) throw error; @@ -485,7 +503,7 @@ export async function syncProvider( ]; const issueModels = [ - ...(provider.skipCreates === true ? skippedRemote : []), + ...(provider.skipCreates === true || provider.trackMissingModels === true ? skippedRemote : []), ...missingReasoning.keys(), ]; if ( @@ -1055,6 +1073,12 @@ export function formatToml(model: z.infer) { if (tier.reasoning !== undefined) lines.push(`reasoning = ${formatNumber(tier.reasoning)}`); if (tier.cache_read !== undefined) lines.push(`cache_read = ${formatNumber(tier.cache_read)}`); if (tier.cache_write !== undefined) lines.push(`cache_write = ${formatNumber(tier.cache_write)}`); + if (tier.input_audio !== undefined) { + lines.push(`input_audio = ${formatNumber(tier.input_audio)}`); + } + if (tier.output_audio !== undefined) { + lines.push(`output_audio = ${formatNumber(tier.output_audio)}`); + } } } diff --git a/packages/core/src/sync/missing-issues.ts b/packages/core/src/sync/missing-issues.ts index 81a01a3742e..ebb88f4a791 100644 --- a/packages/core/src/sync/missing-issues.ts +++ b/packages/core/src/sync/missing-issues.ts @@ -26,7 +26,7 @@ function issueBody(provider: MissingModelIssueTarget, modelId: string, reason?: `| Expected path | \`${provider.modelsDir}/${modelId}.toml\` |`, "", reason === undefined - ? "This provider uses `skipCreates` because the remote source is not enough to auto-author a full TOML." + ? "The sync did not author this model: the remote source is not enough to write a full TOML, or the model belongs on `base_model` and its `models/` metadata is missing." : `Sync diagnostic: ${reason}`, "Add the model manually (prefer `base_model` when matching `models/` metadata exists).", ...(reason === undefined ? [] : [ diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts new file mode 100644 index 00000000000..d7a30c3352d --- /dev/null +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -0,0 +1,699 @@ +import path from "node:path"; + +import { z } from "zod"; + +import { describeModel } from "../../describe.js"; +import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { factorBaseModel, modelMetadata, normalizeModelSlug } from "./openrouter.js"; + +const API_ENDPOINT = "https://aihubmix.com/api/v1/models?type=llm"; + +/** AIHubMix quotes USD per 1M tokens directly, matching the catalog unit. */ +const Pricing = z + .object({ + input: z.number().nullish(), + output: z.number().nullish(), + cache_read: z.number().nullish(), + cache_write: z.number().nullish(), + tiers: z + .array( + z + .object({ + tier: z.object({ + type: z.string().nullish(), + size: z.number(), + }), + input: z.number().nullish(), + output: z.number().nullish(), + cache_read: z.number().nullish(), + cache_write: z.number().nullish(), + }) + .passthrough(), + ) + .nullish(), + }) + .passthrough(); + +/** + * AIHubMix ships `default` alongside `type`/`values`, which the catalog's strict + * ReasoningOption rejects, so the extra key is dropped during translation. + */ +const ReasoningOption = z + .object({ + type: z.string(), + values: z.array(z.string()).nullish(), + min: z.number().nullish(), + max: z.number().nullish(), + }) + .passthrough(); + +export const AihubmixModel = z + .object({ + model_id: z.string().min(1), + model_name: z.string().nullish(), + // The upstream lab that built the model, and the AIHubMix ID this entry is a + // routing variant of. Both are served by the catalog itself, so neither the + // lab nor the variant relationship is inferred from the relay ID here. + vendor: z.string().nullish(), + variant_of: z.string().nullish(), + desc: z.string().nullish(), + pricing: Pricing.nullish(), + features: z.string().nullish(), + input_modalities: z.string().nullish(), + output_modalities: z.string().nullish(), + context_length: z.number().nullish(), + max_output: z.number().nullish(), + reasoning: z.boolean().nullish(), + reasoning_options: z.array(ReasoningOption).nullish(), + tool_call: z.boolean().nullish(), + release_date: z.string().nullish(), + last_updated: z.string().nullish(), + // `knowledge` is not served yet; read opportunistically so it lands without a + // code change once AIHubMix adds it. + knowledge: z.string().nullish(), + open_weights: z.boolean().nullish(), + retire_stage: z.string().nullish(), + }) + .passthrough(); + +export const AihubmixResponse = z + .object({ + success: z.boolean().nullish(), + data: z.array(AihubmixModel).min(1), + }) + .passthrough(); + +export type AihubmixModel = z.infer; + +/** + * AIHubMix relays upstream models under its own IDs, so a relay is factored onto + * the lab metadata it serves whenever that metadata exists — the relay then only + * records what it actually changes (price, reasoning controls, limits). + * + * The catalog answers both halves of that lookup itself: `vendor` names the lab + * that built the model, and `variant_of` names the AIHubMix ID this entry is a + * routing variant of. Relay IDs carry prefixes (`coding-`, `alicloud-`) and + * suffixes (`-free`, `-think`) that are AIHubMix routing modes rather than + * distinct upstream models, and `variant_of` states that relationship instead of + * it being guessed from the string — which also resolves the relays no amount of + * string surgery reaches, such as `ox-alpha` onto `zhipuai/glm-5.3-flash`. + */ +const VENDOR_LABS: Record = { + // The two registries spell four labs differently. This maps namespaces, not + // models: no entry here decides what any model is or which lab built it. + zhipu: "zhipuai", + moonshot: "moonshotai", + bytedance: "bytedance-seed", + "meituan-longcat": "meituan", +}; + +/** Catalog effort levels; AIHubMix spells two of them differently. */ +const EFFORT_ALIASES: Record = { no_think: "none", instant: "minimal" }; +const EFFORT_VALUES = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "default", +]); + +type LabMetadataIDs = Map; +/** Every listed relay by lowercased ID, so `variant_of` can be followed. */ +type RelayCatalog = Map; + +let labMetadataIDs: LabMetadataIDs | undefined; +let relayCatalog: RelayCatalog | undefined; + +/** + * The catalog rejects a `base_model` that resolves to nothing, so relays are + * only factored onto metadata that is actually present on disk. + */ +async function readLabMetadataIDs(modelsDir: string) { + const metadataDir = path.join(path.dirname(path.dirname(path.dirname(modelsDir))), "models"); + const ids = new Map(); + for await (const file of new Bun.Glob("**/*.toml").scan({ cwd: metadataDir, followSymlinks: true })) { + const id = file.split(path.sep).join("/").slice(0, -5); + // AIHubMix lowercases every relay ID while labs keep their own casing + // (`minimax-m2` against `minimax/MiniMax-M2`), so lookups are case-folded. + ids.set(id.toLowerCase(), id); + } + return ids; +} + +// The same off state is reachable from whichever dialect the caller speaks, so +// an off switch has no single wire path. Name one per protocol. +const DIALECT_PATHS = + '# $.enable_thinking = true|false on the OpenAI-compatible /v1/chat/completions path (verified live 2026-09-11);\n' + + '# $.thinking.type = "enabled"|"disabled"|"adaptive" on /v1/messages; $.generationConfig.thinkingConfig on the Gemini path.\n'; +// Cited on its own line, because a human wrote this exact line by hand in +// `gemini-3.7-flash.toml` — it is a source for the whole gateway, not a claim +// about one model's options, and so it is carried through as a note rather than +// being owned by the block. The two lines above are only ever this adapter's own. +const DIALECT_SOURCE = "# https://docs.aihubmix.com/cn/api/unified-inference\n"; +const DIALECTS = DIALECT_PATHS + DIALECT_SOURCE; +const TOGGLE_HEADER = "# Toggle:\n" + DIALECTS; +// Where the catalog spells the off state as `effort = none`, the other dialects +// still reach it, and the folded toggle is the only place that was recorded. +const FOLDED_HEADER = "# Off is effort=none; graded levels — no toggle. The same off elsewhere:\n" + DIALECTS; + +export const aihubmix = { + id: "aihubmix", + name: "AIHubMix", + modelsDir: "providers/aihubmix/models", + trackMissingModels: true, + // A rewrite keeps whatever leading comment the file already had, so a stale + // wire path would outlive the options it documents — and a model whose toggle + // folds into `effort = none` would keep advertising a toggle. translateModel + // re-derives the header from the response, so let it own the block. + authoritativeHeaders: true, + // The listing is AIHubMix's main model list, and a route rotates out of it for a + // spell without being retired, so a local file absent from one response is + // retained rather than deleted. It is not a licence to keep anything: a hidden + // channel alias (`zai-glm-5.1`, which the gateway answers by routing to the + // listed `glm-5.1`) is deliberately outside that list and does not belong here. + deleteMissing: false, + sourceID(model) { + return model.retire_stage === "deprecated" ? undefined : model.model_id; + }, + missingNotice(paths) { + return paths.map( + (file) => + `AIHubMix does not list ${file} in its main model list; confirm the route rotated out for a spell, or drop the file if it is a hidden channel alias of a model already in the catalog.`, + ); + }, + skippedNotice(ids) { + return ids.map( + (id) => + `AIHubMix lists ${id} but it cannot be written yet. If it names a vendor, add the lab model under \`models/${"/"}.toml\` and the relay factors onto it automatically; if it names none, the response is missing the release_date/open_weights/limits a standalone entry has to carry.`, + ); + }, + async fetchModels() { + labMetadataIDs = await readLabMetadataIDs(this.modelsDir); + const response = await fetch(process.env.AIHUBMIX_MODELS_URL ?? API_ENDPOINT); + if (!response.ok) { + throw new Error(`AIHubMix models request failed: ${response.status} ${response.statusText}`); + } + return response.json(); + }, + parseModels(raw) { + const data = AihubmixResponse.parse(raw).data; + // `cc-minimax-m2` and `cc-MiniMax-M2` are the same route under two spellings + // and would claim filenames that differ only in case. Keep the last entry + // whole rather than mixing two records. + relayCatalog = new Map(data.map((model) => [model.model_id.toLowerCase(), model])); + return [...relayCatalog.values()]; + }, + translateModel(model, context) { + const existing = context.existing(model.model_id); + const built = buildAihubmixModel(model, existing, labMetadataIDs, relayCatalog); + if (built === undefined) return undefined; + return { + id: model.model_id, + model: built, + // A rewrite drops whatever header the file carried, so re-author it here + // or the wire path is lost on the first sync that touches the model. + header: composeHeader(context.header?.(model.model_id), reasoningHeader(model, built)), + }; + }, +} satisfies SyncProvider; + +export function buildAihubmixModel( + model: AihubmixModel, + existing: ExistingModel | undefined, + labIDs: LabMetadataIDs | undefined = labMetadataIDs, + catalog: RelayCatalog | undefined = relayCatalog, +): SyncedModel | undefined { + const base = existing?.base_model ?? resolveBaseModel(model, labIDs, catalog); + // `dev` carries 77 aihubmix files, so most of the catalog arrives as a create + // with no file to union against. The lab entry the relay factors onto is the + // only baseline those have, and 14 creates in the current listing would + // otherwise write a narrowing override onto it (`gpt-4o` losing pdf, + // `qwen3.5-27b` losing audio). + const lab = labMetadata(base); + const baseModalities = lab?.modalities; + const input = modalities(model.input_modalities, [ + ...(existing?.modalities?.input ?? []), + ...(baseModalities?.input ?? []), + ]); + const output = modalities(model.output_modalities, [ + ...(existing?.modalities?.output ?? []), + ...(baseModalities?.output ?? []), + ]); + const features = new Set((model.features ?? "").split(",").map((value) => value.trim())); + // The endpoint never sends `false`. 107 of 408 routes omit `reasoning` and 100 + // omit `tool_call` rather than denying them, and no route sends `false` at + // all, so a missing flag means unknown. Reading it as `false` would write an + // override that turns off a reasoner or tool use the lab declares. + const reasoning = model.reasoning ?? existing?.reasoning; + const toolCall = model.tool_call ?? existing?.tool_call; + const structuredOutput = features.has("structured_outputs") || existing?.structured_output; + const name = model.model_name ?? existing?.name; + const context = tokens(model.context_length); + // Two ways AIHubMix signals an unknown output ceiling: backfilling it from + // `context_length` (36 of 408 models quote the two as equal, which would leave + // no room for the prompt) and quoting a value above the window. Both are read + // as absent, the same as the 0 the endpoint also uses. + const quoted = tokens(model.max_output); + const maxOutput = + context !== undefined && quoted !== undefined && quoted >= context ? undefined : quoted; + // Same class of bug as the modalities: the endpoint restates windows in decimal + // (8 glm routes quote 204800 as 200000) and quotes conservative output ceilings, + // and treating those as owned fields writes a narrowing override onto a limit the + // lab entry already states correctly. A restatement resolves to the accepted + // value; a genuine cap the host imposes still lands. + const limit = { + context: resolveLimit(context, existing?.limit?.context, lab?.limit?.context), + // The endpoint models no input cap, so an authored one is the only record of it. + input: existing?.limit?.input, + output: resolveLimit(maxOutput, existing?.limit?.output, lab?.limit?.output), + }; + const shared = { + attachment: input.some((value) => value !== "text"), + reasoning, + reasoning_options: reasoningOptions(model, existing) ?? existing?.reasoning_options, + tool_call: toolCall, + structured_output: structuredOutput, + // AIHubMix serves no temperature, interleaved, fast-mode or request-shape + // surface; all four exist on the file and nowhere else, so keep them. + temperature: existing?.temperature, + interleaved: existing?.interleaved, + experimental: existing?.experimental, + provider: existing?.provider, + status: model.retire_stage === "deprecated" ? ("deprecated" as const) : existing?.status, + modalities: { input, output }, + limit, + cost: buildCost(model.pricing, existing?.cost), + }; + + if (base !== undefined) { + return factorBaseModel( + base, + { name: factoredName(model, base, existing), description: existing?.description, ...shared }, + limit, + existing?.base_model === base ? existing.base_model_omit : undefined, + ); + } + + // `vendor` names the lab that built the model, so this relay hosts someone + // else's model and belongs on `base_model` — AGENTS.md treats a full standalone + // definition for a nameable lab model as a blocker. Reaching here means the lab + // entry does not exist yet (81 of 407 routes, 38 of them complete enough that the + // endpoint answer alone would have satisfied the standalone guard), so the relay is + // reported for a human to add `models//.toml`, after which it factors + // with no change here. A file already in the repo keeps being updated: what it + // should have been is upstream's call, and freezing it would only stall its prices. + if (existing === undefined && model.vendor != null) return undefined; + + // A standalone entry must carry every required catalog field itself, and the + // endpoint still leaves gaps: 304 of 408 models are dated, 289 state + // `open_weights`, and the rest quote 0 for a limit they do not know. A relay + // with neither metadata to inherit nor those fields is reported rather than + // written with invented values. + const releaseDate = model.release_date ?? existing?.release_date; + const openWeights = model.open_weights ?? existing?.open_weights; + if ( + name === undefined || + releaseDate === undefined || + openWeights === undefined || + limit.context === undefined || + limit.output === undefined + ) { + return existing === undefined ? undefined : (existing as SyncedModel); + } + + // A standalone entry has no lab entry to inherit from, so the two flags have + // to resolve to a boolean here. Published reasoning options are the model's + // own statement that it reasons; absent both, the route is recorded as not. + const standaloneReasoning = reasoning ?? shared.reasoning_options !== undefined; + const standaloneToolCall = toolCall ?? false; + return { + ...shared, + reasoning: standaloneReasoning, + tool_call: standaloneToolCall, + name, + description: + existing?.description ?? + model.desc ?? + describeModel({ + id: model.model_id, + providerId: "aihubmix", + name, + reasoning: standaloneReasoning, + tool_call: standaloneToolCall, + structured_output: structuredOutput, + open_weights: openWeights, + limit, + modalities: { input, output }, + }), + family: existing?.family, + release_date: releaseDate, + last_updated: model.last_updated ?? model.release_date ?? existing?.last_updated ?? releaseDate, + knowledge: model.knowledge ?? existing?.knowledge, + open_weights: openWeights, + } as SyncedFullModel; +} + +function resolveBaseModel( + model: AihubmixModel, + labIDs: LabMetadataIDs | undefined, + catalog: RelayCatalog | undefined, +) { + const vendor = model.vendor; + if (vendor == null || labIDs === undefined) return undefined; + const lab = VENDOR_LABS[vendor] ?? vendor; + for (const candidate of relayChain(model, catalog)) { + const id = labIDs.get(`${lab}/${candidate}`.toLowerCase()); + if (id !== undefined) return id; + } + return undefined; +} + +/** + * The relay's own ID first, then one `variant_of` hop at a time toward the + * canonical entry. Nearest first matters: `qwen3.8-max-preview` is a variant of + * `qwen3.8-max` and both are published lab models, so the relay must factor onto + * the preview it actually serves rather than onto the root of its chain. + */ +function relayChain(model: AihubmixModel, catalog: RelayCatalog | undefined) { + const chain = [bareID(model.model_id)]; + const seen = new Set(chain); + let current: AihubmixModel | undefined = model; + while (current?.variant_of != null) { + const parent = bareID(current.variant_of); + if (seen.has(parent)) break; + seen.add(parent); + chain.push(parent); + current = catalog?.get(current.variant_of.toLowerCase()); + } + return chain; +} + +function bareID(modelID: string) { + return modelID.split("/").at(-1) ?? modelID; +} + +function reasoningOptions( + model: AihubmixModel, + existing?: ExistingModel, +): SyncedFullModel["reasoning_options"] { + if (model.reasoning_options == null) return undefined; + const options = model.reasoning_options.flatMap((option) => { + if (option.type === "toggle") return [{ type: option.type }]; + // The endpoint states that a budget exists but not its bounds, so the bounds a + // file already carries are the only record of them and are carried through. + // There is no second baseline to fall back on: `ModelMetadata` has no + // `reasoning_options` field, so a bare budget written here cannot be shadowing + // a range stated on the lab entry — that range can only live on a provider file, + // and a budget range is a property of the host's API, not of the model. + if (option.type === "budget_tokens") { + const authored = existing?.reasoning_options?.find((entry) => entry.type === "budget_tokens"); + const min = option.min ?? (authored?.type === "budget_tokens" ? authored.min : undefined); + const max = option.max ?? (authored?.type === "budget_tokens" ? authored.max : undefined); + return [{ type: "budget_tokens" as const, min: min ?? undefined, max: max ?? undefined }]; + } + if (option.type !== "effort") return []; + const values = (option.values ?? []) + .map((value) => EFFORT_ALIASES[value] ?? value) + .filter((value) => EFFORT_VALUES.has(value)); + return values.length > 0 ? [{ type: "effort" as const, values }] : []; + }); + // AIHubMix accepts whichever off switch the caller's SDK speaks and maps it, + // so a model can publish both a toggle and `effort = none`. The catalog spells + // that one way: graded effort carrying `none` stands alone, and the dialects + // that reach the same off state are named in the file header instead. + const folded = foldsToggle(options) ? options.filter((option) => option.type !== "toggle") : options; + return folded.length > 0 ? (folded as SyncedFullModel["reasoning_options"]) : undefined; +} + +function foldsToggle(options: { type: string; values?: string[] }[]) { + return ( + options.some((option) => option.type === "toggle") && + options.some((option) => option.type === "effort" && (option.values ?? []).includes("none")) + ); +} + +/** + * The display name to record on a factored entry. `inheritedOverride` already + * drops a name the lab entry states identically, but the two registries punctuate + * the same name differently — the endpoint writes `GLM 5.3` where the lab writes + * `GLM-5.3` — and taking the endpoint's spelling as an override on 78 entries + * would fight the lab's own naming across the catalog for no gain. + * + * So the endpoint's label is recorded only where the relay is not simply that lab + * model under another punctuation: its ID, normalised, differs from the base + * model's slug. That is the same test `shouldPreserveFactoredName` applies for + * OpenRouter, and it is what keeps `coding-glm-4.6-free` reading "Coding GLM 4.6 + * (free)" instead of inheriting a bare "GLM-4.6" it shares with two other routes. + * A relay that *is* the lab model keeps deferring to the lab's spelling, including + * where the lab renamed it (`gemini-3-pro-image` shows as "Nano Banana Pro"). And + * the endpoint's label only ever fills a create: an update keeps the name the file + * states, so this cannot rewrite a spelling a human chose. + */ +function factoredName(model: AihubmixModel, base: string, existing: ExistingModel | undefined) { + // A name already on the file is a human's call and outranks the endpoint's label, + // which is a storefront string: 4 files spell their model the way its lab does + // (`MiMo-V2.5`, `MiniMax-M2.7`) where the endpoint sends `Mimo V2.5`. Handing it + // straight through stays correct anyway — `inheritedOverride` drops a name the lab + // states identically, which is what retires the 27 redundant ones `dev` carries. + if (existing?.name !== undefined) return existing.name; + // A blank label is not a name. `ModelBase.name` is `min(1)`, so writing one + // through would abort the whole provider's sync at validation rather than skip + // the field, and the standalone path never had to care because it only ever + // passed a name that had already been validated. + if (model.model_name == null || model.model_name.trim() === "") return undefined; + // Compared on the bare ID, because that is what resolved the base model: + // `relayChain` walks `bareID(model_id)`, so `Qwen/QwQ-32B` reaches + // `qwen/qwq-32b`. Normalising the namespaced form instead would never match its + // own slug, and each of the 10 namespaced routes would take a redundant + // storefront override the moment its lab file lands. + const slug = base.split("/").slice(1).join("/"); + return normalizeModelSlug(bareID(model.model_id)) === normalizeModelSlug(slug) ? undefined : model.model_name; +} + +/** + * A bare wire-path line, which the derived block restates in full. These four + * openings introduce nothing but the field to send, so replacing one loses + * nothing — `# Effort: reasoning_effort = low|high|max` says less than the block + * that supersedes it. + * + * Matching an opening rather than a substring is the point. Keying on `$.` or on + * the docs host would also delete lines that merely mention one: seven files on + * `dev` carry a header, and `claude-opus-5` and `qwen3.8-max` each state a wire + * path together with a dated live test the response cannot reproduce + * ("verified live 2026-08-11"). Those are notes, and notes are carried through + * even where they overlap the block — a second statement of the same wire path + * costs nothing, a deleted verification date cannot be recovered. + */ +const AUTHORED_OPENING = /^#\s*(Toggle|Effort|Budget|Off is effort)\b/; + +function composeHeader(existingHeader: string | undefined, derived: string | undefined) { + // The two wire-path lines are this adapter's own restatement of the block, so + // they go whether or not a block replaces them. Keeping them when nothing is + // derived is what left a route advertising a toggle it no longer has: the block + // vanished, its tail survived as a "note", and no later sync could tell the + // difference — the file never self-corrected. + // + // The source line is kept unless a derived block restates it, which is only to + // avoid stating it twice. It is a citation for the gateway rather than a claim + // about this model, and a human wrote this exact line in `gemini-3.7-flash`. + const authored = new Set( + (derived === undefined ? DIALECT_PATHS : DIALECTS) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""), + ); + const notes = (existingHeader ?? "") + .split("\n") + .filter((line) => + line.trim() !== "" && !AUTHORED_OPENING.test(line.trim()) && !authored.has(line.trim()), + ); + const header = (derived ?? "") + (notes.length > 0 ? `${notes.join("\n")}\n` : ""); + return header === "" ? undefined : header; +} + +function reasoningHeader(model: AihubmixModel, built: SyncedModel) { + const options = built.reasoning_options; + if (options === undefined) return undefined; + if (options.some((option) => option.type === "toggle")) return TOGGLE_HEADER; + // Only say where the off state moved to on a file that actually spells it out. + return options.some((option) => option.type === "effort" && option.values?.includes("none")) && + (model.reasoning_options ?? []).some((option) => option.type === "toggle") + ? FOLDED_HEADER + : undefined; +} + +/** + * The endpoint under-reports what a route accepts: it lists `text,image` for + * `kimi-k2.5`, whose lab entry and this repo both record video, and `text` for + * `qwen3.8-2.4t-a95b`, whose own file notes a live 200 on image input. Both are + * reported upstream, but a sync must not delete an accepted modality in the + * meantime, so the endpoint adds to what the file recorded rather than replacing + * it. A modality the endpoint never listed can still be removed by editing the + * file, which is where it came from. + */ +/** + * The lab entry a relay factors onto, read for what the endpoint can under-report: + * modalities it omits and windows it restates in decimal. Returns nothing when the + * relay is standalone. Reasoning controls are not here to be read: `ModelMetadata` + * has no `reasoning_options` field, so a lab entry cannot state them and only a + * provider file ever does. + */ +function labMetadata(base: string | undefined) { + if (base === undefined) return undefined; + return modelMetadata(base) as { + modalities?: { input?: string[]; output?: string[] }; + limit?: { context?: number; output?: number }; + }; +} + +/** + * A decimal restatement of a binary window can only lose `1000/1024` per K unit, + * so three nested unit swaps — 1024³ tokens quoted as 1000³ — is the floor of what + * a restatement can explain. The endpoint quotes 204800 as 200000 (0.977), 1048576 + * as 1000000 (0.954) and 65536 as 65535; none of that is the host narrowing the + * window, and writing it as an override invents a difference that is not there. + * A real restriction sits far below: grok-code-fast-1 caps output at 10000 of + * 256000 (0.039) and gpt-5-chat-latest serves 128000 of a 400000 window (0.320). + */ +const UNIT_RESTATEMENT_FLOOR = 1000 ** 3 / 1024 ** 3; + +/** + * The limit to record. The endpoint speaks first and the file stands in when it + * says nothing — the authored value is not a worse version of the lab's but a + * narrower one on purpose, the host's own cap (`kimi-k2.5` serves 32768 of a + * 262144 window), so it is kept rather than widened away. + * + * Whichever of the two states the limit, it is only recorded if it is a limit: a + * value that merely restates an accepted window in decimal resolves to that + * window and no override is written. Applying the test to the stated value rather + * than to the endpoint's quote also retires the restatements an earlier sync + * already wrote onto three MiniMax files (128000 and 128100 of 131072). + */ +function resolveLimit(quoted?: number, authored?: number, lab?: number) { + const stated = quoted ?? authored; + if (stated === undefined) return undefined; + // The restatement reads the same from either side, so the comparison is a ratio + // rather than a direction: the endpoint quotes an accepted 204800 as 200000 and + // an accepted 1000000 as 1048576, and neither is the host stating a different + // window. Checking only the narrowing side left 20 routes writing an override + // that states no difference at all (`glm-5.3` recording 1048576 against a lab + // window of 1000000). + // + // The lab entry is tried first, because a restatement should resolve to the + // spelling that makes the override disappear: matching the lab means + // `inheritedOverride` drops the key entirely, while resolving to the value the + // provider file happens to hold would pin that spelling forever — `991000` is + // itself just an imprecise way of writing the lab's 1000000. The file's own + // value still decides where the lab states no such key, and a genuine host + // restriction falls below the floor and is written as the delta it is. + let resolved = stated; + for (const accepted of [lab, authored]) { + if (accepted === undefined) continue; + const ratio = Math.min(stated, accepted) / Math.max(stated, accepted); + if (ratio < UNIT_RESTATEMENT_FLOOR) continue; + resolved = accepted; + break; + } + // A relay cannot serve a wider window than the model it relays: the window is the + // model's property and a host can only restrict it. Applied to whatever the + // restatement resolved, not in place of it — an endpoint quoting the same stale + // ceiling the file already holds resolves to that number, and clamping only + // afterwards is what catches it (`grok-4.5` quoting the file's own 1000000 output + // against a lab window of 500000). Where the lab entry is the stale side, + // `models/` is where that gets corrected. + return lab !== undefined && resolved > lab ? lab : resolved; +} + +function modalities(value: string | null | undefined, fallback: string[]) { + const parsed = (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => ["text", "audio", "image", "video", "pdf"].includes(entry)); + const known = fallback.length > 0 ? fallback : ["text"]; + if (parsed.length === 0) return known as SyncedFullModel["modalities"]["input"]; + // Endpoint order first, so a file only changes when its content changes. + return [...new Set([...parsed, ...known])] as SyncedFullModel["modalities"]["input"]; +} + +/** + * AIHubMix omits a price field when the model has no such rate, so an omitted + * field means "not offered" and an authored value is only kept when the + * endpoint quotes nothing at all for the model. + */ +function buildCost( + pricing: AihubmixModel["pricing"], + authored: ExistingModel["cost"], +): SyncedFullModel["cost"] { + if (pricing == null) return authored; + const input = price(pricing.input); + const output = price(pricing.output); + if (input === undefined || output === undefined) return authored; + + return { + input, + output, + cache_read: cacheRead(pricing.cache_read, input), + cache_write: price(pricing.cache_write), + // AIHubMix quotes only text and cache rates, so an audio or reasoning price + // exists on the file and nowhere else. A rewrite would drop it. + input_audio: authored?.input_audio, + output_audio: authored?.output_audio, + reasoning: authored?.reasoning, + tiers: costTiers(pricing, authored?.tiers) ?? authored?.tiers, + }; +} + +function costTiers( + pricing: NonNullable, + authored: NonNullable["tiers"], +) { + const tiers = (pricing.tiers ?? []).flatMap((tier) => { + const input = price(tier.input); + const output = price(tier.output); + if (input === undefined || output === undefined) return []; + // Audio rates are per tier too, and the endpoint quotes none of them. + const priced = authored?.find((entry) => entry.tier.size === tier.tier.size); + return [ + { + tier: { type: tier.tier.type ?? "context", size: tier.tier.size }, + input, + output, + cache_read: cacheRead(tier.cache_read, input), + cache_write: price(tier.cache_write), + input_audio: priced?.input_audio, + output_audio: priced?.output_audio, + reasoning: priced?.reasoning, + }, + ]; + }); + return tiers.length > 0 ? (tiers as NonNullable["tiers"]) : undefined; +} + +/** + * AIHubMix sends 0 for a limit it does not know rather than omitting the field — + * 104 of 408 models quote `max_output: 0` — so 0 is read as absent. A model that + * truly emitted no tokens would not be servable. + */ +function tokens(value: number | null | undefined) { + if (value == null || !Number.isFinite(value) || value <= 0) return undefined; + return value; +} + +/** + * Six models and four context tiers repeat the input price in `cache_read`, + * which is how the endpoint spells "no cache discount" rather than a real rate. + * Publishing it would understate a cached read by up to 10x. An omitted field + * already means "no such rate" here, so an echoed one is read the same way. + */ +function cacheRead(value: number | null | undefined, input: number) { + const parsed = price(value); + return parsed !== undefined && parsed >= input ? undefined : parsed; +} + +function price(value: number | null | undefined) { + if (value == null || !Number.isFinite(value) || value < 0) return undefined; + return Math.round(value * 1_000_000) / 1_000_000; +} diff --git a/packages/core/src/sync/providers/openrouter.ts b/packages/core/src/sync/providers/openrouter.ts index 77164f7caa6..2121c93bf8d 100644 --- a/packages/core/src/sync/providers/openrouter.ts +++ b/packages/core/src/sync/providers/openrouter.ts @@ -440,7 +440,7 @@ function shouldPreserveFactoredName( return normalizeModelSlug(modelSlug) !== normalizeModelSlug(canonicalSlug); } -function normalizeModelSlug(value: string) { +export function normalizeModelSlug(value: string) { return value.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); } diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 0e51e85cfe1..08d28fcba12 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -4,6 +4,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { formatToml, preserveReasoningOptions, syncProvider, type ExistingModel, type SyncProvider } from "../src/sync/index.js"; +import { + aihubmix, + buildAihubmixModel, + type AihubmixModel, +} from "../src/sync/providers/aihubmix.js"; import { anthropic, buildAnthropicModel, @@ -1185,6 +1190,8 @@ test("tracks missing models except for unreliable first-party inventories", () = expect(openai.trackMissingModels).toBe(false); expect(pioneer.skipCreates).toBe(true); expect(pioneer.trackMissingModels).toBe(true); + expect(aihubmix.skipCreates).toBeUndefined(); + expect(aihubmix.trackMissingModels).toBe(true); expect(ofox.skipCreates).toBe(true); expect(ofox.trackMissingModels).toBe(true); expect(tinfoil.skipCreates).toBe(true); @@ -5014,3 +5021,758 @@ test("rejects synced model paths that differ only in case", async () => { await rm(root, { recursive: true, force: true }); } }); + +function aihubmixModel(overrides: Partial = {}): AihubmixModel { + return { + model_id: "gemini-3.1-flash-lite", + model_name: "Gemini 3.1 Flash Lite", + vendor: "google", + pricing: { input: 0.25, output: 1.5, cache_read: 0.025 }, + ...overrides, + }; +} + +const aihubmixAuthored: ExistingModel = { + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", + attachment: true, + reasoning: true, + reasoning_options: [{ type: "effort", values: ["minimal", "low", "medium", "high"] }], + cost: { input: 0.25, output: 1.5, cache_read: 0.025, cache_write: 1 }, + limit: { context: 1_048_576, output: 65_536 }, + modalities: { input: ["text", "image", "audio", "video", "pdf"], output: ["text"] }, +}; + +const aihubmixLabIDs = new Map( + [ + "google/gemini-3.1-flash-lite", + "google/gemini-3.1-flash-lite-preview", + "openai/gpt-5.5", + "minimax/MiniMax-M2", + ].map((id) => [id.toLowerCase(), id]), +); + +/** The listing every `variant_of` hop is resolved against. */ +const aihubmixCatalog = new Map( + [ + aihubmixModel(), + aihubmixModel({ + model_id: "gemini-3.1-flash-lite-preview", + variant_of: "gemini-3.1-flash-lite", + }), + aihubmixModel({ model_id: "minimax-m2", vendor: "minimax" }), + ].map((model) => [model.model_id, model]), +); + +test("factors an AIHubMix relay onto the lab metadata it serves", () => { + const model = buildAihubmixModel( + aihubmixModel({ + model_id: "gemini-3.1-flash-lite-nothink", + variant_of: "gemini-3.1-flash-lite", + context_length: 1_048_576, + max_output: 65_536, + input_modalities: "text,image", + }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(model).toMatchObject({ + base_model: "google/gemini-3.1-flash-lite", + cost: { input: 0.25, output: 1.5, cache_read: 0.025 }, + }); + // The relay only records what it actually changes. + expect(model).not.toHaveProperty("release_date"); + expect(model).not.toHaveProperty("open_weights"); +}); + +test("follows the AIHubMix variant chain back to the upstream lab model", () => { + // `coding-` and `-free` are routing modes, and the endpoint says so itself + // rather than the prefix and suffix being stripped from the ID here. + for (const id of ["coding-gemini-3.1-flash-lite", "gemini-3.1-flash-lite-free"]) { + const model = buildAihubmixModel( + aihubmixModel({ model_id: id, variant_of: "gemini-3.1-flash-lite" }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(model).toMatchObject({ base_model: "google/gemini-3.1-flash-lite" }); + } +}); + +test("factors an AIHubMix relay onto the nearest published model in its chain", () => { + // `-preview` is a variant of the base model and is itself published, so the + // relay records the preview it actually serves rather than the chain's root. + const model = buildAihubmixModel( + aihubmixModel({ + model_id: "coding-gemini-3.1-flash-lite-preview", + variant_of: "gemini-3.1-flash-lite-preview", + }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(model).toMatchObject({ base_model: "google/gemini-3.1-flash-lite-preview" }); +}); + +test("resolves an AIHubMix relay against a lab that spells its ID differently", () => { + // AIHubMix lowercases every relay ID; the lab keeps `minimax/MiniMax-M2`. + const model = buildAihubmixModel( + aihubmixModel({ model_id: "coding-minimax-m2-free", vendor: "minimax", variant_of: "minimax-m2" }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(model).toMatchObject({ base_model: "minimax/MiniMax-M2" }); +}); + +test("skips an AIHubMix relay with neither base metadata nor standalone fields", () => { + const model = buildAihubmixModel( + aihubmixModel({ model_id: "house-brand-v1", vendor: null }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(model).toBeUndefined(); +}); + +test("resolves an AIHubMix lab whose namespace the catalog spells differently", () => { + // AIHubMix says `zhipu` where the catalog namespace is `zhipuai`. + const labIDs = new Map([["zhipuai/glm-5.3", "zhipuai/glm-5.3"]]); + const model = buildAihubmixModel( + aihubmixModel({ model_id: "coding-glm-5.3", vendor: "zhipu", variant_of: "glm-5.3" }), + undefined, + labIDs, + new Map(), + ); + expect(model).toMatchObject({ base_model: "zhipuai/glm-5.3" }); +}); + +test("skips an AIHubMix standalone entry the endpoint quotes no limits for", () => { + // A full catalog entry must carry its own limits; the endpoint sends 0 for a + // ceiling it does not know, which is read as absent rather than written. + const model = buildAihubmixModel( + aihubmixModel({ + model_id: "house-brand-v1", + vendor: null, + release_date: "2026-01-01", + open_weights: false, + context_length: 0, + max_output: 0, + }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(model).toBeUndefined(); +}); + +test("normalizes AIHubMix reasoning options to the catalog vocabulary", () => { + const model = buildAihubmixModel( + aihubmixModel({ + reasoning: true, + // `default` is AIHubMix-only, and it spells two efforts differently. + reasoning_options: [ + { type: "effort", values: ["no_think", "instant", "high", "bogus"], default: "high" }, + { type: "toggle", default: true }, + ] as AihubmixModel["reasoning_options"], + }), + undefined, + aihubmixLabIDs, + ); + // `AGENTS.md`: graded effort that already carries `none` stands alone. AIHubMix + // publishes both because it accepts either dialect's off switch and maps it. + expect(model?.reasoning_options).toEqual([{ type: "effort", values: ["none", "minimal", "high"] }]); +}); + +test("keeps the AIHubMix toggle when its effort list has no off value", () => { + const model = buildAihubmixModel( + aihubmixModel({ + reasoning: true, + reasoning_options: [ + { type: "effort", values: ["high", "max"] }, + { type: "toggle" }, + ] as AihubmixModel["reasoning_options"], + }), + undefined, + aihubmixLabIDs, + ); + expect(model?.reasoning_options).toEqual([{ type: "effort", values: ["high", "max"] }, { type: "toggle" }]); +}); + +test("authors the AIHubMix toggle wire-path header so a rewrite cannot drop it", () => { + // Standalone relays, so the toggle stays on the written file instead of being + // factored onto a lab base model. A standalone entry is only allowed where the + // response names no vendor — a named lab belongs on `base_model`. + const standalone = { + vendor: null, + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + } satisfies Partial; + const toggled = aihubmixModel({ + ...standalone, + model_id: "somelab-thinker", + model_name: "SomeLab Thinker", + reasoning: true, + reasoning_options: [{ type: "toggle" }] as AihubmixModel["reasoning_options"], + }); + const plain = aihubmixModel({ + ...standalone, + model_id: "somelab-plain", + model_name: "SomeLab Plain", + }); + aihubmix.parseModels({ data: [toggled, plain] }); + + const context = { existing: () => undefined, authored: () => undefined }; + const translated = aihubmix.translateModel(toggled, context); + expect(translated?.model.reasoning_options).toEqual([{ type: "toggle" }]); + expect(translated?.header).toStartWith("# Toggle:\n# $.enable_thinking = true|false"); + // Only a reasoning control needs the wire path spelled out; everything else stays bare. + expect(aihubmix.translateModel(plain, context)?.header).toBeUndefined(); + + // A toggle folded into `effort = none` still records where the off state lives. + const folded = aihubmixModel({ + ...standalone, + model_id: "somelab-folded", + model_name: "SomeLab Folded", + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["no_think", "high"] }, + ] as AihubmixModel["reasoning_options"], + }); + aihubmix.parseModels({ data: [folded] }); + const dropped = aihubmix.translateModel(folded, context); + expect(dropped?.model.reasoning_options).toEqual([{ type: "effort", values: ["none", "high"] }]); + expect(dropped?.header).toStartWith("# Off is effort=none"); +}); + +test("keeps AIHubMix audio and reasoning prices the endpoint never quotes", () => { + // The endpoint models only text and cache rates, so an audio rate lives on the + // file and nowhere else -- at the top level and inside each context tier. + const authored: ExistingModel = { + ...aihubmixAuthored, + cost: { + input: 0.25, + output: 1.5, + input_audio: 1, + output_audio: 2, + reasoning: 3, + tiers: [ + { tier: { type: "context", size: 32_000 }, input: 0.5, output: 3, input_audio: 1.9 }, + ], + }, + }; + const model = buildAihubmixModel( + aihubmixModel({ + pricing: { + input: 0.25, + output: 1.5, + tiers: [{ tier: { type: "context", size: 32_000 }, input: 0.6, output: 3.2 }], + }, + }), + authored, + aihubmixLabIDs, + ); + expect(model?.cost?.input_audio).toBe(1); + expect(model?.cost?.output_audio).toBe(2); + expect(model?.cost?.reasoning).toBe(3); + // The endpoint still owns the text rates it does quote. + expect(model?.cost?.tiers?.[0]).toMatchObject({ input: 0.6, output: 3.2, input_audio: 1.9 }); +}); + +test("keeps AIHubMix fields the endpoint has no surface for", () => { + // Fast mode, request-shape overrides and the input cap live on the file only. + const authored: ExistingModel = { + ...aihubmixAuthored, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + experimental: { modes: { fast: { cost: { input: 5, output: 30 }, provider: { body: { service_tier: "priority" } } } } }, + provider: { body: { service_tier: "flex" } }, + } as ExistingModel; + const model = buildAihubmixModel( + aihubmixModel({ context_length: 1_050_000, max_output: 128_000 }), + authored, + aihubmixLabIDs, + ); + expect(model?.limit?.input).toBe(922_000); + expect(model?.experimental).toEqual(authored.experimental); + expect(model?.provider).toEqual(authored.provider); +}); + +test("reads an AIHubMix cache rate that just repeats input as no discount", () => { + // 6 models and 4 tiers echo `input` in `cache_read`; publishing it would + // understate a cached read by up to 10x. + const model = buildAihubmixModel( + aihubmixModel({ + pricing: { + input: 2, + output: 8, + cache_read: 2, + tiers: [{ tier: { type: "context", size: 200_000 }, input: 4, output: 16, cache_read: 4 }], + }, + }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(model?.cost?.cache_read).toBeUndefined(); + expect(model?.cost?.tiers?.[0]?.cache_read).toBeUndefined(); + // A genuine discount is still published. + const discounted = buildAihubmixModel( + aihubmixModel({ pricing: { input: 2, output: 8, cache_read: 0.2 } }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(discounted?.cost?.cache_read).toBe(0.2); +}); + +test("keeps authored reasoning budget bounds the AIHubMix endpoint omits", () => { + // The endpoint states that a budget exists but never its range, and a bare + // option on a `base_model` file would override the lab's real bounds. + const authored: ExistingModel = { + ...aihubmixAuthored, + reasoning_options: [{ type: "budget_tokens", min: 1_024, max: 32_000 }], + }; + const model = buildAihubmixModel( + aihubmixModel({ reasoning: true, reasoning_options: [{ type: "budget_tokens" }] as AihubmixModel["reasoning_options"] }), + authored, + aihubmixLabIDs, + ); + expect(model?.reasoning_options).toEqual([{ type: "budget_tokens", min: 1_024, max: 32_000 }]); +}); + +test("does not let a narrower AIHubMix modality list delete an accepted one", () => { + // The endpoint lists `text,image` for kimi-k2.5, whose lab entry records video. + // Most of the catalog arrives as a create with no file to union against, so + // the lab entry is the only baseline — and the narrowing must not be written. + const created = buildAihubmixModel( + aihubmixModel({ input_modalities: "text,image" }), + undefined, + aihubmixLabIDs, + ); + expect(created?.modalities).toBeUndefined(); + + // Where the file is the wider record, its modality survives an update too. + const authored: ExistingModel = { + ...aihubmixAuthored, + id: "minimax-m2", + modalities: { input: ["text", "image"], output: ["text"] }, + }; + const updated = buildAihubmixModel( + aihubmixModel({ model_id: "minimax-m2", vendor: "minimax", input_modalities: "text" }), + authored, + aihubmixLabIDs, + ); + expect(updated?.modalities?.input).toEqual(["text", "image"]); + + // A modality the endpoint adds still lands. + const widened = buildAihubmixModel( + aihubmixModel({ model_id: "minimax-m2", vendor: "minimax", input_modalities: "text,image,pdf" }), + undefined, + aihubmixLabIDs, + ); + expect(widened?.modalities?.input).toEqual(["text", "image", "pdf"]); +}); + +test("records an AIHubMix route's own name only where its ID is not the lab slug", () => { + // A factored create used to pass the file's name, so a route with no file yet + // recorded none at all and rendered as the lab model: `coding-glm-4.6-free`, + // `coding-glm-4.6` and `glm-4.6` all read "GLM-4.6". + const catalog = new Map(aihubmixCatalog); + const free = aihubmixModel({ + model_id: "coding-gemini-3.1-flash-lite-free", + model_name: "Coding Gemini 3.1 Flash Lite (free)", + variant_of: "gemini-3.1-flash-lite", + }); + catalog.set(free.model_id, free); + const created = buildAihubmixModel(free, undefined, aihubmixLabIDs, catalog); + expect(created).toMatchObject({ + base_model: "google/gemini-3.1-flash-lite", + name: "Coding Gemini 3.1 Flash Lite (free)", + }); + + // An update keeps the name the file states. Four files spell their model the way + // its lab does (`MiMo-V2.5`) where the endpoint sends a storefront `Mimo V2.5`, + // and the endpoint's label is not a reason to rewrite a human's spelling. + const authored = buildAihubmixModel( + free, + { id: free.model_id, name: "Coding Gemini 3.1 Flash-Lite (free)" }, + aihubmixLabIDs, + catalog, + ); + expect(authored).toMatchObject({ name: "Coding Gemini 3.1 Flash-Lite (free)" }); + + // A relay that *is* that lab model keeps deferring to the lab's spelling, so a + // registry punctuating the same name differently does not become an override + // on every entry in the catalog. + // A blank label is not a name, and it is not merely ignored: `ModelBase.name` is + // `min(1)`, so a `""` handed through aborts the whole provider's sync at + // validation and writes no file at all. The endpoint types the field + // `nullish()`, so both shapes have to resolve to "no name". + for (const blank of [null, "", " "]) { + const bare = aihubmixModel({ + model_id: "coding-gemini-3.1-flash-lite-free", + model_name: blank as never, + variant_of: "gemini-3.1-flash-lite", + }); + const built = buildAihubmixModel(bare, undefined, aihubmixLabIDs, catalog); + expect(built).toMatchObject({ base_model: "google/gemini-3.1-flash-lite" }); + expect(built).not.toHaveProperty("name"); + } + + // A namespaced route is compared on the bare ID, because that is what resolved + // its base model: `relayChain` walks `bareID(model_id)`. Normalising the + // namespaced form would never equal its own slug, and the route would take a + // storefront override restating the lab's own name. + const namespaced = buildAihubmixModel( + // The label deliberately differs from the lab's, so an override would actually + // be recorded if the comparison used the namespaced form. + aihubmixModel({ model_id: "Google/gemini-3.1-flash-lite", model_name: "Gemini 3.1 Flash Lite Turbo" }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(namespaced).toMatchObject({ base_model: "google/gemini-3.1-flash-lite" }); + expect(namespaced).not.toHaveProperty("name"); + + const punctuated = buildAihubmixModel( + aihubmixModel({ model_name: "Gemini 3.1 Flash-Lite" }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(punctuated).toMatchObject({ base_model: "google/gemini-3.1-flash-lite" }); + expect(punctuated).not.toHaveProperty("name"); +}); + +test("refreshes the AIHubMix wire path without discarding a human note", () => { + // The header is authoritative so a stale wire path cannot outlive the options + // it documents, but a price citation or live-test record is not reproducible + // from the response and has to survive the rewrite. + const toggled = aihubmixModel({ + vendor: null, + model_id: "somelab-noted", + model_name: "SomeLab Noted", + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + reasoning: true, + reasoning_options: [{ type: "toggle" }] as AihubmixModel["reasoning_options"], + }); + const plain = aihubmixModel({ + vendor: null, + model_id: "somelab-bare", + model_name: "SomeLab Bare", + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + }); + aihubmix.parseModels({ data: [toggled, plain] }); + const translated = aihubmix.translateModel(toggled, { + existing: () => undefined, + authored: () => undefined, + header: () => + "# Toggle: enable_thinking = true|false\n" + + "# AIHubMix Models API (queried 2026-08-11T09:45:11Z): input 1.69, output 5.07.\n", + }); + expect(translated?.header).toStartWith("# Toggle:\n# $.enable_thinking = true|false"); + // The hand-written wire path it supersedes is gone; the citation is not. + expect(translated?.header).not.toContain("# Toggle: enable_thinking"); + expect(translated?.header).toContain("queried 2026-08-11T09:45:11Z"); + + // A note is recognised by what it opens with, not by mentioning a wire path or + // the docs host: two files on `dev` state a wire path together with a live test + // the response cannot reproduce, and a second statement of the same path costs + // nothing next to a deleted verification date. + const noted = aihubmix.translateModel(toggled, { + existing: () => undefined, + authored: () => undefined, + header: () => + '# Native Messages prefers $.thinking.type = "adaptive" (verified live 2026-08-11).\n' + + "# https://docs.aihubmix.com/cn/api/Claude-Native\n", + }); + expect(noted?.header).toContain("verified live 2026-08-11"); + expect(noted?.header).toContain("docs.aihubmix.com/cn/api/Claude-Native"); + + // A dialect line is only dropped when a derived block restates it. With nothing + // derived there is nothing to restate, and one file's whole citation is that + // line byte for byte. + const citation = "# https://docs.aihubmix.com/cn/api/unified-inference\n"; + expect( + aihubmix.translateModel(plain, { + existing: () => undefined, + authored: () => undefined, + header: () => citation, + })?.header, + ).toBe(citation); + // An opening is recognised after trimming, because `leadingComments` matches on + // the trimmed line but keeps the raw one — so an indented `# Toggle:` arrives + // here still indented, and would otherwise survive as a "note" restating the + // block written directly above it. + const indented = aihubmix.translateModel(toggled, { + existing: () => undefined, + authored: () => undefined, + header: () => " # Toggle: enable_thinking = true|false\n", + })?.header; + expect(indented).not.toContain("# Toggle: enable_thinking"); + + // Written alongside a block that does restate it, it is not doubled. + const doubled = + aihubmix.translateModel(toggled, { + existing: () => undefined, + authored: () => undefined, + header: () => citation, + })?.header ?? ""; + expect(doubled.split(citation.trim()).length - 1).toBe(1); +}); + +test("reads a missing AIHubMix reasoning or tool flag as unknown, not as false", () => { + // 107 of 408 routes omit `reasoning` and 100 omit `tool_call`; none send + // `false`. A create must not write the omission as an override that turns off + // what the lab entry declares. + const created = buildAihubmixModel( + aihubmixModel({ input_modalities: "text,image,video,audio,pdf" }), + undefined, + aihubmixLabIDs, + ); + expect(created?.reasoning).toBeUndefined(); + expect(created?.tool_call).toBeUndefined(); + + // An explicit boolean is still honoured. + const denied = buildAihubmixModel( + aihubmixModel({ tool_call: false, input_modalities: "text,image,video,audio,pdf" }), + undefined, + aihubmixLabIDs, + ); + expect(denied?.tool_call).toBe(false); +}); + +test("reads a zero AIHubMix limit as absent rather than a real ceiling", () => { + // 102 of 415 models quote `max_output: 0` for a limit the endpoint does not know. + const zeroed = buildAihubmixModel( + aihubmixModel({ context_length: 262_144, max_output: 0 }), + aihubmixAuthored, + aihubmixLabIDs, + ); + const omitted = buildAihubmixModel( + aihubmixModel({ context_length: 262_144 }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(zeroed?.limit).toEqual(omitted?.limit); + expect(zeroed?.limit?.output).not.toBe(0); +}); + +test("syncs AIHubMix pricing over the authored entry", () => { + const model = buildAihubmixModel( + aihubmixModel({ pricing: { input: 0.2, output: 1.2, cache_read: 0.02, cache_write: 0.25 } }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(model?.cost).toMatchObject({ + input: 0.2, + output: 1.2, + cache_read: 0.02, + cache_write: 0.25, + }); +}); + +test("drops an authored AIHubMix rate the endpoint no longer quotes", () => { + // An omitted price field means the model has no such rate, not that it is unknown. + const model = buildAihubmixModel( + aihubmixModel({ pricing: { input: 0.25, output: 1.5 } }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(model?.cost?.cache_read).toBeUndefined(); + expect(model?.cost?.cache_write).toBeUndefined(); +}); + +test("keeps authored AIHubMix pricing when the endpoint quotes no rate at all", () => { + const model = buildAihubmixModel( + aihubmixModel({ pricing: null }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(model?.cost).toEqual(aihubmixAuthored.cost); +}); + +test("inherits a limit the AIHubMix endpoint only restates in decimal", () => { + // The lab window is 1_048_576 and the endpoint quotes 1_000_000 for it — the same + // window in decimal, not a cap — so the relay must inherit rather than write an + // override claiming it lost 48_576 tokens. + const restated = buildAihubmixModel( + aihubmixModel({ context_length: 1_000_000, max_output: 65_536 }), + undefined, + aihubmixLabIDs, + ); + expect(restated?.limit?.context).toBeUndefined(); + + // A window the host genuinely restricts is far below any restatement and lands. + const capped = buildAihubmixModel( + aihubmixModel({ context_length: 128_000, max_output: 65_536 }), + undefined, + aihubmixLabIDs, + ); + expect(capped?.limit?.context).toBe(128_000); + + // The restatement reads the same from the other side: the lab window is + // 1_048_576 and the endpoint quotes 1_048_576 against a provider file holding the + // decimal 1_000_000. Resolving to the lab is what lets `inheritedOverride` drop + // the key — resolving to the file's own spelling would pin 1_000_000 forever even + // though it is only an imprecise way of writing the same window. + const reverse = buildAihubmixModel( + aihubmixModel({ context_length: 1_048_576, max_output: 65_536 }), + { ...aihubmixAuthored, limit: { context: 1_000_000, output: 65_536 } }, + aihubmixLabIDs, + ); + expect(reverse?.limit?.context).toBeUndefined(); + + // With no lab entry to defer to, the file's own value is the accepted one, and the + // restatement still reads from either side: a host route quoting the binary + // 1_048_576 for the 1_000_000 already on the file keeps the file's number instead + // of rewriting it to say the same window differently. + const hostRestated = buildAihubmixModel( + aihubmixModel({ + vendor: null, + model_id: "somelab-restated", + model_name: "SomeLab Restated", + release_date: "2026-05-01", + open_weights: false, + context_length: 1_048_576, + max_output: 65_536, + }), + { ...aihubmixAuthored, id: "somelab-restated", limit: { context: 1_000_000, output: 65_536 } }, + aihubmixLabIDs, + ); + expect(hostRestated?.limit?.context).toBe(1_000_000); + + // A relay cannot serve a wider window than the model it relays, so a ceiling above + // the lab's own resolves to the lab's rather than advertising tokens no request can + // reach. Doubling the output is far past any restatement. + const overreach = buildAihubmixModel( + aihubmixModel({ context_length: 1_048_576, max_output: 131_072 }), + undefined, + aihubmixLabIDs, + ); + expect(overreach?.limit?.output).toBeUndefined(); + + // The clamp applies to whatever the restatement resolved, not instead of it. An + // endpoint quoting back the same stale ceiling the file already holds resolves to + // that number — so clamping only afterwards is what catches it. `grok-4.5` did + // exactly this, recording a 1_000_000 output against a 500_000 lab window. + const stale = buildAihubmixModel( + aihubmixModel({ context_length: 1_048_576, max_output: 1_000_000 }), + { ...aihubmixAuthored, limit: { context: 1_048_576, output: 1_000_000 } }, + aihubmixLabIDs, + ); + expect(stale?.limit?.output).toBeUndefined(); + + // And an authored cap survives an endpoint that quotes nothing for the ceiling. + const authored: ExistingModel = { + ...aihubmixAuthored, + limit: { context: 1_048_576, output: 32_768 }, + }; + const unknown = buildAihubmixModel( + aihubmixModel({ max_output: 0 }), + authored, + aihubmixLabIDs, + ); + expect(unknown?.limit?.output).toBe(32_768); +}); + +test("does not author a standalone AIHubMix entry for a model a lab made", () => { + // Every field a standalone entry needs is present, but `vendor` names the lab + // that built the model and no `models/somelab/…` entry exists to factor onto. + // AGENTS.md makes that a blocker, so the relay is reported, not written. + const lab = { + vendor: "somelab", + model_id: "somelab-unmapped", + model_name: "SomeLab Unmapped", + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + } satisfies Partial; + expect(buildAihubmixModel(aihubmixModel(lab), undefined, aihubmixLabIDs)).toBeUndefined(); + expect(aihubmix.sourceID?.(aihubmixModel(lab))).toBe("somelab-unmapped"); + + // A relay the response names no vendor for is the host's own alias, and still writes. + const hostOwn = buildAihubmixModel( + aihubmixModel({ ...lab, vendor: null }), + undefined, + aihubmixLabIDs, + ); + expect(hostOwn?.name).toBe("SomeLab Unmapped"); + + // A standalone file already in the repo keeps being updated rather than freezing: + // what it should have been is upstream's call, and stalling its prices helps no one. + const authored: ExistingModel = { + id: "somelab-unmapped", + name: "SomeLab Unmapped", + release_date: "2026-05-01", + open_weights: false, + cost: { input: 1, output: 2 }, + limit: { context: 262_144, output: 65_536 }, + modalities: { input: ["text"], output: ["text"] }, + }; + const updated = buildAihubmixModel(aihubmixModel(lab), authored, aihubmixLabIDs); + expect(updated?.cost).toEqual({ input: 0.25, output: 1.5, cache_read: 0.025 }); +}); + +test("opens missing-model issues for a provider that creates but still skips", async () => { + // aihubmix does not set skipCreates — it creates what it can — so gating the + // issue path on skipCreates left every relay it cannot write as a notice nobody + // acts on. An explicit trackMissingModels is the opt-in for exactly that case. + const dir = await mkdtemp(path.join(tmpdir(), "sync-track-")); + const modelsDir = path.join(dir, "providers", "tracked", "models"); + await mkdir(modelsDir, { recursive: true }); + try { + const result = await syncProvider({ + id: "aihubmix", + name: "Tracked", + modelsDir, + trackMissingModels: true, + async fetchModels() { + return { data: [] }; + }, + parseModels() { + return [{ model_id: "unmapped-relay" }]; + }, + translateModel() { + return undefined; + }, + sourceID(model: { model_id: string }) { + return model.model_id; + }, + } as never, { dryRun: true, openIssues: true }); + expect(result.notices.some((notice) => notice.includes("unmapped-relay"))).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("marks retired AIHubMix relays deprecated and stops tracking them", () => { + const retired = aihubmixModel({ retire_stage: "deprecated" }); + expect(buildAihubmixModel(retired, aihubmixAuthored, aihubmixLabIDs)?.status).toBe("deprecated"); + expect(aihubmix.sourceID?.(retired)).toBeUndefined(); + expect(aihubmix.sourceID?.(aihubmixModel())).toBe("gemini-3.1-flash-lite"); +}); + +test("writes per-tier audio pricing", () => { + const toml = formatToml({ + name: "Doubao Seed 2.0 Lite", + cost: { + input: 0.09041, + output: 0.54246, + input_audio: 1.269, + tiers: [ + { tier: { type: "context", size: 32_000 }, input: 0.13, output: 0.76, input_audio: 1.902 }, + ], + }, + } as never); + expect(toml).toContain("input_audio = 1.902"); +}); diff --git a/providers/aihubmix/models/alicloud-deepseek-v4-flash.toml b/providers/aihubmix/models/alicloud-deepseek-v4-flash.toml deleted file mode 100644 index 36c21d7cfb1..00000000000 --- a/providers/aihubmix/models/alicloud-deepseek-v4-flash.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "DeepSeek V4 Flash (Alibaba Cloud)" -description = "Fast DeepSeek model for efficient chat, coding help, and agent loops" -family = "deepseek-flash" -release_date = "2026-04-24" -last_updated = "2026-04-24" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -knowledge = "2025-05" -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.14 -output = 0.28 -cache_read = 0.028 - -[limit] -context = 1_000_000 -output = 384_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/alicloud-deepseek-v4-pro.toml b/providers/aihubmix/models/alicloud-deepseek-v4-pro.toml deleted file mode 100644 index 57e26625400..00000000000 --- a/providers/aihubmix/models/alicloud-deepseek-v4-pro.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "DeepSeek V4 Pro (Alibaba Cloud)" -description = "Flagship DeepSeek model for coding, reasoning, and agentic work" -family = "deepseek-thinking" -release_date = "2026-04-24" -last_updated = "2026-04-24" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -knowledge = "2025-05" -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 1.69 -output = 3.38 -cache_read = 0.13 - -[limit] -context = 1_000_000 -output = 384_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/alicloud-glm-5.1.toml b/providers/aihubmix/models/alicloud-glm-5.1.toml deleted file mode 100644 index 877d9e29a4b..00000000000 --- a/providers/aihubmix/models/alicloud-glm-5.1.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "GLM-5.1 (Alibaba Cloud)" -description = "Flagship GLM model for hybrid reasoning, coding, and agentic engineering" -family = "glm" -release_date = "2026-03-27" -last_updated = "2026-03-27" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.84 -output = 3.38 -cache_read = 0.169 -cache_write = 1.05625 - -[limit] -context = 200_000 -output = 128_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/claude-opus-4-6.toml b/providers/aihubmix/models/claude-opus-4-6.toml index 57b40db1786..8f0d7241820 100644 --- a/providers/aihubmix/models/claude-opus-4-6.toml +++ b/providers/aihubmix/models/claude-opus-4-6.toml @@ -1,3 +1,4 @@ +# Native Messages prefers $.thinking.type = "adaptive" with $.output_config.effort = "low"|"medium"|"high"|"max"; enabled budget_tokens >= 1024 is deprecated and must be < $.max_tokens. https://docs.aihubmix.com/cn/api/Claude-Native (accessed 2026-06-25) name = "Claude Opus 4.6" description = "Flagship Claude model for deep reasoning, coding, and long-horizon agents" family = "claude-opus" @@ -6,7 +7,6 @@ last_updated = "2026-03-13" attachment = true reasoning = true reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "max"] }, { type = "budget_tokens", min = 1_024 }] -# Native Messages prefers $.thinking.type = "adaptive" with $.output_config.effort = "low"|"medium"|"high"|"max"; enabled budget_tokens >= 1024 is deprecated and must be < $.max_tokens. https://docs.aihubmix.com/cn/api/Claude-Native (accessed 2026-06-25) temperature = true tool_call = true structured_output = true diff --git a/providers/aihubmix/models/claude-opus-4-7.toml b/providers/aihubmix/models/claude-opus-4-7.toml index 0fb0b1c89f3..b085c8e69a2 100644 --- a/providers/aihubmix/models/claude-opus-4-7.toml +++ b/providers/aihubmix/models/claude-opus-4-7.toml @@ -1,3 +1,4 @@ +# Native Messages uses $.thinking.type = "adaptive" and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected. https://docs.aihubmix.com/cn/blogs/Claude-Opus4.7 (accessed 2026-06-25) name = "Claude Opus 4.7" description = "Flagship Claude model for deep reasoning, coding, and long-horizon agents" family = "claude-opus" @@ -6,7 +7,6 @@ last_updated = "2026-04-16" attachment = true reasoning = true reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }] -# Native Messages uses $.thinking.type = "adaptive" and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected. https://docs.aihubmix.com/cn/blogs/Claude-Opus4.7 (accessed 2026-06-25) temperature = false tool_call = true structured_output = true diff --git a/providers/aihubmix/models/claude-opus-4-8-think.toml b/providers/aihubmix/models/claude-opus-4-8-think.toml index 52f226453d2..a829e13abba 100644 --- a/providers/aihubmix/models/claude-opus-4-8-think.toml +++ b/providers/aihubmix/models/claude-opus-4-8-think.toml @@ -1,6 +1,6 @@ +# Anthropic-compatible /v1/messages: $.thinking.type = "enabled"|"disabled"|"adaptive" (disabled turns reasoning off = toggle) and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected on this Opus tier. https://docs.aihubmix.com/cn/api-reference/anthropic-compatible/create-a-message (accessed 2026-07-02) base_model = "anthropic/claude-opus-4-8" reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }] -# Anthropic-compatible /v1/messages: $.thinking.type = "enabled"|"disabled"|"adaptive" (disabled turns reasoning off = toggle) and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected on this Opus tier. https://docs.aihubmix.com/cn/api-reference/anthropic-compatible/create-a-message (accessed 2026-07-02) [interleaved] field = "reasoning_content" diff --git a/providers/aihubmix/models/claude-opus-4-8.toml b/providers/aihubmix/models/claude-opus-4-8.toml index e8d612cf002..3dd610d9365 100644 --- a/providers/aihubmix/models/claude-opus-4-8.toml +++ b/providers/aihubmix/models/claude-opus-4-8.toml @@ -1,6 +1,6 @@ +# Anthropic-compatible /v1/messages: $.thinking.type = "enabled"|"disabled"|"adaptive" (disabled turns reasoning off = toggle) and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected on this Opus tier. https://docs.aihubmix.com/cn/api-reference/anthropic-compatible/create-a-message (accessed 2026-07-02) base_model = "anthropic/claude-opus-4-8" reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }] -# Anthropic-compatible /v1/messages: $.thinking.type = "enabled"|"disabled"|"adaptive" (disabled turns reasoning off = toggle) and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected on this Opus tier. https://docs.aihubmix.com/cn/api-reference/anthropic-compatible/create-a-message (accessed 2026-07-02) interleaved = true diff --git a/providers/aihubmix/models/claude-sonnet-4-6.toml b/providers/aihubmix/models/claude-sonnet-4-6.toml index b252b0f52b3..be1a367a194 100644 --- a/providers/aihubmix/models/claude-sonnet-4-6.toml +++ b/providers/aihubmix/models/claude-sonnet-4-6.toml @@ -1,3 +1,4 @@ +# Native Messages prefers $.thinking.type = "adaptive" with $.output_config.effort = "low"|"medium"|"high"; enabled budget_tokens >= 1024 is deprecated and must be < $.max_tokens. Chat effort "max" maps to native "high". https://docs.aihubmix.com/cn/api/Claude-Native (accessed 2026-06-25) name = "Claude Sonnet 4.6" description = "Balanced Claude model for coding, analysis, agent workflows, and cost control" family = "claude-sonnet" @@ -6,7 +7,6 @@ last_updated = "2026-03-13" attachment = true reasoning = true reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "max"] }, { type = "budget_tokens", min = 1_024 }] -# Native Messages prefers $.thinking.type = "adaptive" with $.output_config.effort = "low"|"medium"|"high"; enabled budget_tokens >= 1024 is deprecated and must be < $.max_tokens. Chat effort "max" maps to native "high". https://docs.aihubmix.com/cn/api/Claude-Native (accessed 2026-06-25) temperature = true tool_call = true structured_output = true diff --git a/providers/aihubmix/models/deep-deepseek-v4-flash.toml b/providers/aihubmix/models/deep-deepseek-v4-flash.toml deleted file mode 100644 index 89f36a266b7..00000000000 --- a/providers/aihubmix/models/deep-deepseek-v4-flash.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "DeepSeek V4 Flash (DeepSeek)" -description = "Fast DeepSeek model for efficient chat, coding help, and agent loops" -family = "deepseek-flash" -release_date = "2026-04-24" -last_updated = "2026-04-24" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -knowledge = "2025-05" -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.154 -output = 0.308 -cache_read = 0.0308 - -[limit] -context = 1_000_000 -output = 384_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/deep-deepseek-v4-pro.toml b/providers/aihubmix/models/deep-deepseek-v4-pro.toml deleted file mode 100644 index eb762b884f9..00000000000 --- a/providers/aihubmix/models/deep-deepseek-v4-pro.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "DeepSeek V4 Pro (DeepSeek)" -description = "Flagship DeepSeek model for coding, reasoning, and agentic work" -family = "deepseek-thinking" -release_date = "2026-04-24" -last_updated = "2026-04-24" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -knowledge = "2025-05" -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.478 -output = 0.956 -cache_read = 0.004302 - -[limit] -context = 1_000_000 -output = 384_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/gemini-2.5-flash.toml b/providers/aihubmix/models/gemini-2.5-flash.toml index 23f238b714b..22234024838 100644 --- a/providers/aihubmix/models/gemini-2.5-flash.toml +++ b/providers/aihubmix/models/gemini-2.5-flash.toml @@ -1,3 +1,4 @@ +# Native Gemini uses $.generationConfig.thinkingConfig.thinkingBudget: 0 disables, -1 is dynamic, and manual budgets are 1..24576. https://cloud.google.com/vertex-ai/generative-ai/docs/thinking (accessed 2026-06-25) name = "Gemini 2.5 Flash" description = "Fast Gemini model balancing multimodal reasoning, tool use, and cost" family = "gemini-flash" @@ -6,7 +7,6 @@ last_updated = "2025-06-05" attachment = true reasoning = true reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high"] }, { type = "budget_tokens", min = 0, max = 24_576 }] -# Native Gemini uses $.generationConfig.thinkingConfig.thinkingBudget: 0 disables, -1 is dynamic, and manual budgets are 1..24576. https://cloud.google.com/vertex-ai/generative-ai/docs/thinking (accessed 2026-06-25) temperature = true tool_call = true structured_output = true diff --git a/providers/aihubmix/models/gemini-2.5-pro.toml b/providers/aihubmix/models/gemini-2.5-pro.toml index 8ef984bdbf3..e77eedd0faf 100644 --- a/providers/aihubmix/models/gemini-2.5-pro.toml +++ b/providers/aihubmix/models/gemini-2.5-pro.toml @@ -1,3 +1,4 @@ +# Native Gemini uses $.generationConfig.thinkingConfig.thinkingBudget: -1 is dynamic and manual budgets are 128..32768; 0/off is unsupported. https://cloud.google.com/vertex-ai/generative-ai/docs/thinking (accessed 2026-06-25) name = "Gemini 2.5 Pro" description = "Advanced Gemini model for complex reasoning, coding, and multimodal analysis" family = "gemini-pro" @@ -6,7 +7,6 @@ last_updated = "2025-06-05" attachment = true reasoning = true reasoning_options = [{ type = "effort", values = ["low", "medium", "high"] }] -# Native Gemini uses $.generationConfig.thinkingConfig.thinkingBudget: -1 is dynamic and manual budgets are 128..32768; 0/off is unsupported. https://cloud.google.com/vertex-ai/generative-ai/docs/thinking (accessed 2026-06-25) temperature = true tool_call = true structured_output = true diff --git a/providers/aihubmix/models/gemini-3.7-flash.toml b/providers/aihubmix/models/gemini-3.7-flash.toml index 31d0f319a9a..63ad68c0ba2 100644 --- a/providers/aihubmix/models/gemini-3.7-flash.toml +++ b/providers/aihubmix/models/gemini-3.7-flash.toml @@ -1,7 +1,7 @@ -base_model = "google/gemini-3.7-flash" - # AIHubMix unified Chat: $.reasoning_effort = low|medium|high # https://docs.aihubmix.com/cn/api/unified-inference +base_model = "google/gemini-3.7-flash" + reasoning_options = [{ type = "effort", values = ["low", "medium", "high"] }] [cost] diff --git a/providers/aihubmix/models/xiaomi-mimo-v2.5-pro.toml b/providers/aihubmix/models/xiaomi-mimo-v2.5-pro.toml deleted file mode 100644 index 2d493daadb9..00000000000 --- a/providers/aihubmix/models/xiaomi-mimo-v2.5-pro.toml +++ /dev/null @@ -1,19 +0,0 @@ -base_model = "xiaomi/mimo-v2.5-pro" -reasoning_options = [{ type = "toggle" }] -name = "Xiaomi MiMo-V2.5-Pro" -family = "mimo-v2.5-pro" -last_updated = "2026-05-13" - -[interleaved] -field = "reasoning_content" - -[cost] -input = 1.1 -output = 3.3 -cache_read = 0.22 - -[[cost.tiers]] -tier = { type = "context", size = 256_000 } -input = 2.2 -output = 6.6 -cache_read = 0.44 diff --git a/providers/aihubmix/models/xiaomi-mimo-v2.5.toml b/providers/aihubmix/models/xiaomi-mimo-v2.5.toml deleted file mode 100644 index 04bbbaaaf19..00000000000 --- a/providers/aihubmix/models/xiaomi-mimo-v2.5.toml +++ /dev/null @@ -1,19 +0,0 @@ -base_model = "xiaomi/mimo-v2.5" -reasoning_options = [{ type = "toggle" }] -name = "Xiaomi MiMo-V2.5" -family = "mimo-v2.5" -last_updated = "2026-05-13" - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.44 -output = 2.2 -cache_read = 0.088 - -[[cost.tiers]] -tier = { type = "context", size = 256_000 } -input = 0.88 -output = 4.4 -cache_read = 0.176 diff --git a/providers/aihubmix/models/zai-glm-5.1.toml b/providers/aihubmix/models/zai-glm-5.1.toml deleted file mode 100644 index 9f09d1e0b0f..00000000000 --- a/providers/aihubmix/models/zai-glm-5.1.toml +++ /dev/null @@ -1,28 +0,0 @@ -name = "GLM-5.1 (Z.ai)" -description = "Flagship GLM model for hybrid reasoning, coding, and agentic engineering" -family = "glm" -release_date = "2026-03-27" -last_updated = "2026-03-27" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.845 -output = 3.38 -cache_read = 0.183112 - -[limit] -context = 200_000 -output = 128_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/sync.md b/sync.md index 7595a924dc4..20dccc55516 100644 --- a/sync.md +++ b/sync.md @@ -248,6 +248,40 @@ xAI is implemented in `packages/core/src/sync/providers/xai.ts`. - Existing xAI models are updated from API-authoritative fields while local metadata is preserved for fields the API does not expose, especially output token limits and some feature/capability flags. - New xAI API models are not created automatically (`skipCreates`); each missing ID opens a deduped GitHub issue. Alias IDs of models already cataloged under their canonical ID are skipped silently and never reported as missing. +## AIHubMix Notes + +- AIHubMix is implemented in `packages/core/src/sync/providers/aihubmix.ts`. +- Source endpoint: `https://aihubmix.com/api/v1/models?type=llm`. +- No authentication is required; the catalog is public. +- The endpoint owns capabilities, limits, reasoning controls and text/cache pricing. Fields it has no surface for keep whatever was authored: `family`, `temperature`, `interleaved`, `knowledge`, `experimental`, `provider`, `limit.input`, and the `input_audio`/`output_audio`/`reasoning` rates (its `pricing` object carries only `input`, `output`, `cache_read`, `cache_write` and `tiers`). +- Booleans it omits mean unknown, not denied: 107 of 408 routes send no `reasoning` and 100 send no `tool_call`, and none sends `false`. A missing flag is left undefined so the lab entry's value is inherited rather than overridden off. +- Modalities can only widen. The endpoint under-reports some routes (it lists `text,image` for `kimi-k2.5`, whose lab entry records video), so its list is unioned with the lab entry the relay factors onto and with the existing file, never used to replace either. A modality the endpoint never listed is still removable by editing the file. +- Limits are read the same way, and for the same reason: the endpoint restates windows in decimal (8 `glm` routes quote 204800 as 200000) and in binary (four quote 1000000 as 1048576), and a restatement in either direction is not the host stating a different window. A decimal restatement of a binary window loses at most `1000/1024` per K unit, so `1000³/1024³` — three nested unit swaps — is the floor of what a restatement can explain. The comparison is therefore a ratio and not a direction: two limits within that floor of each other are one window spelled twice, and the stated one resolves to the accepted value and writes no override. Checking only the narrowing side left 20 routes recording an override that states no difference at all, `glm-5.3` among them writing 1048576 against a lab window of 1000000. Applying it to whichever side states the limit is also what retires restatements an earlier sync already wrote into files (three MiniMax entries quoted 131072 as 128000/128100). +- The accepted value tried first is the lab entry's, and the file's own only where the lab states no such key, because a restatement should resolve to the spelling that makes the override disappear: matching the lab lets the factoring step drop the key entirely, while resolving to whatever the provider file happens to hold would pin that spelling forever — `qwen3.7-flash` carries 991000, which is itself only an imprecise way of writing the lab's 1000000. Resolving file-first preserved 10 such overrides, including a `claude-opus-4-8` still recording 200000/32000 against a 1000000/128000 lab window. +- Whatever the restatement resolves to is then clamped to the lab's window, since a relay cannot serve a wider one than the model it relays — the window is the model's property and a host can only restrict it. The clamp runs after the resolution rather than in place of it, because an endpoint quoting back the same stale ceiling the file already holds resolves to that number and only a later clamp catches it: `grok-4.5` carried 1000000 for both limits against a 500000 lab window, and the endpoint quotes that same 1000000. Past the two sentinels below, 14 routes quote a window wider than their lab entry's (`qwen3.8-2.4t-a95b` at 1000000 of 262144, `gemma-4-31b-it` at 131100 of 32768), all reported upstream. Where the lab entry is the stale side, `models/` is where that gets corrected. +- A limit the endpoint does not quote falls back to the authored one rather than to the lab's, because an authored value is not a worse copy of the lab's but a narrower one on purpose: `kimi-k2.5` serves 32768 of a 262144 window. Genuine host caps survive the ratio test and are still written — 7 of the current 408 routes, from `grok-code-fast-1` at 10000 of 256000 to `gpt-5-chat-latest` serving 128000 of a 400000 window. +- `cache_read` that merely repeats `input` is how the endpoint spells "no cache discount" (6 models and 4 context tiers do this); it is read as absent rather than published as a rate, which would understate a cached read by up to 10x. +- `budget_tokens` arrives bare — 103 live entries state that a budget exists but never its bounds — so authored `min`/`max` are carried through instead of being replaced with an unbounded range. There is no second baseline behind the file: `ModelMetadata` has no `reasoning_options` field, so a lab entry cannot state a budget range and a bare budget written here is not shadowing one. A budget range is a property of the host's API, not of the model. +- A `toggle` published alongside an effort list containing `none` is folded to the effort list alone, per the `AGENTS.md` reasoning-options table. AIHubMix accepts whichever off switch the caller's SDK speaks and maps it, so the other dialects that reach the same off state are named in the file header instead. +- `authoritativeHeaders` is on: the adapter re-derives the toggle/folded wire-path comment from each response, so a stale header cannot outlive the options it documents. It supersedes hand-written wire paths only — price citations, source links and live-test records in the same header are carried through, since the response cannot reproduce them. +- A superseded wire path is told from a note to keep by what the line opens with (`# Toggle:`, `# Effort:`, `# Budget:`, `# Off is effort`) — the openings `AGENTS.md` itself prescribes — and not by whether the line mentions a field path or the docs host, since keying on the substring would also delete lines that merely contain one: two files state a wire path together with a dated live test the response cannot reproduce. A second statement of the same path costs nothing; a deleted verification date cannot be recovered. The opening is matched on the trimmed line, or an indented ` # Toggle:` outlives the block it documented. +- The two lines naming this gateway's wire paths are the adapter's own restatement of the block, so they go whether or not a block replaces them; only the docs link survives as a note, and only where no derived block already states it. Keeping the wire paths when nothing is derived is what once left a route advertising a toggle it no longer had — the block vanished, its tail survived as a "note", and no later sync could tell the difference, so the file never self-corrected. +- `AGENTS.md` also requires these notes above the first key, since a sync keeps only the leading comment block and drops every comment below it. Eight aihubmix files carried theirs mid-body and are moved up here; without the move the next sync deletes them silently. +- AIHubMix relays upstream models under its own IDs, so a relay is factored onto the lab metadata it serves (`base_model`) whenever that metadata exists, and then records only what it actually changes. +- The endpoint answers both halves of that lookup itself, so nothing about a relay is inferred from its ID here: `vendor` names the lab that built the model, and `variant_of` names the AIHubMix ID the entry is a routing variant of (`variant_kind` labels it a pricing tier, channel tier, mode preset or deprecated alias). A relay is looked up under its own ID first and then under each `variant_of` hop, nearest first — `qwen3.8-max-preview` is a variant of `qwen3.8-max` and both are published, so the relay factors onto the preview it actually serves. Following the declared chain also resolves relays no string rule reaches, such as `ox-alpha` onto `zhipuai/glm-5.3-flash` and `grok-code-fast-1` onto `xai/grok-build-0.1`. +- The endpoint's label is recorded only where the relay is not that lab model under other punctuation — its bare ID, normalised, differs from the base model's slug. It is compared on the bare ID because that is what resolved the base model, so `Qwen/QwQ-32B` reaches `qwen/qwq-32b`; normalising the namespaced form instead matches nothing and each of the 10 namespaced routes would take a redundant storefront override. The rule keeps `coding-glm-4.6-free` reading "Coding GLM 4.6 (free)" instead of the bare "GLM-4.6" it would share with two other routes, while entries whose endpoint spelling differs only in punctuation (`GLM 5.3` against the lab's `GLM-5.3`) keep deferring to the lab and write nothing. A name already on the file outranks both, since four files spell their model the way its lab does (`MiMo-V2.5`) where the endpoint sends a storefront `Mimo V2.5`; handing it through stays correct because the factoring step drops a name the lab states identically, which is what retires the redundant ones. A blank label is not a name: `ModelBase.name` is `min(1)`, so writing one through aborts the whole provider's sync at validation rather than skipping the field. +- `VENDOR_LABS` maps the four labs the two registries spell differently (`zhipu`/`zhipuai`, `moonshot`/`moonshotai`, `bytedance`/`bytedance-seed`, `meituan-longcat`/`meituan`). It maps namespaces only; no entry decides what a model is or which lab built it. +- Dated release tags are deliberately not special-cased: `gemini-2.5-pro-preview-06-05` is a pinned snapshot, not the model `google/gemini-2.5-pro`, and the endpoint does not declare it a variant of one. +- Lab IDs are matched case-insensitively: AIHubMix spells `minimax-m2` where the lab spells `MiniMax-M2`, and the same model can arrive under several casings, so relays are deduped on the case-folded ID. +- A standalone entry is only authored where the response names no `vendor`. A named lab built the model, so the relay belongs on `base_model`, and AGENTS.md treats a full standalone definition for a nameable lab model as a blocker — so a relay whose lab entry does not exist yet (81 of the current 407 routes, 38 of them described completely enough that the adapter would otherwise have written a full standalone definition) is skipped and reported for a human to add `models//.toml`, after which it factors with no change to the adapter. A standalone file already in the repo keeps being updated: what it should have been is upstream's call, and freezing it would only stall its prices. +- Where no vendor is named, the entry still has to carry the `release_date`, `open_weights` and limits a full definition requires, and is skipped rather than written with invented values when it does not. Of 408 listed models the endpoint names a `vendor` for 292, declares `variant_of` for 76, dates 304 and states `open_weights` for 289; the remaining gap is what the skip notices report back upstream. +- Two limit sentinels are read as absent rather than as real ceilings: `max_output: 0` (104 of 408 models) and a `max_output` at or above `context_length` (36), which is the context window quoted a second time. +- `reasoning_options[]` entries carry an AIHubMix-only `default` key that the strict `ReasoningOption` schema rejects, and spell two effort levels differently (`no_think`, `instant`), so translation drops the extra key and maps those onto `none` and `minimal`. +- A price field is omitted when the model has no such rate, so an omitted `cache_read`/`cache_write` clears an authored one; authored pricing survives only when the endpoint quotes nothing at all for the model, or for the rates it never quotes at all (see above). +- The catalog boundary is AIHubMix's main model list, which is what the endpoint returns. Callable is not the boundary: hidden channel aliases such as `alicloud-glm-5.1` and `deep-deepseek-v4-pro` answer HTTP 200 by routing to a listed model and echo that model's ID back, and several hundred further routes are callable without being listed. Eight such alias files were dropped from this provider; they duplicated entries already in the catalog under the listed ID. +- A route can still rotate out of the list for a spell without being retired, so a local file absent from one response is retained (`deleteMissing: false`) and opens a deduped GitHub issue naming both readings — confirm the rotation, or drop the file if it is a hidden channel alias. +- `trackMissingModels` is set, so relays the adapter skips open deduped `[missing-model]` issues even though creates are enabled. Without it a provider that creates most models but cannot write some of them would emit notices nobody acts on; the flag is implied by `skipCreates` and settable on its own for exactly this case. + ## Tinfoil Notes - Tinfoil is implemented in `packages/core/src/sync/providers/tinfoil.ts`.