diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 4cb51a923..5251666de 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -119,7 +119,9 @@ export async function syncProvider( ): Promise { console.log(`\nSyncing ${provider.name}...`); - const { models: existing, brokenSymlinks } = await readExisting(provider.modelsDir); + const existingState = await readExisting(provider.modelsDir); + const { models: existing, brokenSymlinks } = existingState; + let { modelMetadata } = existingState; const sourceModels = provider.parseModels(await provider.fetchModels()); const desired = new Map; content: string }>(); const desiredMetadata = new Map; content: string }>(); @@ -163,13 +165,28 @@ export async function syncProvider( }); } + const translatedModel = provider.preserveBaseModels === false + ? translated.model + : preserveBaseModel(translated.model, existing.get(relativePath)?.authored); + const translatedBase = "base_model" in translatedModel ? translatedModel.base_model : undefined; + let resolvedReasoning: boolean | undefined; + if (translatedBase !== undefined) { + if (translated.metadata?.id === translatedBase) { + resolvedReasoning = translated.metadata.model.reasoning; + } else { + modelMetadata ??= await readModelMetadata(provider.modelsDir); + const canonicalReasoning = modelMetadata[translatedBase]?.reasoning; + resolvedReasoning = typeof canonicalReasoning === "boolean" ? canonicalReasoning : undefined; + } + } else { + resolvedReasoning = existing.get(relativePath)?.toml.reasoning; + } const parsed = SyncedAuthoredModel.safeParse(stripUndefined({ id: translated.id, ...preserveReasoningOptions( - provider.preserveBaseModels === false - ? translated.model - : preserveBaseModel(translated.model, existing.get(relativePath)?.authored), + translatedModel, existing.get(relativePath)?.authored, + resolvedReasoning, ), })); if (!parsed.success) { @@ -319,7 +336,12 @@ export function preserveBaseModel(model: SyncedModel, existing: ExistingModel | export function preserveReasoningOptions( model: SyncedModel, existing: ExistingModel | undefined, + resolvedReasoning: boolean | undefined = existing?.reasoning, ): SyncedModel { + if ((model.reasoning ?? resolvedReasoning) === false) { + const { reasoning_options: _reasoningOptions, ...withoutReasoningOptions } = model; + return withoutReasoningOptions as SyncedModel; + } if (model.reasoning_options !== undefined || existing?.reasoning_options === undefined) return model; return { ...model, @@ -392,7 +414,7 @@ async function readExisting(modelsDir: string) { existing.set(file, { authored, toml, symlink }); } - return { models: existing, brokenSymlinks }; + return { models: existing, brokenSymlinks, modelMetadata }; } async function isSymlink(filePath: string) { diff --git a/packages/core/src/sync/providers/ovhcloud.ts b/packages/core/src/sync/providers/ovhcloud.ts index ef6ffd7c2..0e18565e6 100644 --- a/packages/core/src/sync/providers/ovhcloud.ts +++ b/packages/core/src/sync/providers/ovhcloud.ts @@ -121,6 +121,7 @@ export function buildOvhcloudModel( last_updated: lastUpdated, attachment, reasoning, + reasoning_options: reasoning ? existing?.reasoning_options : undefined, temperature: temperature || undefined, tool_call: toolCall, structured_output: structuredOutput || undefined, diff --git a/packages/core/test/ovhcloud-sync.test.ts b/packages/core/test/ovhcloud-sync.test.ts new file mode 100644 index 000000000..964fdcc11 --- /dev/null +++ b/packages/core/test/ovhcloud-sync.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import path from "node:path"; + +import { buildOvhcloudModel, type OvhcloudModel } from "../src/sync/providers/ovhcloud.js"; + +const model: OvhcloudModel = { + id: "Qwen3-32B", + name: "Qwen3-32B", + created: 1_752_655_628, + hugging_face_id: "Qwen/Qwen3-32B", + context_length: 32_768, + max_output_length: 32_768, + supported_features: ["reasoning"], +}; + +test("OVHcloud sync preserves authored reasoning options", () => { + const synced = buildOvhcloudModel(model, { + release_date: "2025-07-16", + last_updated: "2025-07-16", + reasoning_options: [{ type: "toggle" }], + }); + + expect(synced.reasoning_options).toEqual([{ type: "toggle" }]); +}); + +test("OVHcloud sync omits reasoning options for non-reasoning models", () => { + const synced = buildOvhcloudModel( + { ...model, supported_features: [] }, + { reasoning_options: [{ type: "toggle" }] }, + ); + + expect(synced.reasoning_options).toBeUndefined(); +}); + +test("OVHcloud reasoning models declare the exact provider control matrix", async () => { + const modelsDir = path.join(import.meta.dirname, "..", "..", "..", "providers", "ovhcloud", "models"); + const expected = { + "gpt-oss-20b": [{ type: "effort", values: ["low", "medium", "high"] }], + "gpt-oss-120b": [{ type: "effort", values: ["low", "medium", "high"] }], + "qwen3-32b": [{ type: "toggle" }], + "qwen3.5-9b": [{ type: "effort", values: ["none", "low", "medium", "high"] }], + "qwen3.5-397b-a17b": [{ type: "effort", values: ["none", "low", "medium", "high"] }], + "qwen3.6-27b": [{ type: "effort", values: ["none", "minimal", "low", "medium", "high"] }], + }; + + for (const [id, reasoningOptions] of Object.entries(expected)) { + const authored = Bun.TOML.parse(await Bun.file(path.join(modelsDir, `${id}.toml`)).text()) as { + reasoning?: boolean; + reasoning_options?: unknown; + }; + expect(authored.reasoning, id).toBe(true); + expect(authored.reasoning_options, id).toEqual(reasoningOptions); + } +}); diff --git a/packages/core/test/sync-runner.test.ts b/packages/core/test/sync-runner.test.ts index 10c07969d..075ef8bf3 100644 --- a/packages/core/test/sync-runner.test.ts +++ b/packages/core/test/sync-runner.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { mkdtemp, mkdir, readlink, symlink } from "node:fs/promises"; import os from "node:os"; +import { generate } from "../src/index.js"; import { AuthoredModelShape } from "../src/schema.js"; import { syncProvider, type SyncProvider, type SyncedFullModel } from "../src/sync/index.js"; @@ -48,6 +49,34 @@ async function fixture() { return { root, modelsDir }; } +async function factoredFixture(reasoning: boolean) { + const result = await fixture(); + const metadataDir = path.join(result.root, "models", "test"); + await mkdir(metadataDir, { recursive: true }); + await Bun.write(path.join(result.root, "providers", "test", "provider.toml"), `name = "Test" +npm = "@ai-sdk/openai" +env = ["TEST_API_KEY"] +doc = "https://example.com/models" +`); + await Bun.write(path.join(metadataDir, "model.toml"), `name = "Canonical model" +release_date = "2026-01-01" +last_updated = "2026-01-01" +attachment = false +reasoning = ${reasoning} +tool_call = false +open_weights = false + +[limit] +context = 1000 +output = 100 + +[modalities] +input = ["text"] +output = ["text"] +`); + return result; +} + function provider( modelsDir: string, ids: string[], @@ -172,6 +201,239 @@ output = ["text"] expect(second.unchanged).toBe(1); }); +test("sync removes authored reasoning options when a translator disables reasoning", async () => { + const { modelsDir } = await fixture(); + const filePath = path.join(modelsDir, "model.toml"); + await Bun.write(filePath, `name = "Old name" +release_date = "2026-01-01" +last_updated = "2026-01-01" +attachment = false +reasoning = true +reasoning_options = [{ type = "toggle" }] +tool_call = false +open_weights = false + +[cost] +input = 1 +output = 2 + +[limit] +context = 1000 +output = 100 + +[modalities] +input = ["text"] +output = ["text"] +`); + const sync = provider(modelsDir, ["model"]); + sync.translateModel = (id) => ({ + id, + model: { + ...model, + reasoning: false, + reasoning_options: [{ type: "toggle" }], + }, + }); + + const first = await syncProvider(sync); + const content = await Bun.file(filePath).text(); + const second = await syncProvider(sync); + + expect(first.updated).toBe(1); + expect(content).toContain("reasoning = false"); + expect(content).not.toContain("reasoning_options"); + expect(second.updated).toBe(0); + expect(second.unchanged).toBe(1); +}); + +test("sync removes authored reasoning options inherited beside reasoning false", async () => { + const { root, modelsDir } = await fixture(); + const filePath = path.join(modelsDir, "model.toml"); + await Bun.write(path.join(root, "providers", "test", "provider.toml"), `name = "Test" +npm = "@ai-sdk/openai" +env = ["TEST_API_KEY"] +doc = "https://example.com/models" +`); + await mkdir(path.join(root, "models", "test"), { recursive: true }); + await Bun.write(path.join(root, "models", "test", "model.toml"), `name = "Test model" +release_date = "2026-01-01" +last_updated = "2026-01-01" +attachment = false +reasoning = false +tool_call = false +open_weights = false + +[limit] +context = 1000 +output = 100 + +[modalities] +input = ["text"] +output = ["text"] +`); + await Bun.write(filePath, `base_model = "test/model" +reasoning_options = [{ type = "toggle" }] + +[cost] +input = 1 +output = 2 +`); + const sync = provider(modelsDir, ["model"]); + sync.translateModel = (id) => ({ + id, + model: { + base_model: "test/model", + cost: { input: 1, output: 2 }, + }, + }); + + const first = await syncProvider(sync); + const content = await Bun.file(filePath).text(); + const generated = (await generate(path.join(root, "providers"))).test?.models.model; + const second = await syncProvider(sync); + + expect(first.updated).toBe(1); + expect(content).toContain('base_model = "test/model"'); + expect(content).not.toContain("reasoning_options"); + expect(generated?.reasoning).toBe(false); + expect(generated?.reasoning_options).toBeUndefined(); + expect(second.updated).toBe(0); + expect(second.unchanged).toBe(1); +}); + +test("sync removes authored reasoning options when changing to a non-reasoning base", async () => { + const { root, modelsDir } = await fixture(); + const metadataDir = path.join(root, "models", "test"); + const filePath = path.join(modelsDir, "model.toml"); + await mkdir(metadataDir, { recursive: true }); + await Bun.write(path.join(metadataDir, "reasoning.toml"), `name = "Reasoning base" +release_date = "2026-01-01" +last_updated = "2026-01-01" +attachment = false +reasoning = true +tool_call = false +open_weights = false + +[limit] +context = 1000 +output = 100 + +[modalities] +input = ["text"] +output = ["text"] +`); + await Bun.write(path.join(metadataDir, "standard.toml"), `name = "Standard base" +release_date = "2026-01-01" +last_updated = "2026-01-01" +attachment = false +reasoning = false +tool_call = false +open_weights = false + +[limit] +context = 1000 +output = 100 + +[modalities] +input = ["text"] +output = ["text"] +`); + await Bun.write(filePath, `base_model = "test/reasoning" +reasoning_options = [{ type = "toggle" }] + +[cost] +input = 1 +output = 2 +`); + const sync = provider(modelsDir, ["model"]); + sync.translateModel = (id) => ({ + id, + model: { + base_model: "test/standard", + cost: { input: 1, output: 2 }, + }, + }); + + const first = await syncProvider(sync); + const content = await Bun.file(filePath).text(); + const second = await syncProvider(sync); + + expect(first.updated).toBe(1); + expect(content).toContain('base_model = "test/standard"'); + expect(content).not.toContain("reasoning_options"); + expect(second.updated).toBe(0); + expect(second.unchanged).toBe(1); +}); + +test("sync removes a reasoning override and options over a non-reasoning base", async () => { + const { root, modelsDir } = await factoredFixture(false); + const filePath = path.join(modelsDir, "model.toml"); + await Bun.write(filePath, `base_model = "test/model" +reasoning = true +reasoning_options = [{ type = "toggle" }] + +[cost] +input = 1 +output = 2 +`); + const sync = provider(modelsDir, ["model"]); + sync.translateModel = (id) => ({ + id, + model: { + base_model: "test/model", + cost: { input: 1, output: 2 }, + }, + }); + + const first = await syncProvider(sync); + const content = await Bun.file(filePath).text(); + const generated = (await generate(path.join(root, "providers"))).test?.models.model; + const second = await syncProvider(sync); + + expect(first.updated).toBe(1); + expect(content).not.toContain("reasoning ="); + expect(content).not.toContain("reasoning_options"); + expect(generated?.reasoning).toBe(false); + expect(generated?.reasoning_options).toBeUndefined(); + expect(second.updated).toBe(0); + expect(second.unchanged).toBe(1); +}); + +test("sync preserves options when removing a non-reasoning override over a reasoning base", async () => { + const { root, modelsDir } = await factoredFixture(true); + const filePath = path.join(modelsDir, "model.toml"); + await Bun.write(filePath, `base_model = "test/model" +reasoning = false +reasoning_options = [{ type = "toggle" }] + +[cost] +input = 1 +output = 2 +`); + const sync = provider(modelsDir, ["model"]); + sync.translateModel = (id) => ({ + id, + model: { + base_model: "test/model", + cost: { input: 1, output: 2 }, + }, + }); + + const first = await syncProvider(sync); + const content = await Bun.file(filePath).text(); + const generated = (await generate(path.join(root, "providers"))).test?.models.model; + const second = await syncProvider(sync); + + expect(first.updated).toBe(1); + expect(content).not.toContain("reasoning = false"); + expect(content).toContain("[[reasoning_options]]"); + expect(content).toContain('type = "toggle"'); + expect(generated?.reasoning).toBe(true); + expect(generated?.reasoning_options).toEqual([{ type: "toggle" }]); + expect(second.updated).toBe(0); + expect(second.unchanged).toBe(1); +}); + test("sync writes metadata returned by a provider translator", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "models-dev-sync-metadata-")); const modelsDir = path.join(root, "providers", "test", "models"); diff --git a/providers/ovhcloud/models/gpt-oss-120b.toml b/providers/ovhcloud/models/gpt-oss-120b.toml index 3c762f2c1..3a5e0a0df 100644 --- a/providers/ovhcloud/models/gpt-oss-120b.toml +++ b/providers/ovhcloud/models/gpt-oss-120b.toml @@ -3,6 +3,7 @@ release_date = "2025-08-28" last_updated = "2025-08-28" attachment = false reasoning = true +reasoning_options = [{ type = "effort", values = ["low", "medium", "high"] }] tool_call = true structured_output = true open_weights = true diff --git a/providers/ovhcloud/models/gpt-oss-20b.toml b/providers/ovhcloud/models/gpt-oss-20b.toml index 71e3afc37..e2c154a2e 100644 --- a/providers/ovhcloud/models/gpt-oss-20b.toml +++ b/providers/ovhcloud/models/gpt-oss-20b.toml @@ -3,6 +3,7 @@ release_date = "2025-08-28" last_updated = "2025-08-28" attachment = false reasoning = true +reasoning_options = [{ type = "effort", values = ["low", "medium", "high"] }] tool_call = true structured_output = true open_weights = true diff --git a/providers/ovhcloud/models/qwen3-32b.toml b/providers/ovhcloud/models/qwen3-32b.toml index 58ef5da29..ffdc77096 100644 --- a/providers/ovhcloud/models/qwen3-32b.toml +++ b/providers/ovhcloud/models/qwen3-32b.toml @@ -3,6 +3,7 @@ release_date = "2025-07-16" last_updated = "2025-07-16" attachment = false reasoning = true +reasoning_options = [{ type = "toggle" }] temperature = true tool_call = true structured_output = true diff --git a/providers/ovhcloud/models/qwen3.5-397b-a17b.toml b/providers/ovhcloud/models/qwen3.5-397b-a17b.toml index 9969e34dc..00ff86a11 100644 --- a/providers/ovhcloud/models/qwen3.5-397b-a17b.toml +++ b/providers/ovhcloud/models/qwen3.5-397b-a17b.toml @@ -3,6 +3,7 @@ release_date = "2026-05-18" last_updated = "2026-05-18" attachment = true reasoning = true +reasoning_options = [{ type = "effort", values = ["none", "low", "medium", "high"] }] temperature = true tool_call = true structured_output = true diff --git a/providers/ovhcloud/models/qwen3.5-9b.toml b/providers/ovhcloud/models/qwen3.5-9b.toml index a624b3126..4fe9ba0ea 100644 --- a/providers/ovhcloud/models/qwen3.5-9b.toml +++ b/providers/ovhcloud/models/qwen3.5-9b.toml @@ -3,6 +3,7 @@ release_date = "2026-04-22" last_updated = "2026-04-22" attachment = true reasoning = true +reasoning_options = [{ type = "effort", values = ["none", "low", "medium", "high"] }] temperature = true tool_call = true structured_output = true diff --git a/providers/ovhcloud/models/qwen3.6-27b.toml b/providers/ovhcloud/models/qwen3.6-27b.toml index f8b47531b..83a24178d 100644 --- a/providers/ovhcloud/models/qwen3.6-27b.toml +++ b/providers/ovhcloud/models/qwen3.6-27b.toml @@ -3,6 +3,7 @@ release_date = "2026-06-01" last_updated = "2026-06-01" attachment = true reasoning = true +reasoning_options = [{ type = "effort", values = ["none", "minimal", "low", "medium", "high"] }] temperature = true tool_call = true structured_output = true diff --git a/sync.md b/sync.md index c07e9547f..78cd8bbd9 100644 --- a/sync.md +++ b/sync.md @@ -164,6 +164,7 @@ OVHcloud AI Endpoints is implemented in `packages/core/src/sync/providers/ovhclo - Model IDs are lowercased from the catalog `id` to match the existing TOML paths under `providers/ovhcloud/models`. - API prices are per-token strings and are converted to per-1M-token numbers; free models (price `0`) get no `[cost]` section. - `reasoning`, `tool_call`, and `structured_output` come from `supported_features`; `temperature` comes from `supported_sampling_parameters`. +- Authored `reasoning_options` are preserved for reasoning models. `Qwen3-32B` supports toggling reasoning through OVHcloud's documented `/no_think` prompt control. Both gpt-oss models support `low`, `medium`, and `high` reasoning effort. The Qwen3.5 models support `none`, `low`, `medium`, and `high`; Qwen3.6-27B additionally supports `minimal`. - `attachment` is derived from non-text `input_modalities`, and `open_weights` from the presence of `hugging_face_id`. - `release_date`/`last_updated` default to the catalog `created` timestamp but preserve any existing hand-authored dates; `knowledge`, `family`, `status`, `interleaved`, and `limit.input` are preserved when present.