Skip to content

Commit 25e9b10

Browse files
author
Aeshma-Daeva
committed
fix(shim): preserve forced tool choices
Keep ShimToolSearch reductions aligned with forced tool_choice payloads across chat and responses transports. Prevent responses requests from re-expanding original tools after predict selects an empty set.
1 parent 70f2c22 commit 25e9b10

2 files changed

Lines changed: 327 additions & 9 deletions

File tree

src/services/api/openaiShim.test.ts

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,6 +1491,46 @@ test('ShimToolSearch predict mode includes web tools for current web requests',
14911491
expect(toolNames).toContain('WebFetch')
14921492
})
14931493

1494+
test('ShimToolSearch predict mode includes forced tool_choice tool for conversational prompts', async () => {
1495+
let requestBody: Record<string, unknown> | undefined
1496+
process.env.OPENAI_SHIM_TOOL_MODE = 'predict'
1497+
1498+
globalThis.fetch = (async (_input, init) => {
1499+
requestBody = JSON.parse(String(init?.body))
1500+
1501+
return new Response(
1502+
JSON.stringify({
1503+
id: 'chatcmpl-shim-predict-forced-tool',
1504+
model: 'fake-model',
1505+
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
1506+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
1507+
}),
1508+
{ headers: { 'Content-Type': 'application/json' } },
1509+
)
1510+
}) as FetchType
1511+
1512+
const client = createOpenAIShimClient({}) as OpenAIShimClient
1513+
1514+
await client.beta.messages.create({
1515+
model: 'fake-model',
1516+
system: 'test system',
1517+
messages: [{ role: 'user', content: 'What is 2+2?' }],
1518+
tools: makeShimToolFixtures(),
1519+
tool_choice: { type: 'tool', name: 'WebSearch' },
1520+
max_tokens: 32,
1521+
stream: false,
1522+
})
1523+
1524+
const toolNames = ((requestBody?.tools ?? []) as Array<{ function: { name: string } }>)
1525+
.map(tool => tool.function.name)
1526+
expect(toolNames.length).toBeGreaterThan(0)
1527+
expect(toolNames).toContain('WebSearch')
1528+
expect(requestBody?.tool_choice).toEqual({
1529+
type: 'function',
1530+
function: { name: 'WebSearch' },
1531+
})
1532+
})
1533+
14941534
test('ShimToolSearch predict mode reduces tools for responses transport', async () => {
14951535
let requestBody: Record<string, unknown> | undefined
14961536
process.env.OPENAI_API_FORMAT = 'responses'
@@ -1536,6 +1576,212 @@ test('ShimToolSearch predict mode reduces tools for responses transport', async
15361576
expect(webSearchParameters?.properties?.query?.description).toBeUndefined()
15371577
})
15381578

1579+
test('ShimToolSearch predict mode does not re-expand empty predicted tools for responses transport', async () => {
1580+
let requestBody: Record<string, unknown> | undefined
1581+
process.env.OPENAI_API_FORMAT = 'responses'
1582+
process.env.OPENAI_SHIM_TOOL_MODE = 'predict'
1583+
1584+
globalThis.fetch = (async (_input, init) => {
1585+
requestBody = JSON.parse(String(init?.body))
1586+
1587+
return new Response(
1588+
JSON.stringify({
1589+
id: 'resp-shim-predict-empty',
1590+
model: 'fake-model',
1591+
output: [
1592+
{
1593+
type: 'message',
1594+
role: 'assistant',
1595+
content: [{ type: 'output_text', text: 'ok' }],
1596+
},
1597+
],
1598+
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
1599+
}),
1600+
{ headers: { 'Content-Type': 'application/json' } },
1601+
)
1602+
}) as FetchType
1603+
1604+
const client = createOpenAIShimClient({}) as OpenAIShimClient
1605+
1606+
await client.beta.messages.create({
1607+
model: 'fake-model',
1608+
system: 'test system',
1609+
messages: [{ role: 'user', content: 'What is 2+2?' }],
1610+
tools: makeShimToolFixtures(),
1611+
max_tokens: 32,
1612+
stream: false,
1613+
})
1614+
1615+
expect(requestBody?.tools).toBeUndefined()
1616+
expect(requestBody?.tool_choice).toBeUndefined()
1617+
})
1618+
1619+
test('ShimToolSearch predict mode includes forced tool_choice tool for responses transport', async () => {
1620+
let requestBody: Record<string, unknown> | undefined
1621+
process.env.OPENAI_API_FORMAT = 'responses'
1622+
process.env.OPENAI_SHIM_TOOL_MODE = 'predict'
1623+
1624+
globalThis.fetch = (async (_input, init) => {
1625+
requestBody = JSON.parse(String(init?.body))
1626+
1627+
return new Response(
1628+
JSON.stringify({
1629+
id: 'resp-shim-predict-forced-tool',
1630+
model: 'fake-model',
1631+
output: [
1632+
{
1633+
type: 'message',
1634+
role: 'assistant',
1635+
content: [{ type: 'output_text', text: 'ok' }],
1636+
},
1637+
],
1638+
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
1639+
}),
1640+
{ headers: { 'Content-Type': 'application/json' } },
1641+
)
1642+
}) as FetchType
1643+
1644+
const client = createOpenAIShimClient({}) as OpenAIShimClient
1645+
1646+
await client.beta.messages.create({
1647+
model: 'fake-model',
1648+
system: 'test system',
1649+
messages: [{ role: 'user', content: 'What is 2+2?' }],
1650+
tools: makeShimToolFixtures(),
1651+
tool_choice: { type: 'tool', name: 'WebSearch' },
1652+
max_tokens: 32,
1653+
stream: false,
1654+
})
1655+
1656+
const toolNames = ((requestBody?.tools ?? []) as Array<{ name: string }>)
1657+
.map(tool => tool.name)
1658+
expect(toolNames).toContain('WebSearch')
1659+
expect(requestBody?.tool_choice).toEqual({
1660+
type: 'function',
1661+
name: 'WebSearch',
1662+
})
1663+
})
1664+
1665+
test('OpenAI-compatible chat transport drops forced tool_choice when selected tools lack that schema', async () => {
1666+
let requestBody: Record<string, unknown> | undefined
1667+
process.env.OPENAI_SHIM_TOOL_MODE = 'predict'
1668+
1669+
globalThis.fetch = (async (_input, init) => {
1670+
requestBody = JSON.parse(String(init?.body))
1671+
1672+
return new Response(
1673+
JSON.stringify({
1674+
id: 'chatcmpl-shim-missing-forced-tool',
1675+
model: 'fake-model',
1676+
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
1677+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
1678+
}),
1679+
{ headers: { 'Content-Type': 'application/json' } },
1680+
)
1681+
}) as FetchType
1682+
1683+
const client = createOpenAIShimClient({}) as OpenAIShimClient
1684+
1685+
await client.beta.messages.create({
1686+
model: 'fake-model',
1687+
system: 'test system',
1688+
messages: [{ role: 'user', content: 'What is 2+2?' }],
1689+
tools: makeShimToolFixtures().filter(tool => tool.name !== 'WebSearch'),
1690+
tool_choice: { type: 'tool', name: 'WebSearch' },
1691+
max_tokens: 32,
1692+
stream: false,
1693+
})
1694+
1695+
const toolNames = ((requestBody?.tools ?? []) as Array<{ function: { name: string } }>)
1696+
.map(tool => tool.function.name)
1697+
expect(toolNames).not.toContain('WebSearch')
1698+
expect(requestBody?.tool_choice).toBeUndefined()
1699+
})
1700+
1701+
test('OpenAI-compatible responses transport drops forced tool_choice when selected tools lack that schema', async () => {
1702+
let requestBody: Record<string, unknown> | undefined
1703+
process.env.OPENAI_API_FORMAT = 'responses'
1704+
process.env.OPENAI_SHIM_TOOL_MODE = 'predict'
1705+
1706+
globalThis.fetch = (async (_input, init) => {
1707+
requestBody = JSON.parse(String(init?.body))
1708+
1709+
return new Response(
1710+
JSON.stringify({
1711+
id: 'resp-shim-missing-forced-tool',
1712+
model: 'fake-model',
1713+
output: [
1714+
{
1715+
type: 'message',
1716+
role: 'assistant',
1717+
content: [{ type: 'output_text', text: 'ok' }],
1718+
},
1719+
],
1720+
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
1721+
}),
1722+
{ headers: { 'Content-Type': 'application/json' } },
1723+
)
1724+
}) as FetchType
1725+
1726+
const client = createOpenAIShimClient({}) as OpenAIShimClient
1727+
1728+
await client.beta.messages.create({
1729+
model: 'fake-model',
1730+
system: 'test system',
1731+
messages: [{ role: 'user', content: 'What is 2+2?' }],
1732+
tools: makeShimToolFixtures().filter(tool => tool.name !== 'WebSearch'),
1733+
tool_choice: { type: 'tool', name: 'WebSearch' },
1734+
max_tokens: 32,
1735+
stream: false,
1736+
})
1737+
1738+
const toolNames = ((requestBody?.tools ?? []) as Array<{ name: string }>)
1739+
.map(tool => tool.name)
1740+
expect(toolNames).not.toContain('WebSearch')
1741+
expect(requestBody?.tool_choice).toBeUndefined()
1742+
})
1743+
1744+
test('OpenAI-compatible responses transport drops scalar any when predict selects no tools', async () => {
1745+
let requestBody: Record<string, unknown> | undefined
1746+
process.env.OPENAI_API_FORMAT = 'responses'
1747+
process.env.OPENAI_SHIM_TOOL_MODE = 'predict'
1748+
1749+
globalThis.fetch = (async (_input, init) => {
1750+
requestBody = JSON.parse(String(init?.body))
1751+
1752+
return new Response(
1753+
JSON.stringify({
1754+
id: 'resp-shim-any-empty-tools',
1755+
model: 'fake-model',
1756+
output: [
1757+
{
1758+
type: 'message',
1759+
role: 'assistant',
1760+
content: [{ type: 'output_text', text: 'ok' }],
1761+
},
1762+
],
1763+
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
1764+
}),
1765+
{ headers: { 'Content-Type': 'application/json' } },
1766+
)
1767+
}) as FetchType
1768+
1769+
const client = createOpenAIShimClient({}) as OpenAIShimClient
1770+
1771+
await client.beta.messages.create({
1772+
model: 'fake-model',
1773+
system: 'test system',
1774+
messages: [{ role: 'user', content: 'What is 2+2?' }],
1775+
tools: makeShimToolFixtures(),
1776+
tool_choice: { type: 'any' },
1777+
max_tokens: 32,
1778+
stream: false,
1779+
})
1780+
1781+
expect(requestBody?.tools).toBeUndefined()
1782+
expect(requestBody?.tool_choice).toBeUndefined()
1783+
})
1784+
15391785
test('OpenAI-compatible responses transport preserves tool_choice', async () => {
15401786
let requestBody: Record<string, unknown> | undefined
15411787
process.env.OPENAI_API_FORMAT = 'responses'
@@ -1674,6 +1920,47 @@ test('ShimToolSearch lazy phase 1 sends a valid request_tools schema through sys
16741920
expect(result.request_id).toMatch(/^msg_/)
16751921
})
16761922

1923+
test('ShimToolSearch lazy mode skips phase 1 for forced non-request_tools tool_choice', async () => {
1924+
const requestBodies: Array<Record<string, unknown>> = []
1925+
process.env.OPENAI_SHIM_TOOL_MODE = 'lazy'
1926+
1927+
globalThis.fetch = (async (_input, init) => {
1928+
requestBodies.push(JSON.parse(String(init?.body)))
1929+
1930+
return new Response(
1931+
JSON.stringify({
1932+
id: 'chatcmpl-shim-lazy-forced-tool',
1933+
model: 'fake-model',
1934+
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
1935+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
1936+
}),
1937+
{ headers: { 'Content-Type': 'application/json' } },
1938+
)
1939+
}) as FetchType
1940+
1941+
const client = createOpenAIShimClient({}) as OpenAIShimClient
1942+
1943+
await client.beta.messages.create({
1944+
model: 'fake-model',
1945+
system: 'test system',
1946+
messages: [{ role: 'user', content: 'What is 2+2?' }],
1947+
tools: makeShimToolFixtures(),
1948+
tool_choice: { type: 'tool', name: 'WebSearch' },
1949+
max_tokens: 32,
1950+
stream: false,
1951+
})
1952+
1953+
expect(requestBodies).toHaveLength(1)
1954+
const toolNames = ((requestBodies[0].tools ?? []) as Array<{ function: { name: string } }>)
1955+
.map(tool => tool.function.name)
1956+
expect(toolNames).toContain('WebSearch')
1957+
expect(toolNames).not.toContain('request_tools')
1958+
expect(requestBodies[0].tool_choice).toEqual({
1959+
type: 'function',
1960+
function: { name: 'WebSearch' },
1961+
})
1962+
})
1963+
16771964
test('ShimToolSearch lazy phase 2 falls back to all tools on malformed request_tools JSON', async () => {
16781965
const requestBodies: Array<Record<string, unknown>> = []
16791966
process.env.OPENAI_SHIM_TOOL_MODE = 'lazy'

0 commit comments

Comments
 (0)