From 4f70373306d14ef664d669f6b3fa7acee23f3421 Mon Sep 17 00:00:00 2001 From: twoimo Date: Mon, 17 Aug 2026 20:40:43 +0900 Subject: [PATCH] fix(omlx): keep role thinking effort on discovered models Discovered oMLX rows were synthesized as non-reasoning OpenAI models, so macos-omlx-* :low/:medium/:high suffixes clamped off before the wire. Treat oMLX catalog entries as reasoning models and send chat_template_kwargs.reasoning_effort with enable_thinking. Lore-id: 0m1xef47 Constraint: oMLX accepts reasoning_effort via chat_template_kwargs, not top-level OpenAI effort alone Rejected: drop preset effort suffixes | makes the one-model role contract a lie Confidence: high Scope-risk: narrow Reversibility: easy Tested: bun test packages/coding-agent/test/omlx-discovery.test.ts packages/coding-agent/test/model-profiles-catalog.test.ts packages/ai/test/openai-completions-compat.test.ts Not-tested: live oMLX Qwen template applying every effort literal --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/model-thinking.ts | 19 ++++++++++++ .../ai/src/provider-models/openai-compat.ts | 18 +++++++++++ .../ai/src/providers/openai-completions.ts | 6 +++- .../ai/test/openai-completions-compat.test.ts | 30 +++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/config/model-registry.ts | 22 ++++++++++++-- .../test/model-profiles-catalog.test.ts | 9 +++++- .../coding-agent/test/omlx-discovery.test.ts | 27 +++++++++++++++++ 9 files changed, 129 insertions(+), 4 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5e103da87f..deefdd3c9e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- oMLX OpenAI-compatible completions now send `chat_template_kwargs.reasoning_effort` with `enable_thinking` when `thinkingFormat` is `qwen-chat-template`. Discovered oMLX models are treated as reasoning models with `low`/`medium`/`high` effort so local Qwen presets can differentiate roles without swapping weights. ## [0.14.0] - 2026-08-17 - Cursor native tool calls (shell/read/write/… oneof variants) now convert their protobuf payloads into plain JSON-safe data before attaching them as toolCall `arguments`: `$typeName` markers are stripped, safe-range bigints become numbers (decimal strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 strings, and cycles/functions collapse to null. Raw protobuf-es payloads carry `bigint` fields (`fileSize`, `durationMs`, `fileOutputThresholdBytes`, …) that defeat `JSON.stringify`, which broke managed snapshot staging, JSONL transcript persistence, and provider replay — the issue #4578 local-snapshot producer defect class fixed at its producer boundary. diff --git a/packages/ai/src/model-thinking.ts b/packages/ai/src/model-thinking.ts index b1ab25bb1c..c0a948680d 100644 --- a/packages/ai/src/model-thinking.ts +++ b/packages/ai/src/model-thinking.ts @@ -221,6 +221,9 @@ export function applyGeneratedModelPolicies(models: ApiModel[]): void { if (source.id.split("/").at(-1)?.toLowerCase() === "muse-spark-1.2") { source.reasoning = true; } + if (source.provider === "omlx") { + source.reasoning = true; + } const model = refreshModelThinking(source); applyGeneratedModelPolicy(model); models[index] = model; @@ -430,6 +433,16 @@ function applyGeneratedModelPolicy(model: ApiModel): void { }; delete model.compat.thinkingFormat; } + if (model.provider === "omlx" && model.api === "openai-completions") { + model.compat = { + ...(model.compat ?? {}), + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + thinkingFormat: "qwen-chat-template", + reasoningContentField: "reasoning_content", + }; + } model.name = scrubGeneratedModelName(model.name); if ( model.api === "openai-completions" && @@ -621,6 +634,9 @@ function inferDefaultEffort(model: ApiModel, parsedModel ) { return GPT_5_5_DEFAULT_EFFORT; } + if (model.provider === "omlx") { + return Effort.Medium; + } return undefined; } @@ -784,6 +800,9 @@ function inferFallbackEfforts(model: ApiModel): readonly return DEFAULT_REASONING_EFFORTS; } if (model.api === "openai-completions") { + if (model.provider === "omlx") { + return [Effort.Low, Effort.Medium, Effort.High]; + } const compat = resolveOpenAICompat(model as ApiModel<"openai-completions">); if (compat.thinkingFormat === "openai" && compat.supportsReasoningEffort) { return DEFAULT_REASONING_EFFORTS_WITH_XHIGH; diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index 526b7b85b7..f36043068b 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1418,6 +1418,24 @@ export function omlxModelManagerOptions(config?: OmlxModelManagerConfig): ModelM provider: "omlx", baseUrl, apiKey, + mapModel: (_entry, defaults) => ({ + ...defaults, + reasoning: true, + thinking: { + mode: "effort", + minLevel: Effort.Low, + maxLevel: Effort.High, + defaultLevel: Effort.Medium, + levels: [Effort.Low, Effort.Medium, Effort.High], + }, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + thinkingFormat: "qwen-chat-template", + reasoningContentField: "reasoning_content", + }, + }), }), }; } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index e01eef7a96..122c87c50a 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1398,8 +1398,12 @@ function buildParams( // Qwen uses top-level enable_thinking: boolean params.enable_thinking = !!options?.reasoning && !options?.disableReasoning; } else if (supportsReasoningParams && compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { + const enableThinking = !!options?.reasoning && !options?.disableReasoning; params.chat_template_kwargs = { - enable_thinking: !!options?.reasoning && !options?.disableReasoning, + enable_thinking: enableThinking, + ...(enableThinking && options?.reasoning + ? { reasoning_effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap) } + : {}), }; } else if (supportsReasoningParams && compat.thinkingFormat === "openrouter" && model.reasoning) { // OpenRouter normalizes reasoning across providers via a nested reasoning object. diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index 79be24770c..e2d2cb213c 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -33,6 +33,12 @@ function getNestedBoolean(value: unknown, key: string): boolean | undefined { const property = Reflect.get(obj, key); return typeof property === "boolean" ? property : undefined; } +function getNestedString(value: unknown, key: string): string | undefined { + const obj = toObject(value); + if (!obj) return undefined; + const property = Reflect.get(obj, key); + return typeof property === "string" ? property : undefined; +} function createSseResponse(events: unknown[]): Response { const payload = `${events.map(event => `data: ${typeof event === "string" ? event : JSON.stringify(event)}`).join("\n\n")}\n\n`; @@ -430,6 +436,30 @@ describe("openai-completions compatibility", () => { const chatTemplateArgs = getNestedObject(payload, "chat_template_kwargs"); expect(getNestedBoolean(chatTemplateArgs, "enable_thinking")).toBe(true); }); + it("maps oMLX reasoning effort into chat_template_kwargs.reasoning_effort", async () => { + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + provider: "omlx", + id: "Qwen3.6-35B-A3B-8bit", + reasoning: true, + compat: { + thinkingFormat: "qwen-chat-template", + supportsReasoningEffort: true, + }, + }; + const { promise, resolve } = Promise.withResolvers(); + streamOpenAICompletions(model, baseContext(), { + apiKey: "test-key", + reasoning: "high", + signal: createAbortedSignal(), + onPayload: payload => resolve(payload), + }); + const payload = await promise; + const chatTemplateArgs = getNestedObject(payload, "chat_template_kwargs"); + expect(getNestedBoolean(chatTemplateArgs, "enable_thinking")).toBe(true); + expect(getNestedString(chatTemplateArgs, "reasoning_effort")).toBe("high"); + }); it("treats finish_reason end as stop", async () => { const model: Model<"openai-completions"> = { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 138f77c091..21e465d4a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- Discovered oMLX models now keep thinking metadata (`reasoning: true`, `supportsReasoningEffort`, `thinkingFormat: qwen-chat-template`) so `macos-omlx-*` role suffixes (`:low`/`:medium`/`:high`) survive clamp and reach oMLX as `chat_template_kwargs.reasoning_effort`. - Added built-in `MACOS LOCAL (OMLX)` model profiles (`macos-omlx-fast`, `macos-omlx-balanced`, `macos-omlx-quality`, `macos-omlx-abliterated-fast`, `macos-omlx-abliterated-balanced`) for oMLX local inference on Apple Silicon Macs with native full context support and single-LLM thinking effort role mappings to eliminate model swap latency. - Fixed an HTTP 400 that killed every deep-interview session on the `google-antigravity` provider before the first assistant turn. The Round-0 topology `ask` schema pinned `round` with `z.literal(0)`, which zod serializes as `const: 0` and the Cloud Code Assist normalizer rewrites to a numeric `enum: [0]` — a shape CCA rejects (`TYPE_STRING`). `round` is now pinned with an integer range `[0, 0]` instead, so the wire schema carries `type: integer` with the bounds spilled into the description (the same treatment `ambiguity` already gets) and no numeric enum remains. Runtime contract unchanged: only `0` validates (#4606). - The terminal-app integration docs now cite the upstream work that backs each support rating: Gajae Code is proposed for Paseo's in-app ACP provider catalog ([getpaseo/paseo#3471](https://github.com/getpaseo/paseo/pull/3471)) and for Orca's built-in agent registry ([stablyai/orca#15025](https://github.com/stablyai/orca/pull/15025)), while T3 Code has no GJC harness and the integration shape is under discussion upstream ([pingdotgg/t3code#7290](https://github.com/pingdotgg/t3code/discussions/7290)). diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 9de6dc5c62..1e30076132 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -11,6 +11,7 @@ import { type Context, codexContextOverrideKey, createModelManager, + Effort, enrichModelThinking, getBundledModels, getBundledProviders, @@ -3042,7 +3043,7 @@ export class ModelRegistry { api, provider: providerConfig.provider, baseUrl: requestBaseUrl, - reasoning: referenceModel?.reasoning ?? false, + reasoning: providerConfig.provider === "omlx" ? true : (referenceModel?.reasoning ?? false), thinking: referenceModel?.thinking, input: referenceModel?.input ?? ["text"], output: referenceModel?.output, @@ -3069,8 +3070,25 @@ export class ModelRegistry { ...referenceModel?.compat, supportsStore: false, supportsDeveloperRole: false, - supportsReasoningEffort: false, + supportsReasoningEffort: providerConfig.provider === "omlx", + ...(providerConfig.provider === "omlx" + ? { + thinkingFormat: "qwen-chat-template" as const, + reasoningContentField: "reasoning_content" as const, + } + : {}), }, + ...(providerConfig.provider === "omlx" + ? { + reasoning: true, + thinking: { + mode: "effort" as const, + minLevel: Effort.Low, + maxLevel: Effort.High, + defaultLevel: Effort.Medium, + }, + } + : {}), }), ); } diff --git a/packages/coding-agent/test/model-profiles-catalog.test.ts b/packages/coding-agent/test/model-profiles-catalog.test.ts index b5b9b827a8..cb944519a0 100644 --- a/packages/coding-agent/test/model-profiles-catalog.test.ts +++ b/packages/coding-agent/test/model-profiles-catalog.test.ts @@ -685,6 +685,13 @@ const commandCodeGoatModels = new Set([ "deepseek/deepseek-v4-pro", "moonshotai/Kimi-K3", ]); +const macosOmlxModels = new Set([ + "Qwen3.6-35B-A3B-4bit", + "Qwen3.6-35B-A3B-8bit", + "Qwen3.6-35B-A3B-bf16", + "Qwen3.8-27B-Abliterated-MLX-4bit", + "Qwen3.8-27B-Abliterated-MLX-6bit", +]); function selectorExists(selector: string): boolean { const selectorWithoutThinking = splitSelectorThinkingSuffix(selector).selector; @@ -698,7 +705,7 @@ function selectorExists(selector: string): boolean { if (!parsed) return false; if (parsed.provider === "grok-build") return ["grok-composer-2.5-fast", "grok-build"].includes(parsed.id); if (parsed.provider === "commandcode-goat") return commandCodeGoatModels.has(parsed.id); - if (parsed.provider === "omlx") return true; + if (parsed.provider === "omlx") return macosOmlxModels.has(parsed.id); return (modelsJson as Record>)[parsed.provider]?.[parsed.id] !== undefined; } diff --git a/packages/coding-agent/test/omlx-discovery.test.ts b/packages/coding-agent/test/omlx-discovery.test.ts index 8d6bd4bf90..3096db367f 100644 --- a/packages/coding-agent/test/omlx-discovery.test.ts +++ b/packages/coding-agent/test/omlx-discovery.test.ts @@ -173,4 +173,31 @@ describe("ModelRegistry oMLX Discovery", () => { expect(registry.find("omlx", "valid-model")?.contextWindow).toBe(131072); expect(registry.find("omlx", "valid-model")?.maxTokens).toBe(8192); }); + test("marks discovered oMLX models as reasoning with low/medium/high effort", async () => { + using _hook = hookFetch(input => { + if (!String(input).includes(":8080/v1/models")) return new Response(null, { status: 404 }); + return new Response( + JSON.stringify({ + data: [{ id: "Qwen3.6-35B-A3B-8bit", max_model_len: 262144 }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + await registry.refresh(); + + const model = registry.find("omlx", "Qwen3.6-35B-A3B-8bit"); + expect(model?.reasoning).toBe(true); + expect(model?.thinking).toMatchObject({ + mode: "effort", + minLevel: "low", + maxLevel: "high", + defaultLevel: "medium", + }); + expect(model?.compat).toMatchObject({ + supportsReasoningEffort: true, + thinkingFormat: "qwen-chat-template", + }); + }); });