Skip to content

Commit 424619b

Browse files
committed
fix(runtime): honor Alibaba Responses tool choice contract
Permit documented required and allowed_tools choices when they select exactly one tool, while keeping provider-owned store:false finalization and rejecting unsupported forced shapes.\n\nGenerated-by: OpenAI Codex
1 parent e88e07c commit 424619b

4 files changed

Lines changed: 123 additions & 10 deletions

File tree

packages/runtime/src/__tests__/open-responses-compatibility.test.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { createOpenResponsesCompatibilityFinalizer } from '../open-responses-com
2424
test('applies the declared Open Responses body policies', () => {
2525
const finalize = createOpenResponsesCompatibilityFinalizer('alibaba-token-plan');
2626
assert.ok(finalize);
27+
const tool = { type: 'function', name: 'lookup' };
2728
assert.deepEqual(finalize({ model: 'qwen3.8-max', store: true, tool_choice: 'auto' }), {
2829
model: 'qwen3.8-max',
2930
store: false,
@@ -33,10 +34,36 @@ test('applies the declared Open Responses body policies', () => {
3334
model: 'qwen3.8-max',
3435
store: false,
3536
});
36-
for (const toolChoice of ['required', { type: 'function', name: 'lookup' }]) {
37-
assert.throws(
38-
() => finalize({ tool_choice: toolChoice }),
39-
/does not support forced tool_choice/,
40-
);
41-
}
37+
assert.deepEqual(finalize({ tools: [tool], tool_choice: 'required' }), {
38+
tools: [tool],
39+
tool_choice: 'required',
40+
store: false,
41+
});
42+
assert.deepEqual(
43+
finalize({
44+
tools: [tool],
45+
tool_choice: { type: 'allowed_tools', mode: 'required', tools: [tool] },
46+
}),
47+
{
48+
tools: [tool],
49+
tool_choice: { type: 'allowed_tools', mode: 'required', tools: [tool] },
50+
store: false,
51+
},
52+
);
53+
assert.throws(
54+
() => finalize({ tools: [], tool_choice: 'required' }),
55+
/requires exactly one tool/,
56+
);
57+
assert.throws(
58+
() =>
59+
finalize({
60+
tools: [tool],
61+
tool_choice: { type: 'allowed_tools', mode: 'required', tools: [] },
62+
}),
63+
/requires exactly one tool/,
64+
);
65+
assert.throws(
66+
() => finalize({ tools: [tool], tool_choice: tool }),
67+
/does not support this tool_choice object/,
68+
);
4269
});

packages/runtime/src/__tests__/responses-wire-contract.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,73 @@ describe('responses wire request body', () => {
366366
assert.deepEqual(bodies[0]?.reasoning, { effort: 'medium' });
367367
});
368368

369+
test('Alibaba compatibility preserves documented required tool choices', async () => {
370+
const bodies: Array<Record<string, unknown>> = [];
371+
const fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
372+
bodies.push(JSON.parse(String(init?.body)));
373+
return Response.json({
374+
id: 'response-required-tool',
375+
object: 'response',
376+
created_at: 1,
377+
model: 'qwen3.8-max',
378+
status: 'completed',
379+
output: [],
380+
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
381+
});
382+
}) as unknown as typeof globalThis.fetch;
383+
const connection = {
384+
...conn('alibaba-token-plan-cn'),
385+
baseUrl: 'https://token-plan.example/compatible-mode/v1',
386+
};
387+
const model = getAIModel({
388+
connection,
389+
apiKey: 'token-plan-key',
390+
modelId: 'qwen3.8-max',
391+
fetch,
392+
});
393+
394+
await model.doGenerate({
395+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'look it up' }] }],
396+
tools: [
397+
{
398+
type: 'function',
399+
name: 'lookup',
400+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
401+
},
402+
],
403+
toolChoice: { type: 'required' },
404+
});
405+
406+
const allowedToolsChoice = {
407+
type: 'allowed_tools',
408+
mode: 'required',
409+
tools: [{ type: 'function', name: 'lookup' }],
410+
};
411+
const overlayModel = getAIModel({
412+
connection: { ...connection, requestBodyOverlay: { tool_choice: allowedToolsChoice } },
413+
apiKey: 'token-plan-key',
414+
modelId: 'qwen3.8-max',
415+
fetch,
416+
});
417+
await overlayModel.doGenerate({
418+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'look it up again' }] }],
419+
tools: [
420+
{
421+
type: 'function',
422+
name: 'lookup',
423+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
424+
},
425+
],
426+
});
427+
428+
assert.equal(bodies[0]?.tool_choice, 'required');
429+
assert.equal((bodies[0]?.tools as unknown[] | undefined)?.length, 1);
430+
assert.equal(bodies[0]?.store, false);
431+
assert.deepEqual(bodies[1]?.tool_choice, allowedToolsChoice);
432+
assert.equal((bodies[1]?.tools as unknown[] | undefined)?.length, 1);
433+
assert.equal(bodies[1]?.store, false);
434+
});
435+
369436
test('Alibaba compatibility survives header-only request customization', async () => {
370437
let body: Record<string, unknown> | undefined;
371438
let headers: Headers | undefined;
@@ -402,7 +469,7 @@ describe('responses wire request body', () => {
402469
assert.equal(headers?.get('x-token-plan-routing'), 'custom');
403470
});
404471

405-
test('Alibaba stateless request carries the reconstructed summary item', async () => {
472+
test('Alibaba non-stored request carries the reconstructed summary item', async () => {
406473
let body: Record<string, unknown> | undefined;
407474
const fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
408475
body = JSON.parse(String(init?.body));

packages/runtime/src/model-factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 {
179179
if (reasoningReplay.contract.adapter === 'open-responses') {
180180
// Request customization is applied first; provider compatibility is
181181
// the final authority before network dispatch, so an overlay cannot
182-
// re-enable storage or force a tool-choice shape the provider rejects.
182+
// re-enable storage or violate the provider's tool-choice contract.
183183
const responsesFetch = createRequestCustomizationFetch(baseFetch, {
184184
...requestCustomization,
185185
finalizeBody: createOpenResponsesCompatibilityFinalizer(

packages/runtime/src/open-responses-compatibility.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,33 @@
1919

2020
import type { OpenResponsesCompatibilityProfile } from './provider-runtime-policy.js';
2121

22+
function isRecord(value: unknown): value is Record<string, unknown> {
23+
return typeof value === 'object' && value !== null && !Array.isArray(value);
24+
}
25+
26+
function requiredToolCount(body: Record<string, unknown>): number | undefined {
27+
const choice = body.tool_choice;
28+
if (choice === 'required') {
29+
return Array.isArray(body.tools) ? body.tools.length : 0;
30+
}
31+
if (isRecord(choice) && choice.type === 'allowed_tools' && choice.mode === 'required') {
32+
return Array.isArray(choice.tools) ? choice.tools.length : 0;
33+
}
34+
return undefined;
35+
}
36+
2237
export function createOpenResponsesCompatibilityFinalizer(
2338
profile: OpenResponsesCompatibilityProfile | undefined,
2439
): ((body: Record<string, unknown>) => Record<string, unknown>) | undefined {
2540
if (!profile) return undefined;
2641
return (body) => {
2742
const choice = body.tool_choice;
28-
if (choice === 'required' || (choice !== null && typeof choice === 'object')) {
29-
throw new Error('Alibaba Token Plan Responses does not support forced tool_choice');
43+
const forcedToolCount = requiredToolCount(body);
44+
if (forcedToolCount !== undefined && forcedToolCount !== 1) {
45+
throw new Error('Alibaba Token Plan Responses requires exactly one tool for tool_choice');
46+
}
47+
if (isRecord(choice) && choice.type !== 'allowed_tools') {
48+
throw new Error('Alibaba Token Plan Responses does not support this tool_choice object');
3049
}
3150
return { ...body, store: false };
3251
};

0 commit comments

Comments
 (0)