Skip to content

Commit 4afdb14

Browse files
See USee U
authored andcommitted
feat(llm): lower thinking toggle and max effort for OpenAI-compatible chat
1 parent c925f5c commit 4afdb14

5 files changed

Lines changed: 190 additions & 11 deletions

File tree

packages/llm/src/protocols/openai-chat.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export const bodyFields = {
9797
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
9898
store: Schema.optional(Schema.Boolean),
9999
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
100+
thinking: Schema.optional(OpenAIOptions.OpenAIThinking),
100101
max_tokens: Schema.optional(Schema.Number),
101102
temperature: Schema.optional(Schema.Number),
102103
top_p: Schema.optional(Schema.Number),
@@ -128,6 +129,10 @@ const OpenAIChatUsage = Schema.Struct({
128129
reasoning_tokens: Schema.optional(Schema.Number),
129130
}),
130131
),
132+
// DeepSeek reports the cache split natively (prompt_tokens = hit + miss)
133+
// at the top level; keep the raw fields for billing-level audit trails.
134+
prompt_cache_hit_tokens: Schema.optional(Schema.Number),
135+
prompt_cache_miss_tokens: Schema.optional(Schema.Number),
131136
})
132137

133138
const OpenAIChatToolCallDeltaFunction = Schema.Struct({
@@ -333,14 +338,25 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
333338
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
334339
const store = OpenAIOptions.store(request)
335340
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
336-
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
341+
const thinking = OpenAIOptions.thinking(request)
342+
// `max` is a Responses-only tier on OpenAI-managed chat surfaces (OpenAI,
343+
// Azure, GitHub Copilot); DeepSeek V4 pro honors it natively on Chat
344+
// Completions. The shared protocol keys the rejection off the provider id
345+
// so OpenAI-family requests keep the local guard while deepseek passes.
346+
if (reasoningEffort === "max" && OPENAI_MAX_EFFORT_INVALID.has(request.model.provider))
337347
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
338348
return {
339349
...(store !== undefined ? { store } : {}),
340350
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
351+
...(thinking ? { thinking } : {}),
341352
}
342353
})
343354

