Skip to content
Merged
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
93 changes: 93 additions & 0 deletions src/__tests__/openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,99 @@ describe("translateOpenAiToAnthropic", () => {
expect(result!.tools).toHaveLength(1)
expect(result!.tools![0]!.description).toBe("")
})

// --- structured output ---

const personSchema = {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
}

it("maps response_format json_schema to output_config.format", () => {
const result = translateOpenAiToAnthropic({
messages: [{ role: "user", content: "hi" }],
response_format: {
type: "json_schema",
json_schema: { name: "person", schema: personSchema, strict: true },
},
})
// `name` and `strict` have no Anthropic equivalent and are dropped: the
// SDK always validates, which is never weaker than strict asked for.
expect(result!.output_config).toEqual({
format: { type: "json_schema", schema: personSchema },
})
})

it("keeps response_format alongside output_config.effort", () => {
const result = translateOpenAiToAnthropic({
messages: [{ role: "user", content: "hi" }],
output_config: { effort: "high" },
response_format: { type: "json_schema", json_schema: { schema: personSchema } },
})
expect(result!.output_config).toEqual({
effort: "high",
format: { type: "json_schema", schema: personSchema },
})
})

it("passes through an Anthropic-shaped output_config.format unchanged", () => {
const format = { type: "json_schema", schema: personSchema }
const result = translateOpenAiToAnthropic({
messages: [{ role: "user", content: "hi" }],
output_config: { format },
})
expect(result!.output_config).toEqual({ format })
})

it("forwards json_object intact so the boundary can reject it", () => {
// Not widened into a permissive schema: Anthropic has no schema-less JSON
// mode, and accepting it here would promise an enforcement never applied.
const result = translateOpenAiToAnthropic({
messages: [{ role: "user", content: "hi" }],
response_format: { type: "json_object" },
})
expect(result!.output_config).toEqual({ format: { type: "json_object" } })
})

it("treats response_format text as no constraint", () => {
const result = translateOpenAiToAnthropic({
messages: [{ role: "user", content: "hi" }],
response_format: { type: "text" },
})
expect(result!.output_config).toBeUndefined()
})

// Clients that serialize an unset optional as JSON null rather than omitting
// the key must behave as if it were absent. This threw a TypeError out of the
// /v1/chat/completions handler, which has no try/catch — a 500 on a request
// that worked fine before structured output landed.
it("treats an explicit null response_format as omission", () => {
const result = translateOpenAiToAnthropic({
messages: [{ role: "user", content: "hi" }],
response_format: null,
} as unknown as Parameters<typeof translateOpenAiToAnthropic>[0])
expect(result!.output_config).toBeUndefined()
})

it("keeps output_config.effort when response_format is null", () => {
const result = translateOpenAiToAnthropic({
messages: [{ role: "user", content: "hi" }],
response_format: null,
output_config: { effort: "high" },
} as unknown as Parameters<typeof translateOpenAiToAnthropic>[0])
expect(result!.output_config).toEqual({ effort: "high" })
})

// Forwarded rather than dropped, so the boundary check rejects it with a 400
// naming the client's own field instead of silently ignoring the request.
it("forwards a non-object response_format for rejection downstream", () => {
const result = translateOpenAiToAnthropic({
messages: [{ role: "user", content: "hi" }],
response_format: "json_schema",
} as unknown as Parameters<typeof translateOpenAiToAnthropic>[0])
expect(result!.output_config?.format).toBe("json_schema")
})
})

// ---------------------------------------------------------------------------
Expand Down
157 changes: 157 additions & 0 deletions src/__tests__/proxy-openai-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,133 @@ describe("POST /v1/chat/completions — non-streaming", () => {
expect(capturedOptions?.effort).toBe("high")
})

