diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index c6c8b8f38b2..88238cff2dc 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -21,12 +21,26 @@ const JsonValue: z.ZodType = z.lazy(() => ]), ); +/** + * The catalog's effort levels, exported so a sync provider can filter an upstream + * list against this one rather than restating it. A provider that restates it + * silently drops any level added here until someone remembers to copy the + * addition across. + */ +export const REASONING_EFFORT_VALUES = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "default", +] as const; + const ReasoningEffortValue = z.preprocess( (value) => (value === "null" ? null : value), - z.union([ - z.null(), - z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"]), - ]), + z.union([z.null(), z.enum(REASONING_EFFORT_VALUES)]), ); export const ReasoningOption = z diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 5095f4124ef..fb416c0e96d 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -7,6 +7,7 @@ import { AuthoredModel, AuthoredModelShape, ModelMetadata } from "../schema.js"; import { openMissingModelIssues } from "./missing-issues.js"; import { MissingReasoningOptionsError } from "./missing-reasoning-options.js"; import { aiand } from "./providers/aiand.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"; @@ -84,7 +85,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; @@ -113,6 +119,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; @@ -141,6 +153,7 @@ export interface SyncResult { export const providers: { aiand: SyncProvider; + aihubmix: SyncProvider; ambient: SyncProvider; anthropic: SyncProvider; baseten: SyncProvider; @@ -180,6 +193,7 @@ export const providers: { xai: SyncProvider; } = { aiand, + aihubmix, ambient, anthropic, baseten, @@ -221,6 +235,7 @@ export const providers: { export const groups = { aggregators: [ + "aihubmix", "crossmodel", "edenai", "empiriolabs", @@ -283,6 +298,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; @@ -511,7 +529,7 @@ export async function syncProvider( const issueModels = [...new Set([ ...missingRemote.values(), - ...(provider.skipCreates === true ? skippedRemote : []), + ...(provider.skipCreates === true || provider.trackMissingModels === true ? skippedRemote : []), ...missingReasoning.keys(), ])]; if ( @@ -1081,6 +1099,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 3e841f4aec0..4483bdf5499 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 - ? "Automatic creation was skipped because the remote source is not enough to auto-author a complete catalog entry." + ? "Automatic creation was skipped because the remote source is not enough to auto-author a complete catalog entry, 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..6538be1b2f2 --- /dev/null +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -0,0 +1,962 @@ +import path from "node:path"; + +import { z } from "zod"; + +import { describeModel } from "../../describe.js"; +import { REASONING_EFFORT_VALUES } from "../../schema.js"; +import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; +import { factorBaseModel, modelMetadata, normalizeModelSlug } from "./openrouter.js"; + +const API_ENDPOINT = "https://aihubmix.com/api/v1/models?type=llm"; +/** + * The gateway's second published interface: the canon projection, which is the + * set of models AIHubMix has actually verified rather than merely routes. The + * model list answers "can you reach it here", canon answers "is what we say + * about it checked", and a catalog entry needs the second. + */ +const CANON_ENDPOINT = "https://aihubmix.com/model-data/index.json"; + +/** 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(), + // Which field carries reasoning back, stated per wire protocol rather than + // once for the route. See `interleavedFor` for why the protocol is the unit. + interleaved: z + .record(z.union([z.literal(true), z.object({ field: z.string() }).passthrough()])) + .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", +}; + +// The two levels AIHubMix spells its own way, mapped onto the catalog's. This is +// the one piece of the gateway's own vocabulary left here, and it covers 4 values +// in the whole 409-route list (`no_think` 3, `instant` 1); both are reported +// upstream, and the table goes when the endpoint spells them the catalog's way. +/** + * Route variants the catalog does not carry. Every one of these affixes names a + * way into a model that is already listed under its own ID, not a model of its + * own: `-free` is the free-tier route (53 of them; 40 carry `variant_of` pointing + * at the paid route), `-reasoning`/`-non-reasoning` are the pre-split Grok routes + * that reach one model with thinking forced on or off — a steering choice the + * catalog states as `reasoning_options`, not as two entries — and `coding-` is + * the discounted coding-agent route (32 of them; 19 once the `-free` overlap is + * removed, of which 11 say so through `variant_of` and 8 are the same shape with + * the pointer not yet backfilled). Filtering here rather than at translate keeps + * them out of the missing-model issues too. + * + * A route variant reprices the model, which is exactly what `-free` does too, so + * repricing is not what makes an entry its own model. Every `coding-` route's + * plain sibling is listed and syncs a file of its own. `minimax-m2.7-highspeed` + * is the one reached under other prefixes: `cc-minimax-m2.7-highspeed` and + * `mm-minimax-m2.7-highspeed` are both listed, match no affix here, and declare + * `variant_of = minimax-m2.7-highspeed`, which resolves to the lab entry + * `models/minimax/MiniMax-M2.7-highspeed.toml` — so a sync writes each of them a + * card and what the filter drops is the `coding-` price point, not the model. The + * MiMo V2.5 pair reads the same way: the list spells them `mimo-v2.5` and + * `mimo-v2.5-pro` with no `xiaomi-` prefix, so deleting the `xiaomi-`/`coding-` + * spellings costs nothing a sync does not write back. The one pair that does go + * uncarded is `mimo-v2-omni`/`mimo-v2-pro`, listed as reasoning while publishing + * no `reasoning_options` and skipped for that reason rather than by this filter. + * + * `-reasoning` is the one affix the gateway does not own outright: a lab can end + * a model's real name with it, and `AiHubmix-Phi-4-mini-reasoning` is Microsoft's + * — cataloged here as `providers/azure/models/phi-4-mini-reasoning.toml`. What + * makes the Grok routes a steering pair is that they come as a pair, so + * `isRouteVariant` asks the catalog for the `-non-reasoning` half rather than + * trusting the word. That reads the same list every other rule here reads, so it + * needs no allowlist to keep current. + */ +const ROUTE_VARIANT_ID = /^coding-|-(?:free|non-reasoning)$/i; +const STEERING_ON = /-reasoning$/i; + +function isRouteVariant(id: string, catalog: RelayCatalog) { + if (ROUTE_VARIANT_ID.test(id)) return true; + return STEERING_ON.test(id) && catalog.has(id.toLowerCase().replace(STEERING_ON, "-non-reasoning")); +} + +const EFFORT_ALIASES: Record = { no_think: "none", instant: "minimal" }; +// Taken from the schema rather than restated, so a level added to the catalog is +// accepted here without a second edit. `null` is deliberately not accepted: the +// schema allows it for an authored file that states "no level applies", but a +// relay reaching that through the endpoint's list would be the endpoint sending +// nothing where it means nothing, which the filter below already drops. +const EFFORT_VALUES = new Set(REASONING_EFFORT_VALUES); + +// The catalog previously echoed this protocol-wide domain without model-level +// evidence. The payload has no provenance flag distinguishing such a fallback +// from a verified full domain, so conservatively omit this effort option. This +// is an evidence guard, not a claim that no model could support all seven values. +const UNVERIFIED_PROTOCOL_EFFORT_VALUES = new Set([ + "none", "minimal", "low", "medium", "high", "xhigh", "max", +]); + +function isUnverifiedProtocolDomain(values: string[]) { + const unique = new Set(values); + return unique.size === UNVERIFIED_PROTOCOL_EFFORT_VALUES.size + && [...unique].every((value) => UNVERIFIED_PROTOCOL_EFFORT_VALUES.has(value)); +} + +/** + * Only the IDs are read. The projection carries the resolved parameter domains + * too, but reading those here would make the adapter answer to two sources for + * the same field; the model list stays the one voice on what a route is, and + * canon is asked one question — is this model covered. + */ +const CanonIndex = z + .object({ models: z.array(z.object({ id: z.string() }).passthrough()) }) + .passthrough(); + +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; +let canonIDs: Set | undefined; + +/** + * Routes canon covers. IDs are compared exactly: both registries are generated + * from the same gateway catalog, and all 292 of today's overlaps match without + * case folding, so folding would only invent matches the gateway does not make. + */ +export function canonCoveredModels( + models: T[], + covered: Set | undefined, +) { + // Left unset only when `parseModels` is driven directly, as the tests do: + // `fetchModels` throws rather than returning with canon unfetched, so a real + // sync never reaches the filter without it. + if (covered === undefined) return models; + return models.filter((model) => covered.has(model.model_id)); +} + +/** + * 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; +} + +// A control on this gateway has no single wire path: the same off state, the +// same effort, the same budget are each reachable from whichever SDK dialect the +// caller speaks, and the gateway maps whatever it receives onto the vendor's real +// field. So a control names one path per protocol rather than picking a winner, +// following what `providers/aihubmix/provider.toml` records for each surface. +const TOGGLE_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'; +const EFFORT_PATHS = + "# $.reasoning_effort on /v1/chat/completions (alias $.reasoning.effort, which is also the Responses field);\n" + + "# $.output_config.effort on /v1/messages, subject to model support.\n"; +const BUDGET_PATHS = + "# integer $.reasoning.max_tokens on /v1/chat/completions; $.thinking.budget_tokens >= 1024 on /v1/messages;\n" + + "# integer $.generationConfig.thinkingConfig.thinkingBudget on the Gemini path (-1 dynamic, 0 off where supported);\n" + + "# the Responses path carries effort but has no reasoning-token budget field.\n"; +// Every path line above is this adapter's own restatement of the block, so a +// hand-written copy of one is dropped rather than kept beside it. +const ADAPTER_PATHS = TOGGLE_PATHS + EFFORT_PATHS + BUDGET_PATHS; +// 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 path lines above are only ever this adapter's own. +const DIALECT_SOURCE = "# https://docs.aihubmix.com/cn/api/unified-inference\n"; +// 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_OPENING = "# Off is effort=none; graded levels — no toggle. The same off elsewhere:\n"; + +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}`); + } + // Thrown rather than skipped, because a canon request that fails is not a + // catalog with nothing in it — carrying on without the gate would publish + // exactly the unverified routes it exists to hold back, and the sync writes + // nothing on a throw, so a bad fetch costs a rerun instead of a bad write. + const canon = await fetch(process.env.AIHUBMIX_CANON_URL ?? CANON_ENDPOINT); + if (!canon.ok) { + throw new Error(`AIHubMix canon request failed: ${canon.status} ${canon.statusText}`); + } + canonIDs = new Set(CanonIndex.parse(await canon.json()).models.map((model) => model.id)); + 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])); + // Dropped here rather than in translateModel, so a route variant is absent + // from the sync altogether: no file, and no skip notice or missing-model + // issue asking a human to supply metadata the catalog does not want. The + // relay catalog above keeps every entry, because a variant is still a valid + // `variant_of` target for a route that does belong in the catalog. + // Bound locally because the pairing rule reads the catalog from inside a + // closure, where the module-level binding is no longer narrowed. + const catalog = relayCatalog; + const listed = [...catalog.values()].filter( + (model) => !isRouteVariant(model.model_id, catalog), + ); + // Dropped silently for the same reason: an uncovered route is not a gap in + // this repo that a contributor here can close — the work is to verify the + // model in canon — so it raises nothing for a maintainer to act on. Of the + // 117 routes canon does not cover, the ones that would otherwise reach a + // file are the long tail the endpoint describes worst: `Qwen/QwQ-32B`, + // `codex-mini-latest` and the `qwen3-*` family all report no `reasoning` + // flag despite having no non-thinking mode. + return canonCoveredModels(listed, canonIDs); + }, + 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, fast-mode or request-shape surface; those + // three exist on the file and nowhere else, so keep them. + temperature: existing?.temperature, + interleaved: interleavedFor(model, existing), + experimental: existing?.experimental, + provider: existing?.provider, + status: resolveStatus(model.retire_stage, existing?.status), + modalities: { input, output }, + limit, + cost: buildCost(model.pricing, existing?.cost), + }; + + if (base !== undefined) { + assertReasoningOptions(model.model_id, reasoning ?? lab?.reasoning, shared.reasoning_options); + 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; + assertReasoningOptions(model.model_id, standaloneReasoning, shared.reasoning_options); + 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; +} + +/** + * Which wire protocol a model is actually spoken over. AIHubMix relays one route + * list across four protocols, and `@aihubmix/ai-sdk-provider` — the package this + * provider entry names — picks between them from the model ID: `claude-*` is + * built as an Anthropic messages model, `gemini*`/`imagen*` as a Google + * generative model (except the `-nothink`/`-search` routes, which the provider + * sends back down the OpenAI-compatible path), and everything else as an + * OpenAI-compatible chat model. Rules transcribed from `createChatModel` in + * aihubmix-provider.ts (v2.2.1). The Responses face is reachable only by asking + * for it (`provider.responses(id)`), so it is never the default a catalog entry + * describes. + * + * This matters because the reasoning side channel is a property of the protocol + * shape, not of the model: `claude-opus-5` returns thinking blocks on + * `/v1/messages` and nothing at all on the chat-completions path. Reading one + * fixed protocol for every route would answer for the wrong endpoint — the same + * mistake, in the same direction, that reading `chat_completions` for the + * Gemini-native routes would make for tool calling. + */ +const GOOGLE_NATIVE_EXCLUDED = ["-nothink", "-search"]; + +export function wireProtocol(modelID: string): string { + if (modelID.startsWith("claude")) return "anthropic.messages"; + if ( + (modelID.startsWith("gemini") || modelID.startsWith("imagen")) && + !GOOGLE_NATIVE_EXCLUDED.some((suffix) => modelID.endsWith(suffix)) + ) { + return "google.gemini"; + } + return "openai.chat_completions"; +} + +/** The two side-channel fields the catalog names; anything else is not one. */ +const INTERLEAVED_FIELDS = new Set(["reasoning_content", "reasoning_details"]); + +/** + * The reasoning side channel on the protocol this model is actually spoken over. + * + * The endpoint states this per protocol — `true` where the channel exists but the + * carrier has no settled name, `{field}` where it does — and states nothing at + * all for a model whose channel has not been checked. Absence is therefore + * unknown rather than denial, the same reading the route list's missing + * `reasoning` flag gets, so a silent endpoint leaves an authored value standing. + * A protocol the endpoint does describe is authoritative for that protocol, + * which is what corrects a file naming a carrier the protocol does not use. + */ +function interleavedFor( + model: AihubmixModel, + existing?: ExistingModel, +): SyncedFullModel["interleaved"] { + const served = model.interleaved?.[wireProtocol(model.model_id)]; + if (served === undefined) return existing?.interleaved; + if (served === true) return true; + return INTERLEAVED_FIELDS.has(served.field) + ? { field: served.field as "reasoning_content" | "reasoning_details" } + : true; +} + +function reasoningOptions( + model: AihubmixModel, + existing?: ExistingModel, +): SyncedFullModel["reasoning_options"] { + if (model.reasoning_options == null) return undefined; + // Only an explicitly empty source list states that no controls are exposed. + // Do not confuse it with missing data or a nonempty list we cannot translate. + if (model.reasoning_options.length === 0) return []; + 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)); + if (isUnverifiedProtocolDomain(values)) return []; + 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 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 ? ADAPTER_PATHS : ADAPTER_PATHS + DIALECT_SOURCE) + .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; +} + +/** + * One block per control the file actually authors, so a wire path is documented + * exactly while its option row is on the file and disappears with it. Deriving + * every type — rather than the toggle alone — is what makes `AUTHORED_OPENING` + * honest: an opening is only disposable because the block restates it, and an + * effort or budget opening used to be stripped with nothing put back, leaving the + * option row on the file and its field name nowhere. + */ +function reasoningHeader(model: AihubmixModel, built: SyncedModel) { + const options = built.reasoning_options; + if (options === undefined) return undefined; + const effort = options.find((option) => option.type === "effort"); + const blocks: string[] = []; + if (options.some((option) => option.type === "toggle")) { + blocks.push("# Toggle:\n" + TOGGLE_PATHS); + } else if ( + // Only say where the off state moved to on a file that actually spells it out. + effort?.values.includes("none") && + (model.reasoning_options ?? []).some((option) => option.type === "toggle") + ) { + blocks.push(FOLDED_OPENING + TOGGLE_PATHS); + } + if (effort !== undefined) { + // The levels come from the row they document, so a file never states a set + // the row does not carry — the drift the hand-written openings had. + const levels = effort.values.length > 0 ? ` ${effort.values.join("|")}` : ""; + blocks.push(`# Effort:${levels}\n` + EFFORT_PATHS); + } + if (options.some((option) => option.type === "budget_tokens")) { + blocks.push("# Budget:\n" + BUDGET_PATHS); + } + return blocks.length === 0 ? undefined : blocks.join("") + DIALECT_SOURCE; +} + +/** + * 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. + */ +/** + * A model that reasons but states no controls is a gap in the source, not a model + * without controls. Left alone, the runner reads a missing `reasoning_options` on + * a reasoner as "no caller control" and stamps `[]` — which AGENTS.md forbids + * using for uncertainty — and `ModelMetadata` has no `reasoning_options` field, so + * the lab entry cannot supply them either. Skip the route and let it surface as a + * missing model, the same as the Cloudflare adapter does. Called only on the two + * paths that actually write, so a route skipped for some other gap still reports + * that gap. + */ +function assertReasoningOptions( + id: string, + reasoning: boolean | undefined, + options: SyncedFullModel["reasoning_options"], +) { + if (reasoning !== true || options !== undefined) return; + throw new MissingReasoningOptionsError( + id, + "AIHubMix reports the model as reasoning but publishes no reasoning_options, and neither the file nor the lab entry states them", + ); +} + +/** + * `retire_stage` rides on every route (407 active, 2 deprecated in the current + * listing), so it is authoritative about retirement — and only about retirement. + * A route that comes back has to lose the mark or the file carries `deprecated` + * forever; `alpha` and `beta` survive untouched because the endpoint says nothing + * about either. + */ +function resolveStatus( + stage: string | null | undefined, + existing: ExistingModel["status"], +): ExistingModel["status"] { + if (stage === "deprecated") return "deprecated"; + if (stage == null || stage.trim().length === 0) return existing; + return existing === "deprecated" ? undefined : existing; +} + +/** + * 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` is read as a flag only: `ModelMetadata` has no + * `reasoning_options` field, so a lab entry can say that a model reasons but never + * how it is steered — only a provider file ever states that. + */ +function labMetadata(base: string | undefined) { + if (base === undefined) return undefined; + return modelMetadata(base) as { + reasoning?: boolean; + 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)); + // The fallback is two overlapping records — a narrowing list already on the file + // and the lab entry it narrows — so a shared entry collapses rather than being + // written twice on a route whose modalities the endpoint omits. + const known = fallback.length > 0 ? [...new Set(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-aihubmix-runner.test.ts b/packages/core/test/sync-aihubmix-runner.test.ts new file mode 100644 index 00000000000..5dc455446b2 --- /dev/null +++ b/packages/core/test/sync-aihubmix-runner.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { syncProvider } from "../src/sync/index.js"; +import { aihubmix } from "../src/sync/providers/aihubmix.js"; + +// A real sync installs module-level catalog state (the lab IDs, the relay +// listing, the canon cover) that every other AIHubMix test expects unset, so +// this one lives in its own file rather than leaking that state sideways. + +test("AIHubMix sync carries a hand-authored note through an authoritative header rewrite", async () => { + // Every other AIHubMix test hands `translateModel` a context it builds itself, + // so the `header` callback could be wired nowhere and the suite would still be + // green. This one drives the runner: the note has to survive a real sync, which + // is the only place `authoritativeHeaders` actually replaces a file's header. + const root = await mkdtemp(path.join(tmpdir(), "sync-aihubmix-header-")); + const repo = path.join(import.meta.dirname, "..", "..", ".."); + const modelsDir = path.join(root, "providers", "aihubmix", "models"); + const relayPath = path.join(modelsDir, "deepseek-v4-pro-0813.toml"); + const listingURL = process.env.AIHUBMIX_MODELS_URL; + const canonURL = process.env.AIHUBMIX_CANON_URL; + const note = "# Probed 2026-09-15: low/high/max returned 522/564/510 reasoning tokens.\n"; + try { + await mkdir(path.join(root, "models", "deepseek"), { recursive: true }); + await copyFile( + path.join(repo, "models", "deepseek", "deepseek-v4-pro-0813.toml"), + path.join(root, "models", "deepseek", "deepseek-v4-pro-0813.toml"), + ); + await mkdir(modelsDir, { recursive: true }); + // A stale opening the block owns, and below it a note nothing else records. + await Bun.write( + relayPath, + "# Toggle: enable_thinking = true|false\n" + + note + + 'base_model = "deepseek/deepseek-v4-pro-0813"\nreasoning_options = [{ type = "toggle" }]\n', + ); + + const listing = path.join(root, "models.json"); + const canon = path.join(root, "canon.json"); + await Bun.write( + listing, + JSON.stringify({ + data: [ + { + model_id: "deepseek-v4-pro-0813", + model_name: "DeepSeek V4 Pro", + vendor: "deepseek", + pricing: { input: 0.6918, output: 2.0754, cache_read: 0.023058 }, + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ], + }, + ], + }), + ); + await Bun.write(canon, JSON.stringify({ models: [{ id: "deepseek-v4-pro-0813" }] })); + process.env.AIHUBMIX_MODELS_URL = pathToFileURL(listing).href; + process.env.AIHUBMIX_CANON_URL = pathToFileURL(canon).href; + + expect(await syncProvider({ ...aihubmix, modelsDir })).toMatchObject({ updated: 1 }); + + const content = await readFile(relayPath, "utf8"); + // The note is the whole point: a verification date cannot be re-derived. + expect(content).toContain(note.trim()); + // The block is refreshed rather than appended beside the opening it replaces. + expect(content).toContain("# Toggle:\n# $.enable_thinking = true|false"); + expect(content).toContain("# Effort: low|high|max"); + expect(content).not.toContain("# Toggle: enable_thinking = true|false\n"); + expect(Bun.TOML.parse(content)).toMatchObject({ + reasoning_options: [{ type: "toggle" }, { type: "effort", values: ["low", "high", "max"] }], + }); + + // A later explicit withdrawal must survive the runner's preservation logic, + // remove obsolete control comments, and keep the human verification note. + const response = JSON.parse(await readFile(listing, "utf8")); + response.data[0].reasoning_options = []; + await Bun.write(listing, JSON.stringify(response)); + expect(await syncProvider({ ...aihubmix, modelsDir })).toMatchObject({ updated: 1 }); + const withdrawn = await readFile(relayPath, "utf8"); + expect(Bun.TOML.parse(withdrawn).reasoning_options).toEqual([]); + expect(withdrawn).toContain(note.trim()); + expect(withdrawn).not.toContain("# Toggle:"); + expect(withdrawn).not.toContain("# Effort:"); + expect(await syncProvider({ ...aihubmix, modelsDir })).toMatchObject({ updated: 0, unchanged: 1 }); + } finally { + if (listingURL === undefined) delete process.env.AIHUBMIX_MODELS_URL; + else process.env.AIHUBMIX_MODELS_URL = listingURL; + if (canonURL === undefined) delete process.env.AIHUBMIX_CANON_URL; + else process.env.AIHUBMIX_CANON_URL = canonURL; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 3557ba66127..e729ce53101 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -4,6 +4,14 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { formatToml, preserveReasoningOptions, syncProvider, type ExistingModel, type SyncProvider } from "../src/sync/index.js"; +import { MissingReasoningOptionsError } from "../src/sync/missing-reasoning-options.js"; +import { + aihubmix, + buildAihubmixModel, + canonCoveredModels, + wireProtocol, + type AihubmixModel, +} from "../src/sync/providers/aihubmix.js"; import { anthropic, buildAnthropicModel, @@ -1185,6 +1193,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); @@ -5091,3 +5101,1150 @@ 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", + // 300 of the 409 listed routes publish their controls; a row that omits them + // while the lab entry says the model reasons is the skipped case, tested on + // its own below rather than made the default every other test inherits. + reasoning_options: [{ type: "effort", values: ["none", "low", "high"] }], + 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", + // Spelled out because the fixture default publishes an effort row, and an + // effort row is a reasoning control like any other: it earns its own wire + // path. Bare means the response states no control at all. + reasoning_options: null, + }); + 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("authors an AIHubMix wire path for every reasoning control, not just the toggle", () => { + // The openings `# Effort:` and `# Budget:` are stripped on a rewrite because + // the derived block restates them. It only restated the toggle, so an effort or + // budget row kept its option on the file and lost its field name entirely -- + // `deepseek-v4-pro-0813` and `qwen3.7-flash` both carried one by hand. + const standalone = { + vendor: null, + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + } satisfies Partial; + const graded = aihubmixModel({ + ...standalone, + model_id: "somelab-graded", + model_name: "SomeLab Graded", + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["high", "max"] }, + { type: "budget_tokens", max: 262_144 }, + ] as AihubmixModel["reasoning_options"], + }); + aihubmix.parseModels({ data: [graded] }); + const translated = aihubmix.translateModel(graded, { + existing: () => undefined, + authored: () => undefined, + header: () => + "# Toggle: enable_thinking = true|false\n" + + "# Effort: reasoning_effort = high|max\n" + + "# Budget: thinking_budget = integer reasoning tokens\n", + }); + const header = translated?.header ?? ""; + expect(header).toContain("# Toggle:\n# $.enable_thinking = true|false"); + expect(header).toContain("# Effort: high|max\n# $.reasoning_effort on /v1/chat/completions"); + expect(header).toContain("# Budget:\n# integer $.reasoning.max_tokens on /v1/chat/completions"); + // Each hand-written opening is superseded rather than kept beside its replacement. + expect(header).not.toContain("# Effort: reasoning_effort ="); + expect(header).not.toContain("# Budget: thinking_budget"); + + // The levels are read off the row they document, so the two cannot drift. + expect(header).not.toContain("# Effort: none"); + + // A control the file does not author gets no wire path: a budget-free model + // must not advertise a budget field. + const effortOnly = aihubmixModel({ + ...standalone, + model_id: "somelab-effort-only", + model_name: "SomeLab Effort Only", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["high", "max"] }] as AihubmixModel["reasoning_options"], + }); + aihubmix.parseModels({ data: [effortOnly] }); + const bare = aihubmix.translateModel(effortOnly, { + existing: () => undefined, + authored: () => undefined, + header: () => undefined, + })?.header ?? ""; + expect(bare).toContain("# Effort: high|max"); + expect(bare).not.toContain("# Budget:"); + expect(bare).not.toContain("# Toggle:"); +}); + +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"]); + + // The endpoint omits modalities for part of the catalog, and the fallback is + // two overlapping records — the file's own list and the lab entry it narrows — + // so a shared entry has to collapse rather than be written twice. + const omitted = buildAihubmixModel( + aihubmixModel({ model_id: "minimax-m2", vendor: "minimax" }), + authored, + aihubmixLabIDs, + ); + expect(omitted?.modalities?.input).toEqual(["text", "image"]); +}); + +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, + // Bare is the point here: the fixture default publishes an effort row, which + // derives a block of its own and would leave nothing for the citation to be + // the only line of. + reasoning_options: null, + }); + 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("drops AIHubMix route variants before they reach the catalog", () => { + // `-free` is the free-tier route into a model already listed under its own ID, + // `-reasoning`/`-non-reasoning` are the pre-split Grok routes that reach one + // model with thinking forced on or off — steering the catalog states as + // `reasoning_options`, not as two entries — and `coding-` is the discounted + // coding-agent route into a model listed plainly as well. parseModels drops + // them, so they raise no skip notice and no missing-model issue asking a human + // to fill them in. + const variants = [ + aihubmixModel({ model_id: "coding-glm-5.1-free" }), + aihubmixModel({ model_id: "grok-4-fast-reasoning" }), + aihubmixModel({ model_id: "grok-4-fast-non-reasoning" }), + // The prefix is dropped whether or not the endpoint backfilled the pointer: + // 8 of the 19 paid `coding-` routes carry no `variant_of` yet. + aihubmixModel({ model_id: "coding-minimax-m2.7", variant_of: "minimax-m2.7" }), + aihubmixModel({ model_id: "coding-glm-5.1" }), + ]; + const kept = aihubmixModel({ model_id: "glm-5.1" }); + const parsed = aihubmix.parseModels({ data: [...variants, kept] }); + expect(parsed.map((model) => model.model_id)).toEqual(["glm-5.1"]); + + // Anchored at the front, so a lab that uses the word mid-ID keeps its entry. + const midword = aihubmixModel({ model_id: "qwen3-coding-plus" }); + expect(aihubmix.parseModels({ data: [midword] }).map((model) => model.model_id)).toEqual([ + "qwen3-coding-plus", + ]); +}); + +test("reads a steering pair from the AIHubMix list rather than the word `reasoning`", () => { + // A lab can end a model's real name with the word: `AiHubmix-Phi-4-mini-reasoning` + // is Microsoft's, cataloged here as `providers/azure/models/phi-4-mini-reasoning.toml`. + // What makes the Grok routes steering is that the list carries both halves, so + // the pairing is the test — no allowlist to keep current as routes come and go. + const unpaired = [ + aihubmixModel({ model_id: "AiHubmix-Phi-4-mini-reasoning" }), + aihubmixModel({ model_id: "glm-5.1" }), + ]; + expect(aihubmix.parseModels({ data: unpaired }).map((model) => model.model_id)).toEqual([ + "AiHubmix-Phi-4-mini-reasoning", + "glm-5.1", + ]); + + // Its off half listed alongside it, and the same ID is steering after all. + const paired = [ + aihubmixModel({ model_id: "AiHubmix-Phi-4-mini-reasoning" }), + aihubmixModel({ model_id: "AiHubmix-Phi-4-mini-non-reasoning" }), + aihubmixModel({ model_id: "glm-5.1" }), + ]; + expect(aihubmix.parseModels({ data: paired }).map((model) => model.model_id)).toEqual(["glm-5.1"]); + + // The off half goes on its own name, paired or not: nothing else ends that way. + const offOnly = [ + aihubmixModel({ model_id: "grok-4-fast-non-reasoning" }), + aihubmixModel({ model_id: "glm-5.1" }), + ]; + expect(aihubmix.parseModels({ data: offOnly }).map((model) => model.model_id)).toEqual(["glm-5.1"]); +}); + +test("guards against AIHubMix protocol-wide effort domains without model evidence", () => { + const values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + for (const domain of [values, [...values].reverse(), [...values, "low"]]) { + const model = aihubmixModel({ + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: domain }, + { type: "budget_tokens" }, + ], + }); + expect(buildAihubmixModel(model, aihubmixAuthored, aihubmixLabIDs)?.reasoning_options).toEqual([ + { type: "toggle" }, + { type: "budget_tokens", min: undefined, max: undefined }, + ]); + const effortOnly = { ...model, reasoning_options: [{ type: "effort", values: domain }] }; + // An unverified list is not an affirmative declaration of no controls. + expect(() => buildAihubmixModel(effortOnly, undefined, aihubmixLabIDs)).toThrow(MissingReasoningOptionsError); + expect(buildAihubmixModel(effortOnly, aihubmixAuthored, aihubmixLabIDs)?.reasoning_options).toEqual( + aihubmixAuthored.reasoning_options, + ); + } + const native = aihubmixModel({ reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }] }); + expect(buildAihubmixModel(native, undefined, aihubmixLabIDs)?.reasoning_options).toEqual(native.reasoning_options); +}); + +test("honors an explicit empty AIHubMix reasoning control list on creates and updates", () => { + const model = aihubmixModel({ reasoning: true, reasoning_options: [] }); + expect(buildAihubmixModel(model, undefined, aihubmixLabIDs)?.reasoning_options).toEqual([]); + expect(buildAihubmixModel(model, aihubmixAuthored, aihubmixLabIDs)?.reasoning_options).toEqual([]); +}); + +test("does not interpret unrecognized AIHubMix controls as an explicit empty list", () => { + for (const options of [ + [{ type: "future-control" }], + [{ type: "effort", values: ["unknown-level"] }], + ]) { + const model = aihubmixModel({ reasoning: true, reasoning_options: options }); + expect(() => buildAihubmixModel(model, undefined, aihubmixLabIDs)).toThrow(MissingReasoningOptionsError); + expect(buildAihubmixModel(model, aihubmixAuthored, aihubmixLabIDs)?.reasoning_options).toEqual( + aihubmixAuthored.reasoning_options, + ); + } +}); + +test("keeps only the AIHubMix routes canon covers", () => { + // The model list says a route is reachable here; canon says what AIHubMix has + // verified about the model behind it. A catalog entry needs the second, and + // the routes canon leaves out are the ones the list describes worst — + // `Qwen/QwQ-32B` has no non-thinking mode yet carries no `reasoning` flag. + const listed = [ + aihubmixModel({ model_id: "glm-5.1" }), + aihubmixModel({ model_id: "Qwen/QwQ-32B" }), + aihubmixModel({ model_id: "qwen3-14b" }), + ]; + const covered = canonCoveredModels(listed, new Set(["glm-5.1"])); + expect(covered.map((model) => model.model_id)).toEqual(["glm-5.1"]); + + // Compared exactly: both registries are generated from the same gateway + // catalog, so folding case would only invent matches the gateway does not make. + expect(canonCoveredModels(listed, new Set(["qwen/qwq-32b"]))).toEqual([]); + + // Unset only when parseModels is driven directly, as the tests above do. + // fetchModels throws rather than returning with canon unfetched, so a real + // sync never reaches the filter without it. + expect(canonCoveredModels(listed, undefined)).toHaveLength(3); +}); + +test("clears a stale AIHubMix deprecation when the route goes back to active", () => { + // `retire_stage` rides on every route, so it is authoritative about retirement: + // a relay that comes back has to lose the mark or the file carries `deprecated` + // for good. It says nothing about `alpha`/`beta`, which survive untouched. + const revived = { ...aihubmixAuthored, status: "deprecated" as const }; + const active = aihubmixModel({ retire_stage: "active" }); + expect(buildAihubmixModel(active, revived, aihubmixLabIDs)?.status).toBeUndefined(); + + const beta = { ...aihubmixAuthored, status: "beta" as const }; + expect(buildAihubmixModel(active, beta, aihubmixLabIDs)?.status).toBe("beta"); + + // With no stage quoted at all the endpoint is silent, not contradicting the file. + expect(buildAihubmixModel(aihubmixModel(), revived, aihubmixLabIDs)?.status).toBe("deprecated"); +}); + +test("skips an AIHubMix reasoner that publishes no reasoning options", () => { + // Written through, the runner reads the missing field as "no caller control" and + // stamps `reasoning_options = []`, which AGENTS.md forbids using for uncertainty. + // `ModelMetadata` has no `reasoning_options`, so the lab entry cannot fill it in + // either — 13 creates in the current listing land here. + const silent = aihubmixModel({ reasoning: true, reasoning_options: null }); + expect(() => buildAihubmixModel(silent, undefined, aihubmixLabIDs)).toThrow( + MissingReasoningOptionsError, + ); + + // Same on the standalone path, where nothing is inherited at all. + const standalone = aihubmixModel({ + model_id: "hostown-reasoner", + model_name: "Hostown Reasoner", + vendor: null, + reasoning: true, + reasoning_options: null, + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + }); + expect(() => buildAihubmixModel(standalone, undefined, aihubmixLabIDs)).toThrow( + MissingReasoningOptionsError, + ); + + // An authored `[]` is a human saying the host exposes no control, not a gap, and + // a file that already states its options answers for the endpoint's silence. + const stated = { ...aihubmixAuthored, reasoning_options: [] }; + expect(buildAihubmixModel(silent, stated, aihubmixLabIDs)?.reasoning_options).toEqual([]); + expect( + buildAihubmixModel(silent, aihubmixAuthored, aihubmixLabIDs)?.reasoning_options, + ).toEqual(aihubmixAuthored.reasoning_options); + + // A route that publishes its controls is untouched by any of this. + expect(buildAihubmixModel(aihubmixModel(), undefined, aihubmixLabIDs)).toBeDefined(); +}); + +/** The base fixture only names Google and OpenAI labs; these reach the other two faces. */ +const interleavedLabIDs = new Map([ + ...aihubmixLabIDs, + ["anthropic/claude-opus-4-8", "anthropic/claude-opus-4-8"], + ["moonshotai/kimi-k2.5", "moonshotai/Kimi-K2.5"], +]); + +test("routes an AIHubMix model to the protocol its provider package actually speaks", () => { + // `@aihubmix/ai-sdk-provider` picks the wire protocol from the model ID rather + // than once for the provider, so a catalog entry describes whichever of the + // four protocols that ID lands on. + expect(wireProtocol("claude-opus-4-8-think")).toBe("anthropic.messages"); + expect(wireProtocol("gemini-3.1-flash-lite")).toBe("google.gemini"); + expect(wireProtocol("imagen-4")).toBe("google.gemini"); + // The provider sends these two Gemini routing modes back down the + // OpenAI-compatible path, so the Gemini prefix is not the whole rule. + expect(wireProtocol("gemini-3.1-flash-lite-nothink")).toBe("openai.chat_completions"); + expect(wireProtocol("gemini-3.1-pro-search")).toBe("openai.chat_completions"); + expect(wireProtocol("deepseek-v4-pro-0813")).toBe("openai.chat_completions"); + // The Responses face is reachable only by asking for it, so it is never the + // default a catalog entry describes — `gpt-5-codex` still routes to chat. + expect(wireProtocol("gpt-5-codex")).toBe("openai.chat_completions"); +}); + +test("reads the AIHubMix reasoning side channel on the protocol the model is spoken over", () => { + const named = buildAihubmixModel( + aihubmixModel({ + model_id: "gpt-5.5", + vendor: "openai", + interleaved: { "openai.chat_completions": { field: "reasoning_content" } }, + }), + undefined, + aihubmixLabIDs, + ); + expect(named?.interleaved).toEqual({ field: "reasoning_content" }); + + // The carrier is a property of the protocol shape, so a face this model is not + // spoken over says nothing about the one it is: `claude-*` goes to + // `/v1/messages`, where the channel exists but has no settled field name, and + // reading the chat face here would publish a carrier that path never uses. + const claude = buildAihubmixModel( + aihubmixModel({ + model_id: "claude-opus-4-8", + vendor: "anthropic", + interleaved: { + "anthropic.messages": true, + "openai.chat_completions": { field: "reasoning_content" }, + }, + }), + { + ...aihubmixAuthored, + id: "claude-opus-4-8", + base_model: "anthropic/claude-opus-4-8", + interleaved: { field: "reasoning_content" }, + }, + interleavedLabIDs, + ); + expect(claude?.interleaved).toBe(true); +}); + +test("leaves an authored AIHubMix side channel standing where the endpoint is silent", () => { + // Absence is unknown rather than denial — `kimi-k2.5` is in canon with no + // reasoning-content entry researched — so it gets the same reading the missing + // `reasoning` flag gets and the file answers for it. + const authored: ExistingModel = { + ...aihubmixAuthored, + id: "kimi-k2.5", + base_model: "moonshotai/Kimi-K2.5", + interleaved: { field: "reasoning_content" }, + }; + const silent = buildAihubmixModel( + aihubmixModel({ model_id: "kimi-k2.5", vendor: "moonshot" }), + authored, + interleavedLabIDs, + ); + expect(silent?.interleaved).toEqual({ field: "reasoning_content" }); + + // A face the endpoint does describe, just not this model's, is no less silent + // about this model. + const otherFace = buildAihubmixModel( + aihubmixModel({ + model_id: "kimi-k2.5", + vendor: "moonshot", + interleaved: { "anthropic.messages": true }, + }), + authored, + interleavedLabIDs, + ); + expect(otherFace?.interleaved).toEqual({ field: "reasoning_content" }); + + // With nothing authored either, the file says nothing rather than saying no. + const unknown = buildAihubmixModel( + aihubmixModel({ model_id: "kimi-k2.5", vendor: "moonshot" }), + undefined, + interleavedLabIDs, + ); + expect(unknown).toBeDefined(); + expect(unknown?.interleaved).toBeUndefined(); +}); + +test("keeps an AIHubMix side channel the catalog has no name for", () => { + // The catalog names two carriers. A third would fail its schema, and dropping + // the channel to avoid that would state the model has none — so the channel is + // published without the name. + const model = buildAihubmixModel( + aihubmixModel({ + model_id: "gpt-5.5", + vendor: "openai", + interleaved: { "openai.chat_completions": { field: "thinking_text" } }, + }), + undefined, + aihubmixLabIDs, + ); + expect(model?.interleaved).toBe(true); + + // `reasoning_details` is the other name it does have. + const details = buildAihubmixModel( + aihubmixModel({ + model_id: "gpt-5.5", + vendor: "openai", + interleaved: { "openai.chat_completions": { field: "reasoning_details" } }, + }), + undefined, + aihubmixLabIDs, + ); + expect(details?.interleaved).toEqual({ field: "reasoning_details" }); +}); + +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/coding-glm-5.1-free.toml b/providers/aihubmix/models/coding-glm-5.1-free.toml deleted file mode 100644 index c44e059e3ad..00000000000 --- a/providers/aihubmix/models/coding-glm-5.1-free.toml +++ /dev/null @@ -1,27 +0,0 @@ -name = "Coding GLM 5.1 (free)" -description = "Flagship GLM model for hybrid reasoning, coding, and agentic engineering" -family = "glm-free" -release_date = "2026-04-11" -last_updated = "2026-04-11" -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 -output = 0 - -[limit] -context = 200_000 -output = 128_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/coding-glm-5.1.toml b/providers/aihubmix/models/coding-glm-5.1.toml deleted file mode 100644 index ad67b0aeab4..00000000000 --- a/providers/aihubmix/models/coding-glm-5.1.toml +++ /dev/null @@ -1,28 +0,0 @@ -name = "Coding GLM 5.1" -description = "Flagship GLM model for hybrid reasoning, coding, and agentic engineering" -family = "glm" -release_date = "2026-04-11" -last_updated = "2026-04-11" -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.06 -output = 0.22 -cache_read = 0.013 - -[limit] -context = 200_000 -output = 128_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/coding-minimax-m2.7-free.toml b/providers/aihubmix/models/coding-minimax-m2.7-free.toml deleted file mode 100644 index de8f6b2b766..00000000000 --- a/providers/aihubmix/models/coding-minimax-m2.7-free.toml +++ /dev/null @@ -1,27 +0,0 @@ -name = "Coding MiniMax M2.7 (Free)" -description = "MiniMax model for chat, coding, office work, and agentic tasks" -family = "minimax-free" -release_date = "2026-03-18" -last_updated = "2026-03-18" -attachment = false -reasoning = true -reasoning_options = [] -temperature = true -tool_call = true -structured_output = true -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0 -output = 0 - -[limit] -context = 204_800 -output = 128_100 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/coding-minimax-m2.7-highspeed.toml b/providers/aihubmix/models/coding-minimax-m2.7-highspeed.toml deleted file mode 100644 index 24b69d7fa7b..00000000000 --- a/providers/aihubmix/models/coding-minimax-m2.7-highspeed.toml +++ /dev/null @@ -1,27 +0,0 @@ -name = "Coding MiniMax M2.7 Highspeed" -description = "High-speed MiniMax model for low-latency coding and agent workflows" -family = "minimax" -release_date = "2026-03-18" -last_updated = "2026-03-18" -attachment = false -reasoning = true -reasoning_options = [] -temperature = true -tool_call = true -structured_output = true -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.2 -output = 0.2 - -[limit] -context = 204_800 -output = 128_100 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/coding-minimax-m2.7.toml b/providers/aihubmix/models/coding-minimax-m2.7.toml deleted file mode 100644 index c17cc1f4e4c..00000000000 --- a/providers/aihubmix/models/coding-minimax-m2.7.toml +++ /dev/null @@ -1,27 +0,0 @@ -name = "Coding MiniMax M2.7" -description = "MiniMax model for chat, coding, office work, and agentic tasks" -family = "minimax" -release_date = "2026-03-18" -last_updated = "2026-03-18" -attachment = false -reasoning = true -reasoning_options = [] -temperature = true -tool_call = true -structured_output = true -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.2 -output = 0.2 - -[limit] -context = 204_800 -output = 128_100 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/coding-xiaomi-mimo-v2.5-pro.toml b/providers/aihubmix/models/coding-xiaomi-mimo-v2.5-pro.toml deleted file mode 100644 index a106666530c..00000000000 --- a/providers/aihubmix/models/coding-xiaomi-mimo-v2.5-pro.toml +++ /dev/null @@ -1,19 +0,0 @@ -base_model = "xiaomi/mimo-v2.5-pro" -reasoning_options = [{ type = "toggle" }] -name = "Coding Xiaomi MiMo-V2.5-Pro" -family = "mimo-v2.5-pro" -last_updated = "2026-05-13" - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.2 -output = 0.6 -cache_read = 0.04 - -[[cost.tiers]] -tier = { type = "context", size = 256_000 } -input = 0.4 -output = 1.2 -cache_read = 0.08 diff --git a/providers/aihubmix/models/coding-xiaomi-mimo-v2.5.toml b/providers/aihubmix/models/coding-xiaomi-mimo-v2.5.toml deleted file mode 100644 index fa49278c22d..00000000000 --- a/providers/aihubmix/models/coding-xiaomi-mimo-v2.5.toml +++ /dev/null @@ -1,19 +0,0 @@ -base_model = "xiaomi/mimo-v2.5" -reasoning_options = [{ type = "toggle" }] -name = "Coding Xiaomi MiMo-V2.5" -family = "mimo-v2.5" -last_updated = "2026-05-13" - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.08 -output = 0.4 -cache_read = 0.016 - -[[cost.tiers]] -tier = { type = "context", size = 256_000 } -input = 0.16 -output = 0.8 -cache_read = 0.032 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/deepseek-v4-pro-0813.toml b/providers/aihubmix/models/deepseek-v4-pro-0813.toml index fbe29803f4c..221ff9460ef 100644 --- a/providers/aihubmix/models/deepseek-v4-pro-0813.toml +++ b/providers/aihubmix/models/deepseek-v4-pro-0813.toml @@ -1,12 +1,35 @@ -# Toggle: enable_thinking = true|false -# Effort: reasoning_effort = high|max -# AIHubMix effort levels returned HTTP 200 (validated 2026-08-31T04:17:24Z). +# Toggle: +# $.enable_thinking = true|false on the OpenAI-compatible /v1/chat/completions path (verified live 2026-09-11); +# $.thinking.type = "enabled"|"disabled"|"adaptive" on /v1/messages; $.generationConfig.thinkingConfig on the Gemini path. +# Effort: low|high|max +# $.reasoning_effort on /v1/chat/completions (alias $.reasoning.effort, which is also the Responses field); +# $.output_config.effort on /v1/messages, subject to model support. +# https://docs.aihubmix.com/cn/api/unified-inference +# Model-specific basis (accessed 2026-09-20), not a host-only widening: +# https://api-docs.deepseek.com/news/news260813/ +# The 2026-08-13 V4-Pro GA announcement explicitly introduces low/high/max. +# https://api-docs.deepseek.com/quick_start/pricing/ +# The pricing table maps API model deepseek-v4-pro to DeepSeek-V4-Pro-0813. +# https://api-docs.deepseek.com/guides/thinking_mode/ +# The mapping is low -> low, medium/high/xhigh -> high, max -> max. +# https://aihubmix.com/api/v1/models?type=llm +# This route publishes low/high/max too. The first-party catalog entry still +# cites 2026-06-25, before the GA announcement; it is not the current baseline. +# Host probes on 2026-09-15 accepted low/high/max but returned 522/564/510 +# reasoning tokens on one prompt. They do not establish a depth distinction; +# the three-level declaration is supported by the model-specific release docs. base_model = "deepseek/deepseek-v4-pro-0813" -reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["high", "max"] }] [interleaved] field = "reasoning_content" +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "effort" +values = ["low", "high", "max"] + [cost] input = 0.6918 output = 2.0754 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/qwen3.7-flash.toml b/providers/aihubmix/models/qwen3.7-flash.toml index 3f34f7fd74f..1c39c77be95 100644 --- a/providers/aihubmix/models/qwen3.7-flash.toml +++ b/providers/aihubmix/models/qwen3.7-flash.toml @@ -1,22 +1,39 @@ -# Toggle: enable_thinking = true|false -# Budget: thinking_budget = integer reasoning tokens +# Toggle: +# $.enable_thinking = true|false on the OpenAI-compatible /v1/chat/completions path (verified live 2026-09-11); +# $.thinking.type = "enabled"|"disabled"|"adaptive" on /v1/messages; $.generationConfig.thinkingConfig on the Gemini path. +# Budget: +# integer $.reasoning.max_tokens on /v1/chat/completions; $.thinking.budget_tokens >= 1024 on /v1/messages; +# integer $.generationConfig.thinkingConfig.thinkingBudget on the Gemini path (-1 dynamic, 0 off where supported); +# the Responses path carries effort but has no reasoning-token budget field. +# https://docs.aihubmix.com/cn/api/unified-inference base_model = "alibaba/qwen3.7-flash" -attachment = false -reasoning_options = [{ type = "toggle" }, { type = "budget_tokens", max = 262_144 }] [interleaved] field = "reasoning_content" +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "budget_tokens" +max = 262_144 + [cost] input = 0.0282 output = 0.1128 cache_read = 0.00564 cache_write = 0.03525 -[limit] -context = 991_000 -output = 64_000 +[[cost.tiers]] +tier = { type = "context", size = 32_000 } +input = 0.0845 +output = 0.338 +cache_read = 0.0169 +cache_write = 0.105625 -[modalities] -input = ["text"] -output = ["text"] +[[cost.tiers]] +tier = { type = "context", size = 256_000 } +input = 0.169 +output = 0.676 +cache_read = 0.0338 +cache_write = 0.21125 diff --git a/providers/aihubmix/models/xiaomi-mimo-v2.5-free.toml b/providers/aihubmix/models/xiaomi-mimo-v2.5-free.toml deleted file mode 100644 index 5d9852914e9..00000000000 --- a/providers/aihubmix/models/xiaomi-mimo-v2.5-free.toml +++ /dev/null @@ -1,13 +0,0 @@ -base_model = "xiaomi/mimo-v2.5" -reasoning_options = [{ type = "toggle" }] -name = "Xiaomi MiMo-V2.5 (free)" -family = "mimo-v2.5" -last_updated = "2026-05-13" - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0 -output = 0 -cache_read = 0 diff --git a/providers/aihubmix/models/xiaomi-mimo-v2.5-pro-free.toml b/providers/aihubmix/models/xiaomi-mimo-v2.5-pro-free.toml deleted file mode 100644 index a6277d9bb9c..00000000000 --- a/providers/aihubmix/models/xiaomi-mimo-v2.5-pro-free.toml +++ /dev/null @@ -1,13 +0,0 @@ -base_model = "xiaomi/mimo-v2.5-pro" -reasoning_options = [{ type = "toggle" }] -name = "Xiaomi MiMo-V2.5-Pro (free)" -family = "mimo-v2.5-pro" -last_updated = "2026-05-13" - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0 -output = 0 -cache_read = 0 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 f2bb9a5130e..e5208e436c1 100644 --- a/sync.md +++ b/sync.md @@ -257,6 +257,44 @@ 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. The bare shape is what `AGENTS.md` itself authors (its Qwen3.5 Plus example writes `{ type = "budget_tokens" }` with the wire path in the header comment) and what 256 of the 354 budget entries in `providers/` already use. Bounds carried by a first-party entry for the same model describe that host's API and do not transfer: AIHubMix's Anthropic-compatible path rejects a manual `budget_tokens` on the Opus tier, which the affected files record in their headers. +- 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. +- The endpoint supplies effort domains, checked against model-specific lab documentation and peers. The known seven-value protocol fallback is conservatively omitted until the payload can distinguish verified model domains from protocol defaults. This is an evidence guard, not proof that seven native levels are impossible. Surviving toggle/budget controls remain; an effort-only unknown result retains authored controls or skips a new reasoner, never inventing `[]`. Incorrect source claims must still be corrected at the source. +- An explicit `reasoning_options = []` replaces existing controls. Missing/null options or a nonempty list with no translatable controls remain unknown: they preserve authored options, or skip a new reasoner. The current canon exporter omits unsupported/unverified controls rather than publishing an empty list, so absence must not be interpreted as a declaration of no caller control. +- `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 route the endpoint reports as reasoning while publishing no `reasoning_options` is skipped, not written: left through, the runner reads the missing field as "no caller control" and stamps `[]`, which `AGENTS.md` forbids using for uncertainty, and `ModelMetadata` has no `reasoning_options` field so the lab entry cannot supply them either. Thirteen creates in the current listing land here and surface as missing models; an authored `[]` already on a file is a human stating the host exposes no control and is carried through untouched. +- `retire_stage` rides on every route (407 active, 2 deprecated in the current listing), so it is read as authoritative about retirement and only about retirement: a route that comes back to `active` clears a `deprecated` status the file still carries, while `alpha` and `beta` survive because the endpoint says nothing about either. +- 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; each is absent from the list while the ID it routes to is on it, so every one of the eight is replaced by a catalog entry the first sync writes rather than leaving a gap. +- 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 reported through `missingNotice` naming both readings — confirm the rotation, or drop the file if it is a hidden channel alias. The notice is where it stops: `issueModels` is built from missing remotes, skipped remotes and missing reasoning options, and a retained local path is in none of the three, so a rotated-out file opens no `[missing-model]` issue and the run's notices are the only place it surfaces. +- `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`.