diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6f4d842c10..163776c918 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -17,6 +17,8 @@ - Anthropic cache-control resolution now falls back to `model.cacheRetention` at the provider boundary, preserving configured retention and request-over-model precedence through special dispatch wrappers such as GitLab Duo. A configured `cacheRetention: "none"` can no longer be dropped and replaced by the new automatic Claude-family cache marker. - Anthropic explicit prompt caching now advances its conversation breakpoint during tool-use loops by marking the latest completed assistant tool-use turn while leaving the newest tool result uncached. Previously it kept refreshing only the original human message until another human turn arrived, pinning proxy cache reads to the static tools/system prefix throughout long agentic runs. +- DeepSeek V4 family models (including namespaced custom openai-completions proxies) now treat GJC `max` as a first-class thinking level instead of silently clamping `:max` to `xhigh`, and `resolveWireReasoningEffort` exposes the request-side wire value after `reasoningEffortMap` for operator diagnostics (#3858). + ## [0.12.12] - 2026-08-05 ### Fixed diff --git a/packages/ai/src/model-thinking.ts b/packages/ai/src/model-thinking.ts index aa0bc1a545..54b29d8abc 100644 --- a/packages/ai/src/model-thinking.ts +++ b/packages/ai/src/model-thinking.ts @@ -715,12 +715,26 @@ function inferAnthropicSupportedEfforts( return inferFallbackEfforts(model); } +/** + * DeepSeek V4's documented provider contract is high/max. GJC still exposes the + * full effort ladder (lower levels map upward on the wire), but `max` must remain + * a first-class GJC level so selectors like `:max` are not silently clamped to + * `xhigh` on custom openai-compatible proxies that only inherit generic metadata. + */ +function isDeepSeekV4FamilyModel(model: ApiModel): boolean { + const canonicalId = getCanonicalModelId(model.id).toLowerCase(); + const name = model.name.toLowerCase(); + return canonicalId.includes("deepseek-v4") || name.includes("deepseek-v4"); +} + function inferFallbackEfforts(model: ApiModel): readonly Effort[] { if (model.api === "anthropic-messages") { return DEFAULT_REASONING_EFFORTS_WITH_XHIGH; } - if (model.name.includes("deepseek-v4")) { - return DEFAULT_REASONING_EFFORTS_WITH_XHIGH; + if (isDeepSeekV4FamilyModel(model)) { + // Keep xhigh as a GJC alias that maps to provider `max`, and also accept + // an explicit `:max` so HUD/session state preserve the operator's intent. + return DEFAULT_REASONING_EFFORTS_WITH_XHIGH_AND_MAX; } if (model.api === "bedrock-converse-stream") { return DEFAULT_REASONING_EFFORTS; diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index 37f1120f4a..587c0657be 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -303,3 +303,36 @@ export function resolveOpenAICompat( toolStrictMode: model.compat.toolStrictMode ?? detected.toolStrictMode, }; } + +/** + * Maps a GJC-normalized effort to the string that OpenAI-completions transport + * will put on the wire after `reasoningEffortMap` is applied. + * + * This is the request-side effective value only. Providers that accept the + * request without echoing an effort do not confirm backend-normalized effort. + */ +export function resolveWireReasoningEffort( + model: Model<"openai-completions">, + effort: OpenAIReasoningEffort, + resolvedBaseUrl?: string, +): { + /** GJC effort used as the map key (already metadata-clamped by callers). */ + effort: OpenAIReasoningEffort; + /** Value sent as `reasoning_effort` / nested OpenRouter effort after mapping. */ + wire: string; + /** True when `reasoningEffortMap` rewrote the GJC level to a different string. */ + remapped: boolean; + /** True when an explicit map entry existed for this effort (vs identity passthrough). */ + hasMapEntry: boolean; +} { + const compat = resolveOpenAICompat(model, resolvedBaseUrl); + const mapped = compat.reasoningEffortMap[effort]; + const hasMapEntry = mapped !== undefined; + const wire = mapped ?? effort; + return { + effort, + wire, + remapped: wire !== effort, + hasMapEntry, + }; +} diff --git a/packages/ai/test/issue-3858-deepseek-effort-clamp.test.ts b/packages/ai/test/issue-3858-deepseek-effort-clamp.test.ts new file mode 100644 index 0000000000..737316d0e5 --- /dev/null +++ b/packages/ai/test/issue-3858-deepseek-effort-clamp.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import { + clampThinkingLevelForModel, + Effort, + enrichModelThinking, + getSupportedEfforts, + refreshModelThinking, +} from "@gajae-code/ai/model-thinking"; +import { resolveOpenAICompat, resolveWireReasoningEffort } from "@gajae-code/ai/providers/openai-completions-compat"; +import type { Model } from "@gajae-code/ai/types"; +import { getBundledModel } from "../src/models"; + +function customDeepSeekProxy(overrides: Partial> = {}): Model<"openai-completions"> { + return enrichModelThinking({ + id: "cline-pass/deepseek-v4-flash", + name: "DeepSeek V4 Flash via ClinePass", + api: "openai-completions", + provider: "clinepass", + baseUrl: "https://api.cline.bot/api/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 16_384, + ...overrides, + }); +} + +describe("#3858 DeepSeek V4 proxy effort clamp / wire visibility", () => { + it("keeps :max as a first-class GJC level for namespaced custom DeepSeek V4 models", () => { + const model = customDeepSeekProxy(); + expect(model.thinking?.maxLevel).toBe(Effort.Max); + expect(getSupportedEfforts(model)).toContain(Effort.Max); + expect(clampThinkingLevelForModel(model, Effort.Max)).toBe(Effort.Max); + expect(clampThinkingLevelForModel(model, Effort.XHigh)).toBe(Effort.XHigh); + }); + + it("maps GJC xhigh/max to DeepSeek wire max on custom openai-compatible proxies", () => { + const model = customDeepSeekProxy(); + const compat = resolveOpenAICompat(model); + expect(compat.reasoningEffortMap).toMatchObject({ + minimal: "high", + high: "high", + xhigh: "max", + max: "max", + }); + expect(resolveWireReasoningEffort(model, Effort.Max)).toEqual({ + effort: Effort.Max, + wire: "max", + remapped: false, + hasMapEntry: true, + }); + expect(resolveWireReasoningEffort(model, Effort.XHigh)).toEqual({ + effort: Effort.XHigh, + wire: "max", + remapped: true, + hasMapEntry: true, + }); + }); + + it("refreshes bundled DeepSeek V4 thinking so max is not silently clamped to xhigh", () => { + const bundled = getBundledModel("deepseek", "deepseek-v4-flash") as Model<"openai-completions">; + const refreshed = refreshModelThinking(bundled); + expect(refreshed.thinking?.maxLevel).toBe(Effort.Max); + expect(clampThinkingLevelForModel(refreshed, Effort.Max)).toBe(Effort.Max); + expect(resolveWireReasoningEffort(refreshed, Effort.Max).wire).toBe("max"); + }); + + it("does not invent a DeepSeek effort map for unrelated custom models", () => { + const model = enrichModelThinking({ + id: "corp/generic-reasoner", + name: "Generic Reasoner", + api: "openai-completions", + provider: "custom", + baseUrl: "https://proxy.example.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }) as Model<"openai-completions">; + + expect(model.thinking?.maxLevel).toBe(Effort.XHigh); + expect(clampThinkingLevelForModel(model, Effort.Max)).toBe(Effort.XHigh); + const wire = resolveWireReasoningEffort(model, Effort.XHigh); + expect(wire).toEqual({ + effort: Effort.XHigh, + wire: "xhigh", + remapped: false, + hasMapEntry: false, + }); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 941674524f..9287811a52 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,7 @@ - `gjc models` is no longer treated as a free-form agent prompt. The mistaken subcommand spelling now routes to the existing `--list-models` listing path so a nested bash-tool invocation cannot recursively spawn unbounded GJC agents (#3857). - Always-apply and rulebook rules are injected on the default system prompt path again. Discovery still loaded `.gjc/rules/`, `~/.gjc/agent/rules/`, and sticky `RULES.md`, but only `custom-system-prompt.md` rendered them, so normal sessions silently dropped the content while AGENTS.md in the same directory continued to work (#3859). +- Custom namespaced openai-completions model IDs (for example `cline-pass/deepseek-v4-flash`) inherit bundled leaf metadata, and DeepSeek V4 proxy effort resolution no longer silently rewrites profile `:max` to GJC `xhigh` without operator-visible wire diagnostics in model-preset previews (#3858). - Made Telegram reference-client capability diagnostics safe for TUI embedding. - Custom `anthropic-messages` providers can now configure `compat.promptCacheMode` (`none`, `explicit`, or `automatic`) and `compat.supportsLongCacheRetention` at provider, model, and model-override levels. Canonical Anthropic defaults to automatic caching, while non-canonical Claude-family endpoints default to gateway-safe explicit block markers and can opt into top-level automatic caching when supported. - A Telegram notification daemon whose reconciliation pass fails no longer exits. The pass persists through the shared topic authority, and a momentarily unavailable authority (lock contention or a rejected compare-and-set) rejected out of both the scan timer and the run loop into the process-level fatal handler, killing the owner. Every session topic was then left behind as an unarchived shell that answers nothing — including for sessions that were still live and lost their notifications. The pass now reports the failure and the next scan interval retries it; the queue-flush timer is guarded the same way. diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index bf751da98d..f2c92699ea 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -936,7 +936,8 @@ function getCustomReferenceCandidateIds(modelId: string): string[] { } // Namespaced wire IDs (e.g. `cline-pass/deepseek-v4-flash`) keep the full id for // the API request, but should still try the leaf segment against bundled - // references so capability metadata is not silently replaced by 128K/16K defaults. + // references so capability metadata (thinking levels, reasoningEffortMap, + // context windows) is not silently replaced by generic defaults. // Only an exact leaf match in the reference map inherits; unknown leaves stay defaulted. const slashIndex = trimmedId.lastIndexOf("/"); if (slashIndex >= 0 && slashIndex < trimmedId.length - 1) { diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 2bce0ccb8c..67863d2402 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -38,7 +38,7 @@ import { compareRankedProviders, type ProviderAuthState } from "../../config/pro import type { Settings } from "../../config/settings"; import { type ThemeColor, theme } from "../../modes/theme/theme"; import { formatModelOnboardingInlineHint } from "../../setup/model-onboarding-guidance"; -import { formatClampedModelSelector, getThinkingLevelMetadata, parseThinkingLevel } from "../../thinking"; +import { formatSelectorWithEffortDiagnostics, getThinkingLevelMetadata, parseThinkingLevel } from "../../thinking"; import { getConfiguredImageModel } from "../../tools/image-gen"; import { getTabBarTheme } from "../shared"; import { DynamicBorder } from "./dynamic-border"; @@ -1445,7 +1445,11 @@ export class ModelSelectorComponent extends Container { }); const label = GJC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase(); this.#listContainer.addChild( - new Text(` ${label}: ${formatClampedModelSelector(selectorHead(selector) ?? "", resolved.model)}`, 0, 0), + new Text( + ` ${label}: ${formatSelectorWithEffortDiagnostics(selectorHead(selector) ?? "", resolved.model)}`, + 0, + 0, + ), ); } this.#listContainer.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/thinking.ts b/packages/coding-agent/src/thinking.ts index 771306439f..e5d83ec209 100644 --- a/packages/coding-agent/src/thinking.ts +++ b/packages/coding-agent/src/thinking.ts @@ -1,5 +1,6 @@ import { type ResolvedThinkingLevel, ThinkingLevel } from "@gajae-code/agent-core/thinking"; import { clampThinkingLevelForModel, type Effort, THINKING_EFFORTS } from "@gajae-code/ai/model-thinking"; +import { resolveWireReasoningEffort } from "@gajae-code/ai/providers/openai-completions-compat"; import type { Model } from "@gajae-code/ai/types"; export { getThinkingLevelMetadata, type ThinkingLevelMetadata } from "./thinking-metadata"; @@ -86,3 +87,108 @@ export function formatClampedModelSelector(selector: string, model: Model | unde ? `${selector.slice(0, slashIdx + 1)}${baseId}:${clamped}` : selector.slice(0, slashIdx + 1) + baseId; } + +/** + * Request-side thinking resolution for operator diagnostics. + * + * Distinguishes: + * - requested selector suffix + * - GJC-normalized/clamped level (HUD/session value) + * - OpenAI-completions wire value after `reasoningEffortMap` + * + * Wire values are request-side only; providers that accept the request without + * echoing an effort do not confirm backend-normalized effort. + */ +export interface ThinkingEffortResolution { + requested: ThinkingLevel | undefined; + effective: ThinkingLevel | undefined; + clamped: boolean; + wire?: string; + wireRemapped: boolean; + /** True when the GJC level has no map entry and will be sent unchanged. */ + wireUnmapped: boolean; +} + +export function resolveThinkingEffortResolution( + model: Model | undefined, + requested: ThinkingLevel | undefined, +): ThinkingEffortResolution { + if (requested === undefined || requested === ThinkingLevel.Inherit) { + return { + requested, + effective: undefined, + clamped: false, + wireRemapped: false, + wireUnmapped: false, + }; + } + if (requested === ThinkingLevel.Off) { + return { + requested, + effective: ThinkingLevel.Off, + clamped: false, + wireRemapped: false, + wireUnmapped: false, + }; + } + + const effective = clampExplicitThinkingLevelForModel(model, requested); + const clamped = effective !== undefined && effective !== requested; + if (model?.api !== "openai-completions" || !effective || effective === ThinkingLevel.Off) { + return { + requested, + effective, + clamped, + wireRemapped: false, + wireUnmapped: false, + }; + } + + const effort = toReasoningEffort(effective); + if (!effort) { + return { + requested, + effective, + clamped, + wireRemapped: false, + wireUnmapped: false, + }; + } + + const wireInfo = resolveWireReasoningEffort(model as Model<"openai-completions">, effort); + return { + requested, + effective, + clamped, + wire: wireInfo.wire, + wireRemapped: wireInfo.remapped, + wireUnmapped: !wireInfo.hasMapEntry, + }; +} + +/** + * Display-only selector annotation for model preview / diagnostics. + * Never used for persistence — keeps the pure selector free of annotations. + */ +export function formatSelectorWithEffortDiagnostics(selector: string, model: Model | undefined): string { + const clampedSelector = formatClampedModelSelector(selector, model); + const slashIdx = selector.indexOf("/"); + if (slashIdx <= 0 || !model) return clampedSelector; + + const id = selector.slice(slashIdx + 1); + const { thinkingLevel: requested } = splitSelectorThinkingSuffix(id); + if (!requested) return clampedSelector; + + const resolution = resolveThinkingEffortResolution(model, requested); + const notes: string[] = []; + if (resolution.clamped && resolution.requested && resolution.effective) { + notes.push(`clamped ${resolution.requested}→${resolution.effective}`); + } + if (resolution.wire !== undefined && (resolution.wireRemapped || resolution.clamped || resolution.wireUnmapped)) { + const wireNote = resolution.wireUnmapped + ? `wire=${resolution.wire} (unmapped; not backend-confirmed)` + : `wire=${resolution.wire}`; + notes.push(wireNote); + } + return notes.length > 0 ? `${clampedSelector} (${notes.join("; ")})` : clampedSelector; +} diff --git a/packages/coding-agent/test/issue-3858-effort-diagnostics.test.ts b/packages/coding-agent/test/issue-3858-effort-diagnostics.test.ts new file mode 100644 index 0000000000..f30226b5cd --- /dev/null +++ b/packages/coding-agent/test/issue-3858-effort-diagnostics.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; +import { ThinkingLevel } from "@gajae-code/agent-core"; +import { Effort, enrichModelThinking } from "@gajae-code/ai/model-thinking"; +import type { Model } from "@gajae-code/ai/types"; +import { + formatClampedModelSelector, + formatSelectorWithEffortDiagnostics, + resolveThinkingEffortResolution, +} from "../src/thinking"; + +function customDeepSeekProxy(): Model<"openai-completions"> { + return enrichModelThinking({ + id: "cline-pass/deepseek-v4-flash", + name: "DeepSeek V4 Flash via ClinePass", + api: "openai-completions", + provider: "clinepass", + baseUrl: "https://api.cline.bot/api/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 16_384, + }); +} + +describe("#3858 effort diagnostics for custom DeepSeek proxies", () => { + it("preserves :max in clamped selectors instead of rewriting to :xhigh", () => { + const model = customDeepSeekProxy(); + expect(formatClampedModelSelector("clinepass/cline-pass/deepseek-v4-flash:max", model)).toBe( + "clinepass/cline-pass/deepseek-v4-flash:max", + ); + }); + + it("resolves requested max to effective max and wire max", () => { + const model = customDeepSeekProxy(); + const resolution = resolveThinkingEffortResolution(model, ThinkingLevel.Max); + expect(resolution).toMatchObject({ + requested: ThinkingLevel.Max, + effective: ThinkingLevel.Max, + clamped: false, + wire: "max", + wireRemapped: false, + wireUnmapped: false, + }); + }); + + it("exposes wire=max when GJC xhigh is remapped for DeepSeek", () => { + const model = customDeepSeekProxy(); + const resolution = resolveThinkingEffortResolution(model, ThinkingLevel.XHigh); + expect(resolution).toMatchObject({ + requested: ThinkingLevel.XHigh, + effective: ThinkingLevel.XHigh, + clamped: false, + wire: "max", + wireRemapped: true, + wireUnmapped: false, + }); + expect(formatSelectorWithEffortDiagnostics("clinepass/cline-pass/deepseek-v4-flash:xhigh", model)).toBe( + "clinepass/cline-pass/deepseek-v4-flash:xhigh (wire=max)", + ); + }); + + it("flags unmapped wire passthrough after clamp for generic openai-completions models", () => { + const model = enrichModelThinking({ + id: "generic-reasoner", + name: "Generic Reasoner", + api: "openai-completions", + provider: "custom", + baseUrl: "https://proxy.example.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }); + expect(model.thinking?.maxLevel).toBe(Effort.XHigh); + + const resolution = resolveThinkingEffortResolution(model, ThinkingLevel.Max); + expect(resolution.clamped).toBe(true); + expect(resolution.effective).toBe(ThinkingLevel.XHigh); + expect(resolution.wire).toBe("xhigh"); + expect(resolution.wireUnmapped).toBe(true); + expect(formatSelectorWithEffortDiagnostics("custom/generic-reasoner:max", model)).toContain("clamped max→xhigh"); + expect(formatSelectorWithEffortDiagnostics("custom/generic-reasoner:max", model)).toContain( + "wire=xhigh (unmapped; not backend-confirmed)", + ); + }); +}); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 5e82d8d669..cf15037508 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -541,7 +541,9 @@ describe("ModelRegistry", () => { const variants = registry.getCanonicalVariants("deepseek-v4-pro"); expect(model?.cost.cacheRead).toBeGreaterThan(0); - expect(model?.thinking?.maxLevel).toBe(Effort.XHigh); + // DeepSeek V4 family keeps first-class `max` (not clamped to `xhigh`) so + // selectors like `:max` remain valid on aliases and custom proxies (#3858). + expect(model?.thinking?.maxLevel).toBe(Effort.Max); expect(variants.some(variant => variant.selector === "ollama/deepseek-v4-pro:cloud")).toBe(true); }); @@ -1961,10 +1963,11 @@ describe("ModelRegistry", () => { expect(model?.name).toBe("MiniMax-M3"); }); - test("#3856: namespaced custom model id inherits canonical leaf metadata when omitted", () => { + test("#3856/#3858: namespaced custom DeepSeek V4 proxy inherits leaf metadata and keeps :max", () => { // Proxy wire IDs often namespace the upstream model (`vendor/model-id`). - // When contextWindow/maxTokens are omitted, inherit from the bundled leaf - // id while retaining the namespaced wire id for the request. + // When limits/compat are omitted, inherit from the bundled leaf id while + // retaining the namespaced wire id. DeepSeek V4 must expose `max` so a + // profile `:max` suffix is not silently rewritten to GJC `xhigh`. writeRawModelsJson({ clinepass: { baseUrl: "https://api.cline.bot/api/v1", @@ -1994,6 +1997,12 @@ describe("ModelRegistry", () => { expect(model?.maxTokens).toBe(384_000); expect(model?.reasoning).toBe(true); expect(model?.baseUrl).toBe("https://api.cline.bot/api/v1"); + expect(model?.thinking?.maxLevel).toBe(Effort.Max); + expect(getOpenAICompat(model)?.reasoningEffortMap).toMatchObject({ + high: "high", + xhigh: "max", + max: "max", + }); }); test("#3856: true unknown namespaced custom models still use generic defaults", () => {