355+
// OpenAI Chat Completions (and Azure / GitHub Copilot deployments of it)
356+
// reject the `max` reasoning effort tier; everything else routed through
357+
// this protocol is provider-enforced.
358+
const OPENAI_MAX_EFFORT_INVALID = new Set<string>(["openai", "azure", "github-copilot"])
359+
344360
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
345361
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
346362
// validation, and HTTP execution are composed by `Route.make`.
@@ -387,10 +403,16 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
387403
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
388404
// a `reasoning_tokens` subset. We pass the inclusive totals through and
389405
// derive the non-cached breakdown so the `LLM.Usage` contract is
390-
// satisfied on both sides.
406+
// satisfied on both sides. DeepSeek additionally reports the cache split
407+
// natively (`prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`);
408+
// prefer those when present since they survive even without the details
409+
// object.
391410
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
392411
if (!usage) return undefined
393-
const cached = usage.prompt_tokens_details?.cached_tokens
412+
const cached =
413+
usage.prompt_cache_hit_tokens !== undefined
414+
? usage.prompt_cache_hit_tokens
415+
: usage.prompt_tokens_details?.cached_tokens
394416
const reasoning = usage.completion_tokens_details?.reasoning_tokens
395417
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
396418
return new Usage({

packages/llm/src/protocols/openai-responses.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,8 +457,11 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
457457
const store = OpenAIOptions.store(request)
458458
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
459459
const effort = OpenAIOptions.reasoningEffort(request)
460-
if (effort && !OpenAIOptions.isReasoningEffort(effort))
461-
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
460+
// `reasoningEffort` already filters through the full `ReasoningEfforts`
461+
// set, so the only value that can reach here is the Responses-incompatible
462+
// `max` tier.
463+
if (effort === "max")
464+
return yield* invalid("OpenAI Responses does not support reasoning effort max")
462465
const summary = OpenAIOptions.reasoningSummary(request)
463466
const include = OpenAIOptions.include(request)
464467
const verbosity = OpenAIOptions.textVerbosity(request)

packages/llm/src/protocols/utils/openai-options.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ import { Schema } from "effect"
22
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
33
import { ReasoningEfforts, TextVerbosity } from "../../schema"
44

5-
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
6-
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
7-
)
5+
export const OpenAIReasoningEfforts = [...ReasoningEfforts]
86
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
97

108
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
@@ -24,7 +22,6 @@ export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as con
2422
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
2523

2624
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
27-
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
2825
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
2926
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
3027
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
@@ -37,8 +34,30 @@ export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
3734
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
3835
typeof effort === "string" && REASONING_EFFORTS.has(effort)
3936

40-
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
41-
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
37+
// DeepSeek V4 toggles thinking with `{ type: "enabled" | "disabled" }` on the
38+
// official API; opencode-managed minimax mirrors also surface `"adaptive"`.
39+
// z.ai/zhipuai additionally accept `clear_thinking`. Validate against the
40+
// literal set so an unknown type never poisons the body.
41+
const ThinkingTypes = ["enabled", "disabled", "adaptive"] as const
42+
type ThinkingType = (typeof ThinkingTypes)[number]
43+
const THINKING_TYPES = new Set<string>(ThinkingTypes)
44+
45+
export const OpenAIThinking = Schema.Struct({
46+
type: Schema.Literals(ThinkingTypes),
47+
clear_thinking: Schema.optional(Schema.Boolean),
48+
})
49+
export type OpenAIThinking = Schema.Schema.Type<typeof OpenAIThinking>
50+
51+
const isThinkingType = (value: unknown): value is ThinkingType =>
52+
typeof value === "string" && THINKING_TYPES.has(value)
53+
54+
export const thinking = (request: LLMRequest): OpenAIThinking | undefined => {
55+
const value = options(request)?.thinking
56+
if (typeof value !== "object" || value === null || !("type" in value)) return undefined
57+
const { type, clear_thinking } = value as { type: unknown; clear_thinking?: unknown }
58+
if (!isThinkingType(type)) return undefined
59+
return { type, ...(typeof clear_thinking === "boolean" ? { clear_thinking } : {}) }
60+
}
4261

4362
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
4463
typeof value === "string" && TEXT_VERBOSITY.has(value)

packages/llm/test/provider/openai-chat.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,89 @@ describe("OpenAI Chat route", () => {
107107
}),
108108
)
109109