it("carries response_format json_schema through to the SDK outputFormat", async () => {
// Structured output is enforced by the SDK on the internal /v1/messages
// hop. Without this the field is dropped at the endpoint boundary and the
// client silently gets prose back instead of schema-valid JSON.
const schema = {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
additionalProperties: false,
}
mockMessages = [assistantMessage([{ type: "text", text: "ok" }])]
const app = createTestApp()

await postChatCompletion(app, {
stream: false,
response_format: { type: "json_schema", json_schema: { name: "answer", schema } },
messages: [{ role: "user", content: "Hi" }],
})

expect(capturedOptions?.outputFormat).toEqual({ type: "json_schema", schema })
})

// The test above pins what reaches the SDK, but its request actually ends in
// a 500: the mock yields no `result` carrying structured_output, so nothing
// downstream of the SDK boundary is exercised. These two supply that result
// and assert the bytes the client receives — otherwise the whole point of the
// feature (schema-valid JSON in the response) has no coverage.
it("returns the validated JSON as the message content", async () => {
const schema = {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
additionalProperties: false,
}
mockMessages = [
assistantMessage([{ type: "text", text: "ignored prose" }]),
{ type: "result", subtype: "success", is_error: false, structured_output: { answer: "42" } },
]
const app = createTestApp()

const res = await postChatCompletion(app, {
stream: false,
response_format: { type: "json_schema", json_schema: { name: "answer", schema } },
messages: [{ role: "user", content: "Hi" }],
})

expect(res.status).toBe(200)
const body = await res.json() as { choices: Array<{ message: { content: string } }> }
expect(JSON.parse(body.choices[0]!.message.content)).toEqual({ answer: "42" })
})

it("serves a request whose response_format is an explicit null", async () => {
// Many OpenAI-compatible clients emit `"response_format": null` for an
// unset optional instead of omitting the key. Reading `.type` off it threw
// out of this handler, which has no try/catch — so a plain chat request
// that worked before structured output existed came back 500.
mockMessages = [assistantMessage([{ type: "text", text: "ok" }])]
const app = createTestApp()

const res = await postChatCompletion(app, {
stream: false,
response_format: null,
messages: [{ role: "user", content: "Hi" }],
})

expect(res.status).toBe(200)
expect(capturedOptions?.outputFormat).toBeUndefined()
})

it("rejects response_format json_object instead of silently ignoring it", async () => {
// Anthropic has no schema-less JSON mode, so the request cannot be honored.
// Failing loudly beats returning prose to a client expecting JSON.
mockMessages = [assistantMessage([{ type: "text", text: "ok" }])]
const app = createTestApp()

const res = await postChatCompletion(app, {
stream: false,
response_format: { type: "json_object" },
messages: [{ role: "user", content: "Hi" }],
})

expect(res.status).toBe(400)
})

it("keeps tool calling and drops the schema when both are sent", async () => {
// OpenAI permits tools + response_format; structured-output mode cannot
// honour both, because it replaces the content and swallows the tool_use
// turn. This endpoint dropped response_format entirely before structured
// output existed, so tool calling worked and clients depend on it — a 400
// delivers neither capability, dropping the schema delivers the larger one.
mockMessages = [assistantMessage([{ type: "text", text: "ok" }])]
const app = createTestApp()

const res = await postChatCompletion(app, {
stream: false,
response_format: {
type: "json_schema",
json_schema: { schema: { type: "object", properties: {} } },
},
tools: [{ type: "function", function: { name: "fn", parameters: {} } }],
messages: [{ role: "user", content: "Hi" }],
})

expect(res.status).toBe(200)
// The tools still reach the SDK; only the unsatisfiable schema is dropped.
expect(capturedOptions?.outputFormat).toBeUndefined()
})

// Same rule applied to json_object: on its own it is a 400, because nothing
// can be honoured (see "rejects response_format json_object" above, and note
// Anthropic has no schema-less JSON mode). Sent alongside tools there IS
// something to honour, so it degrades rather than failing the whole request.
it("keeps tool calling when json_object is sent alongside tools", async () => {
mockMessages = [assistantMessage([{ type: "text", text: "ok" }])]
const app = createTestApp()

const res = await postChatCompletion(app, {
stream: false,
response_format: { type: "json_object" },
tools: [{ type: "function", function: { name: "fn", parameters: {} } }],
messages: [{ role: "user", content: "Hi" }],
})

expect(res.status).toBe(200)
expect(capturedOptions?.outputFormat).toBeUndefined()
})

