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
27 changes: 23 additions & 4 deletions packages/ai/src/protocols/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,8 +766,12 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode
return true
}

const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const options = OpenAIOptions.resolve(request)
const lowerOptions = (
request: LLMRequest,
options: ReturnType<typeof OpenAIOptions.resolve>,
supportsStore: boolean,
requiresNoneEffort: boolean,
) => {
// Default off: strict providers 400 on unknown body fields, so only send
// the key where compatibility explicitly allows it. Header-based affinity
// (x-session-affinity, x-grok-conv-id, ...) is unaffected.
Expand All @@ -780,7 +784,11 @@ const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
// native OpenAI Chat default. Non-standard providers omit `store` entirely.
...(supportsStore && options.store === undefined ? { store: false } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
...(requiresNoneEffort
? { reasoning_effort: "none" }
: options.reasoningEffort
? { reasoning_effort: options.reasoningEffort }
: {}),
}
}

Expand Down Expand Up @@ -810,6 +818,17 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = flattened.tools.length > 0
// GPT-6 Sol/Luna only support Chat function calling without reasoning.
// Responses supports tools at every effort, so this applies to native OpenAI Chat only.
const requiresNoneEffort =
provider === "openai" &&
(request.model.id === "gpt-6-sol" || request.model.id === "gpt-6-luna") &&
(hasActiveTools || hasHistory)
const nativeOptions = OpenAIOptions.resolve(request)
if (requiresNoneEffort && nativeOptions.reasoningEffort !== undefined && nativeOptions.reasoningEffort !== "none")
return yield* ProviderShared.invalidRequest(
`${request.model.id} Chat function calling requires reasoningEffort "none"; use the Responses API for reasoning with tools`,
)
return {
model: request.model.id,
messages: yield* lowerMessages(flattened.request, options),
Expand All @@ -832,7 +851,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
presence_penalty: generation?.presencePenalty,
seed: generation?.seed,
stop: generation?.stop,
...lowerOptions(request, supportsStore),
...lowerOptions(request, nativeOptions, supportsStore, requiresNoneEffort),
}
})

Expand Down
82 changes: 82 additions & 0 deletions packages/ai/test/provider/openai-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,88 @@ describe("OpenAI Chat route", () => {
}),
)

for (const id of ["gpt-6-sol", "gpt-6-luna"]) {
it.effect(`enables function calling for ${id} on OpenAI Chat`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).chat(id),
prompt: "Look up the weather.",
tools: [ToolDefinition.make({ name: "weather", description: "Find weather", inputSchema: {} })],
}),
)

expect(prepared.body.tools).toHaveLength(1)
expect(prepared.body.reasoning_effort).toBe("none")
}),
)
}

it.effect("rejects GPT-6 Chat tools with explicit reasoning instead of silently downgrading it", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test", providerOptions: { reasoningEffort: "high" } }).chat("gpt-6-sol"),
prompt: "Look up the weather.",
tools: [ToolDefinition.make({ name: "weather", description: "Find weather", inputSchema: {} })],
}),
).pipe(Effect.flip)

expect(error.message).toContain('requires reasoningEffort "none"')
expect(error.message).toContain("Responses API")
}),
)

it.effect("keeps GPT-6 Chat reasoning when no function calling is involved", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).chat("gpt-6-luna"),
prompt: "Think carefully.",
providerOptions: { reasoningEffort: "high" },
}),
)

expect(prepared.body.reasoning_effort).toBe("high")
}),
)

it.effect("does not impose OpenAI's GPT-6 Chat restriction on compatible providers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "test" }).model(
"gpt-6-sol",
),
prompt: "Look up the weather.",
tools: [ToolDefinition.make({ name: "weather", description: "Find weather", inputSchema: {} })],
providerOptions: { reasoningEffort: "high" },
}),
)

expect(prepared.body.tools).toHaveLength(1)
expect(prepared.body.reasoning_effort).toBe("high")
}),
)

it.effect("keeps GPT-6 Chat tool history valid without active tools", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).chat("gpt-6-sol"),
messages: [
Message.user("Look up the weather."),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "weather", input: {} })]),
Message.tool({ id: "call_1", name: "weather", result: { forecast: "sunny" } }),
],
}),
)

expect(prepared.body.tools).toEqual([])
expect(prepared.body.reasoning_effort).toBe("none")
}),
)

it.effect("keeps valid Chat options when a sibling option is malformed", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
Expand Down
16 changes: 16 additions & 0 deletions packages/ai/test/provider/openai-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ describe("OpenAI Responses route", () => {
}),
)

it.effect("keeps GPT-6 reasoning with function tools on the Responses API", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("gpt-6-sol"),
prompt: "Look up the weather.",
tools: [ToolDefinition.make({ name: "weather", description: "Find weather", inputSchema: {} })],
providerOptions: { reasoningEffort: "high" },
}),
)

expect(prepared.body.tools).toHaveLength(1)
expect(prepared.body.reasoning).toMatchObject({ effort: "high" })
}),
)

it.effect("lowers the hosted OpenAI image generation tool", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
Expand Down
Loading