110+
it.effect("lowers OpenAI-compatible thinking toggles to the wire body", () =>
111+
Effect.gen(function* () {
112+
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
113+
LLM.request({
114+
model,
115+
prompt: "think",
116+
providerOptions: { openai: { thinking: { type: "disabled" } } },
117+
}),
118+
)
119+
120+
expect(prepared.body.thinking).toEqual({ type: "disabled" })
121+
}),
122+
)
123+
124+
it.effect("passes z.ai clear_thinking through the thinking toggle", () =>
125+
Effect.gen(function* () {
126+
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
127+
LLM.request({
128+
model,
129+
prompt: "think",
130+
providerOptions: { openai: { thinking: { type: "enabled", clear_thinking: false } } },
131+
}),
132+
)
133+
134+
expect(prepared.body.thinking).toEqual({ type: "enabled", clear_thinking: false })
135+
}),
136+
)
137+
138+
it.effect("rejects max reasoning effort on OpenAI-managed chat", () =>
139+
Effect.gen(function* () {
140+
const error = yield* LLMClient.prepare(
141+
LLM.request({
142+
model,
143+
prompt: "think",
144+
providerOptions: { openai: { reasoningEffort: "max" } },
145+
}),
146+
).pipe(Effect.flip)
147+
expect(error.message).toContain("does not support reasoning effort max")
148+
}),
149+
)
150+
151+
it.effect("passes max reasoning effort through for OpenAI-compatible providers", () =>
152+
Effect.gen(function* () {
153+
const deepseek = OpenAIChat.route
154+
.with({
155+
provider: "opencode.deepseek",
156+
endpoint: { baseURL: "https://api.example.test/v1/" },
157+
auth: Auth.bearer("test"),
158+
})
159+
.model({ id: "deepseek-v4-pro" })
160+
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
161+
LLM.request({
162+
model: deepseek,
163+
prompt: "think",
164+
providerOptions: { openai: { reasoningEffort: "max" } },
165+
}),
166+
)
167+
168+
expect(prepared.body.reasoning_effort).toBe("max")
169+
}),
170+
)
171+
172+
it.effect("replays reasoning_content from native openaiCompatible metadata", () =>
173+
Effect.gen(function* () {
174+
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
175+
LLM.request({
176+
model,
177+
messages: [
178+
Message.make({
179+
role: "assistant",
180+
content: "Hello",
181+
native: { openaiCompatible: { reasoning_content: "thinking" } },
182+
}),
183+
],
184+
}),
185+
)
186+
187+
expect(prepared.body.messages).toEqual([
188+
{ role: "assistant", content: "Hello", reasoning_content: "thinking" },
189+
])
190+
}),
191+
)
192+
110193
it.effect("adds native query params to the Chat Completions URL", () =>
111194
LLMClient.generate(
112195
LLM.updateRequest(request, {
@@ -526,6 +609,45 @@ describe("OpenAI Chat route", () => {
526609
}),
527610
)
528611

612+
it.effect("prefers DeepSeek native cache fields over the cached_tokens detail", () =>
613+
Effect.gen(function* () {
614+
const body = sseEvents(
615+
deltaChunk({ role: "assistant", content: "Hi" }),
616+
deltaChunk({}, "stop"),
617+
usageChunk({
618+
prompt_tokens: 100,
619+
completion_tokens: 2,
620+
total_tokens: 102,
621+
prompt_tokens_details: { cached_tokens: 1 },
622+
prompt_cache_hit_tokens: 88,
623+
prompt_cache_miss_tokens: 12,
624+
}),
625+
)
626+
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
627+
expect(response.events.at(-1)).toEqual({
628+
type: "finish",
629+
reason: "stop",
630+
usage: new Usage({
631+
inputTokens: 100,
632+
outputTokens: 2,
633+
nonCachedInputTokens: 12,
634+
cacheReadInputTokens: 88,
635+
totalTokens: 102,
636+
providerMetadata: {
637+
openai: {
638+
prompt_tokens: 100,
639+
completion_tokens: 2,
640+
total_tokens: 102,
641+
prompt_tokens_details: { cached_tokens: 1 },
642+
prompt_cache_hit_tokens: 88,
643+
prompt_cache_miss_tokens: 12,
644+
},
645+
},
646+
}),
647+
})
648+
}),
649+
)
650+
529651
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
530652
Effect.gen(function* () {
531653
const body = sseEvents(

packages/llm/test/provider/openai-responses.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,19 @@ describe("OpenAI Responses route", () => {
571571
}),
572572
)
573573

574+
it.effect("rejects max reasoning effort on OpenAI Responses", () =>
575+
Effect.gen(function* () {
576+
const error = yield* LLMClient.prepare(
577+
LLM.request({
578+
model,
579+
prompt: "think",
580+
providerOptions: { openai: { reasoningEffort: "max" } },
581+
}),
582+
).pipe(Effect.flip)
583+
expect(error.message).toContain("does not support reasoning effort max")
584+
}),
585+
)
586+
574587
it.effect("accepts the full ResponseIncludable union", () =>
575588
Effect.gen(function* () {
576589
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(

0 commit comments

Comments
 (0)