diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 71ec5e9c50..3b78ac96f7 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -120,7 +120,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/open-responses@2.0.29 +Package: @ai-sdk/open-responses@2.0.34 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/open-responses @@ -208,6 +208,28 @@ limitations under the License. ================================================================================ +Package: @ai-sdk/provider@4.0.8 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/provider + +--- LICENSE --- +Copyright 2023 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ + Package: @ai-sdk/provider-utils@5.0.28 Declared license: Apache-2.0 Selected license: Apache-2.0 @@ -418,6 +440,216 @@ Apache License ================================================================================ +Package: @ai-sdk/provider-utils@5.0.32 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/provider-utils + +--- VERSION-PINNED LICENSE TEXT OVERRIDE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + Package: @ant-design/icons-svg@4.5.0 Declared license: MIT Selected license: MIT diff --git a/package-lock.json b/package-lock.json index e2e04cc77d..a98298f763 100644 --- a/package-lock.json +++ b/package-lock.json @@ -212,13 +212,13 @@ } }, "node_modules/@ai-sdk/open-responses": { - "version": "2.0.29", - "resolved": "https://registry.npmjs.org/@ai-sdk/open-responses/-/open-responses-2.0.29.tgz", - "integrity": "sha512-XMbOM+mwuegdvOJ0DB0AjFrg164XuQptem3ZOPPTPRSLdi3GMKg/8DQSWSJrLGG+SxqryIZ/6M5NSM9aLX2R2A==", + "version": "2.0.34", + "resolved": "https://registry.npmjs.org/@ai-sdk/open-responses/-/open-responses-2.0.34.tgz", + "integrity": "sha512-9kTfIoj6Cowx37oAMZ/ECS9mFLxROo85MdPkrj0dpxJwIsYJmTos/HOuI2HW/LoelFpyTFz8w6uyPFhaHuUEtA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.7", - "@ai-sdk/provider-utils": "5.0.28" + "@ai-sdk/provider": "4.0.8", + "@ai-sdk/provider-utils": "5.0.32" }, "engines": { "node": ">=22" @@ -227,6 +227,46 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/open-responses/node_modules/@ai-sdk/provider": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.8.tgz", + "integrity": "sha512-aWO7iwhFUGf347tCwNGggggfmZigaSu7TF739IZSrWWABUp7zkb4Cr3fMqvBe5EIS7ABJJu3Cadn0g/zs1G0QQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/open-responses/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.32", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.32.tgz", + "integrity": "sha512-MZUhlINn6FzKIWuX3T36h+yM9d7bG+yatH+kC99ZCe0DHxXfP73KwaoLiLcZDPQDamFyO3umPPBLJieZJyG4DQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.8", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/open-responses/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@ai-sdk/openai": { "version": "4.0.44", "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.44.tgz", @@ -13952,7 +13992,7 @@ "@ai-sdk/code-mode": "1.0.27", "@ai-sdk/cohere": "4.0.28", "@ai-sdk/google": "4.0.47", - "@ai-sdk/open-responses": "2.0.29", + "@ai-sdk/open-responses": "2.0.34", "@ai-sdk/openai": "4.0.44", "@ai-sdk/openai-compatible": "3.0.32", "@larksuiteoapi/node-sdk": "1.73.0", diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index 7f6b2f82de..d1b9143775 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -318,7 +318,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/open-responses@2.0.29 +Package: @ai-sdk/open-responses@2.0.34 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/open-responses @@ -406,6 +406,28 @@ limitations under the License. ================================================================================ +Package: @ai-sdk/provider@4.0.8 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/provider + +--- LICENSE --- +Copyright 2023 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ + Package: @ai-sdk/provider-utils@5.0.28 Declared license: Apache-2.0 Selected license: Apache-2.0 @@ -616,6 +638,216 @@ Apache License ================================================================================ +Package: @ai-sdk/provider-utils@5.0.32 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/provider-utils + +--- VERSION-PINNED LICENSE TEXT OVERRIDE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + Package: @earendil-works/pi-tui@0.84.2 Declared license: MIT Selected license: MIT diff --git a/packages/core/src/__tests__/model-metadata.test.ts b/packages/core/src/__tests__/model-metadata.test.ts index 9eb6090bf8..4e1c54b4e7 100644 --- a/packages/core/src/__tests__/model-metadata.test.ts +++ b/packages/core/src/__tests__/model-metadata.test.ts @@ -104,4 +104,12 @@ describe('openAiAdapterApiProtocol', () => { assert.equal(openAiAdapterApiProtocol('muse-spark-1.2-contributor', 'opencode'), 'openai-chat'); assert.equal(openAiAdapterApiProtocol('minimax-m3', 'opencode-go'), 'openai-chat'); }); + + it('routes only Qwen3.8 Max through Alibaba Token Plan Responses', () => { + for (const providerType of ['alibaba-token-plan-cn', 'alibaba-token-plan'] as const) { + assert.equal(openAiAdapterApiProtocol('qwen3.8-max', providerType), 'openai-responses'); + assert.equal(openAiAdapterApiProtocol('qwen3.7-max', providerType), 'openai-chat'); + } + assert.equal(openAiAdapterApiProtocol('qwen3.8-max', 'alibaba-cn'), 'openai-chat'); + }); }); diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index d5a57da1f0..ef03f43fa2 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -78,6 +78,19 @@ describe('provider catalog contract — structural invariants over CATALOG_PROVI ); } }); + + it('delegates Alibaba Token Plan execution through one explicit Runtime profile', () => { + const delegated = Object.entries(PROVIDER_REGISTRY).flatMap(([providerType, definition]) => { + const adapter = definition.runtimeAdapter; + return adapter.kind === 'openai-compatible' && adapter.runtimeProfile + ? [{ providerType, runtimeProfile: adapter.runtimeProfile }] + : []; + }); + assert.deepEqual(delegated, [ + { providerType: 'alibaba-token-plan-cn', runtimeProfile: 'alibaba-token-plan' }, + { providerType: 'alibaba-token-plan', runtimeProfile: 'alibaba-token-plan' }, + ]); + }); }); describe('retired provider contract', () => { diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 75bbe31742..199fb601f5 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -44,6 +44,7 @@ import { type ProviderCategory, type ProviderDefaults, type ProviderRuntimeAdapter, + type ProviderRuntimeProfileId, type ProviderResponsesContract, type ProviderType, } from './provider-registry.js'; @@ -64,6 +65,7 @@ export type { ProviderCategory, ProviderDefaults, ProviderRuntimeAdapter, + ProviderRuntimeProfileId, ProviderResponsesContract, ProviderType, }; diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index 5949b79fad..179c3eac6c 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -125,11 +125,10 @@ export function lookupModelProviderOverride( /** * The request wire a model served over the OpenAI adapter must use. * - * OpenAI's `gpt-5*` families and xAI's `grok-4.5` are served only over the - * Responses API; every other model on the native OpenAI adapter uses Chat - * Completions. This is the single declared source of that protocol split, - * expressed through the {@link ModelInfo.apiProtocol} seam. It is consumed by - * the runtime model factory and the conformance matrix. + * Provider/model routing facts live here even when the concrete Responses SDK + * and replay policy are delegated to a Runtime profile. This is the single + * declared source of the default protocol split, expressed through the + * {@link ModelInfo.apiProtocol} seam. */ export function openAiAdapterApiProtocol( modelId: string, @@ -138,6 +137,8 @@ export function openAiAdapterApiProtocol( const id = modelId.trim(); return (providerType === 'deepseek' && deepSeekModelSupportsResponses(id)) || (providerType === 'opencode-go' && id === 'muse-spark-1.2-contributor') || + ((providerType === 'alibaba-token-plan-cn' || providerType === 'alibaba-token-plan') && + id === 'qwen3.8-max') || /^gpt-5/i.test(id) || ((providerType === 'xai' || providerType === 'xai-oauth') && id === 'grok-4.5') ? 'openai-responses' diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 68243afa64..36c3c44d0a 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -31,6 +31,13 @@ export type ProviderCatalogGroup = 'recommended' | 'plans' | 'api' | 'aggregator export type ApplyPatchProtocol = 'openai-structured' | 'codex-v4a-freeform'; +/** + * Stable reference to provider execution policy implemented by `@maka/runtime`. + * Core owns only this protocol-level delegation; SDK selection, replay + * carriers, and request mutation remain Runtime implementation details. + */ +export type ProviderRuntimeProfileId = 'alibaba-token-plan'; + export type ProviderResponsesContract = | { readonly adapter: 'openai'; @@ -41,6 +48,29 @@ export type ProviderResponsesContract = readonly reasoningReplay: 'plaintext-content'; }; +type OpenAiCompatibleRuntimeAdapterBase = { + kind: 'openai-compatible'; + name: 'provider' | 'connection'; + includeUsage?: boolean; + requireBaseUrl?: boolean; + replayAssistantReasoningAs?: 'reasoning'; + replayAssistantReasoningDetails?: true; +}; + +type OpenAiCompatibleRuntimeAdapter = OpenAiCompatibleRuntimeAdapterBase & + ( + | { + /** Presence enables a complete Core-owned Responses contract. */ + responses?: ProviderResponsesContract; + runtimeProfile?: never; + } + | { + responses?: never; + /** Explicitly delegates concrete execution policy to `@maka/runtime`. */ + runtimeProfile: ProviderRuntimeProfileId; + } + ); + type ProviderRuntimeAdapterDefinition = | { kind: 'anthropic'; auth: 'api-key' | 'bearer'; normalizeBaseUrl: boolean } /** @@ -53,16 +83,7 @@ type ProviderRuntimeAdapterDefinition = | { kind: 'google'; normalizeBaseUrl?: boolean } | { kind: 'github-copilot' } | { kind: 'cohere' } - | { - kind: 'openai-compatible'; - name: 'provider' | 'connection'; - includeUsage?: boolean; - requireBaseUrl?: boolean; - /** Presence enables Responses and fixes the only supported SDK/replay pairing. */ - responses?: ProviderResponsesContract; - replayAssistantReasoningAs?: 'reasoning'; - replayAssistantReasoningDetails?: true; - }; + | OpenAiCompatibleRuntimeAdapter; export type ProviderRuntimeAdapter = ProviderRuntimeAdapterDefinition & { /** Provider wire contract for ApplyPatch. Model support is resolved separately. */ @@ -1664,7 +1685,11 @@ const providerRegistry = { fallbackModels: [...alibabaTokenPlanModelIds], status: 'ready', protocol: 'openai', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { + kind: 'openai-compatible', + name: 'provider', + runtimeProfile: 'alibaba-token-plan', + }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -1684,7 +1709,11 @@ const providerRegistry = { fallbackModels: [...alibabaTokenPlanModelIds], status: 'ready', protocol: 'openai', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { + kind: 'openai-compatible', + name: 'provider', + runtimeProfile: 'alibaba-token-plan', + }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 53d05f563c..19e1f6eb7d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -129,7 +129,7 @@ "@ai-sdk/code-mode": "1.0.27", "@ai-sdk/cohere": "4.0.28", "@ai-sdk/google": "4.0.47", - "@ai-sdk/open-responses": "2.0.29", + "@ai-sdk/open-responses": "2.0.34", "@ai-sdk/openai": "4.0.44", "@ai-sdk/openai-compatible": "3.0.32", "@larksuiteoapi/node-sdk": "1.73.0", diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 0463b92005..208ea3bee3 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -11938,6 +11938,852 @@ describe('AiSdkBackend thinking persistence', () => { ); }); + test('Alibaba Responses keeps multiple streamed reasoning items distinct through replay', async () => { + const tokenPlanConnection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + let sequenceNumber = 0; + const responseEvents: Array> = [ + { type: 'response.created', sequence_number: sequenceNumber++, response: { id: 'r' } }, + ]; + for (const [outputIndex, item] of [ + { id: 'reasoning-item-1', deltas: ['first ', 'summary'] }, + { id: 'reasoning-item-2', deltas: ['second summary'] }, + { id: 'reasoning-item-empty', deltas: [] }, + ].entries()) { + responseEvents.push({ + type: 'response.output_item.added', + sequence_number: sequenceNumber++, + output_index: outputIndex, + item: { type: 'reasoning', id: item.id, status: 'in_progress', content: [], summary: [] }, + }); + for (const delta of item.deltas) { + responseEvents.push({ + type: 'response.reasoning_summary_text.delta', + sequence_number: sequenceNumber++, + item_id: item.id, + output_index: outputIndex, + summary_index: 0, + delta, + }); + } + if (item.deltas.length > 0) { + responseEvents.push({ + type: 'response.reasoning_summary_text.done', + sequence_number: sequenceNumber++, + item_id: item.id, + output_index: outputIndex, + summary_index: 0, + text: item.deltas.join(''), + }); + } + responseEvents.push({ + type: 'response.output_item.done', + sequence_number: sequenceNumber++, + output_index: outputIndex, + item: { + type: 'reasoning', + id: item.id, + status: 'completed', + content: [], + summary: + item.deltas.length > 0 ? [{ type: 'summary_text', text: item.deltas.join('') }] : [], + }, + }); + } + responseEvents.push( + { + type: 'response.output_item.added', + sequence_number: sequenceNumber++, + output_index: 3, + item: { + type: 'message', + id: 'message-item', + status: 'in_progress', + role: 'assistant', + content: [], + }, + }, + { + type: 'response.output_text.delta', + sequence_number: sequenceNumber++, + item_id: 'message-item', + output_index: 3, + content_index: 0, + delta: 'answer', + }, + { + type: 'response.output_item.done', + sequence_number: sequenceNumber++, + output_index: 3, + item: { + type: 'message', + id: 'message-item', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'answer', annotations: [] }], + }, + }, + { + type: 'response.completed', + sequence_number: sequenceNumber++, + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 3, total_tokens: 4 }, + }, + }, + ); + const rawSse = `${responseEvents + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + const fetch = (async () => + new Response(rawSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as unknown as typeof globalThis.fetch; + const firstBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: tokenPlanConnection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const firstEvents: SessionEvent[] = []; + for await (const event of firstBackend.send({ + turnId: 'turn-prev', + text: 'question', + context: [], + })) { + firstEvents.push(event); + } + const thinkingCompletes = firstEvents.filter( + (event): event is Extract => + event.type === 'thinking_complete', + ); + assert.deepEqual( + thinkingCompletes.map((event) => [ + event.text, + (event.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['first summary', 'reasoning-item-1'], + ['second summary', 'reasoning-item-2'], + ['', 'reasoning-item-empty'], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = firstEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + let replayRequestBody: Record | undefined; + const replayFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + replayRequestBody = JSON.parse(String(init?.body)) as Record; + const completed = { + type: 'response.completed', + response: { + id: 'response-replay', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }; + return new Response(`data: ${JSON.stringify(completed)}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }) as unknown as typeof globalThis.fetch; + const secondBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: tokenPlanConnection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch: replayFetch }), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + runtimeContext, + }), + ); + + const replayInput = replayRequestBody?.input; + assert.ok(Array.isArray(replayInput)); + assert.deepEqual( + replayInput.filter((item) => item?.type === 'reasoning'), + [ + { + type: 'reasoning', + id: 'reasoning-item-1', + summary: [{ type: 'summary_text', text: 'first summary' }], + }, + { + type: 'reasoning', + id: 'reasoning-item-2', + summary: [{ type: 'summary_text', text: 'second summary' }], + }, + { type: 'reasoning', id: 'reasoning-item-empty', summary: [] }, + ], + ); + }); + + test('Alibaba Responses fails when streamed reasoning differs from the final summary', async (t) => { + // The early stop tears down the SDK stream while its settlement promises + // are still in flight; when those rejections land is scheduler-owned (on + // Windows they were observed after the test boundary). Trap unhandled + // rejections for the lifetime of this turn and assert the mismatch path + // leaves none behind, on every event loop, not just the one that raced. + const leakedRejections: unknown[] = []; + const trapUnhandledRejection = (reason: unknown): void => { + leakedRejections.push(reason); + }; + process.on('unhandledRejection', trapUnhandledRejection); + t.after(() => { + process.off('unhandledRejection', trapUnhandledRejection); + }); + const appended: AssistantMessage[] = []; + const mismatchEvents = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: 'reasoning-item', summary: [] }, + }, + { + type: 'response.reasoning_summary_text.delta', + item_id: 'reasoning-item', + summary_index: 0, + delta: 'streamed text', + }, + { + type: 'response.reasoning_summary_text.done', + item_id: 'reasoning-item', + summary_index: 0, + text: 'streamed text', + }, + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: 'reasoning-item', + summary: [{ type: 'summary_text', text: 'different final summary' }], + }, + }, + ]; + const mismatchSse = `${mismatchEvents + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + const mismatchFetch = (async () => + new Response(mismatchSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as unknown as typeof globalThis.fetch; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch: mismatchFetch }), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'question', context: [] })) { + events.push(event); + } + + assert.equal( + events.some((event) => event.type === 'error'), + true, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + assert.equal(JSON.stringify(appended).includes('makaResponses'), false); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'recover', + context: [], + runtimeContext, + }), + ); + assert.ok(compactPrompt(recoveryModel)); + // Let SDK teardown settle across macrotask cycles so a leaked rejection + // is caught before the trap comes off. + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.deepEqual( + leakedRejections, + [], + 'reasoning-mismatch teardown must not leak unhandled rejections', + ); + }); + + test('Alibaba Responses preserves live compatibility reasoning across abrupt transport failure', async () => { + const partialEvents = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: 'reasoning-partial', summary: [] }, + }, + { + type: 'response.reasoning_text.delta', + item_id: 'reasoning-partial', + content_index: 0, + delta: 'partial compatibility reasoning', + }, + ]; + const bytes = new TextEncoder().encode( + `${partialEvents.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\n`, + ); + const fetch = (async () => { + let emitted = false; + return new Response( + new ReadableStream({ + async pull(controller) { + if (!emitted) { + emitted = true; + controller.enqueue(bytes); + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.error(new Error('transport boom')); + }, + }), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ); + }) as unknown as typeof globalThis.fetch; + const appended: AssistantMessage[] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'question', context: [] })) { + events.push(event); + } + + assert.equal( + events.some( + (event) => + event.type === 'thinking_delta' && event.text === 'partial compatibility reasoning', + ), + true, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + assert.equal(JSON.stringify(appended).includes('makaResponses'), false); + }); + + test('Alibaba Responses keeps a finalized item valid when the next item id is unsafe', async () => { + const invalidItemId = 'invalid\nreasoning-item'; + const rawEvents = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: 'reasoning-item-a', summary: [] }, + }, + { + type: 'response.reasoning_text.delta', + item_id: 'reasoning-item-a', + content_index: 0, + delta: 'valid summary', + }, + { + type: 'response.reasoning_text.done', + item_id: 'reasoning-item-a', + content_index: 0, + text: 'valid summary', + }, + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: 'reasoning-item-a', + summary: [{ type: 'summary_text', text: 'valid summary' }], + }, + }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: invalidItemId, summary: [] }, + }, + { + type: 'response.reasoning_text.delta', + item_id: invalidItemId, + content_index: 0, + delta: 'unsafe item summary', + }, + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: invalidItemId, + summary: [{ type: 'summary_text', text: 'unsafe item summary' }], + }, + }, + ]; + const rawSse = `${rawEvents + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + const fetch = (async () => + new Response(rawSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as unknown as typeof globalThis.fetch; + const appended: AssistantMessage[] = []; + const connection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'question', context: [] })) { + events.push(event); + } + + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + const parts = appended[0]?.thinking?.parts; + assert.deepEqual( + parts?.map((part) => [ + part.text, + (part.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['valid summary', 'reasoning-item-a'], + ['unsafe item summary', undefined], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'recover', + context: [], + runtimeContext, + }), + ); + + const prompt = compactPrompt(recoveryModel) as ModelMessage[]; + const assistant = prompt.find( + (message) => message.role === 'assistant' && Array.isArray(message.content), + ); + assert.ok(assistant && Array.isArray(assistant.content)); + assert.deepEqual( + assistant.content + .filter((part) => part.type === 'reasoning') + .map((part) => [ + part.text, + (part.providerOptions?.['alibaba-token-plan-cn'] as { itemId?: unknown } | undefined) + ?.itemId, + ]), + [['valid summary', 'reasoning-item-a']], + ); + }); + + test('Alibaba Responses isolates a same-id delta that arrives after item completion', async () => { + const connection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + const rawEvents = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + item: { type: 'reasoning', id: 'reasoning-item-a', summary: [] }, + }, + { + type: 'response.reasoning_text.delta', + item_id: 'reasoning-item-a', + content_index: 0, + delta: 'valid summary', + }, + { + type: 'response.reasoning_text.done', + item_id: 'reasoning-item-a', + content_index: 0, + text: 'valid summary', + }, + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: 'reasoning-item-a', + summary: [{ type: 'summary_text', text: 'valid summary' }], + }, + }, + { + type: 'response.reasoning_text.delta', + item_id: 'reasoning-item-a', + content_index: 0, + delta: 'late duplicate', + }, + { + type: 'response.completed', + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, + }, + }, + ]; + const rawSse = `${rawEvents + .map((event) => `data: ${JSON.stringify(event)}`) + .join('\n\n')}\n\ndata: [DONE]\n\n`; + const fetch = (async () => + new Response(rawSse, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) as unknown as typeof globalThis.fetch; + const appended: AssistantMessage[] = []; + const firstBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + for await (const event of firstBackend.send({ + turnId: 'turn-1', + text: 'question', + context: [], + })) { + events.push(event); + } + + assert.deepEqual( + appended[0]?.thinking?.parts?.map((part) => [ + part.text, + (part.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['valid summary', 'reasoning-item-a'], + ['late duplicate', undefined], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as RuntimeEventMapContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'follow up', + context: [], + runtimeContext, + }), + ); + const prompt = compactPrompt(recoveryModel) as ModelMessage[]; + assert.equal( + prompt.some( + (message) => + message.role === 'assistant' && + Array.isArray(message.content) && + message.content.some( + (part) => part.type === 'reasoning' && part.text === 'valid summary', + ), + ), + true, + ); + }); + + test('Alibaba Responses skips reasoning it cannot safely replay', async () => { + const foreignSummary = 'summary issued by a different provider profile'; + const futureSummary = 'summary issued by a future durable state version'; + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const runtimeContext: RuntimeEvent[] = [ + runtimeEvent({ + id: 'e1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'summary without a provider item identity', + }, + refs: { providerEventId: 'm1' }, + }), + runtimeEvent({ + id: 'e2', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: foreignSummary, + providerOptions: { + makaResponses: { + version: 1, + profile: 'alibaba-token-plan', + itemId: 'foreign-reasoning-item', + summaryPartLengths: [foreignSummary.length], + }, + }, + }, + refs: { providerEventId: 'm2' }, + }), + runtimeEvent({ + id: 'e3', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: futureSummary, + providerOptions: { + makaResponses: { + version: 2, + profile: 'alibaba-token-plan-cn', + itemId: 'future-reasoning-item', + summaryPartLengths: [futureSummary.length], + }, + }, + }, + refs: { providerEventId: 'm3' }, + }), + ]; + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + runtimeContext, + }), + ); + + const prompt = compactPrompt(model) as ModelMessage[]; + assert.equal( + prompt.some( + (message) => + message.role === 'assistant' && + Array.isArray(message.content) && + message.content.some((part) => part.type === 'reasoning'), + ), + false, + ); + }); + + test('Alibaba Responses rejects malformed state owned by its profile', async () => { + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => completionModel(), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const runtimeContext: RuntimeEvent[] = [ + runtimeEvent({ + id: 'e1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'current-profile reasoning with a widened state', + providerOptions: { + makaResponses: { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'reasoning-item', + raw: 'must not persist', + }, + }, + }, + refs: { providerEventId: 'm1' }, + }), + ]; + + await assert.rejects( + drain( + backend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + runtimeContext, + }), + ), + /Malformed durable plaintext Responses reasoning state/, + ); + }); + test('passes DeepSeek max reasoning through as the provider-native effort', async () => { let requestBody: Record | undefined; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts index 0b2dd3de7c..c789c6548a 100644 --- a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts +++ b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts @@ -44,6 +44,21 @@ function newAdapter(): ModelAdapter { }); } +function newAlibabaAdapter(): ModelAdapter { + return new ModelAdapter({ + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'test', + modelId: 'qwen3.8-max', + modelFactory: () => ({}), + newId: () => 'id', + now: () => 0, + }); +} + describe('settleModelStepOutcome', () => { test('explicit stream failure takes precedence over finish metadata', () => { const failure = { @@ -65,9 +80,179 @@ describe('settleModelStepOutcome', () => { if (outcome.kind !== 'retryable-failure') return; assert.equal(outcome.failure, failure); }); + + test('classifies a raw error finish without widening provider retry policy', () => { + const outcome = settleModelStepOutcome({ + aborted: false, + sawFinish: true, + finishReason: 'error', + rawFinishReason: '503', + request: {}, + }); + + assert.equal(outcome.kind, 'terminal-failure'); + if (outcome.kind !== 'terminal-failure') return; + assert.equal(outcome.failure.kind, 'provider_unavailable'); + assert.equal(outcome.failure.code, '503'); + assert.equal(outcome.failure.retryable, false); + }); }); describe('ModelAdapter.startStream onError', () => { + test('preserves an already-emitted failure when reasoning flush has no final metadata', async () => { + const providerError = new APICallError({ + message: 'rate limited', + url: 'https://provider.invalid/v1/responses', + requestBodyValues: {}, + statusCode: 429, + responseHeaders: { 'retry-after-ms': '2500' }, + }); + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: 'partial reasoning' }, + { type: 'error', error: providerError }, + // This test owns the ModelAdapter boundary sequence. Separate pinned + // SDK fixtures below prove which raw SSE terminal events produce the + // metadata-less reasoning trailer. + { type: 'reasoning-end', id: 'reasoning-1' }, + { + type: 'finish', + finishReason: { unified: 'error', raw: 'provider_error' }, + usage: ZERO_USAGE, + }, + ]), + }), + }); + const result = await newAlibabaAdapter().startStream({ + model, + messages: [{ role: 'user', content: 'hi' }], + tools: {}, + activeTools: [], + onStreamActivity: () => {}, + abortSignal: new AbortController().signal, + repairToolCall: async () => null, + }); + + const failures = []; + for await (const event of result.events) { + if (event.kind === 'error') { + failures.push(event.failure); + // AiSdkBackend stops consuming after the first error. ModelAdapter + // must settle outcome before yielding this deferred validation error. + break; + } + } + + assert.deepEqual(failures, [ + { + type: 'model_failure', + kind: 'rate_limit', + code: '429', + message: 'Rate limit exceeded', + retryable: true, + retryAfterMs: 2500, + }, + ]); + const outcome = await requireAlreadySettled(result.outcome); + assert.equal(outcome.kind, 'retryable-failure'); + if (outcome.kind !== 'retryable-failure') return; + assert.deepEqual(outcome.failure, failures[0]); + }); + + test('fails closed when a successful reasoning stream has no final metadata', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: 'partial reasoning' }, + // A completed response without output_item.done reaches Maka as the + // same metadata-less flush trailer as a failed response. + { type: 'reasoning-end', id: 'reasoning-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: ZERO_USAGE, + }, + ]), + }), + }); + const result = await newAlibabaAdapter().startStream({ + model, + messages: [{ role: 'user', content: 'hi' }], + tools: {}, + activeTools: [], + onStreamActivity: () => {}, + abortSignal: new AbortController().signal, + repairToolCall: async () => null, + }); + + const failures = []; + for await (const event of result.events) { + if (event.kind === 'error') { + failures.push(event.failure); + break; + } + } + + assert.deepEqual(failures, [ + { + type: 'model_failure', + kind: 'unknown', + message: 'Plaintext Responses reasoning item is missing final summary metadata', + retryable: false, + }, + ]); + const outcome = await requireAlreadySettled(result.outcome); + assert.equal(outcome.kind, 'terminal-failure'); + if (outcome.kind !== 'terminal-failure') return; + assert.deepEqual(outcome.failure, failures[0]); + }); + + test('classifies a raw provider error finish ahead of an unfinalized reasoning trailer', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: 'partial reasoning' }, + { type: 'reasoning-end', id: 'reasoning-1' }, + { + type: 'finish', + finishReason: { unified: 'error', raw: 'rate_limit_exceeded' }, + usage: ZERO_USAGE, + }, + ]), + }), + }); + const result = await newAlibabaAdapter().startStream({ + model, + messages: [{ role: 'user', content: 'hi' }], + tools: {}, + activeTools: [], + onStreamActivity: () => {}, + abortSignal: new AbortController().signal, + repairToolCall: async () => null, + }); + + for await (const _event of result.events) void _event; + const outcome = await result.outcome; + + assert.equal(outcome.kind, 'terminal-failure'); + if (outcome.kind !== 'terminal-failure') return; + assert.deepEqual(outcome.failure, { + type: 'model_failure', + kind: 'rate_limit', + retryable: false, + message: 'Rate limit exceeded', + code: 'rate_limit_exceeded', + }); + assert.equal(outcome.usage?.rawFinishReason, 'rate_limit_exceeded'); + }); + test('normalizes provider retry eligibility and Retry-After at the adapter boundary', async () => { const model = new MockLanguageModelV4({ doStream: async () => { @@ -332,3 +517,10 @@ async function settle( for await (const _event of result.events) void _event; return await result.outcome; } + +async function requireAlreadySettled(promise: Promise): Promise { + const pending = Symbol('pending'); + const value = await Promise.race([promise, Promise.resolve(pending)]); + assert.notEqual(value, pending, 'model outcome must settle before the consumer stops iterating'); + return value as T; +} diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 99b08f8991..d6a9871e3c 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -170,6 +170,136 @@ describe('ModelAdapter stream and error normalization', () => { }); }); + test('supports summary-item Responses reasoning replay for Alibaba Token Plan', () => { + const adapter = new ModelAdapter({ + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + + assert.deepEqual(adapter.runtimeEventReplaySupport(), { + toolCalls: true, + toolResults: true, + providerExecutedTools: false, + signedThinking: false, + unsignedThinking: false, + responsesReasoning: { + kind: 'plaintext-item', + profile: 'alibaba-token-plan-cn', + providerOptionsKey: 'alibaba-token-plan-cn', + }, + }); + }); + + test('normalizes Alibaba stream item ids into bounded durable state from item start', () => { + const providerType = 'alibaba-token-plan-cn'; + const adapter = new ModelAdapter({ + connection: { slug: providerType, providerType, defaultModel: 'qwen3.8-max' }, + apiKey: 'token', + modelId: 'qwen3.8-max', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + type Chunk = Parameters[0]; + const providerOptions = { + makaResponses: { + version: 1, + profile: providerType, + itemId: 'alibaba-reasoning-item', + summaryPartLengths: [7], + }, + }; + assert.deepEqual( + adapter.translateChunk({ type: 'reasoning-start', id: 'alibaba-reasoning-item' } as Chunk), + [{ kind: 'thinking', text: '', reasoningItemId: 'alibaba-reasoning-item' }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-delta', + id: 'alibaba-reasoning-item', + delta: 'summary', + } as Chunk), + [{ kind: 'thinking', text: 'summary', reasoningItemId: 'alibaba-reasoning-item' }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-end', + id: 'alibaba-reasoning-item', + providerMetadata: { + [providerType]: { + itemId: 'alibaba-reasoning-item', + reasoningSummary: [{ type: 'summary_text', text: 'summary' }], + }, + }, + } as Chunk), + [ + { + kind: 'thinking', + text: '', + providerOptions, + reasoningItemId: 'alibaba-reasoning-item', + reasoningSummaryText: 'summary', + }, + ], + ); + assert.throws( + () => + adapter.translateChunk({ + type: 'reasoning-end', + id: 'unfinished-flush', + } as Chunk), + /missing final summary metadata/, + ); + assert.throws( + () => + adapter.translateChunk({ + type: 'reasoning-end', + id: 'missing-final-summary', + providerMetadata: { + [providerType]: { itemId: 'missing-final-summary' }, + }, + } as Chunk), + /missing final summary metadata/, + ); + }); + + test('keeps DeepSeek plaintext replay on the main content-only behavior', () => { + const adapter = new ModelAdapter({ + connection: { slug: 'deepseek', providerType: 'deepseek', defaultModel: 'deepseek-v4-flash' }, + apiKey: 'token', + modelId: 'deepseek-v4-flash', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + type Chunk = Parameters[0]; + + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-delta', + id: 'deepseek-reasoning-item', + delta: 'plaintext reasoning', + } as Chunk), + [{ kind: 'thinking', text: 'plaintext reasoning' }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-end', + id: 'deepseek-reasoning-item', + providerMetadata: { deepseek: { itemId: 'deepseek-reasoning-item' } }, + } as Chunk), + [], + ); + }); + test('translates provider text, reasoning, tool calls, and errors into ModelStreamEvents', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; diff --git a/packages/runtime/src/__tests__/open-responses-compatibility.test.ts b/packages/runtime/src/__tests__/open-responses-compatibility.test.ts new file mode 100644 index 0000000000..12230ebd9a --- /dev/null +++ b/packages/runtime/src/__tests__/open-responses-compatibility.test.ts @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createOpenResponsesCompatibilityFinalizer } from '../open-responses-compatibility.js'; + +test('applies the declared Open Responses body policies', () => { + const finalize = createOpenResponsesCompatibilityFinalizer('alibaba-token-plan'); + assert.ok(finalize); + const tool = { type: 'function', name: 'lookup' }; + assert.deepEqual(finalize({ model: 'qwen3.8-max', store: true, tool_choice: 'auto' }), { + model: 'qwen3.8-max', + store: false, + tool_choice: 'auto', + }); + assert.deepEqual(finalize({ model: 'qwen3.8-max' }), { + model: 'qwen3.8-max', + store: false, + }); + assert.deepEqual(finalize({ tools: [tool], tool_choice: 'required' }), { + tools: [tool], + tool_choice: 'required', + store: false, + }); + assert.deepEqual( + finalize({ + tools: [tool], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [tool] }, + }), + { + tools: [tool], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [tool] }, + store: false, + }, + ); + assert.throws( + () => finalize({ tools: [], tool_choice: 'required' }), + /requires exactly one tool/, + ); + assert.throws( + () => + finalize({ + tools: [tool], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [] }, + }), + /requires exactly one tool/, + ); + assert.throws( + () => finalize({ tools: [tool], tool_choice: tool }), + /does not support this tool_choice object/, + ); +}); diff --git a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts index f28f382ce6..f1470d7126 100644 --- a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -50,7 +50,12 @@ const ANSWER = 'No — 91 is 7 x 13.'; * translator that dropped the message entirely would be the worst failure this * code can have, and only an assertion on the reply can see it. */ -function deepseekReasoningStream(deltas: string[], answer = ANSWER): string { +function plaintextReasoningStream( + deltas: string[], + answer = ANSWER, + finalSummary: Array<{ type: 'summary_text'; text: string }> = [], + model = 'deepseek-v4-flash', +): string { const events: Array> = [ { type: 'response.created', response: { id: 'r' } }, { @@ -81,7 +86,7 @@ function deepseekReasoningStream(deltas: string[], answer = ANSWER): string { id: ITEM_ID, status: 'completed', content: [{ type: 'reasoning_text', text: deltas.join('') }], - summary: [], + summary: finalSummary, }, }, { @@ -119,7 +124,100 @@ function deepseekReasoningStream(deltas: string[], answer = ANSWER): string { id: 'r', object: 'response', created_at: 0, - model: 'deepseek-v4-flash', + model, + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; +} + +/** Alibaba's published Responses reasoning-summary stream shape. */ +function officialSummaryReasoningStream( + deltas: string[], + answer = ANSWER, + finalSummary: Array<{ type: 'summary_text'; text: string }> = [ + { type: 'summary_text', text: deltas.join('') }, + ], +): string { + const events: Array> = [ + { type: 'response.created', sequence_number: 0, response: { id: 'r' } }, + { + type: 'response.output_item.added', + sequence_number: 1, + output_index: 0, + item: { type: 'reasoning', id: ITEM_ID, status: 'in_progress', content: [], summary: [] }, + }, + ...deltas.map((delta, index) => ({ + type: 'response.reasoning_summary_text.delta', + sequence_number: 2 + index, + item_id: ITEM_ID, + output_index: 0, + summary_index: 0, + delta, + })), + { + type: 'response.reasoning_summary_text.done', + sequence_number: 2 + deltas.length, + item_id: ITEM_ID, + output_index: 0, + summary_index: 0, + text: deltas.join(''), + }, + { + type: 'response.output_item.done', + sequence_number: 3 + deltas.length, + output_index: 0, + item: { + type: 'reasoning', + id: ITEM_ID, + status: 'completed', + content: [], + summary: finalSummary, + }, + }, + { + type: 'response.output_item.added', + sequence_number: 4 + deltas.length, + output_index: 1, + item: { + type: 'message', + id: MESSAGE_ID, + status: 'in_progress', + role: 'assistant', + content: [], + }, + }, + { + type: 'response.output_text.delta', + sequence_number: 5 + deltas.length, + content_index: 0, + item_id: MESSAGE_ID, + output_index: 1, + delta: answer, + }, + { + type: 'response.output_item.done', + sequence_number: 6 + deltas.length, + output_index: 1, + item: { + type: 'message', + id: MESSAGE_ID, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: answer, annotations: [] }], + }, + }, + { + type: 'response.completed', + sequence_number: 7 + deltas.length, + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'qwen3.8-max', status: 'completed', output: [], usage: { input_tokens: 1, output_tokens: 1 }, @@ -177,6 +275,55 @@ function standardFunctionCallStream(): string { return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; } +function unfinalizedReasoningStream(terminal: 'completed' | 'failed'): string { + const response = { + id: 'r', + object: 'response', + created_at: 0, + model: 'qwen3.8-max', + status: terminal, + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + ...(terminal === 'failed' + ? { error: { code: 'rate_limit_exceeded', message: 'rate limited' } } + : {}), + }; + const events = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'reasoning', id: ITEM_ID, status: 'in_progress', content: [], summary: [] }, + }, + { + type: 'response.reasoning_summary_text.delta', + summary_index: 0, + delta: 'unfinished reasoning', + item_id: ITEM_ID, + output_index: 0, + }, + { type: `response.${terminal}`, response }, + ]; + return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; +} + +async function alibabaStreamParts(body: string, chunkSize = Number.MAX_SAFE_INTEGER) { + const connection = conn('alibaba-token-plan-cn'); + const model = getAIModel({ + connection, + apiKey: 'test-key', + modelId: 'qwen3.8-max', + fetch: sseFetch(body, chunkSize), + }); + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: buildProviderOptions(connection, 'qwen3.8-max', 'high'), + }); + const parts = []; + for await (const part of stream) parts.push(part); + return parts; +} + /** * Chunks are cut from the encoded bytes, not from the string: slicing the * string would hand every chunk a whole character and quietly make multi-byte @@ -226,9 +373,69 @@ async function streamParts( } describe('open responses plaintext reasoning', () => { + test('the pinned SDK maps official summary events across raw byte chunks', async () => { + const deltas = ['检查请求。', '调用 Maka 工具。']; + const parts = await alibabaStreamParts(officialSummaryReasoningStream(deltas), 7); + const streamed = parts + .filter((part) => part.type === 'reasoning-delta') + .map((part) => part.delta); + const reasoningEnd = parts.find((part) => part.type === 'reasoning-end'); + assert.ok(reasoningEnd && reasoningEnd.type === 'reasoning-end'); + const provider = reasoningEnd.providerMetadata?.['alibaba-token-plan-cn'] as + | { reasoningSummary?: Array<{ type: string; text: string }> } + | undefined; + + assert.deepEqual(streamed, deltas); + assert.deepEqual(provider?.reasoningSummary, [ + { type: 'summary_text', text: '检查请求。调用 Maka 工具。' }, + ]); + }); + + test('Alibaba-compatible content deltas still match the final summary metadata', async () => { + const deltas = ['Inspect the request. ', 'Call the Maka tool.']; + const summary = [{ type: 'summary_text' as const, text: deltas.join('') }]; + const parts = await alibabaStreamParts( + plaintextReasoningStream(deltas, ANSWER, summary, 'qwen3.8-max'), + ); + const streamed = parts + .filter((part) => part.type === 'reasoning-delta') + .map((part) => part.delta) + .join(''); + const reasoningEnd = parts.find((part) => part.type === 'reasoning-end'); + assert.ok(reasoningEnd && reasoningEnd.type === 'reasoning-end'); + const provider = reasoningEnd.providerMetadata?.['alibaba-token-plan-cn'] as + | { reasoningSummary?: Array<{ type: string; text: string }> } + | undefined; + assert.deepEqual(provider?.reasoningSummary, [ + { type: 'summary_text', text: 'Inspect the request. Call the Maka tool.' }, + ]); + assert.equal(streamed, provider?.reasoningSummary?.map((part) => part.text).join('')); + }); + + test('the pinned SDK flushes an unfinalized item without provider metadata', async () => { + const parts = await alibabaStreamParts(unfinalizedReasoningStream('completed')); + const reasoningEnd = parts.find((part) => part.type === 'reasoning-end'); + const finish = parts.find((part) => part.type === 'finish'); + + assert.ok(reasoningEnd); + assert.equal(reasoningEnd.providerMetadata, undefined); + assert.equal(finish?.finishReason.unified, 'stop'); + }); + + test('the pinned SDK keeps response.failed ahead of its unfinalized trailer', async () => { + const parts = await alibabaStreamParts(unfinalizedReasoningStream('failed')); + const reasoningEnd = parts.find((part) => part.type === 'reasoning-end'); + const finish = parts.find((part) => part.type === 'finish'); + + assert.ok(reasoningEnd); + assert.equal(reasoningEnd.providerMetadata, undefined); + assert.equal(finish?.finishReason.unified, 'error'); + assert.equal(finish?.finishReason.raw, 'rate_limit_exceeded'); + }); + test('streamed reasoning text reaches the model stream', async () => { const deltas = ['The user asks if 91 is prime. ', '91 = 7 x 13, ', 'so it is composite.']; - const parts = await streamParts('deepseek', sseFetch(deepseekReasoningStream(deltas))); + const parts = await streamParts('deepseek', sseFetch(plaintextReasoningStream(deltas))); assert.equal(parts.reasoning, deltas.join('')); assert.equal(parts.text, ANSWER); }); @@ -239,7 +446,7 @@ describe('open responses plaintext reasoning', () => { // dropping message frames wholesale would otherwise leave the suite green. const parts = await streamParts( 'deepseek', - sseFetch(deepseekReasoningStream(['thinking'], 'The answer is 42.')), + sseFetch(plaintextReasoningStream(['thinking'], 'The answer is 42.')), ); assert.equal(parts.text, 'The answer is 42.'); }); @@ -251,7 +458,7 @@ describe('open responses plaintext reasoning', () => { // was asked in, and a 7-byte chunk cuts these characters mid-sequence, so // this also pins the decoder's cross-chunk state. const deltas = ['用户问 91 是不是质数。', '91 = 7 × 13,', '所以它是合数。']; - const parts = await streamParts('deepseek', sseFetch(deepseekReasoningStream(deltas), 7)); + const parts = await streamParts('deepseek', sseFetch(plaintextReasoningStream(deltas), 7)); assert.equal(parts.reasoning, deltas.join('')); assert.equal(parts.text, ANSWER); }); @@ -260,7 +467,7 @@ describe('open responses plaintext reasoning', () => { // The transport is mounted per provider, not per wire. xAI reaches the same // Responses wire but its reasoning shape has not been measured, so nothing // should rewrite its stream on the strength of the wire alone. - const parts = await streamParts('xai', sseFetch(deepseekReasoningStream(['ignored']))); + const parts = await streamParts('xai', sseFetch(plaintextReasoningStream(['ignored']))); assert.equal(parts.reasoning, ''); assert.equal(parts.text, ANSWER); }); diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.test.ts b/packages/runtime/src/__tests__/provider-contract-matrix.test.ts index 4f3b95ffe5..0da26ce942 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.test.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.test.ts @@ -23,6 +23,7 @@ import { after, describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; import { PROVIDER_CONTRACT_MATRIX_PLAN, + listProviderContractCells, type ProviderContractDiscoveryPlan, type ProviderContractRow, type ProviderContractGeneratedCell, @@ -49,6 +50,16 @@ const plan = PROVIDER_CONTRACT_MATRIX_PLAN; after(closeAllJsonServers); +test('provider override cells and executable bindings are a bijection', () => { + const plannedKeys = listProviderContractCells(plan) + .flatMap(({ cell }) => (cell.state === 'override' ? [cell.overrideKey] : [])) + .sort(); + const bindingKeys = PROVIDER_CONTRACT_OVERRIDE_BINDINGS.flatMap(({ keys }) => keys).sort(); + assert.deepEqual(duplicateValues(plannedKeys), [], 'override cells must be unique'); + assert.deepEqual(duplicateValues(bindingKeys), [], 'override bindings must be unique'); + assert.deepEqual(bindingKeys, plannedKeys); +}); + describe('provider conformance matrix — override cells execute their bound contract', () => { for (const binding of PROVIDER_CONTRACT_OVERRIDE_BINDINGS) { test(`${binding.keys.join(' + ')} · ${binding.title}`, async () => { @@ -57,6 +68,10 @@ describe('provider conformance matrix — override cells execute their bound con } }); +function duplicateValues(values: readonly string[]): string[] { + return values.filter((value, index) => values.indexOf(value) !== index); +} + describe('provider conformance matrix — discovery', () => { for (const row of plan.rows) { const cell = row.cells.discovery; diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.ts b/packages/runtime/src/__tests__/provider-contract-matrix.ts index 274c7851fb..449cdb8f39 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.ts @@ -51,6 +51,7 @@ import { type ProviderRuntimeAdapter, type ProviderType, } from '@maka/core/provider-registry'; +import { resolveRuntimeProviderAdapter } from '../provider-runtime-policy.js'; export const PROVIDER_CONTRACT_DIMENSIONS = [ 'discovery', @@ -214,7 +215,7 @@ function usesOpenAiResponsesWire( def: ProviderDefaults, modelId: string, ): boolean { - const adapter = def.runtimeAdapter; + const adapter = resolveRuntimeProviderAdapter(def.runtimeAdapter); const supportsResponses = adapter.kind === 'openai' || (adapter.kind === 'openai-compatible' && adapter.responses !== undefined); @@ -389,7 +390,7 @@ function reasoningReplayCell( providerType: ProviderType, def: ProviderDefaults, ): ProviderContractCell { - const adapter = def.runtimeAdapter; + const adapter = resolveRuntimeProviderAdapter(def.runtimeAdapter); if (adapter.kind === 'unavailable') { return { state: 'not-applicable', @@ -434,17 +435,12 @@ function reasoningReplayCell( }, }; } - if ( - providerType === 'volcengine-agent-plan' && - adapter.kind === 'openai' && - adapter.apiProtocol === 'openai-responses' - ) { + if (adapter.kind === 'openai' && adapter.apiProtocol === 'openai-responses') { return { state: 'override', dimension: 'reasoning-replay', overrideKey: overrideKeyFor(providerType, 'reasoning-replay'), - contract: - 'Stateless OpenAI Responses reasoning items retain their encrypted content across Maka-owned durable replay', + contract: 'Native OpenAI Responses reasoning items retain their provider continuation state', }; } // Native Anthropic / OpenAI / Google / Cohere SDKs own signed reasoning replay diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index 6a25c8b36a..ca7c049a2f 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -97,6 +97,42 @@ export const PROVIDER_CONTRACT_OVERRIDE_BINDINGS: readonly ProviderContractOverr title: 'ZenMux replays signed reasoning details in the streamed runtime tool loop', run: runZenMuxSignedReasoningReplay, }, + { + keys: ['deepseek:reasoning-replay'], + title: 'DeepSeek replays plaintext reasoning content on its Responses wire', + run: () => + runOpenAIResponsesWire({ + providerType: 'deepseek', + slug: 'deepseek', + name: 'DeepSeek', + basePath: '/v1', + modelId: 'deepseek-v4-flash', + apiKey: 'deepseek-test-key', + plaintextReasoning: true, + }), + }, + { + keys: ['xai:reasoning-replay', 'xai-oauth:reasoning-replay'], + title: 'xAI API-key and OAuth paths retain encrypted Responses reasoning', + run: async () => { + for (const input of [ + { providerType: 'xai' as const, slug: 'xai', apiKey: 'xai-test-key' }, + { + providerType: 'xai-oauth' as const, + slug: 'xai-oauth', + apiKey: 'xai-oauth-test-token', + }, + ]) { + await runOpenAIResponsesWire({ + ...input, + name: input.slug, + basePath: `/${input.slug}/v1`, + modelId: 'grok-4.5', + statelessReasoning: true, + }); + } + }, + }, { keys: [ 'openai-responses-compatible:exact-model-id', @@ -133,8 +169,138 @@ export const PROVIDER_CONTRACT_OVERRIDE_BINDINGS: readonly ProviderContractOverr statelessReasoning: true, }), }, + { + keys: ['alibaba-token-plan-cn:reasoning-replay', 'alibaba-token-plan:reasoning-replay'], + title: 'Alibaba Token Plan replays plaintext summary items on its Responses wire', + run: runAlibabaTokenPlanResponsesWire, + }, ]; +async function runAlibabaTokenPlanResponsesWire(): Promise { + for (const input of [ + { + providerType: 'alibaba-token-plan-cn' as const, + slug: 'alibaba-token-plan-cn', + basePath: '/cn/compatible-mode/v1', + apiKey: 'alibaba-token-plan-cn-key', + }, + { + providerType: 'alibaba-token-plan' as const, + slug: 'alibaba-token-plan', + basePath: '/global/compatible-mode/v1', + apiKey: 'alibaba-token-plan-global-key', + }, + ]) { + const modelId = 'qwen3.8-max'; + const requestBodies: Array> = []; + const server = await startJsonServer(async (request, response) => { + assert.equal(request.method, 'POST'); + assert.equal(request.url, `${input.basePath}/responses`); + assert.equal(request.headers.authorization, `Bearer ${input.apiKey}`); + requestBodies.push(JSON.parse(await readBody(request)) as Record); + if (requestBodies.length === 1) { + respondJson(response, 200, { + id: 'resp_alibaba_tool', + object: 'response', + created_at: 1, + status: 'completed', + model: modelId, + output: [ + { + type: 'reasoning', + id: 'rs_alibaba_tool', + summary: [{ type: 'summary_text', text: 'Use echo.' }], + }, + { + type: 'function_call', + id: 'fc_alibaba_echo', + call_id: 'call_alibaba_echo', + name: 'echo', + arguments: '{"text":"hello"}', + status: 'completed', + }, + ], + usage: { input_tokens: 8, output_tokens: 4, total_tokens: 12 }, + }); + return; + } + respondJson(response, 200, { + id: 'resp_alibaba_final', + object: 'response', + created_at: 2, + status: 'completed', + model: modelId, + output: [ + { + type: 'message', + id: 'msg_alibaba_final', + status: 'completed', + role: 'assistant', + content: [ + { type: 'output_text', text: 'Echoed hello.', annotations: [], logprobs: [] }, + ], + }, + ], + usage: { input_tokens: 14, output_tokens: 3, total_tokens: 17 }, + }); + }); + const connection: LlmConnection = { + slug: input.slug, + name: input.slug, + providerType: input.providerType, + baseUrl: `${server.url}${input.basePath}`, + defaultModel: modelId, + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + const result = await generateText({ + model: getAIModel({ connection, apiKey: input.apiKey, modelId }), + prompt: 'Call echo with hello.', + providerOptions: buildProviderOptions(connection, modelId), + stopWhen: isStepCount(2), + tools: { + echo: tool({ + description: 'Echo text', + inputSchema: z.object({ text: z.string() }), + execute: async ({ text }) => ({ echoed: text }), + }), + }, + }); + + assert.deepEqual( + requestBodies.map((body) => body.model), + [modelId, modelId], + ); + assert.deepEqual( + requestBodies.map((body) => body.store), + [false, false], + ); + assert.deepEqual( + (requestBodies[1].input as Array>).find( + ({ type }) => type === 'reasoning', + ), + { + type: 'reasoning', + id: 'rs_alibaba_tool', + summary: [{ type: 'summary_text', text: 'Use echo.' }], + }, + ); + assert.deepEqual( + (requestBodies[1].input as Array>).find( + ({ type }) => type === 'function_call_output', + ), + { + type: 'function_call_output', + call_id: 'call_alibaba_echo', + output: '{"echoed":"hello"}', + }, + ); + assert.equal(result.text, 'Echoed hello.'); + } +} + async function runCloudflareDiscovery(): Promise { const server = await startJsonServer((request, response) => { assert.equal(request.method, 'GET'); @@ -883,8 +1049,19 @@ async function runOpenAIResponsesWire(input: { modelId: string; apiKey: string; statelessReasoning?: boolean; + plaintextReasoning?: boolean; }): Promise { - const { providerType, slug, name, basePath, modelId, apiKey, statelessReasoning } = input; + const { + providerType, + slug, + name, + basePath, + modelId, + apiKey, + statelessReasoning, + plaintextReasoning, + } = input; + const hasReasoning = statelessReasoning || plaintextReasoning; const requestBodies: Array> = []; const server = await startJsonServer(async (request, response) => { assert.equal(request.method, 'POST'); @@ -908,7 +1085,16 @@ async function runOpenAIResponsesWire(input: { encrypted_content: 'encrypted-relay-reasoning', }, ] - : []), + : plaintextReasoning + ? [ + { + type: 'reasoning', + id: 'rs_relay_tool', + summary: [], + content: [{ type: 'reasoning_text', text: 'Use echo.' }], + }, + ] + : []), { type: 'function_call', id: 'fc_relay_echo', @@ -954,7 +1140,7 @@ async function runOpenAIResponsesWire(input: { const result = await generateText({ model: getAIModel({ connection, apiKey, modelId }), prompt: 'Call echo with hello.', - ...(statelessReasoning ? { providerOptions: buildProviderOptions(connection, modelId) } : {}), + ...(hasReasoning ? { providerOptions: buildProviderOptions(connection, modelId) } : {}), stopWhen: isStepCount(2), tools: { echo: tool({ @@ -984,6 +1170,19 @@ async function runOpenAIResponsesWire(input: { }, ); } + if (plaintextReasoning) { + assert.deepEqual( + (requestBodies[1].input as Array>).find( + ({ type }) => type === 'reasoning', + ), + { + type: 'reasoning', + id: 'rs_relay_tool', + summary: [], + content: [{ type: 'reasoning_text', text: 'Use echo.' }], + }, + ); + } assert.deepEqual( (requestBodies[1].input as Array>).find( ({ type }) => type === 'function_call_output', diff --git a/packages/runtime/src/__tests__/request-customization-fetch.test.ts b/packages/runtime/src/__tests__/request-customization-fetch.test.ts index cca34784aa..5dda1aa769 100644 --- a/packages/runtime/src/__tests__/request-customization-fetch.test.ts +++ b/packages/runtime/src/__tests__/request-customization-fetch.test.ts @@ -119,4 +119,31 @@ describe('createRequestCustomizationFetch', () => { /Extra request body conflicts/, ); }); + + test('applies a provider finalizer after caller body overlays', async () => { + let captured: Request | undefined; + const fetch = createRequestCustomizationFetch( + async (input, init) => { + captured = new Request(input, init); + return Response.json({ ok: true }); + }, + { + bodyOverlay: { store: true }, + finalizeBody: (body) => ({ ...body, store: false }), + }, + ); + + await fetch('https://provider.invalid/responses', { + method: 'POST', + headers: { 'content-length': '999', 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ model: 'qwen3.8-max' })).buffer, + }); + + assert.deepEqual(await captured?.json(), { model: 'qwen3.8-max', store: false }); + assert.equal(captured?.headers.get('content-length'), null); + await assert.rejects( + fetch('https://provider.invalid/responses', { method: 'POST', body: 'not-json' }), + /finalizer requires a JSON object request body/, + ); + }); }); diff --git a/packages/runtime/src/__tests__/responses-reasoning-state.test.ts b/packages/runtime/src/__tests__/responses-reasoning-state.test.ts new file mode 100644 index 0000000000..96753ad4aa --- /dev/null +++ b/packages/runtime/src/__tests__/responses-reasoning-state.test.ts @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + decodePlaintextResponsesReasoningState, + plaintextResponsesReasoningProviderOptions, + replayPlaintextResponsesProviderOptions, + responsesReasoningItemId, +} from '../responses-reasoning-state.js'; + +test('round-trips one bounded versioned plaintext Responses item identity', () => { + const options = plaintextResponsesReasoningProviderOptions( + 'reasoning-item-1', + 'alibaba-token-plan-cn', + ['reasoning summary'], + ); + assert.deepEqual(options, { + makaResponses: { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'reasoning-item-1', + summaryPartLengths: [17], + }, + }); + assert.deepEqual(decodePlaintextResponsesReasoningState(options), { + kind: 'valid', + state: { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'reasoning-item-1', + summaryPartLengths: [17], + }, + }); + assert.equal(responsesReasoningItemId(options), 'reasoning-item-1'); +}); + +test('rejects malformed, widened, and unsafe plaintext Responses state', () => { + for (const makaResponses of [ + { version: '2', profile: 'alibaba-token-plan-cn', itemId: 'item' }, + { version: 1, profile: '', itemId: 'item' }, + { version: 1, profile: 'bad\nprofile', itemId: 'item' }, + { version: 1, profile: 'alibaba-token-plan-cn', itemId: '' }, + { version: 1, profile: 'alibaba-token-plan-cn', itemId: 'bad\nitem' }, + { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'item', + summaryPartLengths: [4], + raw: 'provider-body', + }, + ]) { + assert.equal(decodePlaintextResponsesReasoningState({ makaResponses }).kind, 'malformed'); + } + assert.deepEqual(decodePlaintextResponsesReasoningState(undefined), { kind: 'missing' }); + assert.deepEqual( + decodePlaintextResponsesReasoningState({ + makaResponses: { + version: 2, + profile: 'another-provider', + itemId: 'item', + }, + }), + { kind: 'unsupported-version', version: 2 }, + ); +}); + +test('degrades a well-formed state version that this Runtime cannot replay', () => { + assert.deepEqual( + decodePlaintextResponsesReasoningState({ + makaResponses: { + version: 2, + profile: 'alibaba-token-plan-cn', + itemId: 'item', + summaryPartLengths: [4], + }, + }), + { kind: 'unsupported-version', version: 2 }, + ); +}); + +test('reconstructs provider-native summary parts', () => { + const summary = { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'summary-item', + summaryPartLengths: [10, 7], + } as const; + assert.deepEqual( + replayPlaintextResponsesProviderOptions({ + providerOptionsKey: 'alibaba-token-plan-cn', + state: summary, + text: 'reasoning summary', + }), + { + 'alibaba-token-plan-cn': { + itemId: 'summary-item', + reasoningSummary: [ + { type: 'summary_text', text: 'reasoning ' }, + { type: 'summary_text', text: 'summary' }, + ], + reasoningContent: null, + }, + }, + ); +}); + +test('rejects summary boundaries that disagree with canonical text', () => { + const state = { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'summary-item', + summaryPartLengths: [8], + } as const; + assert.throws( + () => + replayPlaintextResponsesProviderOptions({ + providerOptionsKey: 'alibaba-token-plan-cn', + state: { ...state, summaryPartLengths: [3] }, + text: 'expected', + }), + /summary boundaries do not match text/, + ); +}); + +test('keeps encrypted OpenAI item identity readable for shared step grouping', () => { + assert.equal( + responsesReasoningItemId({ + openai: { itemId: 'openai-item', reasoningEncryptedContent: 'encrypted' }, + }), + 'openai-item', + ); +}); diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 99b1bc93cf..be0b477dc0 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -26,6 +26,10 @@ import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { thinkingVariantsForModel } from '@maka/core/model-thinking'; import { buildProviderOptions, getAIModel } from '../model-factory.js'; import { resolveModelRuntime } from '../model-runtime.js'; +import { + RUNTIME_PROVIDER_PROFILE_IDS, + resolveRuntimeProviderAdapter, +} from '../provider-runtime-policy.js'; import { lowerModelTools } from '../model-adapter.js'; import { openAiCodexCompactionMessages } from '../openai-codex-history-compactor.js'; import { openAiResponsesBaseUrl, openResponsesUrl } from '../provider-urls.js'; @@ -51,13 +55,21 @@ function openAiNamespace(options: Record): Record { - test('keeps Qwen3.8 Max on Token Plan Chat until the provider adapter supports Responses', () => { + test('routes only Qwen3.8 Max through Token Plan Responses', () => { for (const providerType of ['alibaba-token-plan-cn', 'alibaba-token-plan'] as const) { assert.equal( resolveModelRuntime({ providerType }, 'qwen3.8-max').wire, - 'openai-chat', + 'openai-responses', providerType, ); + assert.equal(resolveModelRuntime({ providerType }, 'qwen3.7-max').wire, 'openai-chat'); + assert.equal( + resolveModelRuntime( + { providerType, models: [{ id: 'qwen3.8-max', apiProtocol: 'openai-chat' }] }, + 'qwen3.8-max', + ).wire, + 'openai-chat', + ); } }); @@ -147,6 +159,26 @@ describe('responses wire contract', () => { kind: 'responses', contract: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, }); + assert.equal(deepseek.responsesProviderOptionsKey, undefined); + assert.equal(deepseek.responsesReplayProfile, undefined); + + const alibaba = resolveModelRuntime({ providerType: 'alibaba-token-plan-cn' }, 'qwen3.8-max'); + assert.deepEqual(alibaba.reasoningReplay, { + kind: 'responses', + contract: { + adapter: 'open-responses', + reasoningReplay: 'plaintext-summary', + compatibility: 'alibaba-token-plan', + }, + }); + assert.equal(alibaba.responsesProviderOptionsKey, 'alibaba-token-plan-cn'); + assert.equal(alibaba.responsesReplayProfile, 'alibaba-token-plan-cn'); + const accountScopedAlibaba = resolveModelRuntime( + { providerType: 'alibaba-token-plan-cn', slug: 'token-plan-account-a' }, + 'qwen3.8-max', + ); + assert.equal(accountScopedAlibaba.responsesProviderOptionsKey, 'alibaba-token-plan-cn'); + assert.equal(accountScopedAlibaba.responsesReplayProfile, 'token-plan-account-a'); const xai = resolveModelRuntime({ providerType: 'xai' }, 'grok-4.5'); assert.deepEqual(xai.reasoningReplay, { @@ -195,9 +227,19 @@ describe('responses wire contract', () => { ); }); + test('Core Runtime-profile references exactly match Runtime implementations', () => { + const referenced = Object.values(PROVIDER_REGISTRY).flatMap((definition) => { + const adapter = definition.runtimeAdapter; + return adapter.kind === 'openai-compatible' && adapter.runtimeProfile + ? [adapter.runtimeProfile] + : []; + }); + assert.deepEqual([...new Set(referenced)].sort(), [...RUNTIME_PROVIDER_PROFILE_IDS].sort()); + }); + test('enables Responses only through an explicit supported contract', () => { const configured = Object.entries(PROVIDER_REGISTRY).flatMap(([providerType, definition]) => { - const adapter = definition.runtimeAdapter; + const adapter = resolveRuntimeProviderAdapter(definition.runtimeAdapter); return adapter.kind === 'openai-compatible' && adapter.responses ? [{ providerType, contract: adapter.responses }] : []; @@ -216,6 +258,22 @@ describe('responses wire contract', () => { providerType: 'xai-oauth', contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, + { + providerType: 'alibaba-token-plan-cn', + contract: { + adapter: 'open-responses', + reasoningReplay: 'plaintext-summary', + compatibility: 'alibaba-token-plan', + }, + }, + { + providerType: 'alibaba-token-plan', + contract: { + adapter: 'open-responses', + reasoningReplay: 'plaintext-summary', + compatibility: 'alibaba-token-plan', + }, + }, ]); const relay = PROVIDER_REGISTRY['openai-responses-compatible'].runtimeAdapter; @@ -246,7 +304,7 @@ describe('responses wire contract', () => { if ( runtime.wire !== 'openai-responses' || (runtime.reasoningReplay.kind === 'responses' && - runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-content') + runtime.reasoningReplay.contract.adapter === 'open-responses') ) { continue; } @@ -270,6 +328,209 @@ describe('responses wire contract', () => { }); describe('responses wire request body', () => { + test('Alibaba compatibility owns store:false after request overlays', async () => { + const bodies: Array> = []; + const urls: string[] = []; + const fetch = (async (url: string | URL | Request, init?: RequestInit) => { + urls.push(String(url)); + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ + id: 'response-1', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + requestBodyOverlay: { store: true }, + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], + providerOptions: buildProviderOptions(connection, 'qwen3.8-max', 'medium'), + }); + + assert.deepEqual(urls, ['https://token-plan.example/compatible-mode/v1/responses']); + assert.equal(bodies[0]?.store, false); + assert.deepEqual(bodies[0]?.reasoning, { effort: 'medium' }); + }); + + test('Alibaba compatibility preserves documented required tool choices', async () => { + const bodies: Array> = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ + id: 'response-required-tool', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'look it up' }] }], + tools: [ + { + type: 'function', + name: 'lookup', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + ], + toolChoice: { type: 'required' }, + }); + + const allowedToolsChoice = { + type: 'allowed_tools', + mode: 'required', + tools: [{ type: 'function', name: 'lookup' }], + }; + const overlayModel = getAIModel({ + connection: { ...connection, requestBodyOverlay: { tool_choice: allowedToolsChoice } }, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + await overlayModel.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'look it up again' }] }], + tools: [ + { + type: 'function', + name: 'lookup', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + ], + }); + + assert.equal(bodies[0]?.tool_choice, 'required'); + assert.equal((bodies[0]?.tools as unknown[] | undefined)?.length, 1); + assert.equal(bodies[0]?.store, false); + assert.deepEqual(bodies[1]?.tool_choice, allowedToolsChoice); + assert.equal((bodies[1]?.tools as unknown[] | undefined)?.length, 1); + assert.equal(bodies[1]?.store, false); + }); + + test('Alibaba compatibility survives header-only request customization', async () => { + let body: Record | undefined; + let headers: Headers | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + headers = new Headers(init?.headers); + return Response.json({ + id: 'response-header-customization', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + requestHeaders: { 'x-token-plan-routing': 'custom' }, + fetch, + }); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], + }); + + assert.equal(body?.store, false); + assert.equal(headers?.get('x-token-plan-routing'), 'custom'); + }); + + test('Alibaba non-stored request carries the reconstructed summary item', async () => { + let body: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return Response.json({ + id: 'response-2', + object: 'response', + created_at: 2, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + + await model.doGenerate({ + prompt: [ + { role: 'user', content: [{ type: 'text', text: 'first question' }] }, + { + role: 'assistant', + content: [ + { + type: 'reasoning', + text: 'durable reasoning summary', + providerOptions: { + 'alibaba-token-plan-cn': { + itemId: 'reasoning-item-1', + reasoningSummary: [{ type: 'summary_text', text: 'durable reasoning summary' }], + reasoningContent: null, + }, + }, + }, + { type: 'text', text: 'first answer' }, + ], + }, + { role: 'user', content: [{ type: 'text', text: 'follow up' }] }, + ], + }); + + assert.equal(body?.store, false); + assert.deepEqual( + (body?.input as Array> | undefined)?.find( + (item) => item.type === 'reasoning', + ), + { + type: 'reasoning', + id: 'reasoning-item-1', + summary: [{ type: 'summary_text', text: 'durable reasoning summary' }], + }, + ); + }); + test('adds the V2 trigger only through the explicit OpenAI provider option', async () => { const bodies: Array> = []; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 6f1a31eb36..de6d26cad6 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -172,6 +172,11 @@ import { type RequestProjectionContext, type RequestProjectionStage, } from './request-projection.js'; +import { + decodePlaintextResponsesReasoningState, + replayPlaintextResponsesProviderOptions, + responsesReasoningItemId, +} from './responses-reasoning-state.js'; import type { ActiveToolResultPruneDiagnosticPatch } from './active-tool-result-prune.js'; import { toolResultOutput } from './tool-result-output.js'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; @@ -1483,6 +1488,7 @@ export class AiSdkBackend implements AgentBackend { let sawStepThinking = false; let stepThinkingProviderOptions: NonNullable | undefined; let stepResponsesThinkingParts: AssistantThinkingPart[] = []; + let stepResponsesThinkingPartsByItemId = new Map(); let stepSignature: string | undefined; const startedAt = this.now(); @@ -1578,6 +1584,7 @@ export class AiSdkBackend implements AgentBackend { sawStepThinking = false; stepThinkingProviderOptions = undefined; stepResponsesThinkingParts = []; + stepResponsesThinkingPartsByItemId = new Map(); stepSignature = undefined; }; let tokenUsage: NormalizedAiSdkUsage | undefined; @@ -2387,32 +2394,58 @@ export class AiSdkBackend implements AgentBackend { } stepThinkingProviderOptions = event.providerOptions; } - const openai = event.providerOptions?.openai; const itemId = - openai && typeof openai === 'object' && !Array.isArray(openai) - ? (openai as { itemId?: unknown }).itemId - : undefined; + event.reasoningItemId ?? responsesReasoningItemId(event.providerOptions); if (typeof itemId === 'string' && itemId.length > 0) { - let part = stepResponsesThinkingParts.find( - (candidate) => - (candidate.providerOptions?.openai as { itemId?: unknown } | undefined) - ?.itemId === itemId, - ); + let part = stepResponsesThinkingPartsByItemId.get(itemId); + if ( + part && + event.providerOptions === undefined && + decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' + ) { + // The SDK does not suppress a stray delta after + // output_item.done. Keep it out of the finalized item or + // its durable summary boundaries will no longer match. + part = { text: '' }; + stepResponsesThinkingParts.push(part); + stepResponsesThinkingPartsByItemId.set(itemId, part); + } if (!part) { part = { text: stepResponsesThinkingParts.length === 0 && event.text.length === 0 ? stepThinking : '', - providerOptions: event.providerOptions, }; stepResponsesThinkingParts.push(part); - } else { + stepResponsesThinkingPartsByItemId.set(itemId, part); + } + const nextPartText = part.text + event.text; + if ( + event.reasoningSummaryText !== undefined && + event.reasoningSummaryText !== nextPartText + ) { + throw new Error( + 'Streamed plaintext Responses reasoning does not match final provider summary', + ); + } + part.text = nextPartText; + if (event.providerOptions !== undefined) { part.providerOptions = event.providerOptions; } - part.text += event.text; } else if (stepResponsesThinkingParts.length > 0) { - stepResponsesThinkingParts.at(-1)!.text += event.text; + const lastPart = stepResponsesThinkingParts.at(-1)!; + const lastState = decodePlaintextResponsesReasoningState( + lastPart.providerOptions, + ); + if (lastState.kind === 'valid') { + // An invalid next item has no usable stream id. Do not + // append its deltas to the finalized item: partial-error + // flush must keep that item's durable boundaries valid. + stepResponsesThinkingParts.push({ text: event.text }); + } else { + lastPart.text += event.text; + } } queue.push({ type: 'thinking_delta', @@ -3862,15 +3895,42 @@ export class AiSdkBackend implements AgentBackend { } : undefined; } - if (replaySupport.responsesReasoning === 'plaintext-content') { - if (item.text.length === 0) return undefined; + if ( + typeof replaySupport.responsesReasoning === 'object' && + replaySupport.responsesReasoning.kind === 'plaintext-item' + ) { + const decoded = decodePlaintextResponsesReasoningState(item.providerOptions); + if (decoded.kind === 'missing') return undefined; + if (decoded.kind === 'unsupported-version') return undefined; + if (decoded.kind === 'malformed') { + if ( + decoded.profile !== undefined && + decoded.profile !== replaySupport.responsesReasoning.profile + ) { + return undefined; + } + throw new Error('Malformed durable plaintext Responses reasoning state'); + } + const state = decoded.state; + if (state.profile !== replaySupport.responsesReasoning.profile) { + return undefined; + } return { part: { type: 'reasoning' as const, text: item.text, + providerOptions: replayPlaintextResponsesProviderOptions({ + providerOptionsKey: replaySupport.responsesReasoning.providerOptionsKey, + state, + text: item.text, + }), }, }; } + if (replaySupport.responsesReasoning === 'plaintext-content') { + if (item.text.length === 0) return undefined; + return { part: { type: 'reasoning' as const, text: item.text } }; + } if (replaySupport.responsesReasoning === 'encrypted-content') { const openai = item.providerOptions?.openai; if (openai && typeof openai === 'object' && !Array.isArray(openai)) { diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 9c99c2527a..41bab7f75f 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -56,6 +56,10 @@ export type { } from './model-protocol.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; +import { + plaintextResponsesReasoningProviderOptions, + safePlaintextResponsesReasoningItemId, +} from './responses-reasoning-state.js'; import { classifyError, errorPresentationFromClass, @@ -186,9 +190,17 @@ export class ModelAdapter { // recorded to the event log and rendered regardless. unsignedThinking: this.runtime.reasoningReplay.kind === 'openai-chat-plaintext', responsesReasoning: - this.runtime.reasoningReplay.kind === 'responses' - ? this.runtime.reasoningReplay.contract.reasoningReplay - : 'none', + this.runtime.reasoningReplay.kind !== 'responses' + ? 'none' + : this.runtime.reasoningReplay.contract.reasoningReplay === 'encrypted-content' + ? 'encrypted-content' + : this.runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-content' + ? 'plaintext-content' + : { + kind: 'plaintext-item', + profile: requireResponsesReplayProfile(this.runtime), + providerOptionsKey: requireResponsesProviderOptionsKey(this.runtime), + }, }; } @@ -327,6 +339,7 @@ export class ModelAdapter { ? this.openAiChatReasoningTransportState : undefined; const openAiResponsesTransportState = this.openAiResponsesTransportState; + const resolvedRuntime = this.runtime; let settleOutcome!: (outcome: ModelStepOutcome) => void; const outcome = new Promise((resolve) => { settleOutcome = resolve; @@ -337,10 +350,33 @@ export class ModelAdapter { let failure: ModelFailure | undefined; let sawFinish = false; let streamedFinishReason: string | undefined; + let streamedRawFinishReason: string | undefined; + let sawUnfinalizedPlaintextSummary = false; try { for await (const chunk of sdk.stream as AsyncIterable) { onStreamActivity(); - for (const event of translateChunk(chunk, openAiChatReasoningTransportState)) { + if ( + chunk.type === 'finish' || + chunk.type === 'finish-step' || + chunk.type === 'step-finish' + ) { + streamedRawFinishReason = + rawFinishReasonString(chunk.rawFinishReason) ?? streamedRawFinishReason; + } + if (isUnfinalizedPlaintextSummaryReasoningEnd(chunk, resolvedRuntime)) { + // The SDK emits this trailer from flush() when no + // response.output_item.done finalized the active item. Defer the + // decision until the terminal outcome is known: an existing + // provider failure must win, while a successful stream must + // still fail closed instead of losing replay state silently. + sawUnfinalizedPlaintextSummary = true; + continue; + } + for (const event of translateChunk( + chunk, + openAiChatReasoningTransportState, + resolvedRuntime, + )) { if (event.kind === 'error') failure = event.failure; if (event.kind === 'finish') sawFinish = true; if (event.kind === 'finish' || event.kind === 'step-finish') { @@ -350,24 +386,56 @@ export class ModelAdapter { } } } catch (error) { - failure = normalizeProviderFailure(error); - yield { kind: 'error', failure }; + if (!failure) { + failure = normalizeProviderFailure(error); + yield { kind: 'error', failure }; + } } finally { const [sdkUsage, sdkFinishReason] = await Promise.all([ sdk.usage.catch(() => undefined), sdk.finishReason.catch(() => undefined), ]); + // An early-stopping consumer (a provider-mismatch throw, a user + // stop) ends this stream before any finish chunk exists, so the SDK + // rejects every result promise during teardown. `usage` and + // `finishReason` are consumed above; `response` is only read on the + // completed continuation path below. Sink it unconditionally so the + // error path can never surface an unhandled rejection after the + // turn unwinds — the timing of that settlement is scheduler-owned + // (observed post-test on Windows), and Node's default makes an + // unhandled rejection a crash. + void Promise.resolve(sdk.response).catch(() => undefined); const finishReason = streamedFinishReason ?? rawFinishReasonString(sdkFinishReason) ?? 'unknown'; - const usage = normalizeAiSdkUsage(sdkUsage, { rawFinishReason: finishReason }); + const rawFinishReason = + streamedRawFinishReason ?? rawFinishReasonString(sdkFinishReason) ?? finishReason; + const usage = normalizeAiSdkUsage(sdkUsage, { rawFinishReason }); let settled = settleModelStepOutcome({ aborted: continuation.abortSignal.aborted, failure, sawFinish, finishReason, + rawFinishReason, usage, request, }); + let deferredFailure: ModelFailure | undefined; + + if (sawUnfinalizedPlaintextSummary && settled.kind === 'completed') { + failure = normalizeProviderFailure( + new Error('Plaintext Responses reasoning item is missing final summary metadata'), + ); + deferredFailure = failure; + settled = settleModelStepOutcome({ + aborted: continuation.abortSignal.aborted, + failure, + sawFinish, + finishReason, + rawFinishReason, + usage, + request, + }); + } try { if (continuation.lane) { @@ -392,6 +460,12 @@ export class ModelAdapter { } finally { settleOutcome(settled); } + if (deferredFailure) { + // Consumers may stop iterating at the first error. The outcome and + // continuation lane must already be settled before this yield so a + // generator return cannot strand the caller awaiting result.outcome. + yield { kind: 'error', failure: deferredFailure }; + } } }, }; @@ -427,6 +501,7 @@ export class ModelAdapter { this.runtime.reasoningReplay.kind === 'openai-chat-plaintext' ? this.openAiChatReasoningTransportState : undefined, + this.runtime, ); } @@ -473,12 +548,13 @@ interface ModelStepSettlementEvidence { failure?: ModelFailure; sawFinish: boolean; finishReason: ModelFinishReason; + rawFinishReason?: string; usage?: NormalizedUsage; request: ModelRequestMetadata; } export function settleModelStepOutcome(evidence: ModelStepSettlementEvidence): ModelStepOutcome { - const { aborted, failure, sawFinish, finishReason, usage, request } = evidence; + const { aborted, failure, sawFinish, finishReason, rawFinishReason, usage, request } = evidence; if (aborted || failure?.kind === 'abort') { return failedStepOutcome( 'aborted', @@ -507,14 +583,13 @@ export function settleModelStepOutcome(evidence: ModelStepSettlementEvidence): M ); } if (finishReason === 'content-filter' || finishReason === 'error') { + const terminalFailure = + finishReason === 'error' + ? providerFinishFailure(rawFinishReason) + : modelStepFailure('unknown', 'Provider stopped the stream on a content filter'); return failedStepOutcome( - 'terminal-failure', - modelStepFailure( - finishReason === 'content-filter' ? 'unknown' : 'provider_unavailable', - finishReason === 'content-filter' - ? 'Provider stopped the stream on a content filter' - : 'Provider stopped the stream with an error', - ), + terminalFailure.retryable ? 'retryable-failure' : 'terminal-failure', + terminalFailure, request, usage, ); @@ -532,6 +607,24 @@ function modelStepFailure(kind: ModelFailureKind, message: string): ModelFailure return { type: 'model_failure', kind, message, retryable: false }; } +function providerFinishFailure(rawFinishReason: string | undefined): ModelFailure { + if (rawFinishReason && rawFinishReason !== 'error') { + const normalized = normalizeProviderFailure({ + code: rawFinishReason, + message: 'Provider stopped the stream with an error', + }); + // A finish reason carries no request-level Retry-After or transport + // evidence. Preserve its classification for diagnostics without widening + // the pre-existing retry policy for every provider. + if (normalized.kind !== 'unknown') return { ...normalized, retryable: false }; + return { + ...modelStepFailure('provider_unavailable', 'Provider stopped the stream with an error'), + ...(normalized.code ? { code: normalized.code } : {}), + }; + } + return modelStepFailure('provider_unavailable', 'Provider stopped the stream with an error'); +} + function failedStepOutcome( kind: Exclude, failure: ModelFailure, @@ -590,7 +683,29 @@ export interface ModelAdapterRuntimeEventReplaySupport { providerExecutedTools: boolean; signedThinking: boolean; unsignedThinking: boolean; - responsesReasoning: 'none' | 'encrypted-content' | 'plaintext-content'; + responsesReasoning: + | 'none' + | 'encrypted-content' + | 'plaintext-content' + | { + kind: 'plaintext-item'; + profile: string; + providerOptionsKey: string; + }; +} + +function requireResponsesProviderOptionsKey(runtime: ResolvedModelRuntime): string { + if (!runtime.responsesProviderOptionsKey) { + throw new Error('Plaintext Responses replay requires a provider-options key'); + } + return runtime.responsesProviderOptionsKey; +} + +function requireResponsesReplayProfile(runtime: ResolvedModelRuntime): string { + if (!runtime.responsesReplayProfile) { + throw new Error('Plaintext Responses replay requires a source profile'); + } + return runtime.responsesReplayProfile; } /** @@ -669,8 +784,48 @@ function reasoningSignatureFromChunk(chunk: AiSdkStreamChunk): string | undefine function openAiResponsesReasoningProviderOptionsFromChunk( chunk: AiSdkStreamChunk, + runtime: ResolvedModelRuntime, ): NonNullable | undefined { const meta = chunk.providerMetadata; + if ( + runtime.reasoningReplay.kind === 'responses' && + runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-summary' + ) { + if (chunk.type !== 'reasoning' && chunk.type !== 'reasoning-end') return undefined; + const providerOptionsKey = runtime.responsesProviderOptionsKey; + const provider = + providerOptionsKey && meta && typeof meta === 'object' + ? (meta as Record)[providerOptionsKey] + : undefined; + const metadataItemIdValue = + provider && typeof provider === 'object' && !Array.isArray(provider) + ? (provider as { itemId?: unknown }).itemId + : undefined; + const streamItemIdValue = (chunk as { id?: unknown }).id; + if ( + typeof metadataItemIdValue === 'string' && + typeof streamItemIdValue === 'string' && + metadataItemIdValue !== streamItemIdValue + ) { + throw new Error('Plaintext Responses reasoning item id changed within one stream item'); + } + const itemId = + safePlaintextResponsesReasoningItemId(metadataItemIdValue) ?? + safePlaintextResponsesReasoningItemId(streamItemIdValue); + const summaryParts = plaintextSummaryParts(provider); + if (!itemId || !summaryParts) { + throw new Error('Plaintext Responses reasoning item is missing final summary metadata'); + } + const providerOptions = plaintextResponsesReasoningProviderOptions( + itemId, + requireResponsesReplayProfile(runtime), + summaryParts, + ); + if (!providerOptions) { + throw new Error('Plaintext Responses reasoning summary exceeds durable state bounds'); + } + return providerOptions; + } if (!meta || typeof meta !== 'object') return undefined; const openai = (meta as { openai?: unknown }).openai; if (!openai || typeof openai !== 'object' || Array.isArray(openai)) return undefined; @@ -689,6 +844,65 @@ function openAiResponsesReasoningProviderOptionsFromChunk( }; } +function isUnfinalizedPlaintextSummaryReasoningEnd( + chunk: AiSdkStreamChunk, + runtime: ResolvedModelRuntime, +): boolean { + return ( + runtime.reasoningReplay.kind === 'responses' && + runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-summary' && + chunk.type === 'reasoning-end' && + (chunk.providerMetadata === undefined || chunk.providerMetadata === null) + ); +} + +function plaintextSummaryParts(provider: unknown): string[] | undefined { + if (!provider || typeof provider !== 'object' || Array.isArray(provider)) return undefined; + const summary = (provider as { reasoningSummary?: unknown }).reasoningSummary; + if (!Array.isArray(summary)) return undefined; + const parts: string[] = []; + for (const part of summary) { + if (!part || typeof part !== 'object' || Array.isArray(part)) return undefined; + const { type, text } = part as { type?: unknown; text?: unknown }; + if (type !== 'summary_text' || typeof text !== 'string') return undefined; + parts.push(text); + } + return parts; +} + +function plaintextSummaryTextFromChunk( + chunk: AiSdkStreamChunk, + runtime: ResolvedModelRuntime | undefined, +): string | undefined { + if ( + runtime?.reasoningReplay.kind !== 'responses' || + runtime.reasoningReplay.contract.reasoningReplay !== 'plaintext-summary' || + (chunk.type !== 'reasoning' && chunk.type !== 'reasoning-end') + ) { + return undefined; + } + const providerOptionsKey = runtime.responsesProviderOptionsKey; + const meta = chunk.providerMetadata; + const provider = + providerOptionsKey && meta && typeof meta === 'object' + ? (meta as Record)[providerOptionsKey] + : undefined; + return plaintextSummaryParts(provider)?.join(''); +} + +function plaintextSummaryItemIdFromChunk( + chunk: AiSdkStreamChunk, + runtime: ResolvedModelRuntime | undefined, +): string | undefined { + if ( + runtime?.reasoningReplay.kind !== 'responses' || + runtime.reasoningReplay.contract.reasoningReplay !== 'plaintext-summary' + ) { + return undefined; + } + return safePlaintextResponsesReasoningItemId((chunk as { id?: unknown }).id); +} + /** * Translate one raw AI SDK stream chunk into zero or more Maka-owned * `ModelStreamEvent`s. The sole site that parses SDK chunk names; the backend @@ -697,8 +911,13 @@ function openAiResponsesReasoningProviderOptionsFromChunk( function translateChunk( chunk: AiSdkStreamChunk, openAiChatReasoningTransportState?: OpenAiChatReasoningTransportState, + runtime?: ResolvedModelRuntime, ): ModelStreamEvent[] { switch (chunk.type) { + case 'reasoning-start': { + const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + return reasoningItemId ? [{ kind: 'thinking', text: '', reasoningItemId }] : []; + } case 'text-start': return [{ kind: 'text-start' }]; case 'text-delta': { @@ -725,7 +944,11 @@ function translateChunk( ? chunk.delta : undefined; const signature = reasoningSignatureFromChunk(chunk); - const responsesProviderOptions = openAiResponsesReasoningProviderOptionsFromChunk(chunk); + const responsesProviderOptions = runtime + ? openAiResponsesReasoningProviderOptionsFromChunk(chunk, runtime) + : undefined; + const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + const reasoningSummaryText = plaintextSummaryTextFromChunk(chunk, runtime); const events: ModelStreamEvent[] = []; if (signature) events.push({ kind: 'thinking-signature', signature }); // The signed reasoning chunk arrives as a standalone delta with empty @@ -745,17 +968,31 @@ function translateChunk( providerOptionsOrigin: 'maka_transport' as const, } : {}), + ...(reasoningItemId ? { reasoningItemId } : {}), + ...(reasoningSummaryText !== undefined ? { reasoningSummaryText } : {}), }); } return events; } case 'reasoning-end': { const signature = reasoningSignatureFromChunk(chunk); - const responsesProviderOptions = openAiResponsesReasoningProviderOptionsFromChunk(chunk); + const responsesProviderOptions = runtime + ? openAiResponsesReasoningProviderOptionsFromChunk(chunk, runtime) + : undefined; + const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + const reasoningSummaryText = plaintextSummaryTextFromChunk(chunk, runtime); return [ ...(signature ? [{ kind: 'thinking-signature' as const, signature }] : []), ...(responsesProviderOptions - ? [{ kind: 'thinking' as const, text: '', providerOptions: responsesProviderOptions }] + ? [ + { + kind: 'thinking' as const, + text: '', + providerOptions: responsesProviderOptions, + ...(reasoningItemId ? { reasoningItemId } : {}), + ...(reasoningSummaryText !== undefined ? { reasoningSummaryText } : {}), + }, + ] : []), ]; } @@ -771,9 +1008,12 @@ function translateChunk( case 'finish-step': case 'step-finish': { const finishReason = chunkFinishReason(chunk); + const rawFinishReason = rawFinishReasonString(chunk.rawFinishReason); // The same value the turn's outcome is decided from, so the record and // the outcome cannot name different reasons for the same stream. - const usage = normalizeAiSdkUsage(chunk.usage, { rawFinishReason: finishReason }); + const usage = normalizeAiSdkUsage(chunk.usage, { + rawFinishReason: rawFinishReason ?? finishReason, + }); return [ { kind: 'step-finish', @@ -786,7 +1026,6 @@ function translateChunk( const finishReason = chunkFinishReason(chunk); return [{ kind: 'finish', ...(finishReason ? { finishReason } : {}) }]; } - case 'reasoning-start': case 'start-step': case 'tool-result': case 'tool-error': { diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 37dd1216be..da6b77b27e 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -32,7 +32,6 @@ import { type SharedV4ProviderOptions, } from '@ai-sdk/provider'; import { type RuntimeExecutionConnection } from '@maka/core/llm-connections'; -import type { ProviderRuntimeAdapter } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import { resolveThinkingLevel, @@ -53,7 +52,9 @@ import { openAiResponsesBaseUrl, openResponsesUrl, } from './provider-urls.js'; +import { createOpenResponsesCompatibilityFinalizer } from './open-responses-compatibility.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; +import { runtimeProviderName, type RuntimeProviderAdapter } from './provider-runtime-policy.js'; import { openAiCodexHeaders } from './subscription-auth.js'; import { createRequestCustomizationFetch } from './request-customization-fetch.js'; @@ -85,10 +86,12 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { const hasRequestCustomization = Object.keys(requestHeaders ?? {}).length > 0 || Object.keys(connection.requestBodyOverlay ?? {}).length > 0; - const requestFetch = createRequestCustomizationFetch(fetch ?? globalThis.fetch, { + const baseFetch = fetch ?? globalThis.fetch; + const requestCustomization = { headers: requestHeaders, bodyOverlay: connection.requestBodyOverlay, - }); + } as const; + const requestFetch = createRequestCustomizationFetch(baseFetch, requestCustomization); if (adapter.kind === 'google' && adapter.normalizeBaseUrl === false) { return createGoogle({ apiKey, baseURL, fetch: requestFetch }).chat(modelId); @@ -174,11 +177,20 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { throw new Error('Responses wire requires a Responses continuation contract'); } if (reasoningReplay.contract.adapter === 'open-responses') { + // Request customization is applied first; provider compatibility is + // the final authority before network dispatch, so an overlay cannot + // re-enable storage or violate the provider's tool-choice contract. + const responsesFetch = createRequestCustomizationFetch(baseFetch, { + ...requestCustomization, + finalizeBody: createOpenResponsesCompatibilityFinalizer( + reasoningReplay.contract.compatibility, + ), + }); return createOpenResponses({ - name: openAiCompatibleProviderName(adapter, connection), + name: runtimeProviderName(adapter, connection), apiKey, url: openResponsesUrl(baseURL), - fetch: requestFetch, + fetch: responsesFetch, })(modelId); } return createOpenAI({ @@ -205,7 +217,7 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { ) : reasoningTransport.transformRequestBody; const model = createOpenAICompatible({ - name: openAiCompatibleProviderName(adapter, connection), + name: runtimeProviderName(adapter, connection), apiKey, baseURL, includeUsage: adapter.includeUsage, @@ -622,7 +634,7 @@ function buildFamilyWire( // openai-compatible — so key by the same name getAIModel passes. return reasoningEffort || serviceTier ? { - [openAiCompatibleProviderName(adapter, connection)]: { + [runtimeProviderName(adapter, connection)]: { ...(reasoningEffort ? { reasoningEffort } : {}), ...(serviceTier ? { serviceTier } : {}), }, @@ -684,20 +696,6 @@ function buildFamilyWire( } } -/** - * The provider IDENTITY passed as `name` to `createOpenAICompatible` in - * `getAIModel` — the raw slug for custom relays. Distinct from the - * providerOptions key the SDK wants: see `openAiCompatibleProviderOptionsKey`. - */ -function openAiCompatibleProviderName( - adapter: ProviderRuntimeAdapter, - connection: RuntimeExecutionConnection, -): string { - return adapter.kind === 'openai-compatible' && adapter.name === 'connection' - ? connection.slug - : connection.providerType; -} - // Mirrors @ai-sdk/openai-compatible's own toCamelCase derivation, so the // key we emit always matches the alias the SDK resolves. function toCamelCase(name: string): string { @@ -718,8 +716,8 @@ function toCamelCase(name: string): string { * silently read nothing for dashed providers. */ function openAiCompatibleProviderOptionsKey( - adapter: ProviderRuntimeAdapter, + adapter: RuntimeProviderAdapter, connection: RuntimeExecutionConnection, ): string { - return toCamelCase(openAiCompatibleProviderName(adapter, connection)); + return toCamelCase(runtimeProviderName(adapter, connection)); } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index fe1a1db769..be9c0d0c8c 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -393,6 +393,10 @@ export type ModelStreamEvent = kind: 'thinking'; text: string; providerOptions?: ProviderOptions; + /** Bounded item identity used only while grouping one streamed reasoning item. */ + reasoningItemId?: string; + /** Final provider summary, compared before only its part boundaries are persisted. */ + reasoningSummaryText?: string; /** Maka-authored replay hint; absent provider metadata stays fail-closed. */ providerOptionsOrigin?: 'maka_transport'; } diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index 874c3e206b..bd8d249f13 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -21,7 +21,6 @@ import { PROVIDER_DEFAULTS, effectiveBaseUrl, type ModelInfo, - type ProviderResponsesContract, type ProviderRuntimeAdapter, type ProviderType, } from '@maka/core/llm-connections'; @@ -32,6 +31,12 @@ import { } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { resolveApplyPatchProfile, type ApplyPatchProfile } from './apply-patch-profile.js'; +import { + resolveRuntimeProviderAdapter, + runtimeProviderName, + type RuntimeProviderAdapter, + type RuntimeProviderResponsesContract, +} from './provider-runtime-policy.js'; export type ModelRuntimeWire = | 'anthropic-messages' @@ -44,10 +49,10 @@ export type ReasoningReplayContract = | { kind: 'none' } | { kind: 'anthropic-signed' } | { kind: 'openai-chat-plaintext'; requestField: 'observed' | 'reasoning' } - | { kind: 'responses'; contract: ProviderResponsesContract }; + | { kind: 'responses'; contract: RuntimeProviderResponsesContract }; export interface ResolvedModelRuntime { - adapter: ProviderRuntimeAdapter; + adapter: RuntimeProviderAdapter; baseUrl: string; /** Account-advertised request wire for adapters that route per model. */ apiProtocol?: ModelInfo['apiProtocol']; @@ -57,11 +62,16 @@ export interface ResolvedModelRuntime { reasoningReplay: ReasoningReplayContract; /** Effective parallel-tool-call support after model facts and wire defaults are resolved. */ parallelToolCalls?: boolean; + /** Provider-options namespace used by durable plaintext-summary replay. */ + responsesProviderOptionsKey?: string; + /** Stable connection identity that issued a durable plaintext-summary item. */ + responsesReplayProfile?: string; /** Effective ApplyPatch contract after provider, model, and request wire are resolved. */ applyPatchProfile: ApplyPatchProfile | null; } export interface ModelRuntimeConnection { + readonly slug?: string; readonly providerType: ProviderType; readonly baseUrl?: string; readonly models?: readonly ModelInfo[]; @@ -102,7 +112,7 @@ export function resolveModelRuntime( `Kimi Coding Plan protocol must be openai-chat or anthropic-messages, received ${apiProtocol}`, ); } - const adapter: ProviderRuntimeAdapter = + const baseAdapter: ProviderRuntimeAdapter = connection.providerType === 'kimi-coding-plan' && apiProtocol === 'openai-chat' ? ({ kind: 'openai-compatible', @@ -112,12 +122,14 @@ export function resolveModelRuntime( : override ? runtimeAdapterOverride(override.npm) : defaults.runtimeAdapter; + const adapter = resolveRuntimeProviderAdapter(baseAdapter); const configuredBaseUrl = connection.baseUrl?.trim(); const resolvedBaseUrl = configuredBaseUrl ? effectiveBaseUrl(connection) : (override?.api ?? effectiveBaseUrl(connection)); const wire = resolveModelRuntimeWire(connection.providerType, modelId, adapter, apiProtocol); const parallelToolCalls = resolveParallelToolCalls(connection, modelId, adapter); + const replay = reasoningReplayContract(adapter, wire); return { adapter, baseUrl: @@ -126,8 +138,16 @@ export function resolveModelRuntime( : resolvedBaseUrl, ...(apiProtocol ? { apiProtocol } : {}), wire, - reasoningReplay: reasoningReplayContract(adapter, wire), + reasoningReplay: replay, ...(parallelToolCalls === undefined ? {} : { parallelToolCalls }), + ...(replay.kind === 'responses' && + replay.contract.adapter === 'open-responses' && + replay.contract.reasoningReplay === 'plaintext-summary' + ? { + responsesProviderOptionsKey: runtimeProviderName(adapter, connection), + responsesReplayProfile: connection.slug ?? connection.providerType, + } + : {}), applyPatchProfile: resolveApplyPatchProfile( { wire, @@ -141,7 +161,7 @@ export function resolveModelRuntime( function resolveParallelToolCalls( connection: ModelRuntimeConnection, modelId: string, - adapter: ProviderRuntimeAdapter, + adapter: RuntimeProviderAdapter, ): boolean | undefined { const stored = connection.models?.find((model) => model.id === modelId)?.capabilities ?.parallelToolCalls; @@ -177,7 +197,7 @@ export function modelUsesNativeOpenAiResponses( function resolveModelRuntimeWire( providerType: ProviderType, modelId: string, - adapter: ProviderRuntimeAdapter, + adapter: RuntimeProviderAdapter, apiProtocol: ModelInfo['apiProtocol'] | undefined, ): ModelRuntimeWire { switch (adapter.kind) { @@ -215,7 +235,7 @@ function resolveModelRuntimeWire( } function reasoningReplayContract( - adapter: ProviderRuntimeAdapter, + adapter: RuntimeProviderAdapter, wire: ModelRuntimeWire, ): ReasoningReplayContract { switch (wire) { @@ -237,7 +257,7 @@ function reasoningReplayContract( } } -function responsesContract(adapter: ProviderRuntimeAdapter): ProviderResponsesContract { +function responsesContract(adapter: RuntimeProviderAdapter): RuntimeProviderResponsesContract { if (adapter.kind === 'openai-compatible' && adapter.responses) return adapter.responses; return { adapter: 'openai', reasoningReplay: 'encrypted-content' }; } diff --git a/packages/runtime/src/open-responses-compatibility.ts b/packages/runtime/src/open-responses-compatibility.ts new file mode 100644 index 0000000000..2081efe82d --- /dev/null +++ b/packages/runtime/src/open-responses-compatibility.ts @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { OpenResponsesCompatibilityProfile } from './provider-runtime-policy.js'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requiredToolCount(body: Record): number | undefined { + const choice = body.tool_choice; + if (choice === 'required') { + return Array.isArray(body.tools) ? body.tools.length : 0; + } + if (isRecord(choice) && choice.type === 'allowed_tools' && choice.mode === 'required') { + return Array.isArray(choice.tools) ? choice.tools.length : 0; + } + return undefined; +} + +export function createOpenResponsesCompatibilityFinalizer( + profile: OpenResponsesCompatibilityProfile | undefined, +): ((body: Record) => Record) | undefined { + if (!profile) return undefined; + return (body) => { + const choice = body.tool_choice; + const forcedToolCount = requiredToolCount(body); + if (forcedToolCount !== undefined && forcedToolCount !== 1) { + throw new Error('Alibaba Token Plan Responses requires exactly one tool for tool_choice'); + } + if (isRecord(choice) && choice.type !== 'allowed_tools') { + throw new Error('Alibaba Token Plan Responses does not support this tool_choice object'); + } + return { ...body, store: false }; + }; +} diff --git a/packages/runtime/src/provider-runtime-policy.ts b/packages/runtime/src/provider-runtime-policy.ts new file mode 100644 index 0000000000..2af29f2be0 --- /dev/null +++ b/packages/runtime/src/provider-runtime-policy.ts @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + ProviderResponsesContract, + ProviderRuntimeAdapter, + ProviderRuntimeProfileId, + ProviderType, +} from '@maka/core/llm-connections'; + +export type OpenResponsesCompatibilityProfile = 'alibaba-token-plan'; + +export type RuntimeProviderResponsesContract = + | Extract + | { + readonly adapter: 'open-responses'; + readonly reasoningReplay: 'plaintext-content' | 'plaintext-summary'; + readonly compatibility?: OpenResponsesCompatibilityProfile; + }; + +type OpenAiCompatibleRuntimeAdapter = Omit< + Extract, + 'responses' +> & { + readonly responses?: RuntimeProviderResponsesContract; +}; + +export type RuntimeProviderAdapter = + | Exclude + | OpenAiCompatibleRuntimeAdapter; + +interface RuntimeProviderIdentity { + readonly providerType: ProviderType; + readonly slug?: string; +} + +interface RuntimeProviderProfile { + readonly responses: RuntimeProviderResponsesContract; +} + +const ALIBABA_TOKEN_PLAN_RESPONSES = { + adapter: 'open-responses', + // The pinned SDK maps both the standard `reasoning_summary_text` carrier and + // the regional `reasoning_text` compatibility carrier. Runtime retains + // `output_item.done.item.summary` as the durable identity and part-boundary + // authority. + reasoningReplay: 'plaintext-summary', + compatibility: 'alibaba-token-plan', +} as const satisfies RuntimeProviderResponsesContract; + +const RUNTIME_PROVIDER_PROFILES = { + 'alibaba-token-plan': { responses: ALIBABA_TOKEN_PLAN_RESPONSES }, +} as const satisfies Record; + +export const RUNTIME_PROVIDER_PROFILE_IDS = Object.freeze( + Object.keys(RUNTIME_PROVIDER_PROFILES) as ProviderRuntimeProfileId[], +); + +export function resolveRuntimeProviderAdapter( + adapter: ProviderRuntimeAdapter, +): RuntimeProviderAdapter { + if (adapter.kind !== 'openai-compatible' || adapter.runtimeProfile === undefined) { + return adapter; + } + return { ...adapter, responses: RUNTIME_PROVIDER_PROFILES[adapter.runtimeProfile].responses }; +} + +/** Raw provider identity passed to open-responses and used as its provider-options key. */ +export function runtimeProviderName( + adapter: RuntimeProviderAdapter, + connection: RuntimeProviderIdentity, +): string { + return adapter.kind === 'openai-compatible' && adapter.name === 'connection' + ? (connection.slug ?? connection.providerType) + : connection.providerType; +} diff --git a/packages/runtime/src/request-customization-fetch.ts b/packages/runtime/src/request-customization-fetch.ts index a7e8dc3de0..9fcddabdac 100644 --- a/packages/runtime/src/request-customization-fetch.ts +++ b/packages/runtime/src/request-customization-fetch.ts @@ -26,6 +26,8 @@ import { export interface RequestCustomization { readonly headers?: Readonly>; readonly bodyOverlay?: JsonObject; + /** Final provider-owned body policy, applied after caller overlays. */ + readonly finalizeBody?: (body: Record) => Record; } export function createRequestCustomizationFetch( @@ -34,7 +36,14 @@ export function createRequestCustomizationFetch( ): typeof globalThis.fetch { const headers = normalizeRequestHeaders(customization.headers ?? {}); const bodyOverlay = normalizeRequestBodyOverlay(customization.bodyOverlay ?? {}); - if (Object.keys(headers).length === 0 && Object.keys(bodyOverlay).length === 0) return upstream; + const bodyOverlayKeys = Object.keys(bodyOverlay); + if ( + Object.keys(headers).length === 0 && + bodyOverlayKeys.length === 0 && + !customization.finalizeBody + ) { + return upstream; + } return async (input, init) => { const request = new Request(input, init); @@ -48,14 +57,28 @@ export function createRequestCustomizationFetch( } let body: BodyInit | null = request.body === null ? null : await request.clone().arrayBuffer(); - if (Object.keys(bodyOverlay).length > 0 && requestHasJsonBody(request)) { - const generatedBody = await parseRequestBody(request); - for (const key of Object.keys(bodyOverlay)) { + if (bodyOverlayKeys.length > 0 || customization.finalizeBody) { + if (!requestHasJsonBody(request)) { + if (customization.finalizeBody) { + throw new Error('Request body finalizer requires a JSON object request body'); + } + return upstream(request.url, requestInit(request, nextHeaders, body)); + } + const generatedBody = await parseRequestBody( + request, + customization.finalizeBody + ? 'Request body finalizer requires a JSON object request body' + : 'Extra request body can only be applied to a JSON object request', + ); + for (const key of bodyOverlayKeys) { if (Object.hasOwn(generatedBody, key)) { throw new Error(`Extra request body conflicts with a generated field: ${key}`); } } - body = JSON.stringify({ ...generatedBody, ...bodyOverlay }); + const customizedBody = { ...generatedBody, ...bodyOverlay }; + body = JSON.stringify( + customization.finalizeBody ? customization.finalizeBody(customizedBody) : customizedBody, + ); nextHeaders.delete('content-length'); } return upstream(request.url, requestInit(request, nextHeaders, body)); @@ -87,15 +110,18 @@ function requestHasJsonBody(request: Request): boolean { ); } -async function parseRequestBody(request: Request): Promise> { +async function parseRequestBody( + request: Request, + invalidBodyMessage: string, +): Promise> { let parsed: unknown; try { parsed = JSON.parse(await request.clone().text()); } catch { - throw new Error('Extra request body can only be applied to a JSON object request'); + throw new Error(invalidBodyMessage); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Extra request body can only be applied to a JSON object request'); + throw new Error(invalidBodyMessage); } return parsed as Record; } diff --git a/packages/runtime/src/responses-reasoning-state.ts b/packages/runtime/src/responses-reasoning-state.ts new file mode 100644 index 0000000000..0303686e61 --- /dev/null +++ b/packages/runtime/src/responses-reasoning-state.ts @@ -0,0 +1,189 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ModelMessage } from './model-protocol.js'; + +const STATE_KEY = 'makaResponses'; +const STATE_VERSION = 1; +const MAX_ITEM_ID_LENGTH = 512; +const MAX_PROFILE_LENGTH = 128; +const MAX_SUMMARY_PARTS = 128; +const MAX_SUMMARY_TEXT_LENGTH = 10_000_000; + +export interface PlaintextResponsesReasoningState { + readonly version: 1; + readonly profile: string; + readonly itemId: string; + readonly summaryPartLengths: readonly number[]; +} + +export type PlaintextResponsesReasoningStateDecodeResult = + | { readonly kind: 'missing' } + | { readonly kind: 'unsupported-version'; readonly version: number } + | { readonly kind: 'malformed'; readonly profile?: string } + | { readonly kind: 'valid'; readonly state: PlaintextResponsesReasoningState }; + +export function plaintextResponsesReasoningProviderOptions( + itemId: string, + profile: string, + summaryParts: readonly string[], +): NonNullable | undefined { + if (!isSafeItemId(itemId) || !isSafeProfile(profile) || !isSafeSummaryParts(summaryParts)) { + return undefined; + } + return { + [STATE_KEY]: { + version: STATE_VERSION, + profile, + itemId, + summaryPartLengths: summaryParts.map((part) => part.length), + }, + }; +} + +export function decodePlaintextResponsesReasoningState( + providerOptions: Readonly> | undefined, +): PlaintextResponsesReasoningStateDecodeResult { + const raw = providerOptions?.[STATE_KEY]; + if (raw === undefined) return { kind: 'missing' }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { kind: 'malformed' }; + const record = raw as Record; + const profile = isSafeProfile(record.profile) ? record.profile : undefined; + const itemId = isSafeItemId(record.itemId) ? record.itemId : undefined; + if (isSafeStateVersion(record.version) && record.version !== STATE_VERSION) { + return { kind: 'unsupported-version', version: record.version }; + } + const baseInvalid = record.version !== STATE_VERSION || !profile || !itemId; + if (baseInvalid) { + return { kind: 'malformed', ...(profile ? { profile } : {}) }; + } + if ( + !isSafeSummaryPartLengths(record.summaryPartLengths) || + Object.keys(record).some( + (key) => !['version', 'profile', 'itemId', 'summaryPartLengths'].includes(key), + ) + ) { + return { kind: 'malformed', ...(profile ? { profile } : {}) }; + } + return { + kind: 'valid', + state: { + version: STATE_VERSION, + profile, + itemId, + summaryPartLengths: record.summaryPartLengths, + }, + }; +} + +export function responsesReasoningItemId( + providerOptions: Readonly> | undefined, +): string | undefined { + const plaintext = decodePlaintextResponsesReasoningState(providerOptions); + if (plaintext.kind === 'valid') return plaintext.state.itemId; + const openai = providerOptions?.openai; + if (!openai || typeof openai !== 'object' || Array.isArray(openai)) return undefined; + const itemId = (openai as { itemId?: unknown }).itemId; + return isSafeItemId(itemId) ? itemId : undefined; +} + +export function replayPlaintextResponsesProviderOptions(input: { + providerOptionsKey: string; + state: PlaintextResponsesReasoningState; + text: string; +}): NonNullable { + return { + [input.providerOptionsKey]: { + itemId: input.state.itemId, + reasoningSummary: reconstructSummaryParts(input.text, input.state), + // Presence is meaningful to @ai-sdk/open-responses: null prevents its + // fallback from copying the canonical text into content when the + // provider replays reasoning through summary instead. + reasoningContent: null, + }, + }; +} + +export function safePlaintextResponsesReasoningItemId(value: unknown): string | undefined { + return isSafeItemId(value) ? value : undefined; +} + +function reconstructSummaryParts( + text: string, + state: PlaintextResponsesReasoningState, +): Array<{ type: 'summary_text'; text: string }> { + let offset = 0; + const parts = state.summaryPartLengths.map((length) => { + const part = { type: 'summary_text' as const, text: text.slice(offset, offset + length) }; + offset += length; + return part; + }); + if (offset !== text.length) { + throw new Error('Durable plaintext Responses reasoning summary boundaries do not match text'); + } + return parts; +} + +function isSafeItemId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_ITEM_ID_LENGTH && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +function isSafeStateVersion(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isSafeSummaryParts(value: readonly string[] | undefined): value is readonly string[] { + if (!value || value.length > MAX_SUMMARY_PARTS) return false; + let total = 0; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) return false; + const part = value[index]; + if (typeof part !== 'string') return false; + total += part.length; + if (total > MAX_SUMMARY_TEXT_LENGTH) return false; + } + return true; +} + +function isSafeSummaryPartLengths(value: unknown): value is readonly number[] { + if (!Array.isArray(value) || value.length > MAX_SUMMARY_PARTS) return false; + let total = 0; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) return false; + const length = value[index]; + if (!Number.isSafeInteger(length) || length < 0) return false; + total += length; + if (total > MAX_SUMMARY_TEXT_LENGTH) return false; + } + return true; +} + +function isSafeProfile(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_PROFILE_LENGTH && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs index d406b3dba0..ad979a13d0 100644 --- a/scripts/generate-third-party-notices.mjs +++ b/scripts/generate-third-party-notices.mjs @@ -104,7 +104,10 @@ const LICENSE_METADATA_OVERRIDES = new Map([ ]); // The published tarball omits the repository LICENSE; package.json declares Apache-2.0. // Keyed by exact version so a bump re-checks the license rather than inheriting this. -const APACHE_TEXT_OVERRIDE_KEYS = new Set(['@ai-sdk/provider-utils@5.0.28']); +const APACHE_TEXT_OVERRIDE_KEYS = new Set([ + '@ai-sdk/provider-utils@5.0.28', + '@ai-sdk/provider-utils@5.0.32', +]); const EMBEDDED_COMPONENT_LICENSES = new Map([ [ '@ai-sdk/code-mode',