Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
Expand Down
16 changes: 16 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions tests/volcengine-ark-assistant-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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));
Expand Down
Loading