From 6b9e874d0b09a6bb340c7896cd653000a7ed0ef2 Mon Sep 17 00:00:00 2001 From: chenxue Date: Tue, 15 Sep 2026 12:26:55 +0800 Subject: [PATCH 01/13] feat(aihubmix): interface-driven sync adapter for the AIHubMix gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sync provider for AIHubMix, an aggregator relaying ~409 LLM routes from 26 labs. Everything is read from the public catalog endpoint — no credentials, no per-model tables in the adapter. GET https://aihubmix.com/api/v1/models?type=llm Relay → lab resolution comes from the endpoint itself: `vendor` names the lab, `variant_of` names the route this entry is a variant of, and lookups walk that chain nearest-first. The hand-maintained `developer_id → lab` table and the prefix/suffix strip list are gone; what remains is `VENDOR_LABS`, four entries mapping namespaces the two registries spell differently. Shape rules, not per-model judgement: - `max_output: 0` and `max_output >= context_length` are read as "unknown" and inherited from the base model rather than published. - A stated limit below an accepted one but at or above 1000³/1024³ is a decimal restatement of a binary window, not a host cap, and resolves to the accepted value. - Effort spellings outside `ReasoningEffortValue` are mapped or dropped; the endpoint's extra `default` key inside `reasoning_options[]` is not part of `ReasoningOption` and is dropped. - A model whose lab entry says `reasoning = true` but which publishes no controls is skipped, never stamped with `reasoning_options = []` — an empty array means "confirmed no caller control", not "not researched". - A relay with a named vendor belongs on `base_model`; it is never authored as a full standalone entry. `deleteMissing` is false and `trackMissingModels` is true: AIHubMix rotates routes in and out, and a transient absence should not delete a catalog entry. Unresolvable relays open deduped `[missing-model]` issues instead of notices nobody acts on. Framework side: `formatToml` emits `input_audio` / `output_audio` inside cost tiers (both are already in `Cost`, but were dropped when writing tiered pricing), and `issueModels` also collects skipped remotes when a provider tracks missing models without skipping creates. Rebased onto dev and squashed from 22 commits; the review-round history lives in the PR thread. Two conflicts resolved by union rather than by taking a side: `issueModels` keeps dev's `missingRemote` + dedupe alongside this branch's `trackMissingModels` condition, and the missing-model issue body keeps dev's wording with the `base_model` case appended. Verification: `bun run validate` exit 0; `bun test packages/core/test/sync.test.ts` 221 pass / 3 fail, the same 3 failing on a clean `origin/dev` checkout (Hyper reasoning inheritance, DeepInfra modalities, and an LLM Gateway case-variant assertion that only fails on case-insensitive filesystems). Co-Authored-By: Claude Opus 5 --- packages/core/src/schema.ts | 22 +- packages/core/src/sync/index.ts | 28 +- packages/core/src/sync/missing-issues.ts | 2 +- packages/core/src/sync/providers/aihubmix.ts | 741 ++++++++++++++++ .../core/src/sync/providers/openrouter.ts | 2 +- packages/core/test/sync.test.ts | 820 ++++++++++++++++++ .../models/alicloud-deepseek-v4-flash.toml | 29 - .../models/alicloud-deepseek-v4-pro.toml | 29 - .../aihubmix/models/alicloud-glm-5.1.toml | 29 - .../aihubmix/models/claude-opus-4-6.toml | 2 +- .../aihubmix/models/claude-opus-4-7.toml | 2 +- .../models/claude-opus-4-8-think.toml | 2 +- .../aihubmix/models/claude-opus-4-8.toml | 2 +- .../aihubmix/models/claude-sonnet-4-6.toml | 2 +- .../models/deep-deepseek-v4-flash.toml | 29 - .../aihubmix/models/deep-deepseek-v4-pro.toml | 29 - .../aihubmix/models/gemini-2.5-flash.toml | 2 +- providers/aihubmix/models/gemini-2.5-pro.toml | 2 +- .../aihubmix/models/gemini-3.7-flash.toml | 4 +- .../aihubmix/models/xiaomi-mimo-v2.5-pro.toml | 19 - .../aihubmix/models/xiaomi-mimo-v2.5.toml | 19 - providers/aihubmix/models/zai-glm-5.1.toml | 28 - sync.md | 36 + 23 files changed, 1652 insertions(+), 228 deletions(-) create mode 100644 packages/core/src/sync/providers/aihubmix.ts delete mode 100644 providers/aihubmix/models/alicloud-deepseek-v4-flash.toml delete mode 100644 providers/aihubmix/models/alicloud-deepseek-v4-pro.toml delete mode 100644 providers/aihubmix/models/alicloud-glm-5.1.toml delete mode 100644 providers/aihubmix/models/deep-deepseek-v4-flash.toml delete mode 100644 providers/aihubmix/models/deep-deepseek-v4-pro.toml delete mode 100644 providers/aihubmix/models/xiaomi-mimo-v2.5-pro.toml delete mode 100644 providers/aihubmix/models/xiaomi-mimo-v2.5.toml delete mode 100644 providers/aihubmix/models/zai-glm-5.1.toml 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 39d87a5817e..d8e1a682106 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { AuthoredModel, AuthoredModelShape, ModelMetadata } from "../schema.js"; import { openMissingModelIssues } from "./missing-issues.js"; import { MissingReasoningOptionsError } from "./missing-reasoning-options.js"; +import { aihubmix } from "./providers/aihubmix.js"; import { ambient } from "./providers/ambient.js"; import { anthropic } from "./providers/anthropic.js"; import { baseten } from "./providers/baseten.js"; @@ -82,7 +83,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; @@ -111,6 +117,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; @@ -138,6 +150,7 @@ export interface SyncResult { } export const providers: { + aihubmix: SyncProvider; ambient: SyncProvider; anthropic: SyncProvider; baseten: SyncProvider; @@ -175,6 +188,7 @@ export const providers: { wandb: SyncProvider; xai: SyncProvider; } = { + aihubmix, ambient, anthropic, baseten, @@ -215,6 +229,7 @@ export const providers: { export const groups = { aggregators: [ + "aihubmix", "crossmodel", "edenai", "empiriolabs", @@ -277,6 +292,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; @@ -505,7 +523,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 ( @@ -1075,6 +1093,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..16551ef4c59 --- /dev/null +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -0,0 +1,741 @@ +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"; + +/** AIHubMix quotes USD per 1M tokens directly, matching the catalog unit. */ +const Pricing = z + .object({ + input: z.number().nullish(), + output: z.number().nullish(), + cache_read: z.number().nullish(), + cache_write: z.number().nullish(), + tiers: z + .array( + z + .object({ + tier: z.object({ + type: z.string().nullish(), + size: z.number(), + }), + input: z.number().nullish(), + output: z.number().nullish(), + cache_read: z.number().nullish(), + cache_write: z.number().nullish(), + }) + .passthrough(), + ) + .nullish(), + }) + .passthrough(); + +/** + * AIHubMix ships `default` alongside `type`/`values`, which the catalog's strict + * ReasoningOption rejects, so the extra key is dropped during translation. + */ +const ReasoningOption = z + .object({ + type: z.string(), + values: z.array(z.string()).nullish(), + min: z.number().nullish(), + max: z.number().nullish(), + }) + .passthrough(); + +export const AihubmixModel = z + .object({ + model_id: z.string().min(1), + model_name: z.string().nullish(), + // The upstream lab that built the model, and the AIHubMix ID this entry is a + // routing variant of. Both are served by the catalog itself, so neither the + // lab nor the variant relationship is inferred from the relay ID here. + vendor: z.string().nullish(), + variant_of: z.string().nullish(), + desc: z.string().nullish(), + pricing: Pricing.nullish(), + features: z.string().nullish(), + input_modalities: z.string().nullish(), + output_modalities: z.string().nullish(), + context_length: z.number().nullish(), + max_output: z.number().nullish(), + reasoning: z.boolean().nullish(), + reasoning_options: z.array(ReasoningOption).nullish(), + tool_call: z.boolean().nullish(), + release_date: z.string().nullish(), + last_updated: z.string().nullish(), + // `knowledge` is not served yet; read opportunistically so it lands without a + // code change once AIHubMix adds it. + knowledge: z.string().nullish(), + open_weights: z.boolean().nullish(), + retire_stage: z.string().nullish(), + }) + .passthrough(); + +export const AihubmixResponse = z + .object({ + success: z.boolean().nullish(), + data: z.array(AihubmixModel).min(1), + }) + .passthrough(); + +export type AihubmixModel = z.infer; + +/** + * AIHubMix relays upstream models under its own IDs, so a relay is factored onto + * the lab metadata it serves whenever that metadata exists — the relay then only + * records what it actually changes (price, reasoning controls, limits). + * + * The catalog answers both halves of that lookup itself: `vendor` names the lab + * that built the model, and `variant_of` names the AIHubMix ID this entry is a + * routing variant of. Relay IDs carry prefixes (`coding-`, `alicloud-`) and + * suffixes (`-free`, `-think`) that are AIHubMix routing modes rather than + * distinct upstream models, and `variant_of` states that relationship instead of + * it being guessed from the string — which also resolves the relays no amount of + * string surgery reaches, such as `ox-alpha` onto `zhipuai/glm-5.3-flash`. + */ +const VENDOR_LABS: Record = { + // The two registries spell four labs differently. This maps namespaces, not + // models: no entry here decides what any model is or which lab built it. + zhipu: "zhipuai", + moonshot: "moonshotai", + bytedance: "bytedance-seed", + "meituan-longcat": "meituan", +}; + +// 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. +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); + +type LabMetadataIDs = Map; +/** Every listed relay by lowercased ID, so `variant_of` can be followed. */ +type RelayCatalog = Map; + +let labMetadataIDs: LabMetadataIDs | undefined; +let relayCatalog: RelayCatalog | undefined; + +/** + * The catalog rejects a `base_model` that resolves to nothing, so relays are + * only factored onto metadata that is actually present on disk. + */ +async function readLabMetadataIDs(modelsDir: string) { + const metadataDir = path.join(path.dirname(path.dirname(path.dirname(modelsDir))), "models"); + const ids = new Map(); + for await (const file of new Bun.Glob("**/*.toml").scan({ cwd: metadataDir, followSymlinks: true })) { + const id = file.split(path.sep).join("/").slice(0, -5); + // AIHubMix lowercases every relay ID while labs keep their own casing + // (`minimax-m2` against `minimax/MiniMax-M2`), so lookups are case-folded. + ids.set(id.toLowerCase(), id); + } + return ids; +} + +// The same off state is reachable from whichever dialect the caller speaks, so +// an off switch has no single wire path. Name one per protocol. +const DIALECT_PATHS = + '# $.enable_thinking = true|false on the OpenAI-compatible /v1/chat/completions path (verified live 2026-09-11);\n' + + '# $.thinking.type = "enabled"|"disabled"|"adaptive" on /v1/messages; $.generationConfig.thinkingConfig on the Gemini path.\n'; +// Cited on its own line, because a human wrote this exact line by hand in +// `gemini-3.7-flash.toml` — it is a source for the whole gateway, not a claim +// about one model's options, and so it is carried through as a note rather than +// being owned by the block. The two lines above are only ever this adapter's own. +const DIALECT_SOURCE = "# https://docs.aihubmix.com/cn/api/unified-inference\n"; +const DIALECTS = DIALECT_PATHS + DIALECT_SOURCE; +const TOGGLE_HEADER = "# Toggle:\n" + DIALECTS; +// Where the catalog spells the off state as `effort = none`, the other dialects +// still reach it, and the folded toggle is the only place that was recorded. +const FOLDED_HEADER = "# Off is effort=none; graded levels — no toggle. The same off elsewhere:\n" + DIALECTS; + +export const aihubmix = { + id: "aihubmix", + name: "AIHubMix", + modelsDir: "providers/aihubmix/models", + trackMissingModels: true, + // A rewrite keeps whatever leading comment the file already had, so a stale + // wire path would outlive the options it documents — and a model whose toggle + // folds into `effort = none` would keep advertising a toggle. translateModel + // re-derives the header from the response, so let it own the block. + authoritativeHeaders: true, + // The listing is AIHubMix's main model list, and a route rotates out of it for a + // spell without being retired, so a local file absent from one response is + // retained rather than deleted. It is not a licence to keep anything: a hidden + // channel alias (`zai-glm-5.1`, which the gateway answers by routing to the + // listed `glm-5.1`) is deliberately outside that list and does not belong here. + deleteMissing: false, + sourceID(model) { + return model.retire_stage === "deprecated" ? undefined : model.model_id; + }, + missingNotice(paths) { + return paths.map( + (file) => + `AIHubMix does not list ${file} in its main model list; confirm the route rotated out for a spell, or drop the file if it is a hidden channel alias of a model already in the catalog.`, + ); + }, + skippedNotice(ids) { + return ids.map( + (id) => + `AIHubMix lists ${id} but it cannot be written yet. If it names a vendor, add the lab model under \`models/${"/"}.toml\` and the relay factors onto it automatically; if it names none, the response is missing the release_date/open_weights/limits a standalone entry has to carry.`, + ); + }, + async fetchModels() { + labMetadataIDs = await readLabMetadataIDs(this.modelsDir); + const response = await fetch(process.env.AIHUBMIX_MODELS_URL ?? API_ENDPOINT); + if (!response.ok) { + throw new Error(`AIHubMix models request failed: ${response.status} ${response.statusText}`); + } + return response.json(); + }, + parseModels(raw) { + const data = AihubmixResponse.parse(raw).data; + // `cc-minimax-m2` and `cc-MiniMax-M2` are the same route under two spellings + // and would claim filenames that differ only in case. Keep the last entry + // whole rather than mixing two records. + relayCatalog = new Map(data.map((model) => [model.model_id.toLowerCase(), model])); + return [...relayCatalog.values()]; + }, + translateModel(model, context) { + const existing = context.existing(model.model_id); + const built = buildAihubmixModel(model, existing, labMetadataIDs, relayCatalog); + if (built === undefined) return undefined; + return { + id: model.model_id, + model: built, + // A rewrite drops whatever header the file carried, so re-author it here + // or the wire path is lost on the first sync that touches the model. + header: composeHeader(context.header?.(model.model_id), reasoningHeader(model, built)), + }; + }, +} satisfies SyncProvider; + +export function buildAihubmixModel( + model: AihubmixModel, + existing: ExistingModel | undefined, + labIDs: LabMetadataIDs | undefined = labMetadataIDs, + catalog: RelayCatalog | undefined = relayCatalog, +): SyncedModel | undefined { + const base = existing?.base_model ?? resolveBaseModel(model, labIDs, catalog); + // `dev` carries 77 aihubmix files, so most of the catalog arrives as a create + // with no file to union against. The lab entry the relay factors onto is the + // only baseline those have, and 14 creates in the current listing would + // otherwise write a narrowing override onto it (`gpt-4o` losing pdf, + // `qwen3.5-27b` losing audio). + const lab = labMetadata(base); + const baseModalities = lab?.modalities; + const input = modalities(model.input_modalities, [ + ...(existing?.modalities?.input ?? []), + ...(baseModalities?.input ?? []), + ]); + const output = modalities(model.output_modalities, [ + ...(existing?.modalities?.output ?? []), + ...(baseModalities?.output ?? []), + ]); + const features = new Set((model.features ?? "").split(",").map((value) => value.trim())); + // The endpoint never sends `false`. 107 of 408 routes omit `reasoning` and 100 + // omit `tool_call` rather than denying them, and no route sends `false` at + // all, so a missing flag means unknown. Reading it as `false` would write an + // override that turns off a reasoner or tool use the lab declares. + const reasoning = model.reasoning ?? existing?.reasoning; + const toolCall = model.tool_call ?? existing?.tool_call; + const structuredOutput = features.has("structured_outputs") || existing?.structured_output; + const name = model.model_name ?? existing?.name; + const context = tokens(model.context_length); + // Two ways AIHubMix signals an unknown output ceiling: backfilling it from + // `context_length` (36 of 408 models quote the two as equal, which would leave + // no room for the prompt) and quoting a value above the window. Both are read + // as absent, the same as the 0 the endpoint also uses. + const quoted = tokens(model.max_output); + const maxOutput = + context !== undefined && quoted !== undefined && quoted >= context ? undefined : quoted; + // Same class of bug as the modalities: the endpoint restates windows in decimal + // (8 glm routes quote 204800 as 200000) and quotes conservative output ceilings, + // and treating those as owned fields writes a narrowing override onto a limit the + // lab entry already states correctly. A restatement resolves to the accepted + // value; a genuine cap the host imposes still lands. + const limit = { + context: resolveLimit(context, existing?.limit?.context, lab?.limit?.context), + // The endpoint models no input cap, so an authored one is the only record of it. + input: existing?.limit?.input, + output: resolveLimit(maxOutput, existing?.limit?.output, lab?.limit?.output), + }; + const shared = { + attachment: input.some((value) => value !== "text"), + reasoning, + reasoning_options: reasoningOptions(model, existing) ?? existing?.reasoning_options, + tool_call: toolCall, + structured_output: structuredOutput, + // AIHubMix serves no temperature, interleaved, fast-mode or request-shape + // surface; all four exist on the file and nowhere else, so keep them. + temperature: existing?.temperature, + interleaved: existing?.interleaved, + experimental: existing?.experimental, + provider: existing?.provider, + status: 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; +} + +function reasoningOptions( + model: AihubmixModel, + existing?: ExistingModel, +): SyncedFullModel["reasoning_options"] { + if (model.reasoning_options == null) return undefined; + const options = model.reasoning_options.flatMap((option) => { + if (option.type === "toggle") return [{ type: option.type }]; + // The endpoint states that a budget exists but not its bounds, so the bounds a + // file already carries are the only record of them and are carried through. + // There is no second baseline to fall back on: `ModelMetadata` has no + // `reasoning_options` field, so a bare budget written here cannot be shadowing + // a range stated on the lab entry — that range can only live on a provider file, + // and a budget range is a property of the host's API, not of the model. + if (option.type === "budget_tokens") { + const authored = existing?.reasoning_options?.find((entry) => entry.type === "budget_tokens"); + const min = option.min ?? (authored?.type === "budget_tokens" ? authored.min : undefined); + const max = option.max ?? (authored?.type === "budget_tokens" ? authored.max : undefined); + return [{ type: "budget_tokens" as const, min: min ?? undefined, max: max ?? undefined }]; + } + if (option.type !== "effort") return []; + const values = (option.values ?? []) + .map((value) => EFFORT_ALIASES[value] ?? value) + .filter((value) => EFFORT_VALUES.has(value)); + return values.length > 0 ? [{ type: "effort" as const, values }] : []; + }); + // AIHubMix accepts whichever off switch the caller's SDK speaks and maps it, + // so a model can publish both a toggle and `effort = none`. The catalog spells + // that one way: graded effort carrying `none` stands alone, and the dialects + // that reach the same off state are named in the file header instead. + const folded = foldsToggle(options) ? options.filter((option) => option.type !== "toggle") : options; + return folded.length > 0 ? (folded as SyncedFullModel["reasoning_options"]) : undefined; +} + +function foldsToggle(options: { type: string; values?: string[] }[]) { + return ( + options.some((option) => option.type === "toggle") && + options.some((option) => option.type === "effort" && (option.values ?? []).includes("none")) + ); +} + +/** + * The display name to record on a factored entry. `inheritedOverride` already + * drops a name the lab entry states identically, but the two registries punctuate + * the same name differently — the endpoint writes `GLM 5.3` where the lab writes + * `GLM-5.3` — and taking the endpoint's spelling as an override on 78 entries + * would fight the lab's own naming across the catalog for no gain. + * + * So the endpoint's label is recorded only where the relay is not simply that lab + * model under another punctuation: its ID, normalised, differs from the base + * model's slug. That is the same test `shouldPreserveFactoredName` applies for + * OpenRouter, and it is what keeps `coding-glm-4.6-free` reading "Coding GLM 4.6 + * (free)" instead of inheriting a bare "GLM-4.6" it shares with two other routes. + * A relay that *is* the lab model keeps deferring to the lab's spelling, including + * where the lab renamed it (`gemini-3-pro-image` shows as "Nano Banana Pro"). And + * the endpoint's label only ever fills a create: an update keeps the name the file + * states, so this cannot rewrite a spelling a human chose. + */ +function factoredName(model: AihubmixModel, base: string, existing: ExistingModel | undefined) { + // A name already on the file is a human's call and outranks the endpoint's label, + // which is a storefront string: 4 files spell their model the way its lab does + // (`MiMo-V2.5`, `MiniMax-M2.7`) where the endpoint sends `Mimo V2.5`. Handing it + // straight through stays correct anyway — `inheritedOverride` drops a name the lab + // states identically, which is what retires the 27 redundant ones `dev` carries. + if (existing?.name !== undefined) return existing.name; + // A blank label is not a name. `ModelBase.name` is `min(1)`, so writing one + // through would abort the whole provider's sync at validation rather than skip + // the field, and the standalone path never had to care because it only ever + // passed a name that had already been validated. + if (model.model_name == null || model.model_name.trim() === "") return undefined; + // Compared on the bare ID, because that is what resolved the base model: + // `relayChain` walks `bareID(model_id)`, so `Qwen/QwQ-32B` reaches + // `qwen/qwq-32b`. Normalising the namespaced form instead would never match its + // own slug, and each of the 10 namespaced routes would take a redundant + // storefront override the moment its lab file lands. + const slug = base.split("/").slice(1).join("/"); + return normalizeModelSlug(bareID(model.model_id)) === normalizeModelSlug(slug) ? undefined : model.model_name; +} + +/** + * A bare wire-path line, which the derived block restates in full. These four + * openings introduce nothing but the field to send, so replacing one loses + * nothing — `# Effort: reasoning_effort = low|high|max` says less than the block + * that supersedes it. + * + * Matching an opening rather than a substring is the point. Keying on `$.` or on + * the docs host would also delete lines that merely mention one: seven files on + * `dev` carry a header, and `claude-opus-5` and `qwen3.8-max` each state a wire + * path together with a dated live test the response cannot reproduce + * ("verified live 2026-08-11"). Those are notes, and notes are carried through + * even where they overlap the block — a second statement of the same wire path + * costs nothing, a deleted verification date cannot be recovered. + */ +const AUTHORED_OPENING = /^#\s*(Toggle|Effort|Budget|Off is effort)\b/; + +function composeHeader(existingHeader: string | undefined, derived: string | undefined) { + // The two wire-path lines are this adapter's own restatement of the block, so + // they go whether or not a block replaces them. Keeping them when nothing is + // derived is what left a route advertising a toggle it no longer has: the block + // vanished, its tail survived as a "note", and no later sync could tell the + // difference — the file never self-corrected. + // + // The source line is kept unless a derived block restates it, which is only to + // avoid stating it twice. It is a citation for the gateway rather than a claim + // about this model, and a human wrote this exact line in `gemini-3.7-flash`. + const authored = new Set( + (derived === undefined ? DIALECT_PATHS : DIALECTS) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""), + ); + const notes = (existingHeader ?? "") + .split("\n") + .filter((line) => + line.trim() !== "" && !AUTHORED_OPENING.test(line.trim()) && !authored.has(line.trim()), + ); + const header = (derived ?? "") + (notes.length > 0 ? `${notes.join("\n")}\n` : ""); + return header === "" ? undefined : header; +} + +function reasoningHeader(model: AihubmixModel, built: SyncedModel) { + const options = built.reasoning_options; + if (options === undefined) return undefined; + if (options.some((option) => option.type === "toggle")) return TOGGLE_HEADER; + // Only say where the off state moved to on a file that actually spells it out. + return options.some((option) => option.type === "effort" && option.values?.includes("none")) && + (model.reasoning_options ?? []).some((option) => option.type === "toggle") + ? FOLDED_HEADER + : undefined; +} + +/** + * The endpoint under-reports what a route accepts: it lists `text,image` for + * `kimi-k2.5`, whose lab entry and this repo both record video, and `text` for + * `qwen3.8-2.4t-a95b`, whose own file notes a live 200 on image input. Both are + * reported upstream, but a sync must not delete an accepted modality in the + * meantime, so the endpoint adds to what the file recorded rather than replacing + * it. A modality the endpoint never listed can still be removed by editing the + * file, which is where it came from. + */ +/** + * 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)); + const known = fallback.length > 0 ? fallback : ["text"]; + if (parsed.length === 0) return known as SyncedFullModel["modalities"]["input"]; + // Endpoint order first, so a file only changes when its content changes. + return [...new Set([...parsed, ...known])] as SyncedFullModel["modalities"]["input"]; +} + +/** + * AIHubMix omits a price field when the model has no such rate, so an omitted + * field means "not offered" and an authored value is only kept when the + * endpoint quotes nothing at all for the model. + */ +function buildCost( + pricing: AihubmixModel["pricing"], + authored: ExistingModel["cost"], +): SyncedFullModel["cost"] { + if (pricing == null) return authored; + const input = price(pricing.input); + const output = price(pricing.output); + if (input === undefined || output === undefined) return authored; + + return { + input, + output, + cache_read: cacheRead(pricing.cache_read, input), + cache_write: price(pricing.cache_write), + // AIHubMix quotes only text and cache rates, so an audio or reasoning price + // exists on the file and nowhere else. A rewrite would drop it. + input_audio: authored?.input_audio, + output_audio: authored?.output_audio, + reasoning: authored?.reasoning, + tiers: costTiers(pricing, authored?.tiers) ?? authored?.tiers, + }; +} + +function costTiers( + pricing: NonNullable, + authored: NonNullable["tiers"], +) { + const tiers = (pricing.tiers ?? []).flatMap((tier) => { + const input = price(tier.input); + const output = price(tier.output); + if (input === undefined || output === undefined) return []; + // Audio rates are per tier too, and the endpoint quotes none of them. + const priced = authored?.find((entry) => entry.tier.size === tier.tier.size); + return [ + { + tier: { type: tier.tier.type ?? "context", size: tier.tier.size }, + input, + output, + cache_read: cacheRead(tier.cache_read, input), + cache_write: price(tier.cache_write), + input_audio: priced?.input_audio, + output_audio: priced?.output_audio, + reasoning: priced?.reasoning, + }, + ]; + }); + return tiers.length > 0 ? (tiers as NonNullable["tiers"]) : undefined; +} + +/** + * AIHubMix sends 0 for a limit it does not know rather than omitting the field — + * 104 of 408 models quote `max_output: 0` — so 0 is read as absent. A model that + * truly emitted no tokens would not be servable. + */ +function tokens(value: number | null | undefined) { + if (value == null || !Number.isFinite(value) || value <= 0) return undefined; + return value; +} + +/** + * Six models and four context tiers repeat the input price in `cache_read`, + * which is how the endpoint spells "no cache discount" rather than a real rate. + * Publishing it would understate a cached read by up to 10x. An omitted field + * already means "no such rate" here, so an echoed one is read the same way. + */ +function cacheRead(value: number | null | undefined, input: number) { + const parsed = price(value); + return parsed !== undefined && parsed >= input ? undefined : parsed; +} + +function price(value: number | null | undefined) { + if (value == null || !Number.isFinite(value) || value < 0) return undefined; + return Math.round(value * 1_000_000) / 1_000_000; +} diff --git a/packages/core/src/sync/providers/openrouter.ts b/packages/core/src/sync/providers/openrouter.ts index 77164f7caa6..2121c93bf8d 100644 --- a/packages/core/src/sync/providers/openrouter.ts +++ b/packages/core/src/sync/providers/openrouter.ts @@ -440,7 +440,7 @@ function shouldPreserveFactoredName( return normalizeModelSlug(modelSlug) !== normalizeModelSlug(canonicalSlug); } -function normalizeModelSlug(value: string) { +export function normalizeModelSlug(value: string) { return value.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); } diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 0e51e85cfe1..63198eb9799 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -4,6 +4,12 @@ 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, + type AihubmixModel, +} from "../src/sync/providers/aihubmix.js"; import { anthropic, buildAnthropicModel, @@ -1185,6 +1191,8 @@ test("tracks missing models except for unreliable first-party inventories", () = expect(openai.trackMissingModels).toBe(false); expect(pioneer.skipCreates).toBe(true); expect(pioneer.trackMissingModels).toBe(true); + expect(aihubmix.skipCreates).toBeUndefined(); + expect(aihubmix.trackMissingModels).toBe(true); expect(ofox.skipCreates).toBe(true); expect(ofox.trackMissingModels).toBe(true); expect(tinfoil.skipCreates).toBe(true); @@ -5014,3 +5022,815 @@ 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", + }); + aihubmix.parseModels({ data: [toggled, plain] }); + + const context = { existing: () => undefined, authored: () => undefined }; + const translated = aihubmix.translateModel(toggled, context); + expect(translated?.model.reasoning_options).toEqual([{ type: "toggle" }]); + expect(translated?.header).toStartWith("# Toggle:\n# $.enable_thinking = true|false"); + // Only a reasoning control needs the wire path spelled out; everything else stays bare. + expect(aihubmix.translateModel(plain, context)?.header).toBeUndefined(); + + // A toggle folded into `effort = none` still records where the off state lives. + const folded = aihubmixModel({ + ...standalone, + model_id: "somelab-folded", + model_name: "SomeLab Folded", + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["no_think", "high"] }, + ] as AihubmixModel["reasoning_options"], + }); + aihubmix.parseModels({ data: [folded] }); + const dropped = aihubmix.translateModel(folded, context); + expect(dropped?.model.reasoning_options).toEqual([{ type: "effort", values: ["none", "high"] }]); + expect(dropped?.header).toStartWith("# Off is effort=none"); +}); + +test("keeps AIHubMix audio and reasoning prices the endpoint never quotes", () => { + // The endpoint models only text and cache rates, so an audio rate lives on the + // file and nowhere else -- at the top level and inside each context tier. + const authored: ExistingModel = { + ...aihubmixAuthored, + cost: { + input: 0.25, + output: 1.5, + input_audio: 1, + output_audio: 2, + reasoning: 3, + tiers: [ + { tier: { type: "context", size: 32_000 }, input: 0.5, output: 3, input_audio: 1.9 }, + ], + }, + }; + const model = buildAihubmixModel( + aihubmixModel({ + pricing: { + input: 0.25, + output: 1.5, + tiers: [{ tier: { type: "context", size: 32_000 }, input: 0.6, output: 3.2 }], + }, + }), + authored, + aihubmixLabIDs, + ); + expect(model?.cost?.input_audio).toBe(1); + expect(model?.cost?.output_audio).toBe(2); + expect(model?.cost?.reasoning).toBe(3); + // The endpoint still owns the text rates it does quote. + expect(model?.cost?.tiers?.[0]).toMatchObject({ input: 0.6, output: 3.2, input_audio: 1.9 }); +}); + +test("keeps AIHubMix fields the endpoint has no surface for", () => { + // Fast mode, request-shape overrides and the input cap live on the file only. + const authored: ExistingModel = { + ...aihubmixAuthored, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + experimental: { modes: { fast: { cost: { input: 5, output: 30 }, provider: { body: { service_tier: "priority" } } } } }, + provider: { body: { service_tier: "flex" } }, + } as ExistingModel; + const model = buildAihubmixModel( + aihubmixModel({ context_length: 1_050_000, max_output: 128_000 }), + authored, + aihubmixLabIDs, + ); + expect(model?.limit?.input).toBe(922_000); + expect(model?.experimental).toEqual(authored.experimental); + expect(model?.provider).toEqual(authored.provider); +}); + +test("reads an AIHubMix cache rate that just repeats input as no discount", () => { + // 6 models and 4 tiers echo `input` in `cache_read`; publishing it would + // understate a cached read by up to 10x. + const model = buildAihubmixModel( + aihubmixModel({ + pricing: { + input: 2, + output: 8, + cache_read: 2, + tiers: [{ tier: { type: "context", size: 200_000 }, input: 4, output: 16, cache_read: 4 }], + }, + }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(model?.cost?.cache_read).toBeUndefined(); + expect(model?.cost?.tiers?.[0]?.cache_read).toBeUndefined(); + // A genuine discount is still published. + const discounted = buildAihubmixModel( + aihubmixModel({ pricing: { input: 2, output: 8, cache_read: 0.2 } }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(discounted?.cost?.cache_read).toBe(0.2); +}); + +test("keeps authored reasoning budget bounds the AIHubMix endpoint omits", () => { + // The endpoint states that a budget exists but never its range, and a bare + // option on a `base_model` file would override the lab's real bounds. + const authored: ExistingModel = { + ...aihubmixAuthored, + reasoning_options: [{ type: "budget_tokens", min: 1_024, max: 32_000 }], + }; + const model = buildAihubmixModel( + aihubmixModel({ reasoning: true, reasoning_options: [{ type: "budget_tokens" }] as AihubmixModel["reasoning_options"] }), + authored, + aihubmixLabIDs, + ); + expect(model?.reasoning_options).toEqual([{ type: "budget_tokens", min: 1_024, max: 32_000 }]); +}); + +test("does not let a narrower AIHubMix modality list delete an accepted one", () => { + // The endpoint lists `text,image` for kimi-k2.5, whose lab entry records video. + // Most of the catalog arrives as a create with no file to union against, so + // the lab entry is the only baseline — and the narrowing must not be written. + const created = buildAihubmixModel( + aihubmixModel({ input_modalities: "text,image" }), + undefined, + aihubmixLabIDs, + ); + expect(created?.modalities).toBeUndefined(); + + // Where the file is the wider record, its modality survives an update too. + const authored: ExistingModel = { + ...aihubmixAuthored, + id: "minimax-m2", + modalities: { input: ["text", "image"], output: ["text"] }, + }; + const updated = buildAihubmixModel( + aihubmixModel({ model_id: "minimax-m2", vendor: "minimax", input_modalities: "text" }), + authored, + aihubmixLabIDs, + ); + expect(updated?.modalities?.input).toEqual(["text", "image"]); + + // A modality the endpoint adds still lands. + const widened = buildAihubmixModel( + aihubmixModel({ model_id: "minimax-m2", vendor: "minimax", input_modalities: "text,image,pdf" }), + undefined, + aihubmixLabIDs, + ); + expect(widened?.modalities?.input).toEqual(["text", "image", "pdf"]); +}); + +test("records an AIHubMix route's own name only where its ID is not the lab slug", () => { + // A factored create used to pass the file's name, so a route with no file yet + // recorded none at all and rendered as the lab model: `coding-glm-4.6-free`, + // `coding-glm-4.6` and `glm-4.6` all read "GLM-4.6". + const catalog = new Map(aihubmixCatalog); + const free = aihubmixModel({ + model_id: "coding-gemini-3.1-flash-lite-free", + model_name: "Coding Gemini 3.1 Flash Lite (free)", + variant_of: "gemini-3.1-flash-lite", + }); + catalog.set(free.model_id, free); + const created = buildAihubmixModel(free, undefined, aihubmixLabIDs, catalog); + expect(created).toMatchObject({ + base_model: "google/gemini-3.1-flash-lite", + name: "Coding Gemini 3.1 Flash Lite (free)", + }); + + // An update keeps the name the file states. Four files spell their model the way + // its lab does (`MiMo-V2.5`) where the endpoint sends a storefront `Mimo V2.5`, + // and the endpoint's label is not a reason to rewrite a human's spelling. + const authored = buildAihubmixModel( + free, + { id: free.model_id, name: "Coding Gemini 3.1 Flash-Lite (free)" }, + aihubmixLabIDs, + catalog, + ); + expect(authored).toMatchObject({ name: "Coding Gemini 3.1 Flash-Lite (free)" }); + + // A relay that *is* that lab model keeps deferring to the lab's spelling, so a + // registry punctuating the same name differently does not become an override + // on every entry in the catalog. + // A blank label is not a name, and it is not merely ignored: `ModelBase.name` is + // `min(1)`, so a `""` handed through aborts the whole provider's sync at + // validation and writes no file at all. The endpoint types the field + // `nullish()`, so both shapes have to resolve to "no name". + for (const blank of [null, "", " "]) { + const bare = aihubmixModel({ + model_id: "coding-gemini-3.1-flash-lite-free", + model_name: blank as never, + variant_of: "gemini-3.1-flash-lite", + }); + const built = buildAihubmixModel(bare, undefined, aihubmixLabIDs, catalog); + expect(built).toMatchObject({ base_model: "google/gemini-3.1-flash-lite" }); + expect(built).not.toHaveProperty("name"); + } + + // A namespaced route is compared on the bare ID, because that is what resolved + // its base model: `relayChain` walks `bareID(model_id)`. Normalising the + // namespaced form would never equal its own slug, and the route would take a + // storefront override restating the lab's own name. + const namespaced = buildAihubmixModel( + // The label deliberately differs from the lab's, so an override would actually + // be recorded if the comparison used the namespaced form. + aihubmixModel({ model_id: "Google/gemini-3.1-flash-lite", model_name: "Gemini 3.1 Flash Lite Turbo" }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(namespaced).toMatchObject({ base_model: "google/gemini-3.1-flash-lite" }); + expect(namespaced).not.toHaveProperty("name"); + + const punctuated = buildAihubmixModel( + aihubmixModel({ model_name: "Gemini 3.1 Flash-Lite" }), + undefined, + aihubmixLabIDs, + aihubmixCatalog, + ); + expect(punctuated).toMatchObject({ base_model: "google/gemini-3.1-flash-lite" }); + expect(punctuated).not.toHaveProperty("name"); +}); + +test("refreshes the AIHubMix wire path without discarding a human note", () => { + // The header is authoritative so a stale wire path cannot outlive the options + // it documents, but a price citation or live-test record is not reproducible + // from the response and has to survive the rewrite. + const toggled = aihubmixModel({ + vendor: null, + model_id: "somelab-noted", + model_name: "SomeLab Noted", + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + reasoning: true, + reasoning_options: [{ type: "toggle" }] as AihubmixModel["reasoning_options"], + }); + const plain = aihubmixModel({ + vendor: null, + model_id: "somelab-bare", + model_name: "SomeLab Bare", + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + }); + aihubmix.parseModels({ data: [toggled, plain] }); + const translated = aihubmix.translateModel(toggled, { + existing: () => undefined, + authored: () => undefined, + header: () => + "# Toggle: enable_thinking = true|false\n" + + "# AIHubMix Models API (queried 2026-08-11T09:45:11Z): input 1.69, output 5.07.\n", + }); + expect(translated?.header).toStartWith("# Toggle:\n# $.enable_thinking = true|false"); + // The hand-written wire path it supersedes is gone; the citation is not. + expect(translated?.header).not.toContain("# Toggle: enable_thinking"); + expect(translated?.header).toContain("queried 2026-08-11T09:45:11Z"); + + // A note is recognised by what it opens with, not by mentioning a wire path or + // the docs host: two files on `dev` state a wire path together with a live test + // the response cannot reproduce, and a second statement of the same path costs + // nothing next to a deleted verification date. + const noted = aihubmix.translateModel(toggled, { + existing: () => undefined, + authored: () => undefined, + header: () => + '# Native Messages prefers $.thinking.type = "adaptive" (verified live 2026-08-11).\n' + + "# https://docs.aihubmix.com/cn/api/Claude-Native\n", + }); + expect(noted?.header).toContain("verified live 2026-08-11"); + expect(noted?.header).toContain("docs.aihubmix.com/cn/api/Claude-Native"); + + // A dialect line is only dropped when a derived block restates it. With nothing + // derived there is nothing to restate, and one file's whole citation is that + // line byte for byte. + const citation = "# https://docs.aihubmix.com/cn/api/unified-inference\n"; + expect( + aihubmix.translateModel(plain, { + existing: () => undefined, + authored: () => undefined, + header: () => citation, + })?.header, + ).toBe(citation); + // An opening is recognised after trimming, because `leadingComments` matches on + // the trimmed line but keeps the raw one — so an indented `# Toggle:` arrives + // here still indented, and would otherwise survive as a "note" restating the + // block written directly above it. + const indented = aihubmix.translateModel(toggled, { + existing: () => undefined, + authored: () => undefined, + header: () => " # Toggle: enable_thinking = true|false\n", + })?.header; + expect(indented).not.toContain("# Toggle: enable_thinking"); + + // Written alongside a block that does restate it, it is not doubled. + const doubled = + aihubmix.translateModel(toggled, { + existing: () => undefined, + authored: () => undefined, + header: () => citation, + })?.header ?? ""; + expect(doubled.split(citation.trim()).length - 1).toBe(1); +}); + +test("reads a missing AIHubMix reasoning or tool flag as unknown, not as false", () => { + // 107 of 408 routes omit `reasoning` and 100 omit `tool_call`; none send + // `false`. A create must not write the omission as an override that turns off + // what the lab entry declares. + const created = buildAihubmixModel( + aihubmixModel({ input_modalities: "text,image,video,audio,pdf" }), + undefined, + aihubmixLabIDs, + ); + expect(created?.reasoning).toBeUndefined(); + expect(created?.tool_call).toBeUndefined(); + + // An explicit boolean is still honoured. + const denied = buildAihubmixModel( + aihubmixModel({ tool_call: false, input_modalities: "text,image,video,audio,pdf" }), + undefined, + aihubmixLabIDs, + ); + expect(denied?.tool_call).toBe(false); +}); + +test("reads a zero AIHubMix limit as absent rather than a real ceiling", () => { + // 102 of 415 models quote `max_output: 0` for a limit the endpoint does not know. + const zeroed = buildAihubmixModel( + aihubmixModel({ context_length: 262_144, max_output: 0 }), + aihubmixAuthored, + aihubmixLabIDs, + ); + const omitted = buildAihubmixModel( + aihubmixModel({ context_length: 262_144 }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(zeroed?.limit).toEqual(omitted?.limit); + expect(zeroed?.limit?.output).not.toBe(0); +}); + +test("syncs AIHubMix pricing over the authored entry", () => { + const model = buildAihubmixModel( + aihubmixModel({ pricing: { input: 0.2, output: 1.2, cache_read: 0.02, cache_write: 0.25 } }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(model?.cost).toMatchObject({ + input: 0.2, + output: 1.2, + cache_read: 0.02, + cache_write: 0.25, + }); +}); + +test("drops an authored AIHubMix rate the endpoint no longer quotes", () => { + // An omitted price field means the model has no such rate, not that it is unknown. + const model = buildAihubmixModel( + aihubmixModel({ pricing: { input: 0.25, output: 1.5 } }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(model?.cost?.cache_read).toBeUndefined(); + expect(model?.cost?.cache_write).toBeUndefined(); +}); + +test("keeps authored AIHubMix pricing when the endpoint quotes no rate at all", () => { + const model = buildAihubmixModel( + aihubmixModel({ pricing: null }), + aihubmixAuthored, + aihubmixLabIDs, + ); + expect(model?.cost).toEqual(aihubmixAuthored.cost); +}); + +test("inherits a limit the AIHubMix endpoint only restates in decimal", () => { + // The lab window is 1_048_576 and the endpoint quotes 1_000_000 for it — the same + // window in decimal, not a cap — so the relay must inherit rather than write an + // override claiming it lost 48_576 tokens. + const restated = buildAihubmixModel( + aihubmixModel({ context_length: 1_000_000, max_output: 65_536 }), + undefined, + aihubmixLabIDs, + ); + expect(restated?.limit?.context).toBeUndefined(); + + // A window the host genuinely restricts is far below any restatement and lands. + const capped = buildAihubmixModel( + aihubmixModel({ context_length: 128_000, max_output: 65_536 }), + undefined, + aihubmixLabIDs, + ); + expect(capped?.limit?.context).toBe(128_000); + + // The restatement reads the same from the other side: the lab window is + // 1_048_576 and the endpoint quotes 1_048_576 against a provider file holding the + // decimal 1_000_000. Resolving to the lab is what lets `inheritedOverride` drop + // the key — resolving to the file's own spelling would pin 1_000_000 forever even + // though it is only an imprecise way of writing the same window. + const reverse = buildAihubmixModel( + aihubmixModel({ context_length: 1_048_576, max_output: 65_536 }), + { ...aihubmixAuthored, limit: { context: 1_000_000, output: 65_536 } }, + aihubmixLabIDs, + ); + expect(reverse?.limit?.context).toBeUndefined(); + + // With no lab entry to defer to, the file's own value is the accepted one, and the + // restatement still reads from either side: a host route quoting the binary + // 1_048_576 for the 1_000_000 already on the file keeps the file's number instead + // of rewriting it to say the same window differently. + const hostRestated = buildAihubmixModel( + aihubmixModel({ + vendor: null, + model_id: "somelab-restated", + model_name: "SomeLab Restated", + release_date: "2026-05-01", + open_weights: false, + context_length: 1_048_576, + max_output: 65_536, + }), + { ...aihubmixAuthored, id: "somelab-restated", limit: { context: 1_000_000, output: 65_536 } }, + aihubmixLabIDs, + ); + expect(hostRestated?.limit?.context).toBe(1_000_000); + + // A relay cannot serve a wider window than the model it relays, so a ceiling above + // the lab's own resolves to the lab's rather than advertising tokens no request can + // reach. Doubling the output is far past any restatement. + const overreach = buildAihubmixModel( + aihubmixModel({ context_length: 1_048_576, max_output: 131_072 }), + undefined, + aihubmixLabIDs, + ); + expect(overreach?.limit?.output).toBeUndefined(); + + // The clamp applies to whatever the restatement resolved, not instead of it. An + // endpoint quoting back the same stale ceiling the file already holds resolves to + // that number — so clamping only afterwards is what catches it. `grok-4.5` did + // exactly this, recording a 1_000_000 output against a 500_000 lab window. + const stale = buildAihubmixModel( + aihubmixModel({ context_length: 1_048_576, max_output: 1_000_000 }), + { ...aihubmixAuthored, limit: { context: 1_048_576, output: 1_000_000 } }, + aihubmixLabIDs, + ); + expect(stale?.limit?.output).toBeUndefined(); + + // And an authored cap survives an endpoint that quotes nothing for the ceiling. + const authored: ExistingModel = { + ...aihubmixAuthored, + limit: { context: 1_048_576, output: 32_768 }, + }; + const unknown = buildAihubmixModel( + aihubmixModel({ max_output: 0 }), + authored, + aihubmixLabIDs, + ); + expect(unknown?.limit?.output).toBe(32_768); +}); + +test("does not author a standalone AIHubMix entry for a model a lab made", () => { + // Every field a standalone entry needs is present, but `vendor` names the lab + // that built the model and no `models/somelab/…` entry exists to factor onto. + // AGENTS.md makes that a blocker, so the relay is reported, not written. + const lab = { + vendor: "somelab", + model_id: "somelab-unmapped", + model_name: "SomeLab Unmapped", + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + } satisfies Partial; + expect(buildAihubmixModel(aihubmixModel(lab), undefined, aihubmixLabIDs)).toBeUndefined(); + expect(aihubmix.sourceID?.(aihubmixModel(lab))).toBe("somelab-unmapped"); + + // A relay the response names no vendor for is the host's own alias, and still writes. + const hostOwn = buildAihubmixModel( + aihubmixModel({ ...lab, vendor: null }), + undefined, + aihubmixLabIDs, + ); + expect(hostOwn?.name).toBe("SomeLab Unmapped"); + + // A standalone file already in the repo keeps being updated rather than freezing: + // what it should have been is upstream's call, and stalling its prices helps no one. + const authored: ExistingModel = { + id: "somelab-unmapped", + name: "SomeLab Unmapped", + release_date: "2026-05-01", + open_weights: false, + cost: { input: 1, output: 2 }, + limit: { context: 262_144, output: 65_536 }, + modalities: { input: ["text"], output: ["text"] }, + }; + const updated = buildAihubmixModel(aihubmixModel(lab), authored, aihubmixLabIDs); + expect(updated?.cost).toEqual({ input: 0.25, output: 1.5, cache_read: 0.025 }); +}); + +test("opens missing-model issues for a provider that creates but still skips", async () => { + // aihubmix does not set skipCreates — it creates what it can — so gating the + // issue path on skipCreates left every relay it cannot write as a notice nobody + // acts on. An explicit trackMissingModels is the opt-in for exactly that case. + const dir = await mkdtemp(path.join(tmpdir(), "sync-track-")); + const modelsDir = path.join(dir, "providers", "tracked", "models"); + await mkdir(modelsDir, { recursive: true }); + try { + const result = await syncProvider({ + id: "aihubmix", + name: "Tracked", + modelsDir, + trackMissingModels: true, + async fetchModels() { + return { data: [] }; + }, + parseModels() { + return [{ model_id: "unmapped-relay" }]; + }, + translateModel() { + return undefined; + }, + sourceID(model: { model_id: string }) { + return model.model_id; + }, + } as never, { dryRun: true, openIssues: true }); + expect(result.notices.some((notice) => notice.includes("unmapped-relay"))).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("marks retired AIHubMix relays deprecated and stops tracking them", () => { + const retired = aihubmixModel({ retire_stage: "deprecated" }); + expect(buildAihubmixModel(retired, aihubmixAuthored, aihubmixLabIDs)?.status).toBe("deprecated"); + expect(aihubmix.sourceID?.(retired)).toBeUndefined(); + expect(aihubmix.sourceID?.(aihubmixModel())).toBe("gemini-3.1-flash-lite"); +}); + +test("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(); +}); + +test("writes per-tier audio pricing", () => { + const toml = formatToml({ + name: "Doubao Seed 2.0 Lite", + cost: { + input: 0.09041, + output: 0.54246, + input_audio: 1.269, + tiers: [ + { tier: { type: "context", size: 32_000 }, input: 0.13, output: 0.76, input_audio: 1.902 }, + ], + }, + } as never); + expect(toml).toContain("input_audio = 1.902"); +}); diff --git a/providers/aihubmix/models/alicloud-deepseek-v4-flash.toml b/providers/aihubmix/models/alicloud-deepseek-v4-flash.toml deleted file mode 100644 index 36c21d7cfb1..00000000000 --- a/providers/aihubmix/models/alicloud-deepseek-v4-flash.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "DeepSeek V4 Flash (Alibaba Cloud)" -description = "Fast DeepSeek model for efficient chat, coding help, and agent loops" -family = "deepseek-flash" -release_date = "2026-04-24" -last_updated = "2026-04-24" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -knowledge = "2025-05" -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.14 -output = 0.28 -cache_read = 0.028 - -[limit] -context = 1_000_000 -output = 384_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/alicloud-deepseek-v4-pro.toml b/providers/aihubmix/models/alicloud-deepseek-v4-pro.toml deleted file mode 100644 index 57e26625400..00000000000 --- a/providers/aihubmix/models/alicloud-deepseek-v4-pro.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "DeepSeek V4 Pro (Alibaba Cloud)" -description = "Flagship DeepSeek model for coding, reasoning, and agentic work" -family = "deepseek-thinking" -release_date = "2026-04-24" -last_updated = "2026-04-24" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -knowledge = "2025-05" -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 1.69 -output = 3.38 -cache_read = 0.13 - -[limit] -context = 1_000_000 -output = 384_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/alicloud-glm-5.1.toml b/providers/aihubmix/models/alicloud-glm-5.1.toml deleted file mode 100644 index 877d9e29a4b..00000000000 --- a/providers/aihubmix/models/alicloud-glm-5.1.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "GLM-5.1 (Alibaba Cloud)" -description = "Flagship GLM model for hybrid reasoning, coding, and agentic engineering" -family = "glm" -release_date = "2026-03-27" -last_updated = "2026-03-27" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.84 -output = 3.38 -cache_read = 0.169 -cache_write = 1.05625 - -[limit] -context = 200_000 -output = 128_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/claude-opus-4-6.toml b/providers/aihubmix/models/claude-opus-4-6.toml index 57b40db1786..8f0d7241820 100644 --- a/providers/aihubmix/models/claude-opus-4-6.toml +++ b/providers/aihubmix/models/claude-opus-4-6.toml @@ -1,3 +1,4 @@ +# Native Messages prefers $.thinking.type = "adaptive" with $.output_config.effort = "low"|"medium"|"high"|"max"; enabled budget_tokens >= 1024 is deprecated and must be < $.max_tokens. https://docs.aihubmix.com/cn/api/Claude-Native (accessed 2026-06-25) name = "Claude Opus 4.6" description = "Flagship Claude model for deep reasoning, coding, and long-horizon agents" family = "claude-opus" @@ -6,7 +7,6 @@ last_updated = "2026-03-13" attachment = true reasoning = true reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "max"] }, { type = "budget_tokens", min = 1_024 }] -# Native Messages prefers $.thinking.type = "adaptive" with $.output_config.effort = "low"|"medium"|"high"|"max"; enabled budget_tokens >= 1024 is deprecated and must be < $.max_tokens. https://docs.aihubmix.com/cn/api/Claude-Native (accessed 2026-06-25) temperature = true tool_call = true structured_output = true diff --git a/providers/aihubmix/models/claude-opus-4-7.toml b/providers/aihubmix/models/claude-opus-4-7.toml index 0fb0b1c89f3..b085c8e69a2 100644 --- a/providers/aihubmix/models/claude-opus-4-7.toml +++ b/providers/aihubmix/models/claude-opus-4-7.toml @@ -1,3 +1,4 @@ +# Native Messages uses $.thinking.type = "adaptive" and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected. https://docs.aihubmix.com/cn/blogs/Claude-Opus4.7 (accessed 2026-06-25) name = "Claude Opus 4.7" description = "Flagship Claude model for deep reasoning, coding, and long-horizon agents" family = "claude-opus" @@ -6,7 +7,6 @@ last_updated = "2026-04-16" attachment = true reasoning = true reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }] -# Native Messages uses $.thinking.type = "adaptive" and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected. https://docs.aihubmix.com/cn/blogs/Claude-Opus4.7 (accessed 2026-06-25) temperature = false tool_call = true structured_output = true diff --git a/providers/aihubmix/models/claude-opus-4-8-think.toml b/providers/aihubmix/models/claude-opus-4-8-think.toml index 52f226453d2..a829e13abba 100644 --- a/providers/aihubmix/models/claude-opus-4-8-think.toml +++ b/providers/aihubmix/models/claude-opus-4-8-think.toml @@ -1,6 +1,6 @@ +# Anthropic-compatible /v1/messages: $.thinking.type = "enabled"|"disabled"|"adaptive" (disabled turns reasoning off = toggle) and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected on this Opus tier. https://docs.aihubmix.com/cn/api-reference/anthropic-compatible/create-a-message (accessed 2026-07-02) base_model = "anthropic/claude-opus-4-8" reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }] -# Anthropic-compatible /v1/messages: $.thinking.type = "enabled"|"disabled"|"adaptive" (disabled turns reasoning off = toggle) and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected on this Opus tier. https://docs.aihubmix.com/cn/api-reference/anthropic-compatible/create-a-message (accessed 2026-07-02) [interleaved] field = "reasoning_content" diff --git a/providers/aihubmix/models/claude-opus-4-8.toml b/providers/aihubmix/models/claude-opus-4-8.toml index e8d612cf002..3dd610d9365 100644 --- a/providers/aihubmix/models/claude-opus-4-8.toml +++ b/providers/aihubmix/models/claude-opus-4-8.toml @@ -1,6 +1,6 @@ +# Anthropic-compatible /v1/messages: $.thinking.type = "enabled"|"disabled"|"adaptive" (disabled turns reasoning off = toggle) and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected on this Opus tier. https://docs.aihubmix.com/cn/api-reference/anthropic-compatible/create-a-message (accessed 2026-07-02) base_model = "anthropic/claude-opus-4-8" reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }] -# Anthropic-compatible /v1/messages: $.thinking.type = "enabled"|"disabled"|"adaptive" (disabled turns reasoning off = toggle) and $.output_config.effort = "low"|"medium"|"high"|"xhigh"|"max"; manual budget_tokens is rejected on this Opus tier. https://docs.aihubmix.com/cn/api-reference/anthropic-compatible/create-a-message (accessed 2026-07-02) interleaved = true diff --git a/providers/aihubmix/models/claude-sonnet-4-6.toml b/providers/aihubmix/models/claude-sonnet-4-6.toml index b252b0f52b3..be1a367a194 100644 --- a/providers/aihubmix/models/claude-sonnet-4-6.toml +++ b/providers/aihubmix/models/claude-sonnet-4-6.toml @@ -1,3 +1,4 @@ +# Native Messages prefers $.thinking.type = "adaptive" with $.output_config.effort = "low"|"medium"|"high"; enabled budget_tokens >= 1024 is deprecated and must be < $.max_tokens. Chat effort "max" maps to native "high". https://docs.aihubmix.com/cn/api/Claude-Native (accessed 2026-06-25) name = "Claude Sonnet 4.6" description = "Balanced Claude model for coding, analysis, agent workflows, and cost control" family = "claude-sonnet" @@ -6,7 +7,6 @@ last_updated = "2026-03-13" attachment = true reasoning = true reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high", "max"] }, { type = "budget_tokens", min = 1_024 }] -# Native Messages prefers $.thinking.type = "adaptive" with $.output_config.effort = "low"|"medium"|"high"; enabled budget_tokens >= 1024 is deprecated and must be < $.max_tokens. Chat effort "max" maps to native "high". https://docs.aihubmix.com/cn/api/Claude-Native (accessed 2026-06-25) temperature = true tool_call = true structured_output = true diff --git a/providers/aihubmix/models/deep-deepseek-v4-flash.toml b/providers/aihubmix/models/deep-deepseek-v4-flash.toml deleted file mode 100644 index 89f36a266b7..00000000000 --- a/providers/aihubmix/models/deep-deepseek-v4-flash.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "DeepSeek V4 Flash (DeepSeek)" -description = "Fast DeepSeek model for efficient chat, coding help, and agent loops" -family = "deepseek-flash" -release_date = "2026-04-24" -last_updated = "2026-04-24" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -knowledge = "2025-05" -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.154 -output = 0.308 -cache_read = 0.0308 - -[limit] -context = 1_000_000 -output = 384_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/deep-deepseek-v4-pro.toml b/providers/aihubmix/models/deep-deepseek-v4-pro.toml deleted file mode 100644 index eb762b884f9..00000000000 --- a/providers/aihubmix/models/deep-deepseek-v4-pro.toml +++ /dev/null @@ -1,29 +0,0 @@ -name = "DeepSeek V4 Pro (DeepSeek)" -description = "Flagship DeepSeek model for coding, reasoning, and agentic work" -family = "deepseek-thinking" -release_date = "2026-04-24" -last_updated = "2026-04-24" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -knowledge = "2025-05" -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.478 -output = 0.956 -cache_read = 0.004302 - -[limit] -context = 1_000_000 -output = 384_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/aihubmix/models/gemini-2.5-flash.toml b/providers/aihubmix/models/gemini-2.5-flash.toml index 23f238b714b..22234024838 100644 --- a/providers/aihubmix/models/gemini-2.5-flash.toml +++ b/providers/aihubmix/models/gemini-2.5-flash.toml @@ -1,3 +1,4 @@ +# Native Gemini uses $.generationConfig.thinkingConfig.thinkingBudget: 0 disables, -1 is dynamic, and manual budgets are 1..24576. https://cloud.google.com/vertex-ai/generative-ai/docs/thinking (accessed 2026-06-25) name = "Gemini 2.5 Flash" description = "Fast Gemini model balancing multimodal reasoning, tool use, and cost" family = "gemini-flash" @@ -6,7 +7,6 @@ last_updated = "2025-06-05" attachment = true reasoning = true reasoning_options = [{ type = "toggle" }, { type = "effort", values = ["low", "medium", "high"] }, { type = "budget_tokens", min = 0, max = 24_576 }] -# Native Gemini uses $.generationConfig.thinkingConfig.thinkingBudget: 0 disables, -1 is dynamic, and manual budgets are 1..24576. https://cloud.google.com/vertex-ai/generative-ai/docs/thinking (accessed 2026-06-25) temperature = true tool_call = true structured_output = true diff --git a/providers/aihubmix/models/gemini-2.5-pro.toml b/providers/aihubmix/models/gemini-2.5-pro.toml index 8ef984bdbf3..e77eedd0faf 100644 --- a/providers/aihubmix/models/gemini-2.5-pro.toml +++ b/providers/aihubmix/models/gemini-2.5-pro.toml @@ -1,3 +1,4 @@ +# Native Gemini uses $.generationConfig.thinkingConfig.thinkingBudget: -1 is dynamic and manual budgets are 128..32768; 0/off is unsupported. https://cloud.google.com/vertex-ai/generative-ai/docs/thinking (accessed 2026-06-25) name = "Gemini 2.5 Pro" description = "Advanced Gemini model for complex reasoning, coding, and multimodal analysis" family = "gemini-pro" @@ -6,7 +7,6 @@ last_updated = "2025-06-05" attachment = true reasoning = true reasoning_options = [{ type = "effort", values = ["low", "medium", "high"] }] -# Native Gemini uses $.generationConfig.thinkingConfig.thinkingBudget: -1 is dynamic and manual budgets are 128..32768; 0/off is unsupported. https://cloud.google.com/vertex-ai/generative-ai/docs/thinking (accessed 2026-06-25) temperature = true tool_call = true structured_output = true diff --git a/providers/aihubmix/models/gemini-3.7-flash.toml b/providers/aihubmix/models/gemini-3.7-flash.toml index 31d0f319a9a..63ad68c0ba2 100644 --- a/providers/aihubmix/models/gemini-3.7-flash.toml +++ b/providers/aihubmix/models/gemini-3.7-flash.toml @@ -1,7 +1,7 @@ -base_model = "google/gemini-3.7-flash" - # AIHubMix unified Chat: $.reasoning_effort = low|medium|high # https://docs.aihubmix.com/cn/api/unified-inference +base_model = "google/gemini-3.7-flash" + reasoning_options = [{ type = "effort", values = ["low", "medium", "high"] }] [cost] diff --git a/providers/aihubmix/models/xiaomi-mimo-v2.5-pro.toml b/providers/aihubmix/models/xiaomi-mimo-v2.5-pro.toml deleted file mode 100644 index 2d493daadb9..00000000000 --- a/providers/aihubmix/models/xiaomi-mimo-v2.5-pro.toml +++ /dev/null @@ -1,19 +0,0 @@ -base_model = "xiaomi/mimo-v2.5-pro" -reasoning_options = [{ type = "toggle" }] -name = "Xiaomi MiMo-V2.5-Pro" -family = "mimo-v2.5-pro" -last_updated = "2026-05-13" - -[interleaved] -field = "reasoning_content" - -[cost] -input = 1.1 -output = 3.3 -cache_read = 0.22 - -[[cost.tiers]] -tier = { type = "context", size = 256_000 } -input = 2.2 -output = 6.6 -cache_read = 0.44 diff --git a/providers/aihubmix/models/xiaomi-mimo-v2.5.toml b/providers/aihubmix/models/xiaomi-mimo-v2.5.toml deleted file mode 100644 index 04bbbaaaf19..00000000000 --- a/providers/aihubmix/models/xiaomi-mimo-v2.5.toml +++ /dev/null @@ -1,19 +0,0 @@ -base_model = "xiaomi/mimo-v2.5" -reasoning_options = [{ type = "toggle" }] -name = "Xiaomi MiMo-V2.5" -family = "mimo-v2.5" -last_updated = "2026-05-13" - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.44 -output = 2.2 -cache_read = 0.088 - -[[cost.tiers]] -tier = { type = "context", size = 256_000 } -input = 0.88 -output = 4.4 -cache_read = 0.176 diff --git a/providers/aihubmix/models/zai-glm-5.1.toml b/providers/aihubmix/models/zai-glm-5.1.toml deleted file mode 100644 index 9f09d1e0b0f..00000000000 --- a/providers/aihubmix/models/zai-glm-5.1.toml +++ /dev/null @@ -1,28 +0,0 @@ -name = "GLM-5.1 (Z.ai)" -description = "Flagship GLM model for hybrid reasoning, coding, and agentic engineering" -family = "glm" -release_date = "2026-03-27" -last_updated = "2026-03-27" -attachment = false -reasoning = true -reasoning_options = [{ type = "toggle" }] -temperature = true -tool_call = true -structured_output = true -open_weights = true - -[interleaved] -field = "reasoning_content" - -[cost] -input = 0.845 -output = 3.38 -cache_read = 0.183112 - -[limit] -context = 200_000 -output = 128_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/sync.md b/sync.md index 70a06dab55b..a0fcb8c3974 100644 --- a/sync.md +++ b/sync.md @@ -254,6 +254,42 @@ 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. +- `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 opens a deduped GitHub issue naming both readings — confirm the rotation, or drop the file if it is a hidden channel alias. +- `trackMissingModels` is set, so relays the adapter skips open deduped `[missing-model]` issues even though creates are enabled. Without it a provider that creates most models but cannot write some of them would emit notices nobody acts on; the flag is implied by `skipCreates` and settable on its own for exactly this case. + ## Tinfoil Notes - Tinfoil is implemented in `packages/core/src/sync/providers/tinfoil.ts`. From 360534fee95d589a6edc19f6df060c53eee98189 Mon Sep 17 00:00:00 2001 From: chenxue Date: Tue, 15 Sep 2026 14:26:08 +0800 Subject: [PATCH 02/13] aihubmix: drop route variants from the synced catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-free`, `-reasoning` and `-non-reasoning` all name a way into a model the listing already carries under its own ID. `-free` is the free-tier route (53 of them, 40 pointing at the paid route through `variant_of`); the two Grok suffixes are the pre-split routes that reach one model with thinking forced on or off, which the catalog states as `reasoning_options` rather than as two entries. Filtered in parseModels rather than translateModel, so a 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 keeps every entry, because a variant is still a valid `variant_of` target for a route that does belong here. Removes the four free-tier files already written, and takes the dry run from 129 creates to 106. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 23 +++++++++++++++- packages/core/test/sync.test.ts | 19 +++++++++++++ .../aihubmix/models/coding-glm-5.1-free.toml | 27 ------------------- .../models/coding-minimax-m2.7-free.toml | 27 ------------------- .../models/xiaomi-mimo-v2.5-free.toml | 13 --------- .../models/xiaomi-mimo-v2.5-pro-free.toml | 13 --------- 6 files changed, 41 insertions(+), 81 deletions(-) delete mode 100644 providers/aihubmix/models/coding-glm-5.1-free.toml delete mode 100644 providers/aihubmix/models/coding-minimax-m2.7-free.toml delete mode 100644 providers/aihubmix/models/xiaomi-mimo-v2.5-free.toml delete mode 100644 providers/aihubmix/models/xiaomi-mimo-v2.5-pro-free.toml diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index 16551ef4c59..a60776d4db1 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -113,6 +113,22 @@ const VENDOR_LABS: Record = { // 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. All three suffixes name 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), and `-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. Filtering here + * rather than at translate keeps them out of the missing-model issues too. + * + * Matched on the suffix, so `AiHubmix-Phi-4-mini-reasoning` — where the word is + * part of Microsoft's own model name — is caught as well. It writes no file today + * (the endpoint reports no `reasoning` flag and none of the standalone fields), + * so the filter costs nothing; give it an exception here if it ever should. + */ +const ROUTE_VARIANT_SUFFIX = /-(?:free|non-reasoning|reasoning)$/i; + 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 @@ -205,7 +221,12 @@ export const aihubmix = { // and would claim filenames that differ only in case. Keep the last entry // whole rather than mixing two records. relayCatalog = new Map(data.map((model) => [model.model_id.toLowerCase(), model])); - return [...relayCatalog.values()]; + // 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. + return [...relayCatalog.values()].filter((model) => !ROUTE_VARIANT_SUFFIX.test(model.model_id)); }, translateModel(model, context) { const existing = context.existing(model.model_id); diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 63198eb9799..9d745716e43 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -5767,6 +5767,25 @@ test("marks retired AIHubMix relays deprecated and stops tracking them", () => { 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, + // and `-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. 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 word is part of Microsoft's own model name here, and the suffix match + // catches it too. It writes no file today, so the filter costs nothing. + aihubmixModel({ model_id: "AiHubmix-Phi-4-mini-reasoning" }), + ]; + 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"]); +}); + 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` 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-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/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 From 1eeaa0cbb79743ac1d157afd1eec262f90b0fdca Mon Sep 17 00:00:00 2001 From: chenxue Date: Tue, 15 Sep 2026 15:14:08 +0800 Subject: [PATCH 03/13] aihubmix: read a missing reasoning flag as false, not as unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint uses `reasoning` to state the controls this host exposes, so leaving it off is the catalog saying it exposes none. Reading the omission as unknown and falling back to the lab entry's `true` was what left six routes unwritable: the lab says the model reasons, the endpoint publishes no controls for it, and AGENTS.md requires `reasoning_options` whenever `reasoning = true`, so the route could only be skipped. Nine routes resolve differently under this rule — the four chat-tuned snapshots, gemini-2.5-flash-image, the two deprecated MiMo routes, and three Qwen/Solar entries — and none of them has a file today, so nothing already published flips. `tool_call` keeps the older reading, because nothing in the endpoint denies tool use. Takes the dry run from 13 skips to 7, all of which are now routes the endpoint really does flag as reasoning without publishing the tiers. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 27 ++++++++++++-------- packages/core/test/sync.test.ts | 27 ++++++++++++++++---- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index a60776d4db1..ec713c7ef83 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -265,11 +265,18 @@ export function buildAihubmixModel( ...(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; + // `reasoning` states what this host exposes, so an omission is the catalog + // saying it exposes none: 108 of 409 routes omit the flag and none sends + // `false`. Reading an omission as unknown and inheriting the lab's `true` + // instead was what left six routes unwritable — the lab says the model + // reasons, the host publishes no controls for it, and AGENTS.md requires + // `reasoning_options` whenever `reasoning = true`, so the route could only be + // skipped. Nine routes resolve differently under this rule and none of them + // has a file today, so nothing already published flips. + // + // `tool_call` keeps the older reading: 100 routes omit it, and the endpoint + // models no way to deny tool use, so a missing flag there is still unknown. + const reasoning = model.reasoning ?? false; const toolCall = model.tool_call ?? existing?.tool_call; const structuredOutput = features.has("structured_outputs") || existing?.structured_output; const name = model.model_name ?? existing?.name; @@ -311,7 +318,7 @@ export function buildAihubmixModel( }; if (base !== undefined) { - assertReasoningOptions(model.model_id, reasoning ?? lab?.reasoning, shared.reasoning_options); + assertReasoningOptions(model.model_id, reasoning, shared.reasoning_options); return factorBaseModel( base, { name: factoredName(model, base, existing), description: existing?.description, ...shared }, @@ -347,10 +354,10 @@ export function buildAihubmixModel( 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; + // Published reasoning options are the same catalog stating controls for a + // route whose flag it left off, and the specific statement wins over the + // omission. No route does both today, so this only keeps the pair coherent. + const standaloneReasoning = reasoning || shared.reasoning_options !== undefined; assertReasoningOptions(model.model_id, standaloneReasoning, shared.reasoning_options); const standaloneToolCall = toolCall ?? false; return { diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 9d745716e43..a9b447ba9f0 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -5534,16 +5534,18 @@ test("refreshes the AIHubMix wire path without discarding a human note", () => { 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. +test("reads a missing AIHubMix reasoning flag as false and a missing tool flag as unknown", () => { + // 108 of 409 routes omit `reasoning` and 100 omit `tool_call`; none send + // `false`. The flags diverge because the endpoint uses them differently: + // `reasoning` states the controls this host exposes, so leaving it off is the + // catalog saying it exposes none, while nothing in the endpoint denies tool + // use, so a missing `tool_call` is still unknown. const created = buildAihubmixModel( aihubmixModel({ input_modalities: "text,image,video,audio,pdf" }), undefined, aihubmixLabIDs, ); - expect(created?.reasoning).toBeUndefined(); + expect(created?.reasoning).toBe(false); expect(created?.tool_call).toBeUndefined(); // An explicit boolean is still honoured. @@ -5555,6 +5557,21 @@ test("reads a missing AIHubMix reasoning or tool flag as unknown, not as false", expect(denied?.tool_call).toBe(false); }); +test("writes an AIHubMix route the lab calls a reasoner but the host publishes no controls for", () => { + // The lab entry for a chat-tuned snapshot can say the model reasons while this + // host exposes no reasoning surface for it at all. Inheriting the lab's `true` + // made the route unwritable — AGENTS.md requires `reasoning_options` whenever + // `reasoning = true`, and the endpoint publishes none — so it was skipped + // rather than synced. The host's own omission settles it. + const written = buildAihubmixModel( + aihubmixModel({ model_id: "gpt-5.5", vendor: "openai", features: "", reasoning_options: null }), + undefined, + aihubmixLabIDs, + ); + expect(written?.reasoning).toBe(false); + expect(written?.reasoning_options).toBeUndefined(); +}); + 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( From 60bcb5b418a278fd3b968230e1962db8374dfc57 Mon Sep 17 00:00:00 2001 From: chenxue Date: Tue, 15 Sep 2026 15:38:03 +0800 Subject: [PATCH 04/13] Revert "aihubmix: read a missing reasoning flag as false, not as unknown" This reverts commit 1eeaa0cbb. Reading the endpoint's omission as a denial was wrong. The omission is missing data, not a statement: 108 routes leave the flag off and they include `Qwen/QwQ-32B`, which has no non-thinking mode at all, `AiHubmix-Phi-4-mini-reasoning`, `codex-mini-latest`, and the whole `qwen3-*` hybrid-thinking family. A catalog that omits the flag for a model that can only think is not describing what it exposes. The measurement that justified the rule -- nine routes resolve differently and none has a file today -- sized the blast radius, not the claim. A real sync shows what it actually writes: six cards carry `reasoning = false` against a lab entry that says `true`, and for `mimo-v2-omni` / `mimo-v2-pro` that makes this the only provider in the repo denying that MiMo v2 reasons, where abacus writes `reasoning = true` and the three first-party `xiaomi-token-plan-*` hosts publish a thinking toggle. The distinction matters because `mergeBaseModel` merges the provider entry over the lab entry, so omitting the field defers to the lab while writing `false` overrides it. There is no narrower version worth keeping: when the lab says nothing the inherited value is already absent, so the rule only ever bites on the routes it gets wrong. Back to 13 skips. Those six are honest -- the route is skipped because the catalog contradicts itself, which is the signal that sends the fix to the endpoint instead of burying it here. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 27 ++++++++------------ packages/core/test/sync.test.ts | 27 ++++---------------- 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index ec713c7ef83..a60776d4db1 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -265,18 +265,11 @@ export function buildAihubmixModel( ...(baseModalities?.output ?? []), ]); const features = new Set((model.features ?? "").split(",").map((value) => value.trim())); - // `reasoning` states what this host exposes, so an omission is the catalog - // saying it exposes none: 108 of 409 routes omit the flag and none sends - // `false`. Reading an omission as unknown and inheriting the lab's `true` - // instead was what left six routes unwritable — the lab says the model - // reasons, the host publishes no controls for it, and AGENTS.md requires - // `reasoning_options` whenever `reasoning = true`, so the route could only be - // skipped. Nine routes resolve differently under this rule and none of them - // has a file today, so nothing already published flips. - // - // `tool_call` keeps the older reading: 100 routes omit it, and the endpoint - // models no way to deny tool use, so a missing flag there is still unknown. - const reasoning = model.reasoning ?? false; + // 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; @@ -318,7 +311,7 @@ export function buildAihubmixModel( }; if (base !== undefined) { - assertReasoningOptions(model.model_id, reasoning, shared.reasoning_options); + assertReasoningOptions(model.model_id, reasoning ?? lab?.reasoning, shared.reasoning_options); return factorBaseModel( base, { name: factoredName(model, base, existing), description: existing?.description, ...shared }, @@ -354,10 +347,10 @@ export function buildAihubmixModel( return existing === undefined ? undefined : (existing as SyncedModel); } - // Published reasoning options are the same catalog stating controls for a - // route whose flag it left off, and the specific statement wins over the - // omission. No route does both today, so this only keeps the pair coherent. - const standaloneReasoning = reasoning || shared.reasoning_options !== undefined; + // 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 { diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index a9b447ba9f0..9d745716e43 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -5534,18 +5534,16 @@ test("refreshes the AIHubMix wire path without discarding a human note", () => { expect(doubled.split(citation.trim()).length - 1).toBe(1); }); -test("reads a missing AIHubMix reasoning flag as false and a missing tool flag as unknown", () => { - // 108 of 409 routes omit `reasoning` and 100 omit `tool_call`; none send - // `false`. The flags diverge because the endpoint uses them differently: - // `reasoning` states the controls this host exposes, so leaving it off is the - // catalog saying it exposes none, while nothing in the endpoint denies tool - // use, so a missing `tool_call` is still unknown. +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).toBe(false); + expect(created?.reasoning).toBeUndefined(); expect(created?.tool_call).toBeUndefined(); // An explicit boolean is still honoured. @@ -5557,21 +5555,6 @@ test("reads a missing AIHubMix reasoning flag as false and a missing tool flag a expect(denied?.tool_call).toBe(false); }); -test("writes an AIHubMix route the lab calls a reasoner but the host publishes no controls for", () => { - // The lab entry for a chat-tuned snapshot can say the model reasons while this - // host exposes no reasoning surface for it at all. Inheriting the lab's `true` - // made the route unwritable — AGENTS.md requires `reasoning_options` whenever - // `reasoning = true`, and the endpoint publishes none — so it was skipped - // rather than synced. The host's own omission settles it. - const written = buildAihubmixModel( - aihubmixModel({ model_id: "gpt-5.5", vendor: "openai", features: "", reasoning_options: null }), - undefined, - aihubmixLabIDs, - ); - expect(written?.reasoning).toBe(false); - expect(written?.reasoning_options).toBeUndefined(); -}); - 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( From e0c598dfd8fcbef892a8b9f60d90eda122aa4b17 Mon Sep 17 00:00:00 2001 From: chenxue Date: Tue, 15 Sep 2026 15:49:44 +0800 Subject: [PATCH 05/13] aihubmix: sync only the routes canon covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway publishes two interfaces and they answer different questions. `/api/v1/models` says a route is reachable here; the canon projection at `/model-data/index.json` says what AIHubMix has actually verified about the model behind it. A catalog entry needs the second, so the model list is now read through canon rather than on its own. Canon covers 351 models against the list's 409, and the 117 it leaves out are the long tail the list describes worst: `Qwen/QwQ-32B`, which has no non-thinking mode, `AiHubmix-Phi-4-mini-reasoning`, `codex-mini-latest` and the whole `qwen3-*` hybrid-thinking family all report no `reasoning` flag at all. Reading that silence as a statement was the mistake the previous commit reverted; this stops the adapter from having to read it. Nothing moves today: all 106 creates, all 66 updates and all 66 files already on disk are covered, so the dry run is unchanged at 106/66. The filter is a ratchet, not a cleanup — it holds the line when the list grows a route canon has not reached yet. Sits in parseModels beside the route-variant filter and drops silently for the same reason: an uncovered route is not a gap a contributor here can close, so it should not raise a skip notice or a missing-model issue. Covered IDs are compared exactly; both registries are generated from the same gateway catalog and all 292 of today's overlaps match without case folding. A failed canon request throws instead of syncing ungated, because carrying on would publish exactly the routes the gate exists to hold back. Nothing is written on a throw, and `deleteMissing: false` means a gated-out route never costs a file either way. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 55 +++++++++++++++++++- packages/core/test/sync.test.ts | 24 +++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index a60776d4db1..bbdfd6ec366 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -9,6 +9,13 @@ 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 @@ -137,12 +144,39 @@ const EFFORT_ALIASES: Record = { no_think: "none", instant: "min // nothing where it means nothing, which the filter below already drops. const EFFORT_VALUES = new Set(REASONING_EFFORT_VALUES); +/** + * 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 @@ -213,6 +247,15 @@ export const aihubmix = { 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) { @@ -226,7 +269,17 @@ export const aihubmix = { // 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. - return [...relayCatalog.values()].filter((model) => !ROUTE_VARIANT_SUFFIX.test(model.model_id)); + const listed = [...relayCatalog.values()].filter( + (model) => !ROUTE_VARIANT_SUFFIX.test(model.model_id), + ); + // 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); diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 9d745716e43..4f3c8d40c8a 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -8,6 +8,7 @@ import { MissingReasoningOptionsError } from "../src/sync/missing-reasoning-opti import { aihubmix, buildAihubmixModel, + canonCoveredModels, type AihubmixModel, } from "../src/sync/providers/aihubmix.js"; import { @@ -5786,6 +5787,29 @@ test("drops AIHubMix route variants before they reach the catalog", () => { expect(parsed.map((model) => model.model_id)).toEqual(["glm-5.1"]); }); +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` From 58ea9b49b4f09655d903ef1597d455843888bacc Mon Sep 17 00:00:00 2001 From: chenxue Date: Tue, 15 Sep 2026 18:41:08 +0800 Subject: [PATCH 06/13] aihubmix: author a wire path for every reasoning control Three review findings, all in how the leading comment block is derived. The header is authoritative, so `composeHeader` strips any line opening `# Toggle:`, `# Effort:` or `# Budget:` on the grounds that the derived block restates it. It only ever derived the toggle. An effort or budget opening was therefore deleted with nothing put back, leaving the option row on the file and its field name nowhere -- `deepseek-v4-pro-0813` and `qwen3.7-flash` each lost one that way. Derive a block per control the file actually authors, and read the effort levels off the row they document so the two cannot drift. The toggle block also named `$.enable_thinking` alone. A control on this gateway has no single wire path: the same off state is reachable from whichever SDK dialect the caller speaks. Name one path per protocol, following what `providers/aihubmix/provider.toml` records for each surface. `-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 ask the catalog for the `-non-reasoning` half rather than trusting the word. `coding-` needs no such check: 32 routes carry it, every one a relay of a plain sibling the list also carries, so it joins `-free` as a prefix match and its five cards go with it. Two cards carried hand-written data the endpoint contradicts, which is why the sync kept rewriting them. `deepseek-v4-pro-0813` states effort `high|max` where both the endpoint and canon publish `low|high|max`; `qwen3.7-flash` states no effort row at all against seven published levels, and its `[limit]`/`[modalities]` overrides restate the base model wrongly -- 991_000 is the input-token ceiling, not the context window. Both now match the response and survive a second sync unchanged. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 115 ++++++++++++----- packages/core/test/sync.test.ts | 122 +++++++++++++++++- providers/aihubmix/models/coding-glm-5.1.toml | 28 ---- .../models/coding-minimax-m2.7-highspeed.toml | 27 ---- .../aihubmix/models/coding-minimax-m2.7.toml | 27 ---- .../models/coding-xiaomi-mimo-v2.5-pro.toml | 19 --- .../models/coding-xiaomi-mimo-v2.5.toml | 19 --- .../aihubmix/models/deepseek-v4-pro-0813.toml | 17 ++- providers/aihubmix/models/qwen3.7-flash.toml | 41 ++++-- 9 files changed, 246 insertions(+), 169 deletions(-) delete mode 100644 providers/aihubmix/models/coding-glm-5.1.toml delete mode 100644 providers/aihubmix/models/coding-minimax-m2.7-highspeed.toml delete mode 100644 providers/aihubmix/models/coding-minimax-m2.7.toml delete mode 100644 providers/aihubmix/models/coding-xiaomi-mimo-v2.5-pro.toml delete mode 100644 providers/aihubmix/models/coding-xiaomi-mimo-v2.5.toml diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index bbdfd6ec366..1030737041d 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -121,20 +121,38 @@ const VENDOR_LABS: Record = { // 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. All three suffixes name 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), and `-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. Filtering here - * rather than at translate keeps them out of the missing-model issues too. + * 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. * - * Matched on the suffix, so `AiHubmix-Phi-4-mini-reasoning` — where the word is - * part of Microsoft's own model name — is caught as well. It writes no file today - * (the endpoint reports no `reasoning` flag and none of the standalone fields), - * so the filter costs nothing; give it an exception here if it ever should. + * 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, bar `minimax-m2.7-highspeed` + * — reached through `cc-`/`mm-` routes instead — and the two `mimo-v2-*` entries + * the endpoint under-describes today, so nothing loses its only card here. + * + * `-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_SUFFIX = /-(?:free|non-reasoning|reasoning)$/i; +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 @@ -194,21 +212,32 @@ async function readLabMetadataIDs(modelsDir: string) { return ids; } -// The same off state is reachable from whichever dialect the caller speaks, so -// an off switch has no single wire path. Name one per protocol. -const DIALECT_PATHS = - '# $.enable_thinking = true|false on the OpenAI-compatible /v1/chat/completions path (verified live 2026-09-11);\n' + +// 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 two lines above are only ever this adapter's own. +// 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"; -const DIALECTS = DIALECT_PATHS + DIALECT_SOURCE; -const TOGGLE_HEADER = "# Toggle:\n" + DIALECTS; // Where the catalog spells the off state as `effort = none`, the other dialects // still reach it, and the folded toggle is the only place that was recorded. -const FOLDED_HEADER = "# Off is effort=none; graded levels — no toggle. The same off elsewhere:\n" + DIALECTS; +const FOLDED_OPENING = "# Off is effort=none; graded levels — no toggle. The same off elsewhere:\n"; export const aihubmix = { id: "aihubmix", @@ -269,8 +298,11 @@ export const aihubmix = { // 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. - const listed = [...relayCatalog.values()].filter( - (model) => !ROUTE_VARIANT_SUFFIX.test(model.model_id), + // 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 @@ -567,8 +599,8 @@ function factoredName(model: AihubmixModel, base: string, existing: ExistingMode const AUTHORED_OPENING = /^#\s*(Toggle|Effort|Budget|Off is effort)\b/; function composeHeader(existingHeader: string | undefined, derived: string | undefined) { - // The two wire-path lines are this adapter's own restatement of the block, so - // they go whether or not a block replaces them. Keeping them when nothing is + // 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. @@ -577,7 +609,7 @@ function composeHeader(existingHeader: string | undefined, derived: string | und // avoid stating it twice. It is a citation for the gateway rather than a claim // about this model, and a human wrote this exact line in `gemini-3.7-flash`. const authored = new Set( - (derived === undefined ? DIALECT_PATHS : DIALECTS) + (derived === undefined ? ADAPTER_PATHS : ADAPTER_PATHS + DIALECT_SOURCE) .split("\n") .map((line) => line.trim()) .filter((line) => line !== ""), @@ -591,15 +623,38 @@ function composeHeader(existingHeader: string | undefined, derived: string | und 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; - if (options.some((option) => option.type === "toggle")) return TOGGLE_HEADER; - // Only say where the off state moved to on a file that actually spells it out. - return options.some((option) => option.type === "effort" && option.values?.includes("none")) && + 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") - ? FOLDED_HEADER - : undefined; + ) { + 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; } /** diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 4f3c8d40c8a..6feb759bad6 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -5228,6 +5228,10 @@ test("authors the AIHubMix toggle wire-path header so a rewrite cannot drop it", ...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] }); @@ -5255,6 +5259,69 @@ test("authors the AIHubMix toggle wire-path header so a rewrite cannot drop it", 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. @@ -5475,6 +5542,10 @@ test("refreshes the AIHubMix wire path without discarding a human note", () => { 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, { @@ -5770,21 +5841,60 @@ test("marks retired AIHubMix relays deprecated and stops tracking them", () => { 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, - // and `-reasoning`/`-non-reasoning` are the pre-split Grok routes that reach one + // `-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. parseModels drops them, so they raise - // no skip notice and no missing-model issue asking a human to fill them in. + // `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 word is part of Microsoft's own model name here, and the suffix match - // catches it too. It writes no file today, so the filter costs nothing. - aihubmixModel({ model_id: "AiHubmix-Phi-4-mini-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("keeps only the AIHubMix routes canon covers", () => { 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-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/deepseek-v4-pro-0813.toml b/providers/aihubmix/models/deepseek-v4-pro-0813.toml index fbe29803f4c..e20188a5325 100644 --- a/providers/aihubmix/models/deepseek-v4-pro-0813.toml +++ b/providers/aihubmix/models/deepseek-v4-pro-0813.toml @@ -1,12 +1,23 @@ -# Toggle: enable_thinking = true|false -# Effort: reasoning_effort = high|max +# 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 # AIHubMix effort levels returned HTTP 200 (validated 2026-08-31T04:17:24Z). 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/qwen3.7-flash.toml b/providers/aihubmix/models/qwen3.7-flash.toml index 3f34f7fd74f..ab1a3dafb5c 100644 --- a/providers/aihubmix/models/qwen3.7-flash.toml +++ b/providers/aihubmix/models/qwen3.7-flash.toml @@ -1,22 +1,43 @@ -# Toggle: enable_thinking = true|false -# Budget: thinking_budget = integer reasoning tokens +# Off is effort=none; graded levels — no toggle. The same off elsewhere: +# $.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: none|minimal|low|medium|high|xhigh|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. +# 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 = "effort" +values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + +[[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 From 7dc7d5e4e0755430678696e0a9ac6d7eded1bfe7 Mon Sep 17 00:00:00 2001 From: chenxue Date: Tue, 15 Sep 2026 19:32:03 +0800 Subject: [PATCH 07/13] aihubmix: drop an effort list that states the protocol, not the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIHubMix echoes the OpenAI chat protocol's whole `ReasoningEffort` enum for a route it holds no per-model levels for. The routes it lands on say so: of 409, 18 receive it, among them `grok-4-fast-non-reasoning` — whose name states it does not reason — with `default = none`, the pinned `gpt-5.2-high`/`-low` variants, the image route `gemini-3-pro-image`, and every Qwen 3.5/3.6/3.7 entry while Qwen 3.8 carries a real `low|medium|xhigh`. `default` stays route-specific throughout, so the host knows the route's setting and is stating the protocol in `values`. Live probing agrees. On `qwen3.7-flash` and `deepseek-v4-pro-0813`, only `none` is observable — it returns no reasoning tokens and an empty `reasoning_content` — while the six graded levels are reproducibly non-monotonic across two runs (`minimal` above `high`, `xhigh` lowest). A file copying that would publish a ladder no caller can steer with. So the list is dropped where it is exactly the protocol's seven, and the toggle and budget the same route publishes carry the reasoning surface alone. It is a shape rule over the response, not a per-model baseline: `qwen3.7-flash` lands on `toggle` + `budget_tokens`, matching `alibaba-cn` and `openrouter` without either being consulted, and a narrowed list of any length survives untouched. `deepseek-v4-pro-0813` keeps the `low|high|max` the host narrows for all seven DeepSeek routes; its note now records that `low` is not observably distinct and that `none` works but goes unpublished — both are the host's to fix, and the file follows what it publishes rather than restating a hand baseline. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 29 ++++++++ packages/core/test/sync.test.ts | 69 +++++++++++++++++++ .../aihubmix/models/deepseek-v4-pro-0813.toml | 7 +- providers/aihubmix/models/qwen3.7-flash.toml | 8 +-- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index 1030737041d..34b3f620947 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -162,6 +162,31 @@ const EFFORT_ALIASES: Record = { no_think: "none", instant: "min // nothing where it means nothing, which the filter below already drops. const EFFORT_VALUES = new Set(REASONING_EFFORT_VALUES); +/** + * The full `ReasoningEffort` enum of the OpenAI chat protocol, which the host + * echoes verbatim for a route it holds no per-model levels for. It is the + * protocol's accepted set, not the model's own ladder, and the routes that + * receive it say so themselves: `grok-4-fast-non-reasoning` — a route whose name + * states it does not reason — carries all seven with `default = none`, as do the + * pinned `gpt-5.2-high` / `gpt-5.2-low` variants and the image route + * `gemini-3-pro-image`. 18 of 409 routes are served it, among them every Qwen + * 3.5/3.6/3.7 entry while Qwen 3.8 carries a real `low|medium|xhigh`; live + * probing of `qwen3.7-flash` and `deepseek-v4-pro-0813` finds the six non-`none` + * levels reproducibly non-monotonic (`minimal` above `high`, `xhigh` lowest), + * so nothing but the off state is observable across them. + * + * `default` stays route-specific throughout, so the host does know the route's + * setting and is stating the protocol in `values` rather than the model. A file + * that copied it would publish a ladder no caller can steer with. + */ +const PROTOCOL_EFFORT_SET = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); + +function statesProtocolNotModel(values: string[]) { + return ( + values.length === PROTOCOL_EFFORT_SET.size && values.every((value) => PROTOCOL_EFFORT_SET.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 @@ -527,6 +552,10 @@ function reasoningOptions( const values = (option.values ?? []) .map((value) => EFFORT_ALIASES[value] ?? value) .filter((value) => EFFORT_VALUES.has(value)); + // A level list is only worth recording where it is the model's; the protocol's + // own enum tells a caller nothing, so it is dropped and the toggle and budget + // the same route publishes are left to carry the reasoning surface. + if (statesProtocolNotModel(values)) return []; return values.length > 0 ? [{ type: "effort" as const, values }] : []; }); // AIHubMix accepts whichever off switch the caller's SDK speaks and maps it, diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 6feb759bad6..d2b5e06b553 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -5897,6 +5897,75 @@ test("reads a steering pair from the AIHubMix list rather than the word `reasoni expect(aihubmix.parseModels({ data: offOnly }).map((model) => model.model_id)).toEqual(["glm-5.1"]); }); +test("drops an AIHubMix effort list that is the protocol's enum rather than the model's", () => { + // Standalone so the row itself is the whole answer: a vendor would send the + // build down the factoring path and read levels off the lab entry instead. + const standalone = { + vendor: null, + release_date: "2026-05-01", + open_weights: false, + context_length: 262_144, + max_output: 65_536, + } satisfies Partial; + // The host echoes the OpenAI chat protocol's whole `ReasoningEffort` enum for a + // route it holds no per-model levels for, and the routes it lands on give it + // away: `grok-4-fast-non-reasoning` says in its own name that it does not + // reason, yet carries all seven. Copying that would publish a ladder no caller + // can steer with, so the list is dropped and the controls that are the model's + // — its toggle, its budget — carry the surface alone. + const protocolEnum = aihubmixModel({ + ...standalone, + model_id: "qwen3.7-flash", + model_name: "Qwen3.7 Flash", + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }, + { type: "budget_tokens" }, + ], + }); + expect(buildAihubmixModel(protocolEnum, undefined, aihubmixLabIDs)?.reasoning_options).toEqual([ + { type: "toggle" }, + { type: "budget_tokens", min: undefined, max: undefined }, + ]); + + // Order is the payload's, not a canonical one: the same seven arrive shuffled + // across routes, so membership is what decides. + const shuffled = aihubmixModel({ + ...standalone, + model_id: "qwen3.6-flash", + model_name: "Qwen3.6 Flash", + reasoning_options: [ + { type: "effort", values: ["minimal", "low", "medium", "high", "xhigh", "max", "none"] }, + ], + }); + expect(buildAihubmixModel(shuffled, undefined, aihubmixLabIDs)?.reasoning_options).toBeUndefined(); + + // A narrowed list is the model's own and survives untouched, including one that + // happens to be long: Qwen 3.8 states `low|medium|xhigh` where 3.7 gets the enum, + // and six of the seven levels is still a statement about the model. + const narrowed = aihubmixModel({ + ...standalone, + model_id: "qwen3.8-max", + model_name: "Qwen3.8 Max", + reasoning_options: [{ type: "effort", values: ["low", "medium", "xhigh"] }], + }); + expect(buildAihubmixModel(narrowed, undefined, aihubmixLabIDs)?.reasoning_options).toEqual([ + { type: "effort", values: ["low", "medium", "xhigh"] }, + ]); + + const sixOfSeven = aihubmixModel({ + ...standalone, + model_id: "gpt-5.6-sol", + model_name: "GPT-5.6 Sol", + reasoning_options: [ + { type: "effort", values: ["none", "low", "medium", "high", "xhigh", "max"] }, + ], + }); + expect(buildAihubmixModel(sixOfSeven, undefined, aihubmixLabIDs)?.reasoning_options).toEqual([ + { type: "effort", values: ["none", "low", "medium", "high", "xhigh", "max"] }, + ]); +}); + 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 diff --git a/providers/aihubmix/models/deepseek-v4-pro-0813.toml b/providers/aihubmix/models/deepseek-v4-pro-0813.toml index e20188a5325..2ac720fa25f 100644 --- a/providers/aihubmix/models/deepseek-v4-pro-0813.toml +++ b/providers/aihubmix/models/deepseek-v4-pro-0813.toml @@ -5,7 +5,12 @@ # $.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 -# AIHubMix effort levels returned HTTP 200 (validated 2026-08-31T04:17:24Z). +# The host narrows the levels per model — this set is shared by all seven DeepSeek +# routes — but `low` is not observably distinct here: probed 2026-09-15, low/high/max +# return 522/564/510 reasoning tokens on one prompt, non-monotonic across levels, and +# the lab's own docs give `reasoning_effort = high|max` with low/medium aliased to high. +# `none` turns thinking off here yet is absent from the published set. Both gaps are +# the host's to state; this file follows what it publishes. base_model = "deepseek/deepseek-v4-pro-0813" [interleaved] diff --git a/providers/aihubmix/models/qwen3.7-flash.toml b/providers/aihubmix/models/qwen3.7-flash.toml index ab1a3dafb5c..1c39c77be95 100644 --- a/providers/aihubmix/models/qwen3.7-flash.toml +++ b/providers/aihubmix/models/qwen3.7-flash.toml @@ -1,9 +1,6 @@ -# Off is effort=none; graded levels — no toggle. The same off elsewhere: +# 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: none|minimal|low|medium|high|xhigh|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. # 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); @@ -15,8 +12,7 @@ base_model = "alibaba/qwen3.7-flash" field = "reasoning_content" [[reasoning_options]] -type = "effort" -values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] +type = "toggle" [[reasoning_options]] type = "budget_tokens" From aab1c9011210e0ccd4aaed101938d13b2e6cfacc Mon Sep 17 00:00:00 2001 From: chenxue Date: Tue, 15 Sep 2026 19:45:29 +0800 Subject: [PATCH 08/13] fix(aihubmix): correct the deepseek-v4-pro-0813 effort note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note claimed DeepSeek documents `high|max` with low folded into high. It documents `Possible values: [low, high, max]`, folding only `medium` and `xhigh` into `high` (api-docs.deepseek.com, archived in canon 2026-08-13). `low` is a real published level, so the three values this file carries are the lab's own enum passed through, not a host-side widening. The single-prompt probe is kept as what it is — an observation about one prompt. Co-Authored-By: Claude Opus 5 --- providers/aihubmix/models/deepseek-v4-pro-0813.toml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/providers/aihubmix/models/deepseek-v4-pro-0813.toml b/providers/aihubmix/models/deepseek-v4-pro-0813.toml index 2ac720fa25f..014f474c199 100644 --- a/providers/aihubmix/models/deepseek-v4-pro-0813.toml +++ b/providers/aihubmix/models/deepseek-v4-pro-0813.toml @@ -5,12 +5,13 @@ # $.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 -# The host narrows the levels per model — this set is shared by all seven DeepSeek -# routes — but `low` is not observably distinct here: probed 2026-09-15, low/high/max -# return 522/564/510 reasoning tokens on one prompt, non-monotonic across levels, and -# the lab's own docs give `reasoning_effort = high|max` with low/medium aliased to high. -# `none` turns thinking off here yet is absent from the published set. Both gaps are -# the host's to state; this file follows what it publishes. +# These three are the lab's own published levels, passed through unchanged: DeepSeek +# documents `reasoning_effort` as `Possible values: [low, high, max]`, with `medium` +# and `xhigh` folded into `high` (api-docs.deepseek.com, checked 2026-08-13), and all +# seven DeepSeek routes here carry the same set. Probing on 2026-09-15 could not +# separate them on a single prompt (low/high/max → 522/564/510 reasoning tokens, +# non-monotonic), which says something about that prompt, not about the enum. +# Off is the toggle on this path; `none` is a level only on the Responses face. base_model = "deepseek/deepseek-v4-pro-0813" [interleaved] From a0539cae7804be1a904f29367d5123ed748dde26 Mon Sep 17 00:00:00 2001 From: chenxue Date: Wed, 16 Sep 2026 14:53:40 +0800 Subject: [PATCH 09/13] docs(aihubmix): correct the missing-local claim and the route-filter exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sync.md` said a local file absent from one response is retained *and* opens a deduped GitHub issue. Only the first half is true: `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 surfaces only through `missingNotice`. State notice-only rather than promise automation that does not run. The route-filter block named `minimax-m2.7-highspeed` as an exception without saying why it is not a gap. It is not: `cc-minimax-m2.7-highspeed` and `mm-minimax-m2.7-highspeed` are both listed, match no affix in the filter, and declare `variant_of = minimax-m2.7-highspeed`, which resolves to the lab entry `models/minimax/MiniMax-M2.7-highspeed.toml` — a dry run against the live list writes each of them a card, so the filter drops the `coding-` price point and not the model. The MiMo V2.5 pair reads the same way: the list spells them `mimo-v2.5`/`mimo-v2.5-pro` with no `xiaomi-` prefix. The one pair that really goes uncarded is `mimo-v2-omni`/`mimo-v2-pro`, skipped for publishing no `reasoning_options` rather than by this filter, and that is now said outright. Comments and docs only; no behaviour change. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 14 +++++++++++--- sync.md | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index 34b3f620947..86a0ed93625 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -134,9 +134,17 @@ const VENDOR_LABS: Record = { * * 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, bar `minimax-m2.7-highspeed` - * — reached through `cc-`/`mm-` routes instead — and the two `mimo-v2-*` entries - * the endpoint under-describes today, so nothing loses its only card here. + * 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 diff --git a/sync.md b/sync.md index a0fcb8c3974..f6449ca16fd 100644 --- a/sync.md +++ b/sync.md @@ -287,7 +287,7 @@ xAI is implemented in `packages/core/src/sync/providers/xai.ts`. - `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 opens a deduped GitHub issue naming both readings — confirm the rotation, or drop the file if it is a hidden channel alias. +- 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 From db7e91a109fe7ba492e0c5c43f216a87fadddaa3 Mon Sep 17 00:00:00 2001 From: chenxue Date: Wed, 16 Sep 2026 16:17:27 +0800 Subject: [PATCH 10/13] Read the AIHubMix reasoning side channel off the protocol it is spoken over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint now states `interleaved` per wire protocol — `true` where the channel exists but its carrier has no settled name, `{field}` where it does — because the carrier is a property of the protocol shape rather than of the model: `claude-opus-4-8` returns thinking blocks on `/v1/messages` and nothing at all on the chat-completions path. Which protocol a given model is spoken over is itself per-model, not per provider: `@aihubmix/ai-sdk-provider` builds `claude-*` as an Anthropic messages model, `gemini*`/`imagen*` as a Google generative model (except the `-nothink`/`-search` routes, which it sends back down the OpenAI-compatible path), and everything else as an OpenAI-compatible chat model. `wireProtocol` transcribes that from `createChatModel`, so each model is read on its own face and never on another model's. Silence stays unknown rather than denial, the same reading the missing `reasoning` flag gets: a model the endpoint says nothing about keeps whatever the file authored, and a face the endpoint describes for other models is no less silent about this one. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 69 +++++++++- packages/core/test/sync.test.ts | 129 +++++++++++++++++++ 2 files changed, 195 insertions(+), 3 deletions(-) diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index 86a0ed93625..af1be4f3901 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -81,6 +81,11 @@ export const AihubmixModel = z // 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(); @@ -416,10 +421,10 @@ export function buildAihubmixModel( reasoning_options: reasoningOptions(model, existing) ?? existing?.reasoning_options, tool_call: toolCall, structured_output: structuredOutput, - // AIHubMix serves no temperature, interleaved, fast-mode or request-shape - // surface; all four exist on the file and nowhere else, so keep them. + // 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: existing?.interleaved, + interleaved: interleavedFor(model, existing), experimental: existing?.experimental, provider: existing?.provider, status: resolveStatus(model.retire_stage, existing?.status), @@ -537,6 +542,64 @@ 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, diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index d2b5e06b553..ed61b5ddca1 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -9,6 +9,7 @@ import { aihubmix, buildAihubmixModel, canonCoveredModels, + wireProtocol, type AihubmixModel, } from "../src/sync/providers/aihubmix.js"; import { @@ -6042,6 +6043,134 @@ test("skips an AIHubMix reasoner that publishes no reasoning options", () => { 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", From e0a1fddfef56a396682531624b24e185047a95b2 Mon Sep 17 00:00:00 2001 From: chenxue Date: Fri, 18 Sep 2026 12:06:48 +0800 Subject: [PATCH 11/13] fix(aihubmix): correct the `none` note on deepseek-v4-pro-0813 The header claimed `none` was "a level only on the Responses face". It is not: DeepSeek's chat-completions docs list it in the same set ("Possible values: [none, low, high, max]") and describe it as disabling thinking mode, i.e. an off switch spelled as a level rather than a fourth intensity. Off is already carried by `type = "toggle"`, so `none` stays out of the effort values -- the data was right, the explanation was not. Co-Authored-By: Claude Opus 5 --- providers/aihubmix/models/deepseek-v4-pro-0813.toml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/providers/aihubmix/models/deepseek-v4-pro-0813.toml b/providers/aihubmix/models/deepseek-v4-pro-0813.toml index 014f474c199..fdb588c4205 100644 --- a/providers/aihubmix/models/deepseek-v4-pro-0813.toml +++ b/providers/aihubmix/models/deepseek-v4-pro-0813.toml @@ -6,12 +6,14 @@ # $.output_config.effort on /v1/messages, subject to model support. # https://docs.aihubmix.com/cn/api/unified-inference # These three are the lab's own published levels, passed through unchanged: DeepSeek -# documents `reasoning_effort` as `Possible values: [low, high, max]`, with `medium` -# and `xhigh` folded into `high` (api-docs.deepseek.com, checked 2026-08-13), and all +# documents `reasoning_effort` as `Possible values: [none, low, high, max]`, with `medium` +# and `xhigh` folded into `high` (api-docs.deepseek.com, checked 2026-09-18), and all # seven DeepSeek routes here carry the same set. Probing on 2026-09-15 could not # separate them on a single prompt (low/high/max → 522/564/510 reasoning tokens, # non-monotonic), which says something about that prompt, not about the enum. -# Off is the toggle on this path; `none` is a level only on the Responses face. +# `none` is listed alongside them but is the off switch spelled as a level -- the docs +# read "none disables thinking mode; low/high/max enable thinking mode" -- so it is +# carried by `type = "toggle"` above and deliberately left out of the effort values. base_model = "deepseek/deepseek-v4-pro-0813" [interleaved] From 749bf0c5d81a615431e98ef9ba939e80a85ecfa1 Mon Sep 17 00:00:00 2001 From: chenxue Date: Fri, 18 Sep 2026 15:32:25 +0800 Subject: [PATCH 12/13] chore: re-trigger the PR reviewer The previous run failed on an upstream 402 ("Insufficient account funds" from opencode.ai/zen), not on anything in this PR, and the workflow only fires on opened/reopened/synchronize/ready_for_review -- there is no retry. Empty commit, no content change. Co-Authored-By: Claude Opus 5 From a0823d3f581d1b145eda7845cc2ee96b7b25cca4 Mon Sep 17 00:00:00 2001 From: chenxue Date: Fri, 18 Sep 2026 16:08:37 +0800 Subject: [PATCH 13/13] test: cover the authoritative-header path through syncProvider Every AIHubMix test built its own translateModel context, so the `header` callback could have been wired nowhere and the suite would still be green. The new test drives the real runner against local fixtures: a hand-written note on disk has to survive a sync that rewrites the header block it sits under. It lives in its own file because a real sync installs module-level catalog state the other AIHubMix tests expect unset. Writing it surfaced a duplicate-modality bug: when the endpoint omits modalities, the fallback is two overlapping records -- the narrowing list already on the file and the lab entry it narrows -- and a shared entry was written twice. Co-Authored-By: Claude Opus 5 --- packages/core/src/sync/providers/aihubmix.ts | 5 +- .../core/test/sync-aihubmix-runner.test.ts | 84 +++++++++++++++++++ packages/core/test/sync.test.ts | 10 +++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/sync-aihubmix-runner.test.ts diff --git a/packages/core/src/sync/providers/aihubmix.ts b/packages/core/src/sync/providers/aihubmix.ts index af1be4f3901..a8907274ef9 100644 --- a/packages/core/src/sync/providers/aihubmix.ts +++ b/packages/core/src/sync/providers/aihubmix.ts @@ -883,7 +883,10 @@ function modalities(value: string | null | undefined, fallback: string[]) { .split(",") .map((entry) => entry.trim()) .filter((entry) => ["text", "audio", "image", "video", "pdf"].includes(entry)); - const known = fallback.length > 0 ? fallback : ["text"]; + // 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"]; 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..a9015d53916 --- /dev/null +++ b/packages/core/test/sync-aihubmix-runner.test.ts @@ -0,0 +1,84 @@ +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"] }], + }); + } 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 1dc6ffded23..7ad3cc2f617 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -5524,6 +5524,16 @@ test("does not let a narrower AIHubMix modality list delete an accepted one", () 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", () => {