feat(llm-deepseek): expose off as a dynamic reasoning effort

This commit is contained in:
Yichen Jiang
2026-07-26 13:42:02 +08:00
parent 970f893d6b
commit 15d6f4c085
14 changed files with 240 additions and 73 deletions

View File

@@ -15,7 +15,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; high | max — omitted ⇒ high
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
models: # optional; defaults to V4 Flash and V4 Pro
@@ -30,9 +30,9 @@ The plugin registers the single provider route `deepseek`. A request selects it
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists, `context` is absent without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
The same exact-model result exposes ordered `high` and `max` efforts under `reasoning` for every pass-through model while thinking is enabled. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header` and serialized as the official top-level `reasoning_effort` field. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O.
The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O.
`thinking: disabled` removes the reasoning capability and omits `reasoning_effort`; combining it with a configured default fails plugin loading, and a per-request effort fails as unsupported. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults.
`thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults.
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy.
@@ -43,6 +43,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
## Wire-format notes (verified live + against the official docs)
- Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
- The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`.
- The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
@@ -53,7 +54,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA`
## Testing
Unit suites run against a local `node:http` mock SSE server (no network), including structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
## Model Experience

View File

@@ -51,12 +51,17 @@ export interface DeepSeekAdapterOptions {
/** Default maximum idle interval while an adapter stream read is outstanding. */
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
const OFF_REASONING_EFFORT = ReasoningEffortId('off')
const HIGH_REASONING_EFFORT = ReasoningEffortId('high')
const MAX_REASONING_EFFORT = ReasoningEffortId('max')
const REASONING_EFFORTS = [
{ id: OFF_REASONING_EFFORT, name: 'Off' },
{ id: HIGH_REASONING_EFFORT, name: 'High' },
{ id: MAX_REASONING_EFFORT, name: 'Max' },
] as const
const OFF_ONLY_REASONING_EFFORTS = [
{ id: OFF_REASONING_EFFORT, name: 'Off' },
] as const
function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo {
return {
@@ -113,8 +118,10 @@ export class DeepSeekAdapter extends LlmAdapter {
constructor(private readonly options: DeepSeekAdapterOptions) {
super()
if (options.defaults?.thinking === 'disabled' && options.defaults.reasoningEffort !== undefined) {
throw new Error('llm-deepseek: reasoningEffort cannot be configured when thinking is disabled')
if (options.defaults?.thinking === 'disabled'
&& options.defaults.reasoningEffort !== undefined
&& options.defaults.reasoningEffort !== 'off') {
throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
}
if (options.defaultContextWindow !== undefined
&& (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) {
@@ -152,13 +159,20 @@ export class DeepSeekAdapter extends LlmAdapter {
: modelInfo(provider, configured),
...contextWindow === undefined ? {} : { context: { contextWindow } },
...this.options.defaults?.thinking === 'disabled'
? {}
? {
reasoning: {
efforts: OFF_ONLY_REASONING_EFFORTS,
defaultEffort: OFF_REASONING_EFFORT,
},
}
: {
reasoning: {
efforts: REASONING_EFFORTS,
defaultEffort: this.options.defaults?.reasoningEffort === 'max'
? MAX_REASONING_EFFORT
: HIGH_REASONING_EFFORT,
defaultEffort: this.options.defaults?.reasoningEffort === 'off'
? OFF_REASONING_EFFORT
: this.options.defaults?.reasoningEffort === 'max'
? MAX_REASONING_EFFORT
: HIGH_REASONING_EFFORT,
},
},
})

View File

@@ -37,10 +37,10 @@ export interface Config {
apiKey?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
baseURL?: string
/** Thinking-mode default for every request (provider default: enabled). */
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
/** Default thinking effort when thinking is enabled (default `high`). */
reasoningEffort?: 'high' | 'max'
/** Default thinking effort (default `high`); `off` disables thinking per request. */
reasoningEffort?: 'off' | 'high' | 'max'
/** Positive context capacity used when the selected model has no exact value. */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
@@ -60,7 +60,7 @@ export const Config: z<Config> = z.object({
apiKey: z.string(),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
reasoningEffort: z.union(['high', 'max']),
reasoningEffort: z.union(['off', 'high', 'max']),
defaultContextWindow: z.number().step(1).min(1),
models: z.array(catalogModel).default(DEFAULT_MODELS),
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
@@ -95,8 +95,10 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
}
export function apply(ctx: Context, config: Config): void {
if (config.thinking === 'disabled' && config.reasoningEffort !== undefined) {
throw new Error('llm-deepseek: reasoningEffort cannot be configured when thinking is disabled')
if (config.thinking === 'disabled'
&& config.reasoningEffort !== undefined
&& config.reasoningEffort !== 'off') {
throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
}
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
if (apiKey === undefined || apiKey.length === 0) {

View File

@@ -13,20 +13,44 @@ import type { WireMessage, WireRequest, WireTool } from './types.ts'
/** Adapter-level request defaults (from plugin config). */
export interface RequestDefaults {
thinking?: 'enabled' | 'disabled' | undefined
reasoningEffort?: 'high' | 'max' | undefined
reasoningEffort?: 'off' | 'high' | 'max' | undefined
}
/** Validate the adapter-owned effort before assigning its narrower wire type. */
function reasoningEffort(options: GenerateOptions): 'high' | 'max' | undefined {
const effort = options.reasoningEffort
if (effort === undefined) return undefined
if (effort === 'high' || effort === 'max') return effort as 'high' | 'max'
interface ResolvedThinking {
thinking?: 'enabled' | 'disabled'
reasoningEffort?: 'high' | 'max'
}
/** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */
function reasoningEffort(effort: NonNullable<GenerateOptions['reasoningEffort']>): 'off' | 'high' | 'max' {
if (effort === 'off' || effort === 'high' || effort === 'max') {
return effort as 'off' | 'high' | 'max'
}
throw new LlmError(
`DeepSeek does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
/** Resolve one legal thinking/effort pair without exposing `off` as a wire effort. */
function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking {
if (options.purpose === 'session-title') return { thinking: 'disabled' }
const effort = options.reasoningEffort === undefined
? defaults.reasoningEffort
: reasoningEffort(options.reasoningEffort)
if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') {
throw new LlmError(
`DeepSeek deployment does not support reasoning effort "${effort}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
if (effort === 'off') return { thinking: 'disabled' }
if (effort === 'high' || effort === 'max') {
return { thinking: 'enabled', reasoningEffort: effort }
}
return defaults.thinking === undefined ? {} : { thinking: defaults.thinking }
}
/** Join the text blocks of a message (used for user/tool-result content). */
function flattenText(blocks: ContentBlock[]): string {
return blocks
@@ -132,18 +156,17 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
}))
// A short title budget must produce visible text; conversation and
// compaction calls continue to inherit the adapter's thinking defaults.
const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking
const resolvedReasoningEffort = options.purpose === 'session-title'
? undefined
: reasoningEffort(options)
const resolvedThinking = resolveThinking(options, defaults)
return {
model: options.model,
messages,
stream: true,
stream_options: { include_usage: true },
...thinking !== undefined ? { thinking: { type: thinking } } : {},
...resolvedReasoningEffort !== undefined ? { reasoning_effort: resolvedReasoningEffort } : {},
...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {},
...resolvedThinking.reasoningEffort !== undefined
? { reasoning_effort: resolvedThinking.reasoningEffort }
: {},
...tools !== undefined && tools.length > 0 ? { tools } : {},
...options.temperature !== undefined ? { temperature: options.temperature } : {},
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},

View File

@@ -50,31 +50,29 @@ const weatherTool: ToolSchema = {
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it('flash + thinking disabled: plain text generation', async () => {
const ctx = await harness(FLASH, { thinking: 'disabled' })
const result = await assemble(ctx,{
it('flash dynamically switches from off to high', async () => {
const ctx = await harness(FLASH, { reasoningEffort: 'off' })
const withoutThinking = await assemble(ctx,{
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(textOf(result).toLowerCase()).toContain('pong')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(result.usage?.inputTokens).toBeGreaterThan(0)
expect(result.usage?.outputTokens).toBeGreaterThan(0)
})
expect(withoutThinking.finish.kind).toBe('stop')
expect(textOf(withoutThinking).toLowerCase()).toContain('pong')
expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false)
expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0)
expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0)
it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => {
const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' })
const result = await assemble(ctx,{
const withThinking = await assemble(ctx,{
model: FLASH,
reasoningEffort: ReasoningEffortId('high'),
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
})
expect(result.finish.kind).toBe('stop')
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(result)).toContain('9.8')
expect(result.usage?.reasoningTokens).toBeGreaterThan(0)
expect(withThinking.finish.kind).toBe('stop')
expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true)
expect(textOf(withThinking)).toContain('9.8')
expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0)
})
it.each(['high', 'max'] as const)(

View File

@@ -175,12 +175,13 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1')
})
it('forwards the configured reasoning default and a dynamic request override', async () => {
it('switches dynamically from the configured high default through off to max', async () => {
const server = await mockServer([
{ kind: 'sse', events: textEvents },
{ kind: 'sse', events: textEvents },
{ kind: 'sse', events: textEvents },
])
const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'max' })
const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'high' })
await assemble(ctx,{
model: 'deepseek-v4-flash',
@@ -188,20 +189,29 @@ describe('DeepSeekAdapter against a mock server', () => {
})
await assemble(ctx,{
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('high'),
reasoningEffort: ReasoningEffortId('off'),
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }],
})
await assemble(ctx,{
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('max'),
messages: [{ role: 'user', content: [{ type: 'text', text: 'one more time' }] }],
})
expect(server.requests[0]).toMatchObject({
thinking: { type: 'enabled' },
reasoning_effort: 'max',
reasoning_effort: 'high',
})
expect(server.requests[1]).toMatchObject({
thinking: { type: 'disabled' },
})
expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
expect(server.requests[2]).toMatchObject({
thinking: { type: 'enabled' },
reasoning_effort: 'high',
reasoning_effort: 'max',
})
})
it('omits reasoning capability and effort when thinking is disabled', async () => {
it('publishes only off and omits the wire effort when thinking is disabled', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url, { thinking: 'disabled' })
@@ -214,7 +224,12 @@ describe('DeepSeekAdapter against a mock server', () => {
})
expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.not.toHaveProperty('reasoning')
.resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
defaultEffort: ReasoningEffortId('off'),
},
})
})
it('rejects a per-request effort before I/O when thinking is disabled', async () => {
@@ -229,6 +244,29 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(server.requests).toHaveLength(0)
})
it.each(['high', 'max'])(
'rejects direct adapter effort %s before I/O when thinking is disabled',
async (effort) => {
const server = await mockServer([])
const adapter = new DeepSeekAdapter({
apiKey: 'test-key',
baseURL: server.url,
defaults: { thinking: 'disabled' },
})
const stream = adapter.stream({
provider: 'deepseek',
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId(effort),
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
await expect(async () => {
for await (const _chunk of stream) { /* drain */ }
}).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
expect(server.requests).toHaveLength(0)
},
)
it.each([
[401, 'AUTH'],
[403, 'AUTH'],
@@ -581,6 +619,7 @@ describe('plugin registration and config', () => {
context: { contextWindow: 128_000 },
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
@@ -589,24 +628,83 @@ describe('plugin registration and config', () => {
})
})
it('rejects a configured reasoning effort when thinking is disabled', async () => {
it.each(['off', 'max'] as const)('uses the configured %s reasoning default', async (effort) => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
reasoningEffort: effort,
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
.resolves.toMatchObject({
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId(effort),
},
})
})
it('accepts off as the default when thinking is deployment-disabled', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
thinking: 'disabled',
reasoningEffort: 'high',
})).rejects.toThrow(/reasoningEffort cannot be configured/)
expect(ctx.llm.listProviders()).toEqual([])
reasoningEffort: 'off',
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
.resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
defaultEffort: ReasoningEffortId('off'),
},
})
})
it('rejects a disabled-thinking effort at the direct constructor boundary', () => {
expect(() => new DeepSeekAdapter({
it.each(['high', 'max'] as const)(
'rejects configured reasoning effort %s when thinking is disabled',
async (reasoningEffort) => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
thinking: 'disabled',
reasoningEffort,
})).rejects.toThrow(/only reasoningEffort "off"/)
expect(ctx.llm.listProviders()).toEqual([])
},
)
it.each(['high', 'max'] as const)(
'rejects disabled-thinking effort %s at the direct constructor boundary',
(reasoningEffort) => {
expect(() => new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaults: { thinking: 'disabled', reasoningEffort },
})).toThrow(/only reasoningEffort "off"/)
},
)
it('accepts disabled thinking with off at the direct constructor boundary', async () => {
const adapter = new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaults: { thinking: 'disabled', reasoningEffort: 'high' },
})).toThrow(/reasoningEffort cannot be configured/)
defaults: { thinking: 'disabled', reasoningEffort: 'off' },
})
await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
defaultEffort: ReasoningEffortId('off'),
},
})
})
it('uses the default model catalog when apply is called directly', async () => {

View File

@@ -183,6 +183,31 @@ describe('serializeRequest', () => {
expect(wire.reasoning_effort).toBe('max')
})
it('maps off to disabled thinking without a wire reasoning effort', () => {
const wire = serializeRequest(
request({ messages: history, reasoningEffort: ReasoningEffortId('off') }),
{ thinking: 'enabled', reasoningEffort: 'max' },
)
expect(wire.thinking).toEqual({ type: 'disabled' })
expect(wire.reasoning_effort).toBeUndefined()
})
it('re-enables thinking when max overrides an off default', () => {
const wire = serializeRequest(
request({ messages: history, reasoningEffort: ReasoningEffortId('max') }),
{ reasoningEffort: 'off' },
)
expect(wire.thinking).toEqual({ type: 'enabled' })
expect(wire.reasoning_effort).toBe('max')
})
it('rejects enabling thinking when the deployment is locked to disabled', () => {
expect(() => serializeRequest(
request({ messages: history, reasoningEffort: ReasoningEffortId('high') }),
{ thinking: 'disabled' },
)).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' }))
})
it('disables thinking for session-title requests without changing adapter defaults', () => {
const wire = serializeRequest(
request({
@@ -202,6 +227,12 @@ describe('serializeRequest', () => {
expect(wire.reasoning_effort).toBeUndefined()
})
it('preserves an explicit enabled default without inventing a wire effort', () => {
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' })
expect(wire.thinking).toEqual({ type: 'enabled' })
expect(wire.reasoning_effort).toBeUndefined()
})
it('rejects an effort outside the DeepSeek capability', () => {
expect(() => serializeRequest(request({
messages: history,