it("sends the client system prompt verbatim, without the claude_code preset", async () => {
// The OpenAI endpoint serves generic chat clients (Open WebUI, curl).
// Their system prompt must reach the SDK as a plain string — NOT wrapped
Expand Down Expand Up @@ -720,6 +847,36 @@ describe("POST /v1/chat/completions — streaming", () => {
?.index
expect(startIndex).toBe(0)
})

it("streams the validated JSON as a single content delta", async () => {
const schema = {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
additionalProperties: false,
}
mockMessages = [
messageStart("msg_1"), textBlockStart(0), textDelta(0, "ignored prose"),
blockStop(0), messageDelta("end_turn"), messageStop(),
{ type: "result", subtype: "success", is_error: false, structured_output: { answer: "42" } },
]
const app = createTestApp()

const res = await postChatCompletion(app, {
stream: true,
response_format: { type: "json_schema", json_schema: { name: "answer", schema } },
messages: [{ role: "user", content: "Hi" }],
})

expect(res.status).toBe(200)
const text = await readStream(res)
const content = text.split("\n")
.filter(l => l.startsWith("data: ") && l !== "data: [DONE]")
.map(l => JSON.parse(l.slice(6)) as { choices?: Array<{ delta?: { content?: string } }> })
.map(c => c.choices?.[0]?.delta?.content ?? "")
.join("")
expect(JSON.parse(content)).toEqual({ answer: "42" })
})
})

// ---------------------------------------------------------------------------
Expand Down
62 changes: 62 additions & 0 deletions src/__tests__/structured-output-parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Unit tests for src/proxy/structuredOutput.ts - pure parsing/normalization.
* No I/O, no mocks required.
*/

import { describe, it, expect } from "bun:test"
import { parseOutputFormat } from "../proxy/structuredOutput"

const schema = {
type: "object",
additionalProperties: false,
required: ["a"],
properties: { a: { type: "integer" } },
}

function parseSchema(input: Record<string, unknown>) {
const result = parseOutputFormat({ format: { type: "json_schema", schema: input } })
if (!result.ok) throw new Error(`expected ok, got: ${result.message}`)
return result.value as { type: "json_schema"; schema: Record<string, unknown> }
}

describe("parseOutputFormat - $schema handling", () => {
it("strips a root-level $schema before reaching the SDK", () => {
// Any dialect but draft-07 makes the model fail to submit its structured
// result, which surfaces as a 500. The keyword constrains nothing, so it
// is dropped rather than passed through.
const value = parseSchema({
$schema: "https://json-schema.org/draft/2020-12/schema",
...schema,
})
expect(value.schema).toEqual(schema)
expect("$schema" in value.schema).toBe(false)
})

it("strips the draft-07 $schema too, for one consistent shape", () => {
const value = parseSchema({ $schema: "http://json-schema.org/draft-07/schema#", ...schema })
expect(value.schema).toEqual(schema)
})

it("leaves a schema without $schema untouched", () => {
const value = parseSchema({ ...schema })
expect(value.schema).toEqual(schema)
})

it("does not mutate the caller's schema object", () => {
const input = { $schema: "https://json-schema.org/draft/2020-12/schema", ...schema }
parseSchema(input)
expect(input.$schema).toBe("https://json-schema.org/draft/2020-12/schema")
})

it("keeps a nested $schema, which the SDK already tolerates", () => {
// Only the root keyword breaks submission. Rewriting deeper would risk
// clobbering a property legitimately named `$schema`.
const nested = {
type: "object",
properties: {
p: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object" },
},
}
expect(parseSchema(nested).schema).toEqual(nested)
})
})
Loading
Loading