Skip to content
Open
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
79 changes: 79 additions & 0 deletions packages/types/src/__tests__/provider-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ANTHROPIC_API_PROTOCOL, OPENAI_API_PROTOCOL, providerIdentifiers, provi
import {
getApiProtocol,
OPEN_AI_CODEX_SERVICE_TIER_KEY,
parseOpenAiExtraBody,
PROVIDER_SETTINGS_KEYS,
providerSettingsSchema,
providerSettingsSchemaDiscriminated,
Expand All @@ -22,6 +23,84 @@ describe("provider settings discriminated union", () => {
})
})

describe("OpenAI-compatible extra body settings", () => {
it("accepts a JSON object with provider-specific nested fields", () => {
const settings = {
apiProvider: providerIdentifiers.openai,
openAiExtraBody: JSON.stringify({ metadata: { completion_window: "balanced" }, store: false }),
}

expect(providerSettingsSchemaDiscriminated.parse(settings)).toEqual(settings)
expect(PROVIDER_SETTINGS_KEYS).toContain("openAiExtraBody")
})

it.each(["not json", "[]", "null", '"value"'])("rejects non-object JSON: %s", (openAiExtraBody) => {
expect(
providerSettingsSchemaDiscriminated.safeParse({
apiProvider: providerIdentifiers.openai,
openAiExtraBody,
}).success,
).toBe(false)
})

it.each([
"__proto__",
"constructor",
"prototype",
"max_completion_tokens",
"max_tokens",
"messages",
"model",
"parallel_tool_calls",
"reasoning",
"reasoning_effort",
"response_format",
"stream",
"stream_options",
"temperature",
"tool_choice",
"tools",
])("rejects the reserved extra-body key %s", (reservedKey) => {
expect(
providerSettingsSchemaDiscriminated.safeParse({
apiProvider: providerIdentifiers.openai,
openAiExtraBody: JSON.stringify({ [reservedKey]: "override" }),
}).success,
).toBe(false)
})

it("reports and filters reserved keys while preserving allowed nested fields", () => {
const result = parseOpenAiExtraBody(
JSON.stringify({
metadata: { completion_window: "balanced" },
model: "overridden-model",
response_format: { type: "json_object" },
stream: false,
}),
)

expect(result).toEqual({
success: false,
reason: "reservedKeys",
reservedKeys: ["model", "response_format", "stream"],
data: { metadata: { completion_window: "balanced" } },
})
})

it("accepts stop and n as provider-specific request fields", () => {
const settings = {
apiProvider: providerIdentifiers.openai,
openAiExtraBody: JSON.stringify({ stop: ["DONE"], n: 2 }),
}

expect(providerSettingsSchemaDiscriminated.parse(settings)).toEqual(settings)
expect(parseOpenAiExtraBody(settings.openAiExtraBody)).toEqual({
success: true,
data: { stop: ["DONE"], n: 2 },
})
})
})

describe("OpenAI Codex provider settings", () => {
it("preserves the Fast preference in general and provider-specific schemas", () => {
const settings = {
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { providerDefinitionList, type ProviderDefinition } from "./provider-sett
import { API_PROVIDER_FIELD, SETTINGS_SHAPE_FIELD } from "./provider-settings/common.js"
export {
OPEN_AI_CODEX_SERVICE_TIER_KEY,
parseOpenAiExtraBody,
kimiCodeAuthMethodSchema,
type KimiCodeAuthMethod,
nanoGptDefaultRoutingPreference,
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/provider-settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { basetenProviderDefinition } from "./baseten.js"
import type { ProviderDefinition } from "./common.js"

export { OPEN_AI_CODEX_SERVICE_TIER_KEY } from "./openai-codex.js"
export { parseOpenAiExtraBody } from "./openai.js"
export { kimiCodeAuthMethodSchema, type KimiCodeAuthMethod } from "./kimi-code.js"
export { zaiApiLineSchema, type ZaiApiLine } from "./zai.js"
export {
Expand Down
69 changes: 69 additions & 0 deletions packages/types/src/provider-settings/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,74 @@ import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinit

export const OPEN_AI_MODEL_ID_FIELD = "openAiModelId"

const OPENAI_EXTRA_BODY_RESERVED_KEYS = [
// Prototype-pollution defenses; remaining keys are request-owned, including tool-call protocol controls.
"__proto__",
"constructor",
"prototype",
"max_completion_tokens",
"max_tokens",
"messages",
"model",
"parallel_tool_calls",
"reasoning",
"reasoning_effort",
"response_format",
"stream",
"stream_options",
"temperature",
"tool_choice",
"tools",
] as const

type OpenAiExtraBodyParseResult =
| { success: true; data: Record<string, unknown> }
| {
success: false
reason: "invalidJson" | "objectRequired" | "reservedKeys"
data: Record<string, unknown>
reservedKeys?: string[]
}

export function parseOpenAiExtraBody(value: string | undefined): OpenAiExtraBodyParseResult {
if (!value?.trim()) {
return { success: true, data: {} }
}

let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
return { success: false, reason: "invalidJson", data: {} }
}

if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { success: false, reason: "objectRequired", data: {} }
}

const entries = Object.entries(parsed)
const reservedKeys = entries
.map(([key]) => key)
.filter((key) => (OPENAI_EXTRA_BODY_RESERVED_KEYS as readonly string[]).includes(key))
const data = Object.fromEntries(entries.filter(([key]) => !reservedKeys.includes(key)))

if (reservedKeys.length > 0) {
return { success: false, reason: "reservedKeys", reservedKeys, data }
}

return { success: true, data }
}

const openAiExtraBodySchema = z
.string()
.superRefine((value, ctx) => {
const result = parseOpenAiExtraBody(value)
if (!result.success) {
ctx.addIssue({ code: "custom", message: result.reason })
}
})
.optional()

export const openAiProviderDefinition = createProviderDefinition({
apiProvider: providerIdentifiers.openai,
modelIdKey: OPEN_AI_MODEL_ID_FIELD,
Expand All @@ -22,5 +90,6 @@ export const openAiProviderDefinition = createProviderDefinition({
openAiStreamingEnabled: z.boolean().optional(),
openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration.
openAiHeaders: z.record(z.string(), z.string()).optional(),
openAiExtraBody: openAiExtraBodySchema,
},
})
82 changes: 82 additions & 0 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,20 @@ describe("OpenAiHandler", () => {
})
})

describe("withExtraBody", () => {
it("gives request-owned options precedence when an allowed Extra Body field collides", () => {
const extraBodyHandler = new OpenAiHandler({
...mockOptions,
openAiExtraBody: JSON.stringify({ service_tier: "flex" }),
})

expect(extraBodyHandler["withExtraBody"]({})).toEqual({ service_tier: "flex" })
expect(extraBodyHandler["withExtraBody"]({ service_tier: "default" })).toEqual({
service_tier: "default",
})
})
})

describe("createMessage", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
Expand Down Expand Up @@ -261,6 +275,45 @@ describe("OpenAiHandler", () => {
expect(textChunks[0].text).toBe("Test response")
})

it("adds Extra Body fields to streaming requests without allowing reserved field overrides", async () => {
const extraBodyHandler = new OpenAiHandler({
...mockOptions,
openAiExtraBody: JSON.stringify({
metadata: { completion_window: "balanced" },
model: "overridden-model",
messages: [],
stream: false,
}),
})

await collectStream(extraBodyHandler.createMessage(systemPrompt, messages))

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
metadata: { completion_window: "balanced" },
model: mockOptions.openAiModelId,
stream: true,
messages: expect.arrayContaining([expect.objectContaining({ role: "user" })]),
}),
{},
)
})

it("adds Extra Body fields to non-streaming requests", async () => {
const extraBodyHandler = new OpenAiHandler({
...mockOptions,
openAiStreamingEnabled: false,
openAiExtraBody: JSON.stringify({ metadata: { completion_window: "balanced" } }),
})

await collectStream(extraBodyHandler.createMessage(systemPrompt, messages))

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { completion_window: "balanced" } }),
{},
)
})

it("streams reasoning chunks from delta.reasoning_content", async () => {
mockCreate.mockImplementationOnce(async () =>
asyncStreamFrom([
Expand Down Expand Up @@ -843,6 +896,20 @@ describe("OpenAiHandler", () => {
)
})

it("adds Extra Body fields to single-completion requests", async () => {
const extraBodyHandler = new OpenAiHandler({
...mockOptions,
openAiExtraBody: JSON.stringify({ metadata: { completion_window: "balanced" } }),
})

await extraBodyHandler.completePrompt("Test prompt")

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { completion_window: "balanced" } }),
{},
)
})

it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error: API Error")
Expand Down Expand Up @@ -1103,6 +1170,21 @@ describe("OpenAiHandler", () => {
)
})

it.each([true, false])("adds Extra Body fields to O3 requests when streaming is %s", async (streaming) => {
const o3Handler = new OpenAiHandler({
...o3Options,
openAiStreamingEnabled: streaming,
openAiExtraBody: JSON.stringify({ metadata: { completion_window: "balanced" } }),
})

await collectStream(o3Handler.createMessage("system", []))

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { completion_window: "balanced" } }),
{},
)
})

it("should handle tool calls with O3 model in streaming mode", async () => {
const o3Handler = new OpenAiHandler(o3Options)

Expand Down
Loading
Loading