From 52b1bef7e05aef23711e82cbbc672702943d3c2a Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 13 Aug 2026 01:37:59 +0000 Subject: [PATCH] fix(volcengine): scope assistant placeholder by endpoint --- src/adapters/openai-chat.ts | 8 +++--- structure/04_transports-and-sidecars.md | 16 ++++++++++++ .../volcengine-ark-assistant-content.test.ts | 25 ++++++++++++++----- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 659b1ea59e..d1ea5db69c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -672,16 +672,18 @@ const VOLCENGINE_ARK_HOSTNAMES = new Set([ "ark.ap-southeast.volces.com", ]); -function isVolcengineArkTarget(provider: OcxProviderConfig): boolean { +function isVolcengineArkPaygChatTarget(provider: OcxProviderConfig): boolean { try { - return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname); + const url = new URL(provider.baseUrl); + const pathname = url.pathname.replace(/\/+$/, "") || "/"; + return VOLCENGINE_ARK_HOSTNAMES.has(url.hostname) && pathname === "/api/v3"; } catch { return false; } } function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] { - return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : ""; + return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : ""; } function ensureRootObjectType(parameters: unknown): Record { diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1b09e8fdde..0f0b632a14 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -587,6 +587,22 @@ adapters advertise the catalog bit only on explicit `true`; cursor keeps its own Providers with flaky parallel streaming can be opted out individually. Evidence and provider ledger: `devlog/_fin/260709_parallel_tool_calls/`. +## Volcengine Ark assistant continuation shapes + +The `openai-chat` adapter keeps Volcengine's pay-as-you-go Chat endpoint and Coding Plan endpoint +on separate empty-assistant contracts. The pay-as-you-go `/api/v3` route retains the structured +`[{ "type": "text", "text": "" }]` placeholder inferred for #796, while `/api/coding/v3` uses the +ordinary empty string accepted by its live tool-call continuation contract (#1571). Matching only +the shared Ark hostname is too broad because the two endpoint families reject opposite shapes. + +[Decision Log] +- 목적과 의도: Preserve multi-turn tool-call continuations across both Ark Chat endpoint families. +- 기존 구현 및 제약 조건: The #796 workaround was host-wide and unverified; live Coding Plan evidence shows its structured placeholder returns HTTP 400 while an empty string succeeds. +- 검토한 주요 대안: Remove the workaround globally, select by model ID, or scope it by endpoint path. +- 선택한 방식: Apply the structured placeholder only to recognized Ark hosts whose normalized base path is exactly `/api/v3`. +- 다른 대안 대신 이 방식을 선택한 이유: Global removal would reopen #796, while model IDs can appear behind multiple Ark products and therefore do not identify the wire contract. +- 장점, 단점 및 영향: Coding Plan regains its accepted continuation shape without changing generic providers; any future Ark endpoint family must provide evidence before inheriting the pay-as-you-go quirk. + ## Chat structured-output compatibility The `openai-chat` adapter translates Responses `text.format` and Chat Completions diff --git a/tests/volcengine-ark-assistant-content.test.ts b/tests/volcengine-ark-assistant-content.test.ts index bc4f989bf8..992bb5f35c 100644 --- a/tests/volcengine-ark-assistant-content.test.ts +++ b/tests/volcengine-ark-assistant-content.test.ts @@ -9,9 +9,9 @@ import type { OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../src/typ * tool-call turn. * * This cannot be fixed globally. xAI rejects the opposite way -- "Each message must have at least - * one content element" -- and the existing "" is what satisfies it. So the two contracts conflict - * and the fix is host-gated, which is exactly what these tests have to prove: every case runs the - * SAME input through Ark and a generic host and asserts the two diverge. + * one content element" -- and the existing "" is what satisfies it. Ark's Coding Plan endpoint + * also rejects the structured placeholder while accepting "" (#1571). The contracts therefore + * diverge by endpoint family, so these tests pin both the exact host and the exact Ark path. * * Scope of these tests: they verify the WIRE SHAPE we emit and that the host gate is real. They * cannot verify that Ark accepts it -- no request here reaches Volcengine. The array form is @@ -23,6 +23,7 @@ function providerFor(baseUrl: string): OcxProviderConfig { } const ark = providerFor("https://ark.cn-beijing.volces.com/api/v3"); +const arkCodingPlan = providerFor("https://ark.cn-beijing.volces.com/api/coding/v3"); const generic = providerFor("https://example.test/v1"); interface ChatMsg { @@ -80,6 +81,12 @@ describe("Volcengine Ark empty assistant content (#796)", () => { expect(assistant.content).toBe(""); }); + test("Ark Coding Plan keeps the accepted empty-string continuation", () => { + const [assistant] = assistantsOf(wire(arkCodingPlan, history)); + expect(assistant.tool_calls).toHaveLength(1); + expect(assistant.content).toBe(""); + }); + test("a synthesized orphan tool-call assistant follows the same rule", () => { // A tool result with no matching call: the adapter fabricates the assistant turn, and that // fabricated message hits the same Ark validator. @@ -93,14 +100,20 @@ describe("Volcengine Ark empty assistant content (#796)", () => { expect(genericOrphan?.content).toBe(""); }); - test("the gate matches the host, not the path or a substring of it", () => { - // A lookalike host must not inherit the quirk: the fix keys on an exact hostname set, so a - // provider merely mentioning the string in its path stays on the default contract. + test("the gate matches the host and pay-as-you-go path, not a substring", () => { + // A lookalike host must not inherit the quirk: a provider merely mentioning the Ark hostname + // in its path stays on the default contract. const lookalike = providerFor("https://example.test/ark.cn-beijing.volces.com/v1"); const [assistant] = assistantsOf(wire(lookalike, history)); expect(assistant.content).toBe(""); }); + test("an unrelated path on the real Ark host does not inherit the pay-as-you-go quirk", () => { + const unrelatedArkPath = providerFor("https://ark.cn-beijing.volces.com/api/custom/v3"); + const [assistant] = assistantsOf(wire(unrelatedArkPath, history)); + expect(assistant.content).toBe(""); + }); + test("Ark's regional sibling endpoint gets the same treatment", () => { const sea = providerFor("https://ark.ap-southeast.volces.com/api/v3"); const [assistant] = assistantsOf(wire(sea, history));