Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions packages/ai/src/model-thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,12 +715,26 @@ function inferAnthropicSupportedEfforts<TApi extends Api>(
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<TApi extends Api>(model: ApiModel<TApi>): boolean {
const canonicalId = getCanonicalModelId(model.id).toLowerCase();
const name = model.name.toLowerCase();
return canonicalId.includes("deepseek-v4") || name.includes("deepseek-v4");
}

function inferFallbackEfforts<TApi extends Api>(model: ApiModel<TApi>): 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;
Expand Down
33 changes: 33 additions & 0 deletions packages/ai/src/providers/openai-completions-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
93 changes: 93 additions & 0 deletions packages/ai/test/issue-3858-deepseek-effort-clamp.test.ts
Original file line number Diff line number Diff line change
@@ -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">> = {}): 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,
});
});
});
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/src/config/model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions packages/coding-agent/src/modes/components/model-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
106 changes: 106 additions & 0 deletions packages/coding-agent/src/thinking.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
Loading
Loading