From 2d4e6accc99c55a3c8f8a0da0dcf6802b5adab35 Mon Sep 17 00:00:00 2001 From: "Islomjon (Toji)" Date: Mon, 14 Sep 2026 11:09:53 +0900 Subject: [PATCH 01/10] feat(sync): add ai& sync module with auto-merge allowlist and motif family Co-Authored-By: Claude Fable 5.1 --- models/motif-technologies/motif-3.toml | 33 +++ package.json | 1 + packages/core/src/family.ts | 3 + packages/core/src/sync/auto-merge.ts | 1 + packages/core/src/sync/index.ts | 5 +- packages/core/src/sync/providers/aiand.ts | 307 ++++++++++++++++++++++ packages/core/test/aiand.test.ts | 226 ++++++++++++++++ packages/core/test/auto-merge.test.ts | 2 +- sync.md | 10 + 9 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 models/motif-technologies/motif-3.toml create mode 100644 packages/core/src/sync/providers/aiand.ts create mode 100644 packages/core/test/aiand.test.ts diff --git a/models/motif-technologies/motif-3.toml b/models/motif-technologies/motif-3.toml new file mode 100644 index 00000000000..c42517fba3d --- /dev/null +++ b/models/motif-technologies/motif-3.toml @@ -0,0 +1,33 @@ +# Source: https://huggingface.co/Motif-Technologies/Motif-3 (accessed +# 2026-09-14): repo created 2026-08-07, 155 safetensors shards, ungated, +# MIT license. +# tool_call: the model card documents agentic tool use and ships a vLLM +# --tool-call-parser motif; live probe on 2026-09-14 returned a real +# tool_calls response (finish_reason = tool_calls). +# temperature: the card evaluates at sampling temperature 1.0; live probe on +# 2026-09-14 accepted 0.2 and 1.7, with 1.7 visibly changing the output. +name = "Motif 3" +family = "motif" +description = "Motif 3 is a large-scale, decoder-only Mixture-of-Experts (MoE) language model with 314 billion total parameters and 13.2 billion parameters activated per token." +release_date = "2026-08-07" +last_updated = "2026-08-07" +attachment = false +reasoning = true +temperature = true +tool_call = true +structured_output = false +open_weights = true +license = "MIT" + +[limit] +context = 262_144 +output = 262_144 + +[modalities] +input = ["text"] +output = ["text"] + +[[weights]] +label = "Model weights" +url = "https://huggingface.co/Motif-Technologies/Motif-3" +format = "safetensors" diff --git a/package.json b/package.json index a1de7f5bb8e..099cbc05e8b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "vercel:generate": "bun ./packages/core/script/sync-models.ts vercel", "wandb:generate": "bun ./packages/core/script/sync-models.ts wandb", "digitalocean:sync": "bun ./packages/core/script/sync-models.ts digitalocean", + "aiand:sync": "bun ./packages/core/script/sync-models.ts aiand", "fireworks:sync": "bun ./packages/core/script/sync-models.ts fireworks-ai", "ambient:sync": "bun ./packages/core/script/sync-models.ts ambient", "models:sync": "bun ./packages/core/script/sync-models.ts", diff --git a/packages/core/src/family.ts b/packages/core/src/family.ts index 9f719d80376..c7f641fe8ca 100644 --- a/packages/core/src/family.ts +++ b/packages/core/src/family.ts @@ -84,6 +84,9 @@ export const ModelFamilyValues = [ "kimi-free", "kimi-thinking", + // Motif Technologies + "motif", + // Poolside Laguna "laguna", "laguna-s", diff --git a/packages/core/src/sync/auto-merge.ts b/packages/core/src/sync/auto-merge.ts index 244d2e277d3..12629b95c9e 100644 --- a/packages/core/src/sync/auto-merge.ts +++ b/packages/core/src/sync/auto-merge.ts @@ -5,6 +5,7 @@ export const MAX_CREATED_MODELS = 10; export const MAX_DELETED_MODELS = 10; export const MAX_MODEL_CHURN = 15; const REVIEWED_REASONING_PROVIDERS = new Set([ + "aiand", "crossmodel", "edenai", "empiriolabs", diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 9156827c689..5095f4124ef 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 { aiand } from "./providers/aiand.js"; import { ambient } from "./providers/ambient.js"; import { anthropic } from "./providers/anthropic.js"; import { baseten } from "./providers/baseten.js"; @@ -139,6 +140,7 @@ export interface SyncResult { } export const providers: { + aiand: SyncProvider; ambient: SyncProvider; anthropic: SyncProvider; baseten: SyncProvider; @@ -177,6 +179,7 @@ export const providers: { wandb: SyncProvider; xai: SyncProvider; } = { + aiand, ambient, anthropic, baseten, @@ -234,7 +237,7 @@ export const groups = { "vercel", ], cloudflare: ["cloudflare-ai-gateway", "cloudflare-workers-ai"], - direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], + direct: ["aiand", "ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/aiand.ts b/packages/core/src/sync/providers/aiand.ts new file mode 100644 index 00000000000..8fdf17fa56d --- /dev/null +++ b/packages/core/src/sync/providers/aiand.ts @@ -0,0 +1,307 @@ +import { readdirSync } from "node:fs"; +import path from "node:path"; +import { z } from "zod"; + +import { ModelFamily } from "../../family.js"; +import { ReasoningOption as CatalogReasoningOption } from "../../schema.js"; +import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { factorBaseModel } from "./openrouter.js"; + +const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models"); + +// ai&'s /v1/api.json publishes this repo's shape directly; the `aiand` key is +// the attributable provider entry and lists only schema-complete models, so +// translation is near-identity. Reference: +// https://api.aiand.com/v1/api.json +const API_ENDPOINT = process.env.AIAND_API_URL ?? "https://api.aiand.com/v1/api.json"; + +const MODALITIES = ["text", "audio", "image", "video", "pdf"] as const; +type Modality = (typeof MODALITIES)[number]; + +// The feed publishes the catalog's reasoning_options shape. Effort values are +// parsed leniently (any string) so a vocabulary the schema doesn't know yet +// is filtered per value instead of aborting the whole feed parse; toggle and +// budget_tokens controls take the shared catalog schema as-is. +const FeedReasoningOption = z.union([ + z.object({ type: z.literal("effort"), values: z.array(z.string()) }).passthrough(), + CatalogReasoningOption, +]); + +export const AiandModel = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1).optional(), + family: z.string().optional(), + release_date: z.string(), + last_updated: z.string().optional(), + attachment: z.boolean(), + reasoning: z.boolean(), + reasoning_options: z.array(FeedReasoningOption).optional(), + temperature: z.boolean(), + tool_call: z.boolean(), + structured_output: z.boolean().optional(), + cost: z + .object({ + input: z.number().nonnegative(), + output: z.number().nonnegative(), + cache_read: z.number().nonnegative().optional(), + }) + .passthrough(), + limit: z + .object({ + context: z.number().int().positive(), + output: z.number().int().positive(), + }) + .passthrough(), + modalities: z + .object({ + input: z.array(z.string()), + output: z.array(z.string()), + }) + .passthrough(), + open_weights: z.boolean().optional(), + status: z.enum(["alpha", "beta", "deprecated"]).optional(), + }) + .passthrough(); + +export const AiandResponse = z + .object({ + aiand: z.object({ models: z.record(AiandModel) }).passthrough(), + }) + .passthrough(); + +export type AiandModel = z.infer; + +export const aiand = { + id: "aiand", + name: "ai&", + modelsDir: "providers/aiand/models", + // Factoring is explicit (factorBaseModel) so files stay override-only; the + // runner's default preservation keeps the base_model reference but does not + // drop fields identical to the base. + preserveBaseModels: false, + async fetchModels() { + const response = await fetch(API_ENDPOINT); + if (!response.ok) { + throw new Error(`ai& catalog request failed: ${response.status} ${response.statusText}`); + } + return response.json(); + }, + parseModels(raw) { + const models = Object.values(AiandResponse.parse(raw).aiand.models); + // deleteMissing runs at the runner default: an empty or truncated feed + // must fail loudly here rather than read as "delete the local catalog". + if (models.length === 0) { + throw new Error("ai& catalog returned no models; refusing an empty feed as authoritative"); + } + return models; + }, + translateModel(model, context) { + const existing = context.existing(model.id); + const baseModel = existing?.base_model ?? resolveAiandBaseModel(model.id, model.name); + // A new feed model with no resolvable lab entry must never be written as + // an unfactored full definition; skip it (reported via sourceID) until a + // models/ file exists. An existing local file is always translated — + // skipping one would delete it. + if (existing === undefined && baseModel === undefined) return undefined; + return { + id: model.id, + model: buildAiandModel(model, existing, baseModel), + }; + }, + sourceID(model) { + return model.id; + }, + skippedNotice(ids) { + return [ + `Skipped ${ids.length} feed model(s) with no resolvable base model — author a models/ lab file to list them: ${ids.join(", ")}`, + ]; + }, +} satisfies SyncProvider; + +export function buildAiandModel( + model: AiandModel, + existing: ExistingModel | undefined, + baseModel: string | null | undefined = existing?.base_model ?? resolveAiandBaseModel(model.id, model.name), + today = new Date().toISOString().slice(0, 10), +): SyncedModel { + // Unknown families must degrade to the curated value rather than fail + // validation, so a model whose family is not in the enum yet cannot stall + // the sync. + const family = ModelFamily.safeParse(model.family); + const reasoningOptions = resolveReasoningOptions(model, existing); + const limit = { + context: model.limit.context, + input: existing?.limit?.input, + output: model.limit.output, + }; + const values: SyncedFullModel = { + // Curated display names and descriptions win over the gateway's. + name: existing?.name ?? model.name, + description: existing?.description ?? model.description ?? model.name, + family: family.success ? family.data : existing?.family, + attachment: model.attachment, + reasoning: model.reasoning, + // The schema refuses reasoning_options on a non-reasoner; a non-reasoning + // feed model must not stall the sync on that refine. + reasoning_options: model.reasoning ? reasoningOptions : undefined, + tool_call: model.tool_call, + structured_output: model.structured_output, + temperature: model.temperature, + knowledge: existing?.knowledge, + // Release dates are lab metadata the gateway is not authoritative for; + // the curated (or base-resolved) value wins, and a wrong one gets fixed + // in the models/ lab file, not by a provider override. + release_date: existing?.release_date ?? model.release_date, + // The feed's last_updated tracks catalog-row edits, not model revisions, + // so the curated value wins to keep the hourly sync free of date churn. + last_updated: existing?.last_updated ?? model.last_updated ?? today, + // Canonical order so a feed-side reordering never churns a TOML. + modalities: { + input: sortModalities(model.modalities.input.filter(isModality)), + output: sortModalities(model.modalities.output.filter(isModality)), + }, + open_weights: model.open_weights ?? existing?.open_weights ?? false, + limit, + cost: { + input: model.cost.input, + output: model.cost.output, + reasoning: existing?.cost?.reasoning, + cache_read: model.cost.cache_read, + cache_write: existing?.cost?.cache_write, + input_audio: existing?.cost?.input_audio, + output_audio: existing?.cost?.output_audio, + tiers: existing?.cost?.tiers, + }, + // Absence means active on the feed; a curated alpha/beta stays until the + // gateway publishes a status of its own. + status: model.status ?? existing?.status, + interleaved: existing?.interleaved, + }; + if (baseModel == null) return values; + // Lab-owned fields are never asserted from the gateway on a factored file: + // they come from the curated file (base-resolved, so an authored override + // survives and a new file inherits the lab entry). Host-specific facts — + // pricing, limits, controls, capability flags, modalities, status — stay + // feed-authoritative and factor to overrides only where they differ. + return factorBaseModel( + baseModel, + { + ...values, + name: existing?.name, + description: existing?.description, + family: undefined, + release_date: existing?.release_date, + last_updated: existing?.last_updated, + knowledge: existing?.knowledge, + open_weights: existing?.open_weights, + }, + limit, + existing?.base_model_omit, + ); +} + +interface MetadataEntry { + id: string; + normalizedFull: string; + normalizedFilename: string; +} + +let metadataEntries: MetadataEntry[] | undefined; + +/** + * ai& publishes lab-prefixed ids ("deepseek-ai/deepseek-v4-flash") whose lab + * segment can differ from the models/ directory ("deepseek/…"), so new ids + * resolve like Venice's: a unique normalized match on the full id first, then + * on the filename alone. + */ +export function resolveAiandBaseModel(id: string, name: string): string | undefined { + const entries = getMetadataEntries(); + for (const candidate of [...new Set([id, name])]) { + const normalizedFull = normalize(candidate); + const normalizedFilename = normalize(candidate.split("/").pop() ?? candidate); + const ranked = [ + entries.filter((entry) => entry.normalizedFull === normalizedFull), + entries.filter((entry) => entry.normalizedFilename === normalizedFilename), + ]; + const match = ranked.find((matches) => matches.length === 1)?.[0]?.id; + if (match !== undefined) return match; + } + return undefined; +} + +function getMetadataEntries() { + if (metadataEntries !== undefined) return metadataEntries; + metadataEntries = []; + for (const provider of readdirSync(MODELS_DIR, { withFileTypes: true })) { + if (!provider.isDirectory()) continue; + for (const file of readdirSync(path.join(MODELS_DIR, provider.name), { withFileTypes: true })) { + if (!file.isFile() || !file.name.endsWith(".toml")) continue; + const filename = file.name.slice(0, -5); + metadataEntries.push({ + id: `${provider.name}/${filename}`, + normalizedFull: normalize(`${provider.name}/${filename}`), + normalizedFilename: normalize(filename), + }); + } + } + return metadataEntries; +} + +function normalize(value: string) { + return value.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); +} + +type ReasoningEffortValue = Extract< + Extract, { type: "effort" }>["values"][number], + string +>; + +function isReasoningEffort(value: string): value is ReasoningEffortValue { + return CatalogReasoningOption.safeParse({ type: "effort", values: [value] }).success; +} + +/** + * Omit, empty, and vocabulary-miss are three different assertions: an omitted + * feed list asserts nothing (authored options stay), an explicit [] asserts + * "no caller controls", and a non-empty list whose values the schema doesn't + * know yet keeps the authored options rather than inventing "no control" and + * auto-merging it. + */ +function resolveReasoningOptions( + model: AiandModel, + existing: ExistingModel | undefined, +): SyncedFullModel["reasoning_options"] { + if (model.reasoning_options === undefined) return authoredReasoningOptions(existing); + if (model.reasoning_options.length === 0) return []; + const feed = model.reasoning_options.flatMap((option) => { + if (option.type === "effort") { + const values = option.values.filter( + (value): value is ReasoningEffortValue => typeof value === "string" && isReasoningEffort(value), + ); + return values.length > 0 ? [{ type: "effort" as const, values }] : []; + } + // toggle / budget_tokens carry no vocabulary to filter; the catalog + // schema already validated them at parse time. + return [option]; + }); + return feed.length > 0 ? feed : authoredReasoningOptions(existing); +} + +function authoredReasoningOptions( + existing: ExistingModel | undefined, +): SyncedFullModel["reasoning_options"] { + const options = (existing?.reasoning_options ?? []) + .map((option) => CatalogReasoningOption.safeParse(option)) + .flatMap((result) => (result.success ? [result.data] : [])); + return options.length > 0 ? options : undefined; +} + +function isModality(value: string): value is Modality { + return (MODALITIES as readonly string[]).includes(value); +} + +function sortModalities(values: Modality[]): Modality[] { + return [...new Set(values)].sort((a, b) => MODALITIES.indexOf(a) - MODALITIES.indexOf(b)); +} diff --git a/packages/core/test/aiand.test.ts b/packages/core/test/aiand.test.ts new file mode 100644 index 00000000000..37715e50c96 --- /dev/null +++ b/packages/core/test/aiand.test.ts @@ -0,0 +1,226 @@ +import { expect, test } from "bun:test"; + +import { + aiand, + AiandModel, + AiandResponse, + buildAiandModel, + resolveAiandBaseModel, + type AiandModel, +} from "../src/sync/providers/aiand.js"; + +function aiandModel(overrides: Partial = {}): AiandModel { + return { + id: "deepseek-ai/deepseek-v4-flash", + name: "deepseek-ai/DeepSeek-V4-Flash", + description: "Fast DeepSeek V4 lane for economical reasoning, coding, and long-context work", + family: "deepseek", + release_date: "2026-04-24", + last_updated: "2026-09-14", + attachment: false, + reasoning: true, + reasoning_options: [{ type: "effort", values: ["none", "high", "max"] }], + temperature: true, + tool_call: true, + structured_output: true, + cost: { input: 0.15, output: 0.25, cache_read: 0.08 }, + limit: { context: 1_048_576, output: 384_000 }, + modalities: { input: ["text"], output: ["text"] }, + open_weights: true, + ...overrides, + }; +} + +test("translates a feed model near-identity when nothing is authored", () => { + const built = buildAiandModel(aiandModel(), undefined, null); + expect(built).toMatchObject({ + name: "deepseek-ai/DeepSeek-V4-Flash", + family: "deepseek", + release_date: "2026-04-24", + reasoning: true, + reasoning_options: [{ type: "effort", values: ["none", "high", "max"] }], + structured_output: true, + cost: { input: 0.15, output: 0.25, cache_read: 0.08 }, + limit: { context: 1_048_576, output: 384_000 }, + open_weights: true, + }); +}); + +test("curated name, knowledge, and last_updated win over the feed", () => { + const built = buildAiandModel( + aiandModel(), + { name: "DeepSeek V4 Flash", knowledge: "2025-05", last_updated: "2026-07-31" }, + null, + ); + expect(built.name).toBe("DeepSeek V4 Flash"); + expect(built.knowledge).toBe("2025-05"); + expect(built.last_updated).toBe("2026-07-31"); +}); + +test("the feed's last_updated is row-edit noise: today only fills a blank", () => { + const built = buildAiandModel(aiandModel({ last_updated: undefined }), undefined, null, "2026-09-14"); + expect(built.last_updated).toBe("2026-09-14"); +}); + +test("a curated release_date wins over the feed's", () => { + const built = buildAiandModel( + aiandModel({ release_date: "2026-05-01" }), + { release_date: "2026-04-24" }, + null, + ); + expect(built.release_date).toBe("2026-04-24"); +}); + +test("a vocabulary miss preserves authored reasoning options instead of publishing none", () => { + const built = buildAiandModel( + aiandModel({ reasoning_options: [{ type: "effort", values: ["turbo"] }] }), + { reasoning_options: [{ type: "effort", values: ["high"] }] }, + null, + ); + expect(built.reasoning_options).toEqual([{ type: "effort", values: ["high"] }]); +}); + +test("a family the enum does not know falls back to the curated value", () => { + const unknown = buildAiandModel(aiandModel({ family: "not-a-family" }), undefined, null); + expect(unknown.family).toBeUndefined(); + const curated = buildAiandModel(aiandModel({ family: "not-a-family" }), { family: "deepseek" }, null); + expect(curated.family).toBe("deepseek"); +}); + +test("unknown reasoning efforts and modalities are dropped without inventing 'no control'", () => { + const built = buildAiandModel( + aiandModel({ + reasoning_options: [{ type: "effort", values: ["turbo"] }], + modalities: { input: ["text", "smell"], output: ["text"] }, + }), + undefined, + null, + ); + expect(built.reasoning_options).toBeUndefined(); + expect(built.modalities?.input).toEqual(["text"]); +}); + +test("omitted reasoning_options assert nothing: authored options stay", () => { + const built = buildAiandModel(aiandModel({ reasoning_options: undefined }), { + reasoning_options: [{ type: "effort", values: ["high"] }], + }); + expect(built.reasoning_options).toEqual([{ type: "effort", values: ["high"] }]); +}); + +test("an explicit empty list asserts no caller controls and is written as-is", () => { + const built = buildAiandModel( + aiandModel({ reasoning_options: [] }), + { reasoning_options: [{ type: "effort", values: ["high"] }] }, + null, + ); + expect(built.reasoning_options).toEqual([]); +}); + +test("parseModels refuses an empty feed instead of authorizing catalog deletion", () => { + expect(() => aiand.parseModels({ aiand: { models: {} } })).toThrow(/refusing an empty feed/); +}); + +test("modalities keep canonical order regardless of feed order", () => { + const built = buildAiandModel( + aiandModel({ modalities: { input: ["video", "text", "pdf", "image"], output: ["text"] } }), + undefined, + null, + ); + expect(built.modalities?.input).toEqual(["text", "image", "video", "pdf"]); +}); + +test("factors against an authored base_model and never overrides family", () => { + const built = buildAiandModel(aiandModel(), { + base_model: "deepseek/deepseek-v4-flash", + base_model_omit: ["limit.input"], + }); + expect(built).toMatchObject({ + base_model: "deepseek/deepseek-v4-flash", + base_model_omit: ["limit.input"], + }); + expect("family" in built ? built.family : undefined).toBeUndefined(); +}); + +test("a curated status survives a feed that omits one; a feed status wins", () => { + const kept = buildAiandModel(aiandModel(), { status: "beta" }, null); + expect(kept.status).toBe("beta"); + const overridden = buildAiandModel(aiandModel({ status: "deprecated" }), { status: "beta" }, null); + expect(overridden.status).toBe("deprecated"); +}); + +test("authored-only cost fields ride along; feed prices are authoritative", () => { + const built = buildAiandModel( + aiandModel(), + { cost: { input: 9, output: 9, cache_write: 0.5 }, limit: { context: 1, input: 128_000, output: 1 } }, + null, + ); + expect(built.cost).toMatchObject({ input: 0.15, output: 0.25, cache_write: 0.5 }); + expect(built.limit).toEqual({ context: 1_048_576, input: 128_000, output: 384_000 }); +}); + +test("a non-reasoning feed model omits reasoning_options entirely", () => { + const built = buildAiandModel( + aiandModel({ reasoning: false, reasoning_options: undefined }), + undefined, + null, + ); + expect(built.reasoning).toBe(false); + expect(built.reasoning_options).toBeUndefined(); +}); + +test("a new feed id resolves its lab base model despite a different lab prefix", () => { + expect(resolveAiandBaseModel("deepseek-ai/deepseek-v4-flash", "DeepSeek V4 Flash")).toBe( + "deepseek/deepseek-v4-flash", + ); + expect(resolveAiandBaseModel("openai/gpt-oss-120b", "GPT OSS 120B")).toBe("openai/gpt-oss-120b"); + expect(resolveAiandBaseModel("unknown-lab/mystery-9", "Mystery 9")).toBeUndefined(); +}); + +test("translateModel skips a new id with no resolvable base instead of writing a full definition", () => { + const context = { existing: () => undefined, authored: () => undefined }; + const skipped = aiand.translateModel( + aiandModel({ id: "unknown-lab/mystery-9", name: "Mystery 9" }), + context, + ); + expect(skipped).toBeUndefined(); + + const resolved = aiand.translateModel(aiandModel(), context); + expect(resolved?.model).toMatchObject({ base_model: "deepseek/deepseek-v4-flash" }); +}); + +test("a new factored file inherits every lab-owned field instead of asserting the feed's", () => { + const context = { existing: () => undefined, authored: () => undefined }; + const created = aiand.translateModel( + aiandModel({ description: "gateway blurb", release_date: "2099-01-01", open_weights: false }), + context, + ); + expect(created?.model).toMatchObject({ base_model: "deepseek/deepseek-v4-flash" }); + for (const field of ["name", "description", "family", "release_date", "last_updated", "open_weights"]) { + expect(created?.model).not.toHaveProperty(field); + } +}); + +test("a curated description wins over the feed's on a standalone file", () => { + const built = buildAiandModel(aiandModel(), { description: "curated" }, null); + expect(built.description).toBe("curated"); +}); + +test("toggle and budget_tokens controls parse and pass through untouched", () => { + const model = AiandModel.parse({ + ...aiandModel(), + reasoning_options: [{ type: "toggle" }, { type: "budget_tokens", min: 1024, max: 32_768 }], + }); + const built = buildAiandModel(model, undefined, null); + expect(built.reasoning_options).toEqual([ + { type: "toggle" }, + { type: "budget_tokens", min: 1024, max: 32_768 }, + ]); +}); + +test("parses the provider entry from the full api.json document", () => { + const parsed = AiandResponse.parse({ + opencode: { models: {} }, + aiand: { models: { "deepseek-ai/deepseek-v4-flash": aiandModel() } }, + }); + expect(Object.keys(parsed.aiand.models)).toEqual(["deepseek-ai/deepseek-v4-flash"]); +}); diff --git a/packages/core/test/auto-merge.test.ts b/packages/core/test/auto-merge.test.ts index 1b4a44e3fd5..51eb633de48 100644 --- a/packages/core/test/auto-merge.test.ts +++ b/packages/core/test/auto-merge.test.ts @@ -94,7 +94,7 @@ test("does not inspect deleted models", async () => { }); test("allows reviewed providers with explicit reasoning options", async () => { - for (const provider of ["crossmodel", "edenai", "empiriolabs", "hyper", "kilo", "llmgateway", "llmgateway-providers", "merge-gateway", "nano-gpt", "openrouter", "venice"]) { + for (const provider of ["aiand", "crossmodel", "edenai", "empiriolabs", "hyper", "kilo", "llmgateway", "llmgateway-providers", "merge-gateway", "nano-gpt", "openrouter", "venice"]) { const decision = await classifyAutoMerge( [{ status: "updated", path: `providers/${provider}/models/reasoner.toml` }], async () => fullModel(true, 'reasoning_options = [{ type = "toggle" }]'), diff --git a/sync.md b/sync.md index d2c8bc3520c..1178a760e72 100644 --- a/sync.md +++ b/sync.md @@ -9,6 +9,7 @@ The grouped sync targets are available for local convenience, but CI syncs each ## Commands - `bun models:sync aggregators` syncs every provider in the `aggregators` group. +- `bun models:sync aiand` syncs only ai&. - `bun models:sync openrouter` syncs only OpenRouter. - `bun models:sync cloudflare-workers-ai` syncs only Cloudflare Workers AI. - `bun models:sync cloudflare-ai-gateway` syncs only Cloudflare AI Gateway's proxied catalog. @@ -359,3 +360,12 @@ Venice is implemented in `packages/core/src/sync/providers/venice.ts`. ## Standalone Generators Some provider scripts in `packages/core/script/generate-*.ts` are not wired into `bun models:sync`. When updating those scripts, preserve existing `base_model` and `base_model_omit` fields for generated TOMLs that already use model metadata inheritance. New inheritance-aware output should use `base_model`; do not reintroduce legacy `[extends]` syntax. + +## ai& Notes + +- Endpoint: `GET https://api.aiand.com/v1/api.json` (public, no auth). The module reads the `aiand` provider entry; `AIAND_API_URL` overrides the endpoint for staging dry runs. +- The feed publishes this repo's `api.json` shape, so translation is near-identity. The feed is authoritative for prices (including `cache_read`), limits, capability flags, modalities, gateway-enforced `reasoning_options`, and `deprecated` status; the `aiand` entry lists only models whose catalog metadata is complete. +- Curated values win for `name`, `description`, `knowledge`, `release_date`, and `last_updated` — the feed's `last_updated` tracks catalog-row edits, not model revisions, and release dates are lab metadata the gateway is not authoritative for. A curated alpha/beta `status` also survives a feed that omits one. +- On base-factored files, lab-owned fields (`name`, `description`, `family`, `release_date`, `last_updated`, `knowledge`, `open_weights`) are never asserted from the feed: they come from the curated file, so an authored override survives and a new file inherits the lab entry. A new feed id resolves its `base_model` by normalized match against `models/` and is skipped with a report notice when nothing resolves — an unfactored full definition is never created. An empty feed fails the run rather than deleting the local catalog. +- `family` passes through `ModelFamily.safeParse` and is omitted when unknown. +- If effort filtering would empty a non-empty feed list (a vocabulary the schema doesn't know yet), the authored `reasoning_options` are preserved rather than publishing "no caller control". From 4cf2d87c388f75ef1222e70032e3fbda3c458612 Mon Sep 17 00:00:00 2001 From: fenil modi Date: Mon, 14 Sep 2026 17:16:24 +0000 Subject: [PATCH 02/10] chore(sync): regenerate aiand catalog on rebased dev Rerun of the ai& sync after cherry-picking onto current dev. Interleaved reasoning_content blocks from #6839 are preserved by the adapter (existing?.interleaved); motif-3 factors onto the new lab entry. --- .../models/moonshotai/kimi-k2.7-code.toml | 7 ++++--- .../aiand/models/moonshotai/kimi-k3.toml | 7 ++++--- .../models/motif-technologies/motif-3.toml | 21 ++++--------------- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/providers/aiand/models/moonshotai/kimi-k2.7-code.toml b/providers/aiand/models/moonshotai/kimi-k2.7-code.toml index a384aa028b6..d7ef690bb09 100644 --- a/providers/aiand/models/moonshotai/kimi-k2.7-code.toml +++ b/providers/aiand/models/moonshotai/kimi-k2.7-code.toml @@ -5,14 +5,15 @@ # Modalities: image + PDF accepted; video rejected (2026-07-24 probes). # Reasoning side channel: message.reasoning_content base_model = "moonshotai/kimi-k2.7-code" +temperature = true + +[interleaved] +field = "reasoning_content" [[reasoning_options]] type = "effort" values = ["high"] -[interleaved] -field = "reasoning_content" - [cost] input = 0.75 output = 3.5 diff --git a/providers/aiand/models/moonshotai/kimi-k3.toml b/providers/aiand/models/moonshotai/kimi-k3.toml index b28cbd68903..362ff44d681 100644 --- a/providers/aiand/models/moonshotai/kimi-k3.toml +++ b/providers/aiand/models/moonshotai/kimi-k3.toml @@ -5,14 +5,15 @@ # Modalities: text + image + PDF; video rejected on this host. # Reasoning side channel: message.reasoning_content base_model = "moonshotai/kimi-k3" +temperature = true + +[interleaved] +field = "reasoning_content" [[reasoning_options]] type = "effort" values = ["low", "high", "max"] -[interleaved] -field = "reasoning_content" - [cost] input = 3 output = 12.5 diff --git a/providers/aiand/models/motif-technologies/motif-3.toml b/providers/aiand/models/motif-technologies/motif-3.toml index d0dcf873cc7..d7f872187c4 100644 --- a/providers/aiand/models/motif-technologies/motif-3.toml +++ b/providers/aiand/models/motif-technologies/motif-3.toml @@ -4,33 +4,20 @@ # Live probe 2026-09-11: none|high → 200; low → 400. # First-party Motif host entry (no shared lab base_model in catalog yet). # Reasoning side channel: message.reasoning_content -name = "Motif 3" +base_model = "motif-technologies/motif-3" description = "Motif 3 is a large-scale, decoder-only Mixture-of-Experts (MoE) language model with 314 billion total parameters and 13.2 billion parameters activated per token." release_date = "2026-08-12" last_updated = "2026-09-11" -attachment = false -reasoning = true -temperature = false -tool_call = false -structured_output = false open_weights = false +[interleaved] +field = "reasoning_content" + [[reasoning_options]] type = "effort" values = ["none", "high"] -[interleaved] -field = "reasoning_content" - [cost] input = 0.5 output = 2 cache_read = 0.2 - -[limit] -context = 262_144 -output = 262_144 - -[modalities] -input = ["text"] -output = ["text"] From 54e02838d66b02a6b315aad09ddcee710254a8da Mon Sep 17 00:00:00 2001 From: fenil modi Date: Mon, 14 Sep 2026 17:18:53 +0000 Subject: [PATCH 03/10] docs(aiand): refresh kimi-k3 and gpt-oss-120b probe headers Live reasoning_effort probes on 2026-09-14 (negative controls included) confirm the authored effort sets are unchanged on the gateway. --- providers/aiand/models/moonshotai/kimi-k3.toml | 2 +- providers/aiand/models/openai/gpt-oss-120b.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/aiand/models/moonshotai/kimi-k3.toml b/providers/aiand/models/moonshotai/kimi-k3.toml index 362ff44d681..c9c3a3a5e89 100644 --- a/providers/aiand/models/moonshotai/kimi-k3.toml +++ b/providers/aiand/models/moonshotai/kimi-k3.toml @@ -1,7 +1,7 @@ # Effort: reasoning_effort = low|high|max (always-on; no none) # Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) # Docs: https://docs.aiand.com/models/catalog/ -# K3 accepts only low|high|max (lab docs + host 400 on none/minimal/medium/xhigh). +# Live probe 2026-09-14: low|high|max → 200; none/minimal/medium/xhigh → 400 (negative control). # Modalities: text + image + PDF; video rejected on this host. # Reasoning side channel: message.reasoning_content base_model = "moonshotai/kimi-k3" diff --git a/providers/aiand/models/openai/gpt-oss-120b.toml b/providers/aiand/models/openai/gpt-oss-120b.toml index 7083c30e20d..dfd90af1ce9 100644 --- a/providers/aiand/models/openai/gpt-oss-120b.toml +++ b/providers/aiand/models/openai/gpt-oss-120b.toml @@ -1,7 +1,7 @@ # Effort: reasoning_effort = low|medium|high # Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-07-24: low|medium|high → 200; none|minimal|xhigh → 400. +# Live probe 2026-09-14: low|medium|high → 200; none|minimal|xhigh|max → 400 (negative control). # Reasoning side channel: message.reasoning_content base_model = "openai/gpt-oss-120b" From 4fc185dd8eee6907ad9dc41fc42fe37373e490b1 Mon Sep 17 00:00:00 2001 From: "Islomjon (Toji)" Date: Tue, 15 Sep 2026 09:18:36 +0900 Subject: [PATCH 04/10] fix(aiand): factor motif-3 override-only onto its lab entry The rebased regen kept dev's stale inline lab fields (open_weights = false, release_date 2026-08-12) as overrides against the new lab file. Lab facts now inherit from models/motif-technologies/motif-3.toml; the [interleaved] block from #6839 is preserved. Co-Authored-By: Claude Fable 5.1 --- .../aiand/models/motif-technologies/motif-3.toml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/providers/aiand/models/motif-technologies/motif-3.toml b/providers/aiand/models/motif-technologies/motif-3.toml index d7f872187c4..5f6a039899e 100644 --- a/providers/aiand/models/motif-technologies/motif-3.toml +++ b/providers/aiand/models/motif-technologies/motif-3.toml @@ -1,14 +1,12 @@ # Effort: reasoning_effort = none|high -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14) # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-11: none|high → 200; low → 400. -# First-party Motif host entry (no shared lab base_model in catalog yet). +# Live probe 2026-09-14: none|high → 200; minimal|low → 400 (negative control). +# Lab facts (release date, open weights, tool calling, temperature) live in +# models/motif-technologies/motif-3.toml. structured_output stays false: live +# probe 2026-09-14 — response_format json_schema is ignored (prose reply). # Reasoning side channel: message.reasoning_content base_model = "motif-technologies/motif-3" -description = "Motif 3 is a large-scale, decoder-only Mixture-of-Experts (MoE) language model with 314 billion total parameters and 13.2 billion parameters activated per token." -release_date = "2026-08-12" -last_updated = "2026-09-11" -open_weights = false [interleaved] field = "reasoning_content" From f57a665a99040ebb0ede1fffead62a4aa67f9d69 Mon Sep 17 00:00:00 2001 From: "Islomjon (Toji)" Date: Tue, 15 Sep 2026 10:13:34 +0900 Subject: [PATCH 05/10] fix(aiand): clear curated deprecated on omission; silence empty skip notice The feed owns deprecation and absence means active, so a curated deprecated no longer survives a feed that omits status (alpha/beta still do). A clean sync no longer emits a zero-count skip notice into the report. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/sync/providers/aiand.ts | 8 +++++--- packages/core/test/aiand.test.ts | 12 +++++++++++- sync.md | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/core/src/sync/providers/aiand.ts b/packages/core/src/sync/providers/aiand.ts index 8fdf17fa56d..463d6530435 100644 --- a/packages/core/src/sync/providers/aiand.ts +++ b/packages/core/src/sync/providers/aiand.ts @@ -114,6 +114,7 @@ export const aiand = { return model.id; }, skippedNotice(ids) { + if (ids.length === 0) return []; return [ `Skipped ${ids.length} feed model(s) with no resolvable base model — author a models/ lab file to list them: ${ids.join(", ")}`, ]; @@ -174,9 +175,10 @@ export function buildAiandModel( output_audio: existing?.cost?.output_audio, tiers: existing?.cost?.tiers, }, - // Absence means active on the feed; a curated alpha/beta stays until the - // gateway publishes a status of its own. - status: model.status ?? existing?.status, + // Absence means active on the feed, and the feed owns deprecation: a + // curated alpha/beta survives omission, a curated deprecated does not, + // or a route the gateway reactivated would stay marked retired forever. + status: model.status ?? (existing?.status === "deprecated" ? undefined : existing?.status), interleaved: existing?.interleaved, }; if (baseModel == null) return values; diff --git a/packages/core/test/aiand.test.ts b/packages/core/test/aiand.test.ts index 37715e50c96..ae3b3ccd189 100644 --- a/packages/core/test/aiand.test.ts +++ b/packages/core/test/aiand.test.ts @@ -141,13 +141,23 @@ test("factors against an authored base_model and never overrides family", () => expect("family" in built ? built.family : undefined).toBeUndefined(); }); -test("a curated status survives a feed that omits one; a feed status wins", () => { +test("a curated alpha/beta survives a feed that omits status; a feed status wins", () => { const kept = buildAiandModel(aiandModel(), { status: "beta" }, null); expect(kept.status).toBe("beta"); const overridden = buildAiandModel(aiandModel({ status: "deprecated" }), { status: "beta" }, null); expect(overridden.status).toBe("deprecated"); }); +test("the feed owns deprecation: an omitted status clears a curated deprecated", () => { + const reactivated = buildAiandModel(aiandModel(), { status: "deprecated" }, null); + expect(reactivated.status).toBeUndefined(); +}); + +test("skippedNotice stays silent on a clean sync", () => { + expect(aiand.skippedNotice([])).toEqual([]); + expect(aiand.skippedNotice(["unknown-lab/mystery-9"])).toHaveLength(1); +}); + test("authored-only cost fields ride along; feed prices are authoritative", () => { const built = buildAiandModel( aiandModel(), diff --git a/sync.md b/sync.md index 1178a760e72..a474fb35ef0 100644 --- a/sync.md +++ b/sync.md @@ -365,7 +365,7 @@ Some provider scripts in `packages/core/script/generate-*.ts` are not wired into - Endpoint: `GET https://api.aiand.com/v1/api.json` (public, no auth). The module reads the `aiand` provider entry; `AIAND_API_URL` overrides the endpoint for staging dry runs. - The feed publishes this repo's `api.json` shape, so translation is near-identity. The feed is authoritative for prices (including `cache_read`), limits, capability flags, modalities, gateway-enforced `reasoning_options`, and `deprecated` status; the `aiand` entry lists only models whose catalog metadata is complete. -- Curated values win for `name`, `description`, `knowledge`, `release_date`, and `last_updated` — the feed's `last_updated` tracks catalog-row edits, not model revisions, and release dates are lab metadata the gateway is not authoritative for. A curated alpha/beta `status` also survives a feed that omits one. +- Curated values win for `name`, `description`, `knowledge`, `release_date`, and `last_updated` — the feed's `last_updated` tracks catalog-row edits, not model revisions, and release dates are lab metadata the gateway is not authoritative for. A curated alpha/beta `status` survives a feed that omits one; a curated `deprecated` does not, since the feed owns deprecation and absence means active. - On base-factored files, lab-owned fields (`name`, `description`, `family`, `release_date`, `last_updated`, `knowledge`, `open_weights`) are never asserted from the feed: they come from the curated file, so an authored override survives and a new file inherits the lab entry. A new feed id resolves its `base_model` by normalized match against `models/` and is skipped with a report notice when nothing resolves — an unfactored full definition is never created. An empty feed fails the run rather than deleting the local catalog. - `family` passes through `ModelFamily.safeParse` and is omitted when unknown. - If effort filtering would empty a non-empty feed list (a vocabulary the schema doesn't know yet), the authored `reasoning_options` are preserved rather than publishing "no caller control". From 6d89ea35db75a8ea9e13b32ca6d1b79ffc922a0b Mon Sep 17 00:00:00 2001 From: "Islomjon (Toji)" Date: Tue, 15 Sep 2026 10:40:04 +0900 Subject: [PATCH 06/10] fix(aiand): publish interleaved for new reasoners, route lab-metadata skips to missing-model issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the feed's interleaved (authored fallback, gateway default of message.reasoning_content for reasoners) so a new reasoner is never created without its side channel; return skipped ids from missingModelID so the runner preserves local entries and opens deduped issues. qwen3.8 header records the 2026-09-14 probe including the announced high→xhigh substitution. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/sync/providers/aiand.ts | 30 ++++++++++++++++++-- packages/core/test/aiand.test.ts | 23 +++++++++++++++ providers/aiand/models/qwen/qwen3.8-27b.toml | 4 ++- sync.md | 2 ++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/core/src/sync/providers/aiand.ts b/packages/core/src/sync/providers/aiand.ts index 463d6530435..8e77f42b9e1 100644 --- a/packages/core/src/sync/providers/aiand.ts +++ b/packages/core/src/sync/providers/aiand.ts @@ -16,6 +16,9 @@ const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", const API_ENDPOINT = process.env.AIAND_API_URL ?? "https://api.aiand.com/v1/api.json"; const MODALITIES = ["text", "audio", "image", "video", "pdf"] as const; + +const GATEWAY_INTERLEAVED = { field: "reasoning_content" } as const; +const INTERLEAVED_FIELDS = ["reasoning_content", "reasoning_details"] as const; type Modality = (typeof MODALITIES)[number]; // The feed publishes the catalog's reasoning_options shape. Effort values are @@ -62,6 +65,7 @@ export const AiandModel = z .passthrough(), open_weights: z.boolean().optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(), + interleaved: z.union([z.boolean(), z.object({ field: z.string() }).passthrough()]).optional(), }) .passthrough(); @@ -113,10 +117,16 @@ export const aiand = { sourceID(model) { return model.id; }, + // The only skip is "no lab base yet", never an intentional removal, so every + // skip enters the missing-model issue flow: the runner keeps any existing + // local entry and opens one deduped issue for the lab metadata. + missingModelID(model) { + return model.id; + }, skippedNotice(ids) { if (ids.length === 0) return []; return [ - `Skipped ${ids.length} feed model(s) with no resolvable base model — author a models/ lab file to list them: ${ids.join(", ")}`, + `Skipped ${ids.length} feed model(s) with no resolvable base model (missing-model issue flow): ${ids.join(", ")}`, ]; }, } satisfies SyncProvider; @@ -179,7 +189,12 @@ export function buildAiandModel( // curated alpha/beta survives omission, a curated deprecated does not, // or a route the gateway reactivated would stay marked retired forever. status: model.status ?? (existing?.status === "deprecated" ? undefined : existing?.status), - interleaved: existing?.interleaved, + // Every ai& reasoner streams its thinking in message.reasoning_content — + // a gateway-wide side channel — so a brand-new reasoner gets it even + // before the feed publishes `interleaved` itself. + interleaved: model.reasoning + ? (normalizeInterleaved(model.interleaved) ?? existing?.interleaved ?? GATEWAY_INTERLEAVED) + : undefined, }; if (baseModel == null) return values; // Lab-owned fields are never asserted from the gateway on a factored file: @@ -304,6 +319,17 @@ function isModality(value: string): value is Modality { return (MODALITIES as readonly string[]).includes(value); } +function normalizeInterleaved( + value: AiandModel["interleaved"], +): SyncedFullModel["interleaved"] | undefined { + if (value === true) return true; + if (value === undefined || value === false) return undefined; + const field = value.field; + return (INTERLEAVED_FIELDS as readonly string[]).includes(field) + ? { field: field as (typeof INTERLEAVED_FIELDS)[number] } + : undefined; +} + function sortModalities(values: Modality[]): Modality[] { return [...new Set(values)].sort((a, b) => MODALITIES.indexOf(a) - MODALITIES.indexOf(b)); } diff --git a/packages/core/test/aiand.test.ts b/packages/core/test/aiand.test.ts index ae3b3ccd189..dbf59e96fa7 100644 --- a/packages/core/test/aiand.test.ts +++ b/packages/core/test/aiand.test.ts @@ -227,6 +227,29 @@ test("toggle and budget_tokens controls parse and pass through untouched", () => ]); }); +test("a new reasoner gets the gateway's reasoning_content side channel; feed and authored values win over it", () => { + const created = buildAiandModel(aiandModel(), undefined, null); + expect(created.interleaved).toEqual({ field: "reasoning_content" }); + const authored = buildAiandModel(aiandModel(), { interleaved: true }, null); + expect(authored.interleaved).toBe(true); + const fromFeed = buildAiandModel( + AiandModel.parse({ ...aiandModel(), interleaved: { field: "reasoning_details" } }), + { interleaved: true }, + null, + ); + expect(fromFeed.interleaved).toEqual({ field: "reasoning_details" }); + const nonReasoner = buildAiandModel( + aiandModel({ reasoning: false, reasoning_options: undefined }), + { interleaved: true }, + null, + ); + expect(nonReasoner.interleaved).toBeUndefined(); +}); + +test("an unresolvable new id enters the missing-model issue flow", () => { + expect(aiand.missingModelID(aiandModel({ id: "unknown-lab/mystery-9" }))).toBe("unknown-lab/mystery-9"); +}); + test("parses the provider entry from the full api.json document", () => { const parsed = AiandResponse.parse({ opencode: { models: {} }, diff --git a/providers/aiand/models/qwen/qwen3.8-27b.toml b/providers/aiand/models/qwen/qwen3.8-27b.toml index 487eb3e6f98..d762a3dad9c 100644 --- a/providers/aiand/models/qwen/qwen3.8-27b.toml +++ b/providers/aiand/models/qwen/qwen3.8-27b.toml @@ -1,7 +1,9 @@ # Effort: reasoning_effort = none|low|medium|xhigh # Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-08-29: none|low|medium|xhigh → 200; minimal|high|max → 400. +# Live probe 2026-09-14: none|low|medium|xhigh → 200; minimal|max → 400. high also +# returns 200 via ai&'s announced substitution (X-Reasoning-Effort: xhigh) and is +# deliberately not listed. # Reasoning side channel: message.reasoning_content base_model = "alibaba/qwen3.8-27b" diff --git a/sync.md b/sync.md index a474fb35ef0..03bd1c85bc1 100644 --- a/sync.md +++ b/sync.md @@ -368,4 +368,6 @@ Some provider scripts in `packages/core/script/generate-*.ts` are not wired into - Curated values win for `name`, `description`, `knowledge`, `release_date`, and `last_updated` — the feed's `last_updated` tracks catalog-row edits, not model revisions, and release dates are lab metadata the gateway is not authoritative for. A curated alpha/beta `status` survives a feed that omits one; a curated `deprecated` does not, since the feed owns deprecation and absence means active. - On base-factored files, lab-owned fields (`name`, `description`, `family`, `release_date`, `last_updated`, `knowledge`, `open_weights`) are never asserted from the feed: they come from the curated file, so an authored override survives and a new file inherits the lab entry. A new feed id resolves its `base_model` by normalized match against `models/` and is skipped with a report notice when nothing resolves — an unfactored full definition is never created. An empty feed fails the run rather than deleting the local catalog. - `family` passes through `ModelFamily.safeParse` and is omitted when unknown. +- A skipped new id is returned from `missingModelID` (the only skip is "no lab base yet"), so the runner preserves any existing local entry and opens a deduped missing-model issue for the lab metadata. +- `interleaved` comes from the feed when published, else the authored value, else `{ field = "reasoning_content" }` for reasoners — every ai& reasoner streams thinking in `message.reasoning_content`, so a new reasoner is never created without its side channel. - If effort filtering would empty a non-empty feed list (a vocabulary the schema doesn't know yet), the authored `reasoning_options` are preserved rather than publishing "no caller control". From f454a50728a14afc561b71eec71a64e0382e1450 Mon Sep 17 00:00:00 2001 From: "Islomjon (Toji)" Date: Tue, 15 Sep 2026 11:30:08 +0900 Subject: [PATCH 07/10] fix(aiand): read overrides from the authored TOML, refresh all headers to the 2026-09-14 probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lab-owned fields on factored files come only from deltas the authored file already carried on its base_model (context.authored, never the base-resolved merge), so a full-inline file factored for the first time inherits the lab entry instead of re-emitting stale values as overrides — the Motif regression in general form. Host fields stay feed-authoritative. All 11 headers now cite the 2026-09-14 probe matrix (11 models x 7 effort values). Co-Authored-By: Claude Fable 5.1 --- packages/core/src/sync/providers/aiand.ts | 141 ++++++++++-------- packages/core/test/aiand.test.ts | 54 +++++++ .../models/deepseek-ai/deepseek-v4-flash.toml | 5 +- .../models/deepseek-ai/deepseek-v4-pro.toml | 5 +- .../aiand/models/google/gemma-4-31b-it.toml | 4 +- .../models/moonshotai/kimi-k2.7-code.toml | 7 +- .../aiand/models/moonshotai/kimi-k3.toml | 5 +- .../models/motif-technologies/motif-3.toml | 4 +- .../aiand/models/openai/gpt-oss-120b.toml | 4 +- providers/aiand/models/qwen/qwen3.6-27b.toml | 5 +- providers/aiand/models/qwen/qwen3.8-27b.toml | 3 +- providers/aiand/models/zai-org/glm-5.2.toml | 5 +- providers/aiand/models/zai-org/glm-5.3.toml | 5 +- sync.md | 2 +- 14 files changed, 163 insertions(+), 86 deletions(-) diff --git a/packages/core/src/sync/providers/aiand.ts b/packages/core/src/sync/providers/aiand.ts index 8e77f42b9e1..216790b50ef 100644 --- a/packages/core/src/sync/providers/aiand.ts +++ b/packages/core/src/sync/providers/aiand.ts @@ -102,16 +102,19 @@ export const aiand = { return models; }, translateModel(model, context) { - const existing = context.existing(model.id); - const baseModel = existing?.base_model ?? resolveAiandBaseModel(model.id, model.name); + // `authored` is the raw TOML — the only thing that can carry a genuine + // override. `existing` is the base-resolved merge and would make inherited + // lab values look hand-written. + const authored = context.authored(model.id); + const baseModel = authored?.base_model ?? resolveAiandBaseModel(model.id, model.name); // A new feed model with no resolvable lab entry must never be written as - // an unfactored full definition; skip it (reported via sourceID) until a - // models/ file exists. An existing local file is always translated — - // skipping one would delete it. - if (existing === undefined && baseModel === undefined) return undefined; + // an unfactored full definition; it goes to the missing-model issue flow + // instead. An existing local file is always translated — skipping one + // would delete it. + if (authored === undefined && baseModel === undefined) return undefined; return { id: model.id, - model: buildAiandModel(model, existing, baseModel), + model: buildAiandModel(model, authored, baseModel), }; }, sourceID(model) { @@ -131,92 +134,104 @@ export const aiand = { }, } satisfies SyncProvider; +type HostFields = Omit< + SyncedFullModel, + "name" | "description" | "family" | "release_date" | "last_updated" | "knowledge" | "open_weights" +>; + +/** + * `authored` is the provider TOML as written (not base-resolved). Host facts — + * pricing, limits, controls, capability flags, modalities, status, the side + * channel — are feed-authoritative. Lab-owned facts are never asserted from + * the gateway on a factored file: they appear only as deltas the authored + * file already carried on top of its base_model, so a full-inline file being + * factored for the first time (or a brand-new file) inherits the lab entry + * outright instead of re-emitting the lab's values as overrides. + */ export function buildAiandModel( model: AiandModel, - existing: ExistingModel | undefined, - baseModel: string | null | undefined = existing?.base_model ?? resolveAiandBaseModel(model.id, model.name), + authored: ExistingModel | undefined, + baseModel: string | null | undefined = authored?.base_model ?? resolveAiandBaseModel(model.id, model.name), today = new Date().toISOString().slice(0, 10), ): SyncedModel { - // Unknown families must degrade to the curated value rather than fail - // validation, so a model whose family is not in the enum yet cannot stall - // the sync. - const family = ModelFamily.safeParse(model.family); - const reasoningOptions = resolveReasoningOptions(model, existing); const limit = { context: model.limit.context, - input: existing?.limit?.input, + input: authored?.limit?.input, output: model.limit.output, }; - const values: SyncedFullModel = { - // Curated display names and descriptions win over the gateway's. - name: existing?.name ?? model.name, - description: existing?.description ?? model.description ?? model.name, - family: family.success ? family.data : existing?.family, + const host: HostFields = { attachment: model.attachment, reasoning: model.reasoning, // The schema refuses reasoning_options on a non-reasoner; a non-reasoning // feed model must not stall the sync on that refine. - reasoning_options: model.reasoning ? reasoningOptions : undefined, + reasoning_options: model.reasoning ? resolveReasoningOptions(model, authored) : undefined, tool_call: model.tool_call, structured_output: model.structured_output, temperature: model.temperature, - knowledge: existing?.knowledge, - // Release dates are lab metadata the gateway is not authoritative for; - // the curated (or base-resolved) value wins, and a wrong one gets fixed - // in the models/ lab file, not by a provider override. - release_date: existing?.release_date ?? model.release_date, - // The feed's last_updated tracks catalog-row edits, not model revisions, - // so the curated value wins to keep the hourly sync free of date churn. - last_updated: existing?.last_updated ?? model.last_updated ?? today, // Canonical order so a feed-side reordering never churns a TOML. modalities: { input: sortModalities(model.modalities.input.filter(isModality)), output: sortModalities(model.modalities.output.filter(isModality)), }, - open_weights: model.open_weights ?? existing?.open_weights ?? false, limit, cost: { input: model.cost.input, output: model.cost.output, - reasoning: existing?.cost?.reasoning, + reasoning: authored?.cost?.reasoning, cache_read: model.cost.cache_read, - cache_write: existing?.cost?.cache_write, - input_audio: existing?.cost?.input_audio, - output_audio: existing?.cost?.output_audio, - tiers: existing?.cost?.tiers, + cache_write: authored?.cost?.cache_write, + input_audio: authored?.cost?.input_audio, + output_audio: authored?.cost?.output_audio, + tiers: authored?.cost?.tiers, }, // Absence means active on the feed, and the feed owns deprecation: a // curated alpha/beta survives omission, a curated deprecated does not, // or a route the gateway reactivated would stay marked retired forever. - status: model.status ?? (existing?.status === "deprecated" ? undefined : existing?.status), + status: model.status ?? (authored?.status === "deprecated" ? undefined : authored?.status), // Every ai& reasoner streams its thinking in message.reasoning_content — // a gateway-wide side channel — so a brand-new reasoner gets it even // before the feed publishes `interleaved` itself. interleaved: model.reasoning - ? (normalizeInterleaved(model.interleaved) ?? existing?.interleaved ?? GATEWAY_INTERLEAVED) + ? (normalizeInterleaved(model.interleaved) ?? authored?.interleaved ?? GATEWAY_INTERLEAVED) : undefined, }; - if (baseModel == null) return values; - // Lab-owned fields are never asserted from the gateway on a factored file: - // they come from the curated file (base-resolved, so an authored override - // survives and a new file inherits the lab entry). Host-specific facts — - // pricing, limits, controls, capability flags, modalities, status — stay - // feed-authoritative and factor to overrides only where they differ. - return factorBaseModel( - baseModel, - { - ...values, - name: existing?.name, - description: existing?.description, - family: undefined, - release_date: existing?.release_date, - last_updated: existing?.last_updated, - knowledge: existing?.knowledge, - open_weights: existing?.open_weights, - }, - limit, - existing?.base_model_omit, - ); + + if (baseModel != null) { + // Only a file that already sat on this base can carry genuine lab-field + // deltas; anything else inherits the lab entry. + const deltas = authored?.base_model === baseModel ? authored : undefined; + return factorBaseModel( + baseModel, + { + ...host, + name: deltas?.name, + description: deltas?.description, + release_date: deltas?.release_date, + last_updated: deltas?.last_updated, + knowledge: deltas?.knowledge, + open_weights: deltas?.open_weights, + }, + limit, + deltas?.base_model_omit, + ); + } + + // Standalone (no lab entry anywhere): curated values win for lab-owned + // fields, the feed fills the rest. Unknown families degrade to the curated + // value rather than fail validation. + const family = ModelFamily.safeParse(model.family); + return { + ...host, + name: authored?.name ?? model.name, + description: authored?.description ?? model.description ?? model.name, + family: family.success ? family.data : authored?.family, + // Release dates are lab metadata the gateway is not authoritative for. + release_date: authored?.release_date ?? model.release_date, + // The feed's last_updated tracks catalog-row edits, not model revisions. + last_updated: authored?.last_updated ?? model.last_updated ?? today, + knowledge: authored?.knowledge, + open_weights: model.open_weights ?? authored?.open_weights ?? false, + }; } interface MetadataEntry { @@ -288,9 +303,9 @@ function isReasoningEffort(value: string): value is ReasoningEffortValue { */ function resolveReasoningOptions( model: AiandModel, - existing: ExistingModel | undefined, + authored: ExistingModel | undefined, ): SyncedFullModel["reasoning_options"] { - if (model.reasoning_options === undefined) return authoredReasoningOptions(existing); + if (model.reasoning_options === undefined) return authoredReasoningOptions(authored); if (model.reasoning_options.length === 0) return []; const feed = model.reasoning_options.flatMap((option) => { if (option.type === "effort") { @@ -303,13 +318,13 @@ function resolveReasoningOptions( // schema already validated them at parse time. return [option]; }); - return feed.length > 0 ? feed : authoredReasoningOptions(existing); + return feed.length > 0 ? feed : authoredReasoningOptions(authored); } function authoredReasoningOptions( - existing: ExistingModel | undefined, + authored: ExistingModel | undefined, ): SyncedFullModel["reasoning_options"] { - const options = (existing?.reasoning_options ?? []) + const options = (authored?.reasoning_options ?? []) .map((option) => CatalogReasoningOption.safeParse(option)) .flatMap((result) => (result.success ? [result.data] : [])); return options.length > 0 ? options : undefined; diff --git a/packages/core/test/aiand.test.ts b/packages/core/test/aiand.test.ts index dbf59e96fa7..e25edad4b1b 100644 --- a/packages/core/test/aiand.test.ts +++ b/packages/core/test/aiand.test.ts @@ -250,6 +250,60 @@ test("an unresolvable new id enters the missing-model issue flow", () => { expect(aiand.missingModelID(aiandModel({ id: "unknown-lab/mystery-9" }))).toBe("unknown-lab/mystery-9"); }); +test("first factor of a full-inline file inherits the lab entry instead of re-emitting its stale lab fields", () => { + const fullInline = { + name: "Motif 3", + description: "old inline description", + release_date: "2026-08-12", + last_updated: "2026-09-11", + attachment: false, + reasoning: true, + temperature: false, + tool_call: false, + structured_output: false, + open_weights: false, + reasoning_options: [], + interleaved: { field: "reasoning_content" as const }, + cost: { input: 0.5, output: 2 }, + limit: { context: 262_144, output: 262_144 }, + modalities: { input: ["text" as const], output: ["text" as const] }, + }; + const built = buildAiandModel( + aiandModel({ + id: "motif-technologies/motif-3", + name: "Motif-Technologies/Motif-3", + reasoning_options: [{ type: "effort", values: ["none", "high"] }], + structured_output: false, + cost: { input: 0.5, output: 2, cache_read: 0.2 }, + limit: { context: 262_144, output: 262_144 }, + }), + fullInline, + "motif-technologies/motif-3", + ); + expect(built).toMatchObject({ base_model: "motif-technologies/motif-3" }); + for (const field of ["name", "description", "release_date", "last_updated", "open_weights", "family", "temperature", "tool_call"]) { + expect(built).not.toHaveProperty(field); + } + expect(built).toMatchObject({ cost: { cache_read: 0.2 }, interleaved: { field: "reasoning_content" } }); +}); + +test("an already-factored file keeps its authored lab-field deltas and omit list", () => { + const built = buildAiandModel(aiandModel(), { + base_model: "deepseek/deepseek-v4-flash", + base_model_omit: ["limit.input"], + name: "DeepSeek V4 Flash (ai& lane)", + knowledge: "2025-06", + }); + // Deltas that differ from the lab entry survive; identical values would be + // dropped by factoring as redundant, which is the point. + expect(built).toMatchObject({ + base_model: "deepseek/deepseek-v4-flash", + base_model_omit: ["limit.input"], + name: "DeepSeek V4 Flash (ai& lane)", + knowledge: "2025-06", + }); +}); + test("parses the provider entry from the full api.json document", () => { const parsed = AiandResponse.parse({ opencode: { models: {} }, diff --git a/providers/aiand/models/deepseek-ai/deepseek-v4-flash.toml b/providers/aiand/models/deepseek-ai/deepseek-v4-flash.toml index 9b8bea586bd..50018b3d802 100644 --- a/providers/aiand/models/deepseek-ai/deepseek-v4-flash.toml +++ b/providers/aiand/models/deepseek-ai/deepseek-v4-flash.toml @@ -1,7 +1,8 @@ # Effort: reasoning_effort = none|high|max -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-11: none|high|max → 200; minimal|low|medium|xhigh → 400. +# Live probe 2026-09-14: none|high|max → 200; minimal|low|medium|xhigh → 400. +# context_window = 1048576 on this host overrides the lab's 1_000_000. # Reasoning side channel: message.reasoning_content base_model = "deepseek/deepseek-v4-flash" diff --git a/providers/aiand/models/deepseek-ai/deepseek-v4-pro.toml b/providers/aiand/models/deepseek-ai/deepseek-v4-pro.toml index 88498c5c29a..4beec143d13 100644 --- a/providers/aiand/models/deepseek-ai/deepseek-v4-pro.toml +++ b/providers/aiand/models/deepseek-ai/deepseek-v4-pro.toml @@ -1,7 +1,8 @@ # Effort: reasoning_effort = none|high|max -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-11: none|high|max → 200; low → 400. +# Live probe 2026-09-14: none|high|max → 200; minimal|low|medium|xhigh → 400. +# context_window = 1048576 on this host overrides the lab's 1_000_000. # Reasoning side channel: message.reasoning_content base_model = "deepseek/deepseek-v4-pro" diff --git a/providers/aiand/models/google/gemma-4-31b-it.toml b/providers/aiand/models/google/gemma-4-31b-it.toml index 312e02bf19b..834d710d9db 100644 --- a/providers/aiand/models/google/gemma-4-31b-it.toml +++ b/providers/aiand/models/google/gemma-4-31b-it.toml @@ -1,7 +1,7 @@ # Effort: reasoning_effort = none|high -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-11: none|high → 200; minimal|low|xhigh → 400. +# Live probe 2026-09-14: none|high → 200; minimal|low|medium|xhigh|max → 400. # Modalities: image, video, and PDF accepted on this host (2026-07-24 probes). # Reasoning side channel: message.reasoning_content base_model = "google/gemma-4-31b-it" diff --git a/providers/aiand/models/moonshotai/kimi-k2.7-code.toml b/providers/aiand/models/moonshotai/kimi-k2.7-code.toml index d7ef690bb09..de7ff0f4876 100644 --- a/providers/aiand/models/moonshotai/kimi-k2.7-code.toml +++ b/providers/aiand/models/moonshotai/kimi-k2.7-code.toml @@ -1,8 +1,9 @@ -# Effort: reasoning_effort = high (always-on; none/low rejected) -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Effort: reasoning_effort = high (always-on) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-11: high → 200; none|low → 400. +# Live probe 2026-09-14: high → 200; none|minimal|low|medium|xhigh|max → 400. # Modalities: image + PDF accepted; video rejected (2026-07-24 probes). +# temperature = true: the host accepts the parameter (live probe 2026-09-14: 200 at 0.0 and 1.8); the lab file records Moonshot's own API as false. # Reasoning side channel: message.reasoning_content base_model = "moonshotai/kimi-k2.7-code" temperature = true diff --git a/providers/aiand/models/moonshotai/kimi-k3.toml b/providers/aiand/models/moonshotai/kimi-k3.toml index c9c3a3a5e89..0a249611cd8 100644 --- a/providers/aiand/models/moonshotai/kimi-k3.toml +++ b/providers/aiand/models/moonshotai/kimi-k3.toml @@ -1,8 +1,9 @@ # Effort: reasoning_effort = low|high|max (always-on; no none) -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-14: low|high|max → 200; none/minimal/medium/xhigh → 400 (negative control). +# Live probe 2026-09-14: low|high|max → 200; none|minimal|medium|xhigh → 400. # Modalities: text + image + PDF; video rejected on this host. +# temperature = true: the host accepts the parameter (live probe 2026-09-14: 200 at 0.0 and 1.8); the lab file records Moonshot's own API as false. # Reasoning side channel: message.reasoning_content base_model = "moonshotai/kimi-k3" temperature = true diff --git a/providers/aiand/models/motif-technologies/motif-3.toml b/providers/aiand/models/motif-technologies/motif-3.toml index 5f6a039899e..e8b372d3399 100644 --- a/providers/aiand/models/motif-technologies/motif-3.toml +++ b/providers/aiand/models/motif-technologies/motif-3.toml @@ -1,7 +1,7 @@ # Effort: reasoning_effort = none|high -# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-14: none|high → 200; minimal|low → 400 (negative control). +# Live probe 2026-09-14: none|high → 200; minimal|low|medium|xhigh|max → 400. # Lab facts (release date, open weights, tool calling, temperature) live in # models/motif-technologies/motif-3.toml. structured_output stays false: live # probe 2026-09-14 — response_format json_schema is ignored (prose reply). diff --git a/providers/aiand/models/openai/gpt-oss-120b.toml b/providers/aiand/models/openai/gpt-oss-120b.toml index dfd90af1ce9..c36d59ddb11 100644 --- a/providers/aiand/models/openai/gpt-oss-120b.toml +++ b/providers/aiand/models/openai/gpt-oss-120b.toml @@ -1,7 +1,7 @@ # Effort: reasoning_effort = low|medium|high -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-14: low|medium|high → 200; none|minimal|xhigh|max → 400 (negative control). +# Live probe 2026-09-14: low|medium|high → 200; none|minimal|xhigh|max → 400. # Reasoning side channel: message.reasoning_content base_model = "openai/gpt-oss-120b" diff --git a/providers/aiand/models/qwen/qwen3.6-27b.toml b/providers/aiand/models/qwen/qwen3.6-27b.toml index 1f874fc1bc4..114703224f2 100644 --- a/providers/aiand/models/qwen/qwen3.6-27b.toml +++ b/providers/aiand/models/qwen/qwen3.6-27b.toml @@ -1,7 +1,8 @@ # Effort: reasoning_effort = none|high -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-11: none|high → 200; minimal|low|xhigh → 400. +# Live probe 2026-09-14: none|high → 200; minimal|low|medium|xhigh|max → 400. +# Modalities: image, video, and PDF accepted on this host (catalog vision|video|document). # Reasoning side channel: message.reasoning_content base_model = "alibaba/qwen3.6-27b" diff --git a/providers/aiand/models/qwen/qwen3.8-27b.toml b/providers/aiand/models/qwen/qwen3.8-27b.toml index d762a3dad9c..cdb5d7d084c 100644 --- a/providers/aiand/models/qwen/qwen3.8-27b.toml +++ b/providers/aiand/models/qwen/qwen3.8-27b.toml @@ -1,9 +1,10 @@ # Effort: reasoning_effort = none|low|medium|xhigh -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ # Live probe 2026-09-14: none|low|medium|xhigh → 200; minimal|max → 400. high also # returns 200 via ai&'s announced substitution (X-Reasoning-Effort: xhigh) and is # deliberately not listed. +# Modalities: image, video, and PDF accepted on this host (catalog vision|video|document). # Reasoning side channel: message.reasoning_content base_model = "alibaba/qwen3.8-27b" diff --git a/providers/aiand/models/zai-org/glm-5.2.toml b/providers/aiand/models/zai-org/glm-5.2.toml index ed3b498d41c..3c8e485f19d 100644 --- a/providers/aiand/models/zai-org/glm-5.2.toml +++ b/providers/aiand/models/zai-org/glm-5.2.toml @@ -1,7 +1,8 @@ # Effort: reasoning_effort = none|high|max -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-11: none|high|max → 200; low|xhigh → 400. +# Live probe 2026-09-14: none|high|max → 200; minimal|low|medium|xhigh → 400. +# context_window = 1048576 on this host overrides the lab's 1_000_000. # Reasoning side channel: message.reasoning_content base_model = "zhipuai/glm-5.2" diff --git a/providers/aiand/models/zai-org/glm-5.3.toml b/providers/aiand/models/zai-org/glm-5.3.toml index d7d6ad36c6d..29850bab7b8 100644 --- a/providers/aiand/models/zai-org/glm-5.3.toml +++ b/providers/aiand/models/zai-org/glm-5.3.toml @@ -1,7 +1,8 @@ # Effort: reasoning_effort = low|high|max (always-on; no none) -# Pricing: GET https://api.aiand.com/v1/models (accessed 2026-09-11) +# Pricing: GET https://api.aiand.com/v1/api.json (accessed 2026-09-14); synced hourly by the aiand module. # Docs: https://docs.aiand.com/models/catalog/ -# Live probe 2026-09-11: low|high|max → 200; none|xhigh → 400. +# Live probe 2026-09-14: low|high|max → 200; none|minimal|medium|xhigh → 400. +# context_window = 1048576 on this host overrides the lab's 1_000_000. # Reasoning side channel: message.reasoning_content base_model = "zhipuai/glm-5.3" diff --git a/sync.md b/sync.md index 03bd1c85bc1..3265ab7154c 100644 --- a/sync.md +++ b/sync.md @@ -366,7 +366,7 @@ Some provider scripts in `packages/core/script/generate-*.ts` are not wired into - Endpoint: `GET https://api.aiand.com/v1/api.json` (public, no auth). The module reads the `aiand` provider entry; `AIAND_API_URL` overrides the endpoint for staging dry runs. - The feed publishes this repo's `api.json` shape, so translation is near-identity. The feed is authoritative for prices (including `cache_read`), limits, capability flags, modalities, gateway-enforced `reasoning_options`, and `deprecated` status; the `aiand` entry lists only models whose catalog metadata is complete. - Curated values win for `name`, `description`, `knowledge`, `release_date`, and `last_updated` — the feed's `last_updated` tracks catalog-row edits, not model revisions, and release dates are lab metadata the gateway is not authoritative for. A curated alpha/beta `status` survives a feed that omits one; a curated `deprecated` does not, since the feed owns deprecation and absence means active. -- On base-factored files, lab-owned fields (`name`, `description`, `family`, `release_date`, `last_updated`, `knowledge`, `open_weights`) are never asserted from the feed: they come from the curated file, so an authored override survives and a new file inherits the lab entry. A new feed id resolves its `base_model` by normalized match against `models/` and is skipped with a report notice when nothing resolves — an unfactored full definition is never created. An empty feed fails the run rather than deleting the local catalog. +- On base-factored files, lab-owned fields (`name`, `description`, `family`, `release_date`, `last_updated`, `knowledge`, `open_weights`) are never asserted from the feed: they appear only as deltas the authored TOML already carried on top of its `base_model` (read via `context.authored()`, never the base-resolved merge), so a full-inline file being factored for the first time — or a new file — inherits the lab entry outright. A new feed id resolves its `base_model` by normalized match against `models/` and is skipped with a report notice when nothing resolves — an unfactored full definition is never created. An empty feed fails the run rather than deleting the local catalog. - `family` passes through `ModelFamily.safeParse` and is omitted when unknown. - A skipped new id is returned from `missingModelID` (the only skip is "no lab base yet"), so the runner preserves any existing local entry and opens a deduped missing-model issue for the lab metadata. - `interleaved` comes from the feed when published, else the authored value, else `{ field = "reasoning_content" }` for reasoners — every ai& reasoner streams thinking in `message.reasoning_content`, so a new reasoner is never created without its side channel. From 181639247fcf8a307b4c0926c715d0ae92f656ca Mon Sep 17 00:00:00 2001 From: "Islomjon (Toji)" Date: Tue, 15 Sep 2026 13:50:45 +0900 Subject: [PATCH 08/10] fix(aiand): stop the runner re-injecting pre-factor descriptions; seed created files with a reasoning-wire header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preserveDescriptions is off so a full-inline → factored transition cannot recreate a lab-identical description override (runner-level test covers the Motif shape end to end). translateModel now returns a leading header for creates naming the reasoning wire path, so a toggle control is never written without it. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/sync/providers/aiand.ts | 44 ++++++++++-- packages/core/test/aiand.test.ts | 82 +++++++++++++++++++++++ sync.md | 1 + 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/packages/core/src/sync/providers/aiand.ts b/packages/core/src/sync/providers/aiand.ts index 216790b50ef..83b4687aa3e 100644 --- a/packages/core/src/sync/providers/aiand.ts +++ b/packages/core/src/sync/providers/aiand.ts @@ -85,6 +85,10 @@ export const aiand = { // runner's default preservation keeps the base_model reference but does not // drop fields identical to the base. preserveBaseModels: false, + // The runner would otherwise re-inject the pre-factor authored description + // whenever the translator leaves it unset — recreating a lab-identical + // override on every full-inline → factored transition. + preserveDescriptions: false, async fetchModels() { const response = await fetch(API_ENDPOINT); if (!response.ok) { @@ -112,10 +116,10 @@ export const aiand = { // instead. An existing local file is always translated — skipping one // would delete it. if (authored === undefined && baseModel === undefined) return undefined; - return { - id: model.id, - model: buildAiandModel(model, authored, baseModel), - }; + const built = buildAiandModel(model, authored, baseModel); + // Existing headers win; this only seeds a create, and a toggle control + // must never be written without its wire path. + return { id: model.id, model: built, header: reasoningHeader(built) }; }, sourceID(model) { return model.id; @@ -234,6 +238,38 @@ export function buildAiandModel( }; } +const DOCS_URL = "https://docs.aiand.com/models/catalog/"; + +/** + * Leading comment block for a created file. ai& exposes one reasoning wire + * path — `reasoning_effort` on /v1/chat/completions and `reasoning.effort` on + * /v1/responses, enforced per model — so a toggle is "none" versus the graded + * levels on that same field. + */ +function reasoningHeader(model: SyncedModel): string | undefined { + const options = model.reasoning_options; + if (options === undefined || options.length === 0) return undefined; + const lines = [ + `# Pricing: GET https://api.aiand.com/v1/api.json (synced hourly by the aiand module)`, + `# Docs: ${DOCS_URL}`, + ]; + for (const option of options) { + if (option.type === "effort" && option.values.length > 0) { + lines.push(`# Effort: reasoning_effort = ${option.values.map((value) => `"${value}"`).join(" | ")}`); + } + if (option.type === "toggle") { + lines.push( + '# Toggle: reasoning_effort = "none" (off) vs the graded levels — field `reasoning_effort` on /v1/chat/completions, `reasoning.effort` on /v1/responses; enforced per model.', + ); + } + if (option.type === "budget_tokens") { + lines.push("# Budget: ai& publishes no token-budget control; value carried from the feed as-is."); + } + } + lines.push("# Reasoning side channel: message.reasoning_content"); + return `${lines.join("\n")}\n`; +} + interface MetadataEntry { id: string; normalizedFull: string; diff --git a/packages/core/test/aiand.test.ts b/packages/core/test/aiand.test.ts index e25edad4b1b..8a4c98c9871 100644 --- a/packages/core/test/aiand.test.ts +++ b/packages/core/test/aiand.test.ts @@ -1,4 +1,9 @@ import { expect, test } from "bun:test"; +import { copyFile, mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { syncProvider } from "../src/sync/index.js"; import { aiand, @@ -304,6 +309,83 @@ test("an already-factored file keeps its authored lab-field deltas and omit list }); }); +test("a created reasoner with a toggle control gets a leading header naming the wire path", () => { + const context = { existing: () => undefined, authored: () => undefined }; + const created = aiand.translateModel( + AiandModel.parse({ ...aiandModel(), reasoning_options: [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }] }), + context, + ); + expect(created?.header).toContain("# Toggle: reasoning_effort = \"none\" (off)"); + expect(created?.header).toContain('# Effort: reasoning_effort = "high" | "max"'); + expect(created?.header?.endsWith("\n")).toBe(true); +}); + +test("runner-level: a full-inline file factored for the first time is written without a lab-identical description", async () => { + const root = await mkdtemp(path.join(tmpdir(), "models-dev-aiand-")); + const modelsDir = path.join(root, "providers", "aiand", "models"); + const labSource = path.join(import.meta.dirname, "..", "..", "..", "models", "motif-technologies", "motif-3.toml"); + const labDest = path.join(root, "models", "motif-technologies", "motif-3.toml"); + await mkdir(path.dirname(labDest), { recursive: true }); + await copyFile(labSource, labDest); + const providerFile = path.join(modelsDir, "motif-technologies", "motif-3.toml"); + await mkdir(path.dirname(providerFile), { recursive: true }); + await Bun.write(providerFile, [ + "# First-party Motif host entry (no shared lab base_model in catalog yet).", + 'name = "Motif 3"', + 'description = "Motif 3 is a large-scale, decoder-only Mixture-of-Experts (MoE) language model with 314 billion total parameters and 13.2 billion parameters activated per token."', + 'release_date = "2026-08-12"', + 'last_updated = "2026-09-11"', + "attachment = false", + "reasoning = true", + "temperature = false", + "tool_call = false", + "structured_output = false", + "open_weights = false", + "", + "[[reasoning_options]]", + 'type = "effort"', + 'values = ["none", "high"]', + "", + "[interleaved]", + 'field = "reasoning_content"', + "", + "[cost]", + "input = 0.5", + "output = 2", + "", + "[limit]", + "context = 262_144", + "output = 262_144", + "", + "[modalities]", + 'input = ["text"]', + 'output = ["text"]', + "", + ].join("\n")); + + const feed = aiandModel({ + id: "motif-technologies/motif-3", + name: "Motif-Technologies/Motif-3", + reasoning_options: [{ type: "effort", values: ["none", "high"] }], + structured_output: false, + cost: { input: 0.5, output: 2, cache_read: 0.2 }, + limit: { context: 262_144, output: 262_144 }, + }); + await syncProvider( + { ...aiand, modelsDir, fetchModels: async () => ({ aiand: { models: { [feed.id]: feed } } }) }, + { openIssues: false }, + ); + + const written = await readFile(providerFile, "utf8"); + expect(written).toContain('base_model = "motif-technologies/motif-3"'); + expect(written).not.toMatch(/^description = /m); + expect(written).not.toMatch(/^open_weights = /m); + expect(written).not.toMatch(/^release_date = /m); + expect(written).toContain("cache_read = 0.2"); + expect(written).toContain('field = "reasoning_content"'); + expect(written.startsWith("# First-party Motif host entry")).toBe(true); +}); + test("parses the provider entry from the full api.json document", () => { const parsed = AiandResponse.parse({ opencode: { models: {} }, diff --git a/sync.md b/sync.md index 3265ab7154c..4af4139e3c5 100644 --- a/sync.md +++ b/sync.md @@ -369,5 +369,6 @@ Some provider scripts in `packages/core/script/generate-*.ts` are not wired into - On base-factored files, lab-owned fields (`name`, `description`, `family`, `release_date`, `last_updated`, `knowledge`, `open_weights`) are never asserted from the feed: they appear only as deltas the authored TOML already carried on top of its `base_model` (read via `context.authored()`, never the base-resolved merge), so a full-inline file being factored for the first time — or a new file — inherits the lab entry outright. A new feed id resolves its `base_model` by normalized match against `models/` and is skipped with a report notice when nothing resolves — an unfactored full definition is never created. An empty feed fails the run rather than deleting the local catalog. - `family` passes through `ModelFamily.safeParse` and is omitted when unknown. - A skipped new id is returned from `missingModelID` (the only skip is "no lab base yet"), so the runner preserves any existing local entry and opens a deduped missing-model issue for the lab metadata. +- `preserveDescriptions` is off: the runner must not re-inject a pre-factor authored description when the translator leaves it unset, or a full-inline → factored transition would recreate a lab-identical override. Created files get a leading header from `translateModel` naming the reasoning wire path (a `toggle` is `reasoning_effort = "none"` versus the graded levels on the same field). - `interleaved` comes from the feed when published, else the authored value, else `{ field = "reasoning_content" }` for reasoners — every ai& reasoner streams thinking in `message.reasoning_content`, so a new reasoner is never created without its side channel. - If effort filtering would empty a non-empty feed list (a vocabulary the schema doesn't know yet), the authored `reasoning_options` are preserved rather than publishing "no caller control". From 07188a100fd35671fc2d9264739e17936ad0d862 Mon Sep 17 00:00:00 2001 From: "Islomjon (Toji)" Date: Tue, 15 Sep 2026 14:12:52 +0900 Subject: [PATCH 09/10] fix(aiand): fail a control-less reasoner for review instead of letting [] be written A reasoner whose feed yields no schema-valid reasoning_options and has no authored set to keep now throws MissingReasoningOptionsError: the runner preserves the local file, reports the reason, and routes the id to the missing-model issue flow. An explicit [] from the feed remains the feed's own assertion. Runner-level test covers the untouched-file path. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/sync/providers/aiand.ts | 22 +++++++++++- packages/core/test/aiand.test.ts | 42 +++++++++++++++++++---- sync.md | 2 +- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/core/src/sync/providers/aiand.ts b/packages/core/src/sync/providers/aiand.ts index 83b4687aa3e..74414272440 100644 --- a/packages/core/src/sync/providers/aiand.ts +++ b/packages/core/src/sync/providers/aiand.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { ModelFamily } from "../../family.js"; import { ReasoningOption as CatalogReasoningOption } from "../../schema.js"; import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; import { factorBaseModel } from "./openrouter.js"; const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models"); @@ -168,7 +169,7 @@ export function buildAiandModel( reasoning: model.reasoning, // The schema refuses reasoning_options on a non-reasoner; a non-reasoning // feed model must not stall the sync on that refine. - reasoning_options: model.reasoning ? resolveReasoningOptions(model, authored) : undefined, + reasoning_options: model.reasoning ? requireReasoningOptions(model, authored) : undefined, tool_call: model.tool_call, structured_output: model.structured_output, temperature: model.temperature, @@ -330,6 +331,25 @@ function isReasoningEffort(value: string): value is ReasoningEffortValue { return CatalogReasoningOption.safeParse({ type: "effort", values: [value] }).success; } +/** + * A reasoner must never be written with an invented empty control set: `[]` + * means "no caller control", not uncertainty (AGENTS.md → Reasoning options). + * When the feed yields no schema-valid controls and nothing authored can be + * kept, the model fails sync for manual review — the runner preserves the + * local file and routes the id to the missing-model issue flow. + */ +function requireReasoningOptions( + model: AiandModel, + authored: ExistingModel | undefined, +): SyncedFullModel["reasoning_options"] { + const options = resolveReasoningOptions(model, authored); + if (options !== undefined) return options; + throw new MissingReasoningOptionsError( + model.id, + "feed publishes reasoning = true without a schema-valid reasoning_options set and no authored controls exist to keep; research the ai& effort set before listing", + ); +} + /** * Omit, empty, and vocabulary-miss are three different assertions: an omitted * feed list asserts nothing (authored options stay), an explicit [] asserts diff --git a/packages/core/test/aiand.test.ts b/packages/core/test/aiand.test.ts index 8a4c98c9871..e2830d6f17b 100644 --- a/packages/core/test/aiand.test.ts +++ b/packages/core/test/aiand.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { syncProvider } from "../src/sync/index.js"; +import { MissingReasoningOptionsError } from "../src/sync/missing-reasoning-options.js"; import { aiand, @@ -92,19 +93,26 @@ test("a family the enum does not know falls back to the curated value", () => { expect(curated.family).toBe("deepseek"); }); -test("unknown reasoning efforts and modalities are dropped without inventing 'no control'", () => { +test("unknown modalities are dropped", () => { const built = buildAiandModel( - aiandModel({ - reasoning_options: [{ type: "effort", values: ["turbo"] }], - modalities: { input: ["text", "smell"], output: ["text"] }, - }), + aiandModel({ modalities: { input: ["text", "smell"], output: ["text"] } }), undefined, null, ); - expect(built.reasoning_options).toBeUndefined(); expect(built.modalities?.input).toEqual(["text"]); }); +test("a reasoner with no schema-valid controls and nothing authored fails instead of writing []", () => { + const vocabularyMiss = aiandModel({ reasoning_options: [{ type: "effort", values: ["turbo"] }] }); + expect(() => buildAiandModel(vocabularyMiss, undefined, null)).toThrow(MissingReasoningOptionsError); + const omitted = aiandModel({ reasoning_options: undefined }); + expect(() => buildAiandModel(omitted, undefined, null)).toThrow(MissingReasoningOptionsError); + // Authored controls are the escape hatch, and an explicit [] is the feed's own assertion. + expect(buildAiandModel(omitted, { reasoning_options: [{ type: "effort", values: ["high"] }] }, null).reasoning_options) + .toEqual([{ type: "effort", values: ["high"] }]); + expect(buildAiandModel(aiandModel({ reasoning_options: [] }), undefined, null).reasoning_options).toEqual([]); +}); + test("omitted reasoning_options assert nothing: authored options stay", () => { const built = buildAiandModel(aiandModel({ reasoning_options: undefined }), { reasoning_options: [{ type: "effort", values: ["high"] }], @@ -386,6 +394,28 @@ test("runner-level: a full-inline file factored for the first time is written wi expect(written.startsWith("# First-party Motif host entry")).toBe(true); }); +test("runner-level: a reasoner the feed leaves without controls is reported and its local file is left untouched", async () => { + const root = await mkdtemp(path.join(tmpdir(), "models-dev-aiand-missing-")); + const modelsDir = path.join(root, "providers", "aiand", "models"); + const labDest = path.join(root, "models", "motif-technologies", "motif-3.toml"); + await mkdir(path.dirname(labDest), { recursive: true }); + await copyFile(path.join(import.meta.dirname, "..", "..", "..", "models", "motif-technologies", "motif-3.toml"), labDest); + const providerFile = path.join(modelsDir, "motif-technologies", "motif-3.toml"); + await mkdir(path.dirname(providerFile), { recursive: true }); + const original = ['base_model = "motif-technologies/motif-3"', "", "[cost]", "input = 0.5", "output = 2", ""].join("\n"); + await Bun.write(providerFile, original); + + const feed = aiandModel({ id: "motif-technologies/motif-3", name: "Motif-Technologies/Motif-3", reasoning_options: undefined }); + const result = await syncProvider( + { ...aiand, modelsDir, fetchModels: async () => ({ aiand: { models: { [feed.id]: feed } } }) }, + { openIssues: false }, + ); + + expect(await readFile(providerFile, "utf8")).toBe(original); + expect(result.notices.join(" ")).toContain("motif-technologies/motif-3"); + expect(result.notices.join(" ")).toContain("no authored controls"); +}); + test("parses the provider entry from the full api.json document", () => { const parsed = AiandResponse.parse({ opencode: { models: {} }, diff --git a/sync.md b/sync.md index 4af4139e3c5..54636358f27 100644 --- a/sync.md +++ b/sync.md @@ -371,4 +371,4 @@ Some provider scripts in `packages/core/script/generate-*.ts` are not wired into - A skipped new id is returned from `missingModelID` (the only skip is "no lab base yet"), so the runner preserves any existing local entry and opens a deduped missing-model issue for the lab metadata. - `preserveDescriptions` is off: the runner must not re-inject a pre-factor authored description when the translator leaves it unset, or a full-inline → factored transition would recreate a lab-identical override. Created files get a leading header from `translateModel` naming the reasoning wire path (a `toggle` is `reasoning_effort = "none"` versus the graded levels on the same field). - `interleaved` comes from the feed when published, else the authored value, else `{ field = "reasoning_content" }` for reasoners — every ai& reasoner streams thinking in `message.reasoning_content`, so a new reasoner is never created without its side channel. -- If effort filtering would empty a non-empty feed list (a vocabulary the schema doesn't know yet), the authored `reasoning_options` are preserved rather than publishing "no caller control". +- A reasoner never gets an invented `[]`: an omitted feed list keeps the authored controls, and a non-empty list whose effort values the schema doesn't know yet does too; when neither yields a schema-valid set the model fails with `MissingReasoningOptionsError` — the runner preserves the local file and routes the id to the missing-model issue flow. Only an explicit `[]` published by the feed is written as "no caller control". From fe6ccf348829b28d7637cec04f7f84e3ea57fd7c Mon Sep 17 00:00:00 2001 From: "Islomjon (Toji)" Date: Tue, 15 Sep 2026 15:33:46 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix(aiand):=20never=20write=20toggle=20or?= =?UTF-8?q?=20budget=20controls=20=E2=80=94=20ai&=20has=20a=20single=20rea?= =?UTF-8?q?soning=20wire=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reasoning_effort ("none" = off) is the only control this host exposes, so toggle and budget_tokens are parsed for tolerance but dropped from feed and authored options alike; an emptied set falls through to the existing fail-for-review path. The create header states the single wire path instead of describing controls the host does not have. Co-Authored-By: Claude Fable 5.1 --- packages/core/src/sync/providers/aiand.ts | 39 ++++++++++------------- packages/core/test/aiand.test.ts | 26 +++++++++------ sync.md | 2 +- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/packages/core/src/sync/providers/aiand.ts b/packages/core/src/sync/providers/aiand.ts index 74414272440..9545bd91e0f 100644 --- a/packages/core/src/sync/providers/aiand.ts +++ b/packages/core/src/sync/providers/aiand.ts @@ -242,10 +242,10 @@ export function buildAiandModel( const DOCS_URL = "https://docs.aiand.com/models/catalog/"; /** - * Leading comment block for a created file. ai& exposes one reasoning wire - * path — `reasoning_effort` on /v1/chat/completions and `reasoning.effort` on - * /v1/responses, enforced per model — so a toggle is "none" versus the graded - * levels on that same field. + * Leading comment block for a created file. ai& exposes one reasoning control + * — `reasoning_effort` on /v1/chat/completions and `reasoning.effort` on + * /v1/responses, enforced per model, with "none" as off — so the header names + * that path and states that no separate toggle or token budget exists. */ function reasoningHeader(model: SyncedModel): string | undefined { const options = model.reasoning_options; @@ -258,15 +258,10 @@ function reasoningHeader(model: SyncedModel): string | undefined { if (option.type === "effort" && option.values.length > 0) { lines.push(`# Effort: reasoning_effort = ${option.values.map((value) => `"${value}"`).join(" | ")}`); } - if (option.type === "toggle") { - lines.push( - '# Toggle: reasoning_effort = "none" (off) vs the graded levels — field `reasoning_effort` on /v1/chat/completions, `reasoning.effort` on /v1/responses; enforced per model.', - ); - } - if (option.type === "budget_tokens") { - lines.push("# Budget: ai& publishes no token-budget control; value carried from the feed as-is."); - } } + lines.push( + "# Controls: reasoning_effort only (`reasoning_effort` on /v1/chat/completions, `reasoning.effort` on /v1/responses; \"none\" = off) — no separate toggle or token-budget field on this host.", + ); lines.push("# Reasoning side channel: message.reasoning_content"); return `${lines.join("\n")}\n`; } @@ -364,15 +359,15 @@ function resolveReasoningOptions( if (model.reasoning_options === undefined) return authoredReasoningOptions(authored); if (model.reasoning_options.length === 0) return []; const feed = model.reasoning_options.flatMap((option) => { - if (option.type === "effort") { - const values = option.values.filter( - (value): value is ReasoningEffortValue => typeof value === "string" && isReasoningEffort(value), - ); - return values.length > 0 ? [{ type: "effort" as const, values }] : []; - } - // toggle / budget_tokens carry no vocabulary to filter; the catalog - // schema already validated them at parse time. - return [option]; + // ai& has exactly one reasoning control — `reasoning_effort`, enforced per + // model, where "off" is the "none" level. A toggle or token budget cannot + // be true of this host, so neither is ever written; parsing them keeps a + // feed that publishes one from aborting the run. + if (option.type !== "effort") return []; + const values = option.values.filter( + (value): value is ReasoningEffortValue => typeof value === "string" && isReasoningEffort(value), + ); + return values.length > 0 ? [{ type: "effort" as const, values }] : []; }); return feed.length > 0 ? feed : authoredReasoningOptions(authored); } @@ -382,7 +377,7 @@ function authoredReasoningOptions( ): SyncedFullModel["reasoning_options"] { const options = (authored?.reasoning_options ?? []) .map((option) => CatalogReasoningOption.safeParse(option)) - .flatMap((result) => (result.success ? [result.data] : [])); + .flatMap((result) => (result.success && result.data.type === "effort" ? [result.data] : [])); return options.length > 0 ? options : undefined; } diff --git a/packages/core/test/aiand.test.ts b/packages/core/test/aiand.test.ts index e2830d6f17b..d4e0c74c1fb 100644 --- a/packages/core/test/aiand.test.ts +++ b/packages/core/test/aiand.test.ts @@ -12,7 +12,6 @@ import { AiandResponse, buildAiandModel, resolveAiandBaseModel, - type AiandModel, } from "../src/sync/providers/aiand.js"; function aiandModel(overrides: Partial = {}): AiandModel { @@ -228,16 +227,21 @@ test("a curated description wins over the feed's on a standalone file", () => { expect(built.description).toBe("curated"); }); -test("toggle and budget_tokens controls parse and pass through untouched", () => { +test("non-effort controls parse but are never written: ai& has no toggle or budget wire path", () => { const model = AiandModel.parse({ ...aiandModel(), - reasoning_options: [{ type: "toggle" }, { type: "budget_tokens", min: 1024, max: 32_768 }], + reasoning_options: [{ type: "toggle" }, { type: "budget_tokens", min: 1024, max: 32_768 }, { type: "effort", values: ["high"] }], }); - const built = buildAiandModel(model, undefined, null); - expect(built.reasoning_options).toEqual([ - { type: "toggle" }, - { type: "budget_tokens", min: 1024, max: 32_768 }, - ]); + expect(buildAiandModel(model, undefined, null).reasoning_options).toEqual([{ type: "effort", values: ["high"] }]); + + // Dropping them can't leave an invented [] behind: with nothing authored the model fails for review. + const toggleOnly = AiandModel.parse({ ...aiandModel(), reasoning_options: [{ type: "toggle" }] }); + expect(() => buildAiandModel(toggleOnly, undefined, null)).toThrow(MissingReasoningOptionsError); + + // A stale authored toggle is not carried forward on update either. + const authoredStale = { reasoning_options: [{ type: "toggle" as const }, { type: "effort" as const, values: ["high" as const] }] }; + expect(buildAiandModel(aiandModel({ reasoning_options: undefined }), authoredStale, null).reasoning_options) + .toEqual([{ type: "effort", values: ["high"] }]); }); test("a new reasoner gets the gateway's reasoning_content side channel; feed and authored values win over it", () => { @@ -317,14 +321,16 @@ test("an already-factored file keeps its authored lab-field deltas and omit list }); }); -test("a created reasoner with a toggle control gets a leading header naming the wire path", () => { +test("a created reasoner gets a leading header naming the single effort wire path and no other control", () => { const context = { existing: () => undefined, authored: () => undefined }; const created = aiand.translateModel( AiandModel.parse({ ...aiandModel(), reasoning_options: [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }] }), context, ); - expect(created?.header).toContain("# Toggle: reasoning_effort = \"none\" (off)"); expect(created?.header).toContain('# Effort: reasoning_effort = "high" | "max"'); + expect(created?.header).toContain("no separate toggle or token-budget field"); + expect(created?.header).not.toContain("# Toggle:"); + expect(created?.header).not.toContain("# Budget:"); expect(created?.header?.endsWith("\n")).toBe(true); }); diff --git a/sync.md b/sync.md index 54636358f27..96f2b59fb75 100644 --- a/sync.md +++ b/sync.md @@ -369,6 +369,6 @@ Some provider scripts in `packages/core/script/generate-*.ts` are not wired into - On base-factored files, lab-owned fields (`name`, `description`, `family`, `release_date`, `last_updated`, `knowledge`, `open_weights`) are never asserted from the feed: they appear only as deltas the authored TOML already carried on top of its `base_model` (read via `context.authored()`, never the base-resolved merge), so a full-inline file being factored for the first time — or a new file — inherits the lab entry outright. A new feed id resolves its `base_model` by normalized match against `models/` and is skipped with a report notice when nothing resolves — an unfactored full definition is never created. An empty feed fails the run rather than deleting the local catalog. - `family` passes through `ModelFamily.safeParse` and is omitted when unknown. - A skipped new id is returned from `missingModelID` (the only skip is "no lab base yet"), so the runner preserves any existing local entry and opens a deduped missing-model issue for the lab metadata. -- `preserveDescriptions` is off: the runner must not re-inject a pre-factor authored description when the translator leaves it unset, or a full-inline → factored transition would recreate a lab-identical override. Created files get a leading header from `translateModel` naming the reasoning wire path (a `toggle` is `reasoning_effort = "none"` versus the graded levels on the same field). +- `preserveDescriptions` is off: the runner must not re-inject a pre-factor authored description when the translator leaves it unset, or a full-inline → factored transition would recreate a lab-identical override. Created files get a leading header from `translateModel` naming the single reasoning wire path (`reasoning_effort`, "none" = off). `toggle` and `budget_tokens` controls are parsed (so a feed that publishes one never aborts the run) but never written, from feed or authored file: ai& has no separate on/off or token-budget field, so they cannot be true of this host. - `interleaved` comes from the feed when published, else the authored value, else `{ field = "reasoning_content" }` for reasoners — every ai& reasoner streams thinking in `message.reasoning_content`, so a new reasoner is never created without its side channel. - A reasoner never gets an invented `[]`: an omitted feed list keeps the authored controls, and a non-empty list whose effort values the schema doesn't know yet does too; when neither yields a schema-valid set the model fails with `MissingReasoningOptionsError` — the runner preserves the local file and routes the id to the missing-model issue flow. Only an explicit `[]` published by the feed is written as "no caller control".