feat(providers): modernize CLOVA Studio chat and embeddings (#12277)

Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun.
This commit is contained in:
backryun
2026-09-03 19:50:22 +09:00
committed by GitHub
parent cdd07df700
commit 82c64d76d3
17 changed files with 2638 additions and 556 deletions

View File

@@ -10,6 +10,7 @@
export type EmbeddingModality = "text" | "image" | "audio" | "video" | "document";
export type StructuredEmbeddingProtocol = "jina-v1" | "gemini-embed-content";
export type SingleTextEmbeddingProtocol = "clova-v2";
export interface EmbeddingModel {
id: string;
@@ -34,6 +35,13 @@ export interface EmbeddingProvider {
models: EmbeddingModel[];
/** Provider-native serializer required for canonical structured input. */
structuredInputProtocol?: StructuredEmbeddingProtocol;
/**
* Set when the endpoint embeds exactly ONE text per request (`{"text": …}` →
* one vector) instead of accepting OpenAI's `input` array. A batched
* `/v1/embeddings` call is then fanned out into N sequential upstream calls and
* merged back into a single OpenAI list response.
*/
singleTextProtocol?: SingleTextEmbeddingProtocol;
}
export interface EmbeddingProviderNodeRow {
@@ -297,6 +305,18 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
],
},
// Naver CLOVA Studio — embedding v2. The endpoint takes a single `{"text": …}`
// body and returns `{status, result:{embedding:[…1024 floats], inputTokens}}`,
// with no batch array and no `usage` object, hence `singleTextProtocol`.
"clova-studio": {
id: "clova-studio",
baseUrl: "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2",
authType: "apikey",
authHeader: "bearer",
singleTextProtocol: "clova-v2",
models: [{ id: "clova-embedding-v2", name: "CLOVA Embedding v2", dimensions: 1024 }],
},
"jina-ai": {
id: "jina-ai",
structuredInputProtocol: "jina-v1",
@@ -471,6 +491,62 @@ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | nu
return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null;
}
function findDynamicEmbeddingProvider(
modelStr: string,
dynamicProviders: EmbeddingProvider[] | undefined
): { provider: string; model: string } | null {
const match = dynamicProviders?.find((provider) => modelStr.startsWith(`${provider.id}/`));
return match ? { provider: match.id, model: modelStr.slice(match.id.length + 1) } : null;
}
function parsePrefixedEmbeddingModel(
modelStr: string,
slashIdx: number,
dynamicProviders: EmbeddingProvider[] | undefined
): { provider: string; model: string } {
const rawProvider = modelStr.slice(0, slashIdx);
const dynamicExact = dynamicProviders?.find((provider) => provider.id === rawProvider);
if (dynamicExact) {
return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) };
}
const resolvedProvider = resolveEmbeddingProviderId(rawProvider);
if (EMBEDDING_PROVIDERS[resolvedProvider]) {
return {
provider: resolvedProvider,
model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)),
};
}
const hardcodedProvider = Object.keys(EMBEDDING_PROVIDERS).find((providerId) =>
modelStr.startsWith(`${providerId}/`)
);
if (hardcodedProvider) {
return {
provider: hardcodedProvider,
model: normalizeProviderScopedModelId(
hardcodedProvider,
modelStr.slice(hardcodedProvider.length + 1)
),
};
}
return (
findDynamicEmbeddingProvider(modelStr, dynamicProviders) ?? {
provider: rawProvider,
model: modelStr.slice(slashIdx + 1),
}
);
}
function findEmbeddingModelProvider(modelStr: string): string | null {
return (
Object.entries(EMBEDDING_PROVIDERS).find(([, config]) =>
config.models.some((model) => model.id === modelStr)
)?.[0] ?? null
);
}
/**
* Derive an OpenAI-compatible embeddings config for a chat provider that has NO
* curated EMBEDDING_PROVIDERS entry. Works for any registry provider whose base
@@ -517,59 +593,11 @@ export function parseEmbeddingModel(
// Check for "provider/model" format
const slashIdx = modelStr.indexOf("/");
if (slashIdx > 0) {
const rawProvider = modelStr.slice(0, slashIdx);
// A configured provider_node whose prefix exactly equals the requested
// provider segment always wins — even when that segment is also an alias
// of a curated provider (a local node must not be hijacked by a registry
// alias). Same exact-match precedence documented for
// EMBEDDING_MODEL_ALIASES above.
const dynamicExact =
dynamicProviders && dynamicProviders.find((dp) => dp.id === rawProvider);
if (dynamicExact) {
return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) };
}
const resolvedProvider = resolveEmbeddingProviderId(rawProvider);
if (EMBEDDING_PROVIDERS[resolvedProvider]) {
return {
provider: resolvedProvider,
model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)),
};
}
// Phase 1: Try each hardcoded provider prefix
for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) {
if (modelStr.startsWith(providerId + "/")) {
return {
provider: providerId,
model: normalizeProviderScopedModelId(providerId, modelStr.slice(providerId.length + 1)),
};
}
}
// Phase 2: Try dynamic provider_nodes prefix
if (dynamicProviders) {
for (const dp of dynamicProviders) {
if (modelStr.startsWith(dp.id + "/")) {
return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) };
}
}
}
// Phase 3: Fallback — first segment is provider
const provider = modelStr.slice(0, slashIdx);
const model = modelStr.slice(slashIdx + 1);
return { provider, model };
return parsePrefixedEmbeddingModel(modelStr, slashIdx, dynamicProviders);
}
// No provider prefix — search hardcoded providers for the model
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
if (config.models.some((m) => m.id === modelStr)) {
return { provider: providerId, model: modelStr };
}
}
return { provider: null, model: modelStr };
return { provider: findEmbeddingModelProvider(modelStr), model: modelStr };
}
/**

View File

@@ -1,17 +1,75 @@
import type { RegistryEntry } from "../../shared.ts";
/**
* Naver CLOVA Studio — Chat Completions **v3** (native API).
*
* Previously this entry pointed at Naver's OpenAI-compatibility shim
* (`/v1/openai/chat/completions`), which meant `format: "openai"` and a
* pass-through `DefaultExecutor`. The v3 API is Naver's own wire format, so the
* entry now uses `format: "clova"` and the translator pair
* (`openai-to-clova` / `clova-to-openai`).
*
* v3 moves the model into the URL path (`/v3/chat-completions/{modelName}`), uses
* camelCase sampling params, and returns a `{status, result}` envelope instead of
* an OpenAI `choices[]` body — see the translators for the exact mapping.
*
* All three v3 models are live-verified against the real API (2026-09-01):
*
* | Model | Surface | Notes |
* | ------------- | -------- | -------------------------------------------------------- |
* | HCX-007 | thinking | rejects `maxTokens` (use `maxCompletionTokens`); no vision |
* | HCX-005 | text+img | vision via public URL **or** inline base64 data URI |
* | HCX-DASH-002 | text | lightweight, text only |
*
* Docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3
* https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-thinking
*/
export const clova_studioProvider: RegistryEntry = {
id: "clova-studio",
alias: "clova",
format: "openai",
executor: "default",
baseUrl: "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions",
format: "clova",
executor: "clova-studio",
baseUrl: "https://clovastudio.stream.ntruss.com/v3/chat-completions",
authType: "apikey",
authHeader: "bearer",
/**
* The v3 API does answer non-streaming requests (`Accept: application/json`),
* but only the streaming surface is expressed in the translator: CLOVA's SSE
* frames carry incremental `token` events plus a terminal `result` event that
* repeats the full text. Forcing the upstream stream lets OmniRoute consume
* that single, well-tested path and accumulate it into a JSON body for
* non-streaming clients, instead of maintaining a second parser for the
* `{status, result}` envelope.
*/
forceStream: true,
models: [
// HCX-007 stays first so it remains the provider default (deep-reasoning
// flagship); HCX-005 is the multimodal option.
{ id: "HCX-007", name: "HCX-007" },
{ id: "HCX-005", name: "HCX-005" },
{
// Reasoning flagship. Input+output ≤ 128000 tokens; the output cap counts
// thinking tokens too, so `maxCompletionTokens` may be up to 32768.
id: "HCX-007",
name: "HCX-007",
contextLength: 128000,
maxOutputTokens: 32768,
supportsReasoning: true,
},
{
// HyperCLOVA X vision model. Input+output ≤ 128000 tokens, output ≤ 4096,
// up to 5 images per request (1 per turn). Accepts a public URL or an
// inline base64 data URI — the data URI must keep its
// `data:<mime>;base64,` prefix inside `dataUri.data` or the request is
// rejected with `40001 Invalid parameter`.
id: "HCX-005",
name: "HCX-005",
contextLength: 128000,
maxOutputTokens: 4096,
supportsVision: true,
},
{
// Lightweight model. Input+output ≤ 32000 tokens, output ≤ 4096, text only.
id: "HCX-DASH-002",
name: "HCX-DASH-002",
contextLength: 32000,
maxOutputTokens: 4096,
},
],
};

View File

@@ -0,0 +1,12 @@
import { DefaultExecutor } from "./default.ts";
/** CLOVA Chat Completions v3 places the selected model in the URL path. */
export class ClovaStudioExecutor extends DefaultExecutor {
constructor() {
super("clova-studio");
}
buildUrl(model: string): string {
return `${this.config.baseUrl}/${encodeURIComponent(model)}`;
}
}

View File

@@ -180,6 +180,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
xai: () => import("./xai.ts").then((m) => new m.XaiExecutor()),
"xai-oauth": () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
xao: () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
"clova-studio": () => import("./clova-studio.ts").then((m) => new m.ClovaStudioExecutor()),
"conol-web": () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()),
cnl: () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()), // Alias
};

View File

@@ -128,10 +128,7 @@ export async function prepareJinaMixedEmbeddingInput(
continue;
}
if (isCanonicalEmbeddingItem(item)) {
const [translated] = await prepareJinaInput(
[item as EmbeddingMultimodalItem],
fetchMedia
);
const [translated] = await prepareJinaInput([item as EmbeddingMultimodalItem], fetchMedia);
out.push(translated);
continue;
}
@@ -163,7 +160,9 @@ function embeddingValues(entry: unknown): unknown[] {
return Array.isArray(values) ? values : [];
}
function normalizeGeminiEmbedContentResponse(data: Record<string, unknown>): Record<string, unknown> {
function normalizeGeminiEmbedContentResponse(
data: Record<string, unknown>
): Record<string, unknown> {
return {
object: "list",
data: [{ object: "embedding", embedding: embeddingValues(data.embedding), index: 0 }],
@@ -263,10 +262,7 @@ async function itemToGeminiContent(
return { parts: [await jinaDocToGeminiPart(item, fetchMedia)] };
}
if (isCanonicalEmbeddingItem(item)) {
const [part] = await prepareGeminiParts(
[item as EmbeddingMultimodalItem],
fetchMedia
);
const [part] = await prepareGeminiParts([item as EmbeddingMultimodalItem], fetchMedia);
return { parts: [part] };
}
throw new Error("Unsupported Gemini embedding input item");
@@ -346,3 +342,41 @@ export async function prepareStructuredEmbeddingRequest(
}
throw new Error(`Provider ${provider.id} has no structured embedding input translator`);
}
/**
* Normalize a single-text embedding endpoint's response into OpenAI's
* `/v1/embeddings` list shape.
*
* CLOVA Studio's embedding v2 answers:
*
* ```
* {"status":{"code":"20000","message":"OK"},
* "result":{"embedding":[…1024 floats],"inputTokens":4}}
* ```
*
* There is no `data[]` and no `usage` object, so both are synthesized. `index` is
* left at 0 here — the batching loop in `embeddings.ts` rewrites it to the
* caller's position before the response is returned.
*
* A non-20000 status or malformed success envelope throws so an HTTP-200 error
* envelope can never be exposed as an empty successful embedding response.
*/
export function normalizeClovaEmbeddingV2Response(
rawData: Record<string, unknown>
): Record<string, unknown> {
const statusCode = (rawData?.status as { code?: unknown } | undefined)?.code;
if (String(statusCode) !== "20000") {
throw new Error("CLOVA Studio embedding v2 returned an unsuccessful status");
}
const result = (rawData?.result ?? {}) as Record<string, unknown>;
if (!Array.isArray(result.embedding)) {
throw new Error("CLOVA Studio embedding v2 response is missing an embedding vector");
}
const inputTokens = Number(result.inputTokens) || 0;
return {
data: [{ object: "embedding", index: 0, embedding: result.embedding }],
usage: { prompt_tokens: inputTokens, total_tokens: inputTokens },
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
import "./request/claude-to-openai.ts";
import "./request/openai-to-claude.ts";
import "./request/openai-to-clova.ts";
import "./request/gemini-to-openai.ts";
import "./request/openai-to-gemini.ts";
import "./request/antigravity-to-openai.ts";
@@ -15,6 +16,7 @@ import "./request/claude-to-gemini.ts";
import "./response/claude-to-openai.ts";
import "./response/openai-to-claude.ts";
import "./response/clova-to-openai.ts";
import "./response/gemini-to-openai.ts";
import "./response/gemini-to-claude.ts";
import "./response/openai-to-antigravity.ts";

View File

@@ -5,6 +5,8 @@ export const FORMATS = {
OPENAI_RESPONSE: "openai-response",
CLAUDE: "claude",
GEMINI: "gemini",
/** Naver CLOVA Studio Chat Completions v3 (native envelope, model in URL path). */
CLOVA: "clova",
CODEX: "codex",
ANTIGRAVITY: "antigravity",
KIRO: "kiro",

View File

@@ -0,0 +1,375 @@
/**
* OpenAI → Naver CLOVA Studio "Chat Completions v3" request translator.
*
* Wire format: `POST https://clovastudio.stream.ntruss.com/v3/chat-completions/{modelName}`
*
* Everything below that is marked "live-verified" was confirmed against the real
* API on 2026-09-01 — several of these rules contradict a plausible reading of
* the vendor docs, so they are recorded with the evidence.
*
* Vendor docs:
* - text/image: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3
* - thinking: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-thinking
* - FC: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-fc
* - SO: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-so
*/
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
/** Output cap for the non-reasoning v3 models (HCX-005, HCX-DASH-002). */
export const CLOVA_V3_MAX_OUTPUT_TOKENS = 4096;
/** Output cap for the reasoning model (HCX-007) — includes thinking tokens. */
export const CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS = 32768;
/**
* Function calling rejects any cap below 1024 (live-verified: `40001 Invalid
* parameter: tools, maxTokens`).
*/
export const CLOVA_V3_MIN_TOOL_TOKENS = 1024;
export const CLOVA_V3_REASONING_MODELS: ReadonlySet<string> = new Set(["HCX-007"]);
export const CLOVA_V3_VISION_MODELS: ReadonlySet<string> = new Set(["HCX-005"]);
/**
* All three v3 models accept function calling (live-verified). HCX-007 needs
* `thinking.effort: "none"` alongside it or the call fails with
* `40001 Invalid parameter: tools, thinking`.
*/
export const CLOVA_V3_FUNCTION_CALLING_MODELS: ReadonlySet<string> = new Set([
"HCX-005",
"HCX-007",
"HCX-DASH-002",
]);
/** Structured Outputs is HCX-007 only (live-verified: HCX-005 rejects `thinking`). */
export const CLOVA_V3_STRUCTURED_OUTPUT_MODELS: ReadonlySet<string> = new Set(["HCX-007"]);
const CLOVA_THINKING_EFFORTS: ReadonlySet<string> = new Set(["none", "low", "medium", "high"]);
type JsonRecord = Record<string, unknown>;
function toRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}
function nonEmptyString(value: unknown): string {
return typeof value === "string" && value.length > 0 ? value : "";
}
export function isClovaReasoningModel(model: string): boolean {
return typeof model === "string" && CLOVA_V3_REASONING_MODELS.has(model.toUpperCase());
}
export function isClovaVisionModel(model: string): boolean {
return typeof model === "string" && CLOVA_V3_VISION_MODELS.has(model.toUpperCase());
}
export function isClovaFunctionCallingModel(model: string): boolean {
return typeof model === "string" && CLOVA_V3_FUNCTION_CALLING_MODELS.has(model.toUpperCase());
}
export function isClovaStructuredOutputModel(model: string): boolean {
return typeof model === "string" && CLOVA_V3_STRUCTURED_OUTPUT_MODELS.has(model.toUpperCase());
}
function clampNumeric(value: unknown, min: number, max: number): number | null {
const n = typeof value === "string" ? Number(value) : value;
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Math.min(Math.max(n, min), max);
}
/**
* Map OpenAI `reasoning_effort` onto CLOVA's `thinking.effort`.
* `minimal` collapses to `low`; unrecognised values are dropped so CLOVA applies
* its own default (`low`).
*/
export function toClovaThinkingEffort(reasoningEffort: unknown): string {
if (typeof reasoningEffort !== "string") return "";
const effort = reasoningEffort.toLowerCase();
if (effort === "minimal") return "low";
return CLOVA_THINKING_EFFORTS.has(effort) ? effort : "";
}
/** Flatten OpenAI message content into a single string (text only). */
function contentToString(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return content == null ? "" : String(content);
return content
.map((part) =>
part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
)
.filter(Boolean)
.join("\n");
}
/**
* Convert an OpenAI `content` value into CLOVA v3 typed content parts.
*
* Both image transports work (live-verified): a public URL becomes
* `imageUrl.url`, and a `data:` URL becomes `dataUri.data` — which must keep the
* FULL `data:<mime>;base64,` prefix or CLOVA rejects the request with
* `40001 Invalid parameter`.
*/
export function toClovaContent(
content: unknown,
supportsImages: boolean
): Array<Record<string, unknown>> {
if (typeof content === "string") {
return [{ type: "text", text: content }];
}
if (!Array.isArray(content)) {
return [{ type: "text", text: content == null ? "" : String(content) }];
}
const parts = content
.map((part) => toClovaContentPart(part, supportsImages))
.filter((part): part is JsonRecord => part !== null);
// CLOVA rejects a message with an empty content array, so always emit a part.
return parts.length > 0 ? parts : [{ type: "text", text: "" }];
}
function toClovaContentPart(part: unknown, supportsImages: boolean): JsonRecord | null {
const record = toRecord(part);
if (!record) return null;
const text = nonEmptyString(record.text);
if (record.type === "text" || text) return text ? { type: "text", text } : null;
if (record.type !== "image_url" || !supportsImages) return null;
const imageUrl = toRecord(record.image_url);
const url = nonEmptyString(imageUrl?.url) || nonEmptyString(record.url);
if (!url) return null;
return url.startsWith("data:")
? { type: "image_url", dataUri: { data: url } }
: { type: "image_url", imageUrl: { url } };
}
/** Parse OpenAI's JSON-string tool arguments into the object CLOVA expects. */
function toolArgumentsToObject(raw: unknown): Record<string, unknown> {
if (raw == null) return {};
if (typeof raw === "object") return raw as Record<string, unknown>;
if (typeof raw !== "string" || !raw.trim()) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/**
* Convert OpenAI tool declarations into CLOVA's `tools` array.
* The shapes are nearly identical; empty declarations are skipped because CLOVA
* rejects a tool without a name.
*/
export function toClovaTools(tools: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(tools)) return [];
return tools.map(toClovaTool).filter((tool): tool is JsonRecord => tool !== null);
}
function toClovaTool(tool: unknown): JsonRecord | null {
const record = toRecord(tool);
if (!record) return null;
const fn = toRecord(record.function);
const name = nonEmptyString(fn?.name) || nonEmptyString(record.name);
if (!name) return null;
const description =
nonEmptyString(fn?.description) || nonEmptyString(record.description) || `Tool: ${name}`;
const parameters = fn?.parameters ?? record.parameters;
return {
type: "function",
function: {
name,
description,
...(parameters ? { parameters } : {}),
},
};
}
/**
* Which mutually-exclusive v3 mode does this request use?
*
* CLOVA forbids combining function calling with thinking or images, and forbids
* combining structured outputs with either. Exactly one mode is chosen.
*/
export function resolveClovaMode(
model: string,
body: Record<string, unknown>
): "tools" | "structured" | "plain" {
const tools = toClovaTools(body?.tools);
if (tools.length > 0 && isClovaFunctionCallingModel(model)) return "tools";
const format = toRecord(body?.response_format);
const wantsSchema = format && (format.type === "json_schema" || format.type === "json_object");
if (wantsSchema && isClovaStructuredOutputModel(model)) return "structured";
return "plain";
}
type ClovaMode = "tools" | "structured" | "plain";
function normalizeMessageRole(role: unknown): "assistant" | "system" | "user" {
return role === "assistant" || role === "system" ? role : "user";
}
function toClovaToolCall(call: unknown): JsonRecord {
const record = toRecord(call) ?? {};
const fn = toRecord(record.function);
return {
id: record.id ?? "",
type: "function",
function: {
name: fn?.name ?? record.name ?? "",
arguments: toolArgumentsToObject(fn?.arguments ?? record.arguments),
},
};
}
function toClovaToolModeMessage(message: unknown): JsonRecord {
const record = toRecord(message) ?? {};
if (record.role === "tool") {
return {
role: "tool",
content: contentToString(record.content),
...(record.tool_call_id ? { toolCallId: String(record.tool_call_id) } : {}),
};
}
const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : [];
if (record.role === "assistant" && toolCalls.length > 0) {
return {
role: "assistant",
content: "",
toolCalls: toolCalls.map(toClovaToolCall),
};
}
return {
role: normalizeMessageRole(record.role),
content: contentToString(record.content),
};
}
function toClovaPlainMessage(message: unknown, supportsImages: boolean): JsonRecord {
const record = toRecord(message) ?? {};
return {
role: normalizeMessageRole(record.role),
content: toClovaContent(record.content, supportsImages),
};
}
function toClovaMessages(body: JsonRecord, mode: ClovaMode, supportsImages: boolean): JsonRecord[] {
const messages = Array.isArray(body.messages) ? body.messages : [];
return messages.map((message) =>
mode === "tools"
? toClovaToolModeMessage(message)
: toClovaPlainMessage(message, supportsImages)
);
}
function applyThinking(payload: JsonRecord, body: JsonRecord, reasoning: boolean, mode: ClovaMode) {
if (!reasoning) return;
const effort = toClovaThinkingEffort(body.reasoning_effort);
if (mode === "tools" || mode === "structured") {
payload.thinking = { effort: "none" };
} else if (effort) {
payload.thinking = { effort };
}
}
function applySampling(payload: JsonRecord, body: JsonRecord): void {
const temperature = clampNumeric(body.temperature, 0, 1);
if (temperature !== null) payload.temperature = temperature;
const topP = clampNumeric(body.top_p, 0, 1);
if (topP !== null && topP > 0) payload.topP = topP;
const topK = clampNumeric(body.top_k, 0, 128);
if (topK !== null && topK > 0) payload.topK = topK;
const penalty = clampNumeric(body.repetition_penalty, 0, 2);
if (penalty !== null && penalty > 0) payload.repetitionPenalty = penalty;
}
function applyOutputCap(
payload: JsonRecord,
body: JsonRecord,
reasoning: boolean,
mode: ClovaMode
): void {
const cap = reasoning ? CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS : CLOVA_V3_MAX_OUTPUT_TOKENS;
const key = reasoning ? "maxCompletionTokens" : "maxTokens";
let tokens = clampNumeric(body.max_completion_tokens ?? body.max_tokens, 1, cap);
if (mode === "tools") {
const floor = Math.min(CLOVA_V3_MIN_TOOL_TOKENS, cap);
tokens = tokens === null ? floor : Math.max(tokens, floor);
}
if (tokens !== null) payload[key] = tokens;
}
function responseSchema(body: JsonRecord): unknown {
const format = toRecord(body.response_format);
const jsonSchema = toRecord(format?.json_schema);
return jsonSchema?.schema ?? format?.schema;
}
function applyModeFields(payload: JsonRecord, body: JsonRecord, mode: ClovaMode): void {
if (mode === "tools") {
payload.tools = toClovaTools(body.tools);
if (body.tool_choice === "none") payload.toolChoice = "none";
if (body.tool_choice === "auto" || body.tool_choice === "required") {
payload.toolChoice = "auto";
}
return;
}
if (mode !== "structured") return;
const schema = responseSchema(body);
if (schema && typeof schema === "object") {
payload.responseFormat = { type: "json", schema };
} else {
delete payload.thinking;
}
}
function applyPlainOptions(
payload: JsonRecord,
body: JsonRecord,
reasoning: boolean,
mode: ClovaMode
): void {
if (mode === "plain" && !reasoning) {
if (Array.isArray(body.stop) && body.stop.length > 0) {
payload.stop = body.stop.filter((value) => typeof value === "string");
} else if (typeof body.stop === "string" && body.stop) {
payload.stop = [body.stop];
}
}
const seed = clampNumeric(body.seed, 0, 4294967295);
if (seed !== null && seed > 0) payload.seed = Math.floor(seed);
if (body.include_ai_filters === true) payload.includeAiFilters = true;
}
/** Build the CLOVA Studio v3 request body from an OpenAI Chat Completions body. */
export function buildClovaPayload(
model: string,
body: Record<string, unknown>,
stream: boolean,
credentials?: Record<string, unknown> | null
): Record<string, unknown> {
void stream;
void credentials;
const reasoning = isClovaReasoningModel(model);
const mode = resolveClovaMode(model, body);
const supportsImages = mode === "plain" && isClovaVisionModel(model);
const payload: JsonRecord = { messages: toClovaMessages(body, mode, supportsImages) };
applyThinking(payload, body, reasoning, mode);
applySampling(payload, body);
applyOutputCap(payload, body, reasoning, mode);
applyModeFields(payload, body, mode);
applyPlainOptions(payload, body, reasoning, mode);
return payload;
}
register(FORMATS.OPENAI, FORMATS.CLOVA, buildClovaPayload, null);

View File

@@ -0,0 +1,354 @@
/**
* Naver CLOVA Studio "Chat Completions v3" → OpenAI response translator.
*
* CLOVA v3 streams as SSE with **named events**:
*
* ```
* id: <uuid>
* event: token
* data: {"message":{"role":"assistant","content":"안"},"finishReason":null,...}
*
* id: <uuid>
* event: result
* data: {"message":{"role":"assistant","content":"안녕"},"finishReason":"stop",
* "usage":{"promptTokens":20,"completionTokens":5,"totalTokens":25}}
* ```
*
* Three traps this translator exists to defuse:
*
* 1. **`event: token` carries an incremental delta, but `event: result` repeats
* the COMPLETE text.** Concatenating both duplicates the whole answer at the
* end of the stream, so the result event is treated as a terminal snapshot:
* it contributes `finish_reason` + `usage` only.
* 2. **Function-calling streams deliver arguments as `partialJson` fragments.**
* The first token carries the tool `id` + `name`; every later token carries
* only a JSON fragment (`{`, `"location`, `":`, ` "`, `Se`, `oul`, `"}`),
* which have to be reassembled into OpenAI's `tool_calls[].function.arguments`
* string. The terminal frame repeats the finished call, so — same rule as the
* text snapshot — it is not re-emitted.
* 3. **Failures can arrive as an in-stream payload** whose `status.code` is not
* `20000`, not just as an HTTP error. Those are surfaced through
* `state.upstreamError` so stream.ts fails the request out and combo fallback
* can run, mirroring the Gemini translator.
*
* Docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3
*/
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
/** CLOVA's success status code (a string, not an HTTP number). */
const CLOVA_STATUS_OK = "20000";
type JsonRecord = Record<string, unknown>;
interface ClovaStreamState extends JsonRecord {
responseId?: string;
created?: number;
model?: string;
chunkIndex?: number;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
upstreamError?: { status: number; type: string; code: string; message: string };
toolCallStarted?: boolean;
finishReason?: string;
}
function toRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}
/** Map a CLOVA `finishReason` onto the OpenAI vocabulary. */
function mapFinishReason(reason: unknown): string {
switch (String(reason || "")) {
case "length":
return "length";
case "tool_calls":
return "tool_calls";
case "content_filter":
return "content_filter";
default:
return "stop";
}
}
/**
* Map a CLOVA string status code onto an HTTP status for error surfacing.
* Codes are 5-digit strings: `2xxxx` success, `4xxxx` client, `5xxxx` server.
*/
function httpStatusFromClovaCode(code: unknown): number {
const first = String(code || "").charAt(0);
if (first === "4") return 400;
return 502;
}
/**
* Parse one raw SSE frame into `{ event, data }`.
* CLOVA emits `id:` / `event:` / `data:` lines per frame.
*/
export function parseClovaSseFrame(raw: string): { event: string; data: unknown } | null {
if (typeof raw !== "string" || !raw.trim()) return null;
let event = "";
let dataLine = "";
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("event:")) {
event = trimmed.slice(6).trim();
} else if (trimmed.startsWith("data:")) {
dataLine = trimmed.slice(5).trim();
}
}
if (!dataLine) return null;
try {
return { event, data: JSON.parse(dataLine) };
} catch {
return null;
}
}
function baseChunk(state: ClovaStreamState): Record<string, unknown> {
return {
id: state.responseId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "clova",
};
}
/**
* Build one OpenAI delta chunk.
*
* `field` selects the delta key: `"content"` for the visible answer and
* `"reasoning_content"` for CLOVA's `thinkingContent` (HCX-007).
*/
function deltaChunk(
state: ClovaStreamState,
content: string,
field = "content"
): Record<string, unknown> {
const chunk = baseChunk(state);
chunk.choices = [
{
index: 0,
delta: {
...((state.chunkIndex ?? 0) === 0 ? { role: "assistant" } : {}),
[field]: content,
},
finish_reason: null,
},
];
state.chunkIndex = (state.chunkIndex ?? 0) + 1;
return chunk;
}
/** First tool-call chunk: carries id + name and opens an empty argument string. */
function toolCallStartChunk(
state: ClovaStreamState,
id: string,
name: string
): Record<string, unknown> {
const chunk = baseChunk(state);
chunk.choices = [
{
index: 0,
delta: {
...((state.chunkIndex ?? 0) === 0 ? { role: "assistant" } : {}),
tool_calls: [
{
index: 0,
id: id || `call_${state.responseId}`,
type: "function",
function: { name, arguments: "" },
},
],
},
finish_reason: null,
},
];
state.chunkIndex = (state.chunkIndex ?? 0) + 1;
return chunk;
}
/** Subsequent tool-call chunk: appends one `partialJson` fragment. */
function toolCallArgumentsChunk(
state: ClovaStreamState,
fragment: string
): Record<string, unknown> {
const chunk = baseChunk(state);
chunk.choices = [
{
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: fragment } }] },
finish_reason: null,
},
];
state.chunkIndex = (state.chunkIndex ?? 0) + 1;
return chunk;
}
function terminalChunk(state: ClovaStreamState, finishReason: string): Record<string, unknown> {
const chunk = baseChunk(state);
chunk.choices = [{ index: 0, delta: {}, finish_reason: finishReason }];
if (state.usage) chunk.usage = state.usage;
return chunk;
}
function recordUsage(state: ClovaStreamState, usage: unknown): void {
const record = toRecord(usage);
if (!record) return;
const prompt = Number(record.promptTokens) || 0;
const completion = Number(record.completionTokens) || 0;
const total = Number(record.totalTokens) || prompt + completion;
state.usage = {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: total,
};
}
function recordUpstreamError(state: ClovaStreamState, code: unknown, message: unknown): void {
const status = httpStatusFromClovaCode(code);
state.upstreamError = {
status,
type: status === 429 ? "rate_limit_error" : "server_error",
code: String(code || "clova_error"),
message: typeof message === "string" && message ? message : "CLOVA Studio upstream failure",
};
}
interface DecodedClovaChunk {
event: string;
data: JsonRecord;
}
function initializeState(state: ClovaStreamState): void {
if (state.responseId) return;
state.responseId = `chatcmpl-${Date.now()}`;
state.created = Math.floor(Date.now() / 1000);
state.chunkIndex = 0;
}
function decodeClovaChunk(chunk: unknown): DecodedClovaChunk | null {
if (typeof chunk === "string") {
const frame = parseClovaSseFrame(chunk);
const data = toRecord(frame?.data);
return frame && data ? { event: frame.event, data } : null;
}
const data = toRecord(chunk);
if (!data) return null;
return { event: String(data.event || data._eventType || ""), data };
}
function handleErrorEnvelope(state: ClovaStreamState, event: string, data: JsonRecord): boolean {
const status = toRecord(data.status);
const statusCode = status?.code ?? data.statusCode;
if (statusCode != null && String(statusCode) !== CLOVA_STATUS_OK) {
recordUpstreamError(state, statusCode, status?.message ?? data.message);
return true;
}
const error = toRecord(data.error);
if (event !== "error" && !error) return false;
const source = error ?? data;
const errorStatus = toRecord(source.status);
recordUpstreamError(
state,
errorStatus?.code ?? source.code,
errorStatus?.message ?? source.message
);
return true;
}
function toolCallDelta(state: ClovaStreamState, call: unknown): Record<string, unknown> | null {
const record = toRecord(call);
const fn = toRecord(record?.function);
if (!record || !fn) return null;
const id = typeof record.id === "string" ? record.id : "";
const name = typeof fn.name === "string" ? fn.name : "";
if (id || name) {
if (state.toolCallStarted) return null;
state.toolCallStarted = true;
return toolCallStartChunk(state, id, name);
}
return typeof fn.partialJson === "string" && fn.partialJson
? toolCallArgumentsChunk(state, fn.partialJson)
: null;
}
function toolCallDeltas(
state: ClovaStreamState,
message: JsonRecord
): Record<string, unknown> | Array<Record<string, unknown>> | null {
if (!Array.isArray(message.toolCalls) || message.toolCalls.length === 0) return null;
const out = message.toolCalls
.map((call) => toolCallDelta(state, call))
.filter((chunk): chunk is Record<string, unknown> => chunk !== null);
if (out.length === 0) return null;
return out.length === 1 ? out[0] : out;
}
function convertTokenEvent(
state: ClovaStreamState,
data: JsonRecord
): Record<string, unknown> | Array<Record<string, unknown>> | null {
const message = toRecord(data.message) ?? data;
const toolDeltas = toolCallDeltas(state, message);
if (toolDeltas) return toolDeltas;
const thinking = message.thinkingContent ?? data.thinkingContent;
if (thinking) return deltaChunk(state, String(thinking), "reasoning_content");
const content = message.content ?? data.content;
return content ? deltaChunk(state, String(content)) : null;
}
function shouldEmitResultSnapshot(
state: ClovaStreamState,
isResultEvent: boolean,
snapshot: unknown
): snapshot is string {
return (
!isResultEvent && (state.chunkIndex ?? 0) === 0 && typeof snapshot === "string" && !!snapshot
);
}
function convertResultEvent(
state: ClovaStreamState,
event: string,
data: JsonRecord
): Record<string, unknown> | Array<Record<string, unknown>> | null {
const isResultEvent = event === "result" || event === "stop";
const resultEnvelope = toRecord(data.result);
if (!isResultEvent && (event || !resultEnvelope)) return null;
const result = resultEnvelope ?? data;
const message = toRecord(result.message);
recordUsage(state, result.usage);
const hasToolCalls = Array.isArray(message?.toolCalls) && message.toolCalls.length > 0;
const finishReason = hasToolCalls ? "tool_calls" : mapFinishReason(result.finishReason);
state.finishReason = finishReason;
const snapshot = message?.content ?? result.content;
if (shouldEmitResultSnapshot(state, isResultEvent, snapshot)) {
return [deltaChunk(state, snapshot), terminalChunk(state, finishReason)];
}
return terminalChunk(state, finishReason);
}
/** Convert one CLOVA stream frame or JSON envelope into OpenAI chunk(s). */
export function convertClovaToOpenAI(
chunk: unknown,
state: Record<string, unknown>
): Record<string, unknown> | Array<Record<string, unknown>> | null {
if (chunk == null) return null;
const streamState = state as ClovaStreamState;
initializeState(streamState);
const decoded = decodeClovaChunk(chunk);
if (!decoded) return null;
if (handleErrorEnvelope(streamState, decoded.event, decoded.data)) return null;
return decoded.event === "token"
? convertTokenEvent(streamState, decoded.data)
: convertResultEvent(streamState, decoded.event, decoded.data);
}
register(FORMATS.CLOVA, FORMATS.OPENAI, null, convertClovaToOpenAI);

View File

@@ -211,6 +211,8 @@ export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [
"antigravity:openai",
"claude:gemini",
"claude:openai",
// Naver CLOVA Studio Chat Completions v3 (native envelope, model in URL path).
"clova:openai",
"cursor:openai",
"gemini:claude",
"gemini:openai",
@@ -218,6 +220,7 @@ export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [
"openai-responses:openai",
"openai:antigravity",
"openai:claude",
"openai:clova",
"openai:cursor",
"openai:gemini",
"openai:kiro",

View File

@@ -494,7 +494,7 @@ export const APIKEY_PROVIDERS_REGIONAL = {
textIcon: "CS",
website: "https://api.ncloud-docs.com/docs/en/ai-naver-clovastudio-summary",
apiHint:
"CLOVA Studio (HyperCLOVA X) is OpenAI-compatible on /v1/openai. OmniRoute probes /v1/openai/models and routes chat traffic to /v1/openai/chat/completions. Uses the current clovastudio.stream.ntruss.com host — the legacy clovastudio.apigw.ntruss.com endpoint is being deprecated.",
"OmniRoute routes chat traffic to the native Chat Completions v3 API (/v3/chat-completions/{model}), not the OpenAI-compatibility shim. All three v3 models are served: HCX-007 (reasoning, text only), HCX-005 (vision — accepts both public image URLs and inline base64 images), and HCX-DASH-002 (lightweight, text only). Requests stream upstream and are accumulated into a JSON body when the client asks for a non-streaming response.",
},
internlm: {
id: "internlm",

View File

@@ -57,6 +57,11 @@ export const VISION_MODEL_ID_FRAGMENTS = [
"mistral-medium-3",
"minimax-m3",
"kimi-k2.",
// Naver CLOVA Studio: HCX-005 is the only v3 model with image input. Listed by
// exact id (not a family fragment) to stay conservative — live-verified on
// 2026-09-01 that it answers image prompts over both a public URL and a
// base64 data URI, while HCX-007 and HCX-DASH-002 reject images.
"hcx-005",
"-vision",
"multimodal",
] as const;

View File

@@ -135,6 +135,11 @@
"configSource": "<custom-config>",
"provider": "cloudflare-playground"
},
"clova-studio": {
"className": "ClovaStudioExecutor",
"configSource": "clova-studio",
"provider": "clova-studio"
},
"cmd": {
"className": "CommandCodeExecutor",
"configSource": "<custom-config>",
@@ -671,6 +676,6 @@
"provider": "zai-web"
}
},
"keyCount": 134,
"keyCount": 135,
"sharedInstances": []
}

View File

@@ -1217,7 +1217,7 @@
}
},
"clova-studio": {
"format": "openai",
"format": "clova",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
@@ -1235,8 +1235,8 @@
}
},
"url": {
"nonStream": "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions",
"stream": "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions"
"nonStream": "https://clovastudio.stream.ntruss.com/v3/chat-completions",
"stream": "https://clovastudio.stream.ntruss.com/v3/chat-completions"
}
},
"codebuddy-cn": {

View File

@@ -0,0 +1,258 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Naver CLOVA Studio embedding v2.
//
// The endpoint embeds exactly ONE text per request (`{"text": …}` → one vector)
// and answers `{status, result:{embedding:[…1024 floats], inputTokens}}`, so a
// batched `/v1/embeddings` call has to be fanned out into N upstream calls and
// merged back into OpenAI's list shape.
//
// Live-verified against the API on 2026-09-01: 1024 dimensions, ~100ms per call,
// and an empty string is rejected with `40004 Text empty`.
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-clova-embeddings-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.SQLITE_FILE = join(TEST_DATA_DIR, "storage.sqlite");
const registry = await import("../../open-sse/config/embeddingRegistry.ts");
const { normalizeClovaEmbeddingV2Response } =
await import("../../open-sse/handlers/embeddingStructuredInput.ts");
const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts");
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
test.after(async () => {
// handleEmbedding records call logs asynchronously; let those writes settle
// before closing the singleton so a late write cannot reopen the test DB.
await new Promise((resolve) => setTimeout(resolve, 50));
resetDbInstance();
});
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
test("clova embedding v2 is registered with the single-text protocol", () => {
const provider = registry.getEmbeddingProvider("clova-studio");
assert.ok(provider, "clova-studio must be an embedding provider");
assert.equal(provider.baseUrl, "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2");
assert.equal(provider.singleTextProtocol, "clova-v2");
assert.equal(provider.authType, "apikey");
assert.equal(provider.authHeader, "bearer");
assert.deepEqual(provider.models, [
{ id: "clova-embedding-v2", name: "CLOVA Embedding v2", dimensions: 1024 },
]);
});
test("clova embedding v2 resolves its model and dimension", () => {
assert.deepEqual(registry.parseEmbeddingModel("clova-studio/clova-embedding-v2"), {
provider: "clova-studio",
model: "clova-embedding-v2",
});
assert.equal(registry.getEmbeddingDimension("clova-studio/clova-embedding-v2"), 1024);
});
// ---------------------------------------------------------------------------
// Response normalisation
// ---------------------------------------------------------------------------
test("a success envelope is normalised into OpenAI list shape", () => {
const normalized = normalizeClovaEmbeddingV2Response({
status: { code: "20000", message: "OK" },
result: { embedding: [0.1, -0.2, 0.3], inputTokens: 4 },
});
assert.deepEqual(normalized, {
data: [{ object: "embedding", index: 0, embedding: [0.1, -0.2, 0.3] }],
usage: { prompt_tokens: 4, total_tokens: 4 },
});
});
test("a failure envelope is rejected instead of becoming an empty success", () => {
assert.throws(
() =>
normalizeClovaEmbeddingV2Response({
status: { code: "40004", message: "Text empty" },
}),
/unsuccessful status/
);
});
test("a payload without an embedding vector is rejected", () => {
assert.throws(
() =>
normalizeClovaEmbeddingV2Response({
status: { code: "20000" },
result: { inputTokens: 0 },
}),
/missing an embedding vector/
);
});
// ---------------------------------------------------------------------------
// Batch fan-out through the real handler
// ---------------------------------------------------------------------------
const originalFetch = globalThis.fetch;
function mockClova(calls: Array<Record<string, unknown>>): void {
globalThis.fetch = (async (_url: string, init: RequestInit) => {
calls.push(JSON.parse(String(init.body)));
const text = String((calls[calls.length - 1] as { text?: string }).text ?? "");
return new Response(
JSON.stringify({
status: { code: "20000", message: "OK" },
result: { embedding: [text.length, 1, 2], inputTokens: text.length },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
}
function mockClovaEnvelope(
calls: Array<Record<string, unknown>>,
envelope: Record<string, unknown>
): void {
globalThis.fetch = (async (_url: string, init: RequestInit) => {
calls.push(JSON.parse(String(init.body)));
return new Response(JSON.stringify(envelope), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
}
test("a batched input is fanned out into one upstream call per text", async () => {
const calls: Array<Record<string, unknown>> = [];
mockClova(calls);
try {
const result = await handleEmbedding({
body: { model: "clova-studio/clova-embedding-v2", input: ["alpha", "beta", "gamma"] },
credentials: { apiKey: "test-key" },
});
assert.equal(result.success, true, JSON.stringify(result));
// One request per text — the endpoint cannot batch.
assert.deepEqual(
calls.map((c) => c.text),
["alpha", "beta", "gamma"]
);
const data = (result as { data: Record<string, unknown> }).data;
assert.equal(data.object, "list");
assert.equal(data.model, "clova-studio/clova-embedding-v2");
assert.equal((data.data as unknown[]).length, 3);
// Indexes must reflect the caller's positions, not each upstream call's 0.
assert.deepEqual(
(data.data as Array<{ index: number }>).map((d) => d.index),
[0, 1, 2]
);
// Token usage is summed across the fan-out.
assert.equal((data.usage as { prompt_tokens: number }).prompt_tokens, 5 + 4 + 5);
} finally {
globalThis.fetch = originalFetch;
}
});
test("an empty text rejects the batch without changing response indexes", async () => {
const calls: Array<Record<string, unknown>> = [];
mockClova(calls);
try {
const result = await handleEmbedding({
body: { model: "clova-studio/clova-embedding-v2", input: ["", " ", "real"] },
credentials: { apiKey: "test-key" },
});
assert.equal(result.success, false);
assert.equal((result as { status: number }).status, 400);
assert.equal(calls.length, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("an all-empty input fails without calling upstream", async () => {
const calls: Array<Record<string, unknown>> = [];
mockClova(calls);
try {
const result = await handleEmbedding({
body: { model: "clova-studio/clova-embedding-v2", input: ["", ""] },
credentials: { apiKey: "test-key" },
});
assert.equal(result.success, false);
assert.equal((result as { status: number }).status, 400);
assert.equal(calls.length, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("a single string input takes the fan-out path too", async () => {
const calls: Array<Record<string, unknown>> = [];
mockClova(calls);
try {
const result = await handleEmbedding({
body: { model: "clova-studio/clova-embedding-v2", input: "solo" },
credentials: { apiKey: "test-key" },
});
assert.equal(result.success, true, JSON.stringify(result));
assert.deepEqual(
calls.map((c) => c.text),
["solo"]
);
assert.equal(((result as { data: Record<string, unknown> }).data.data as unknown[]).length, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test("an HTTP-200 CLOVA error envelope becomes a provider failure", async () => {
const calls: Array<Record<string, unknown>> = [];
mockClovaEnvelope(calls, { status: { code: "40004", message: "Text empty" } });
try {
const result = await handleEmbedding({
body: { model: "clova-studio/clova-embedding-v2", input: "text" },
credentials: { apiKey: "test-key" },
});
assert.equal(result.success, false);
assert.equal((result as { status: number }).status, 502);
assert.equal(calls.length, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test("token-array input is rejected instead of being silently dropped", async () => {
const calls: Array<Record<string, unknown>> = [];
mockClova(calls);
try {
const result = await handleEmbedding({
body: { model: "clova-studio/clova-embedding-v2", input: [101, 202] },
credentials: { apiKey: "test-key" },
});
assert.equal(result.success, false);
assert.equal((result as { status: number }).status, 400);
assert.equal(calls.length, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("unsupported output options are rejected instead of ignored", async () => {
for (const extra of [{ encoding_format: "base64" }, { dimensions: 1536 }]) {
const calls: Array<Record<string, unknown>> = [];
mockClova(calls);
try {
const result = await handleEmbedding({
body: { model: "clova-studio/clova-embedding-v2", input: "text", ...extra },
credentials: { apiKey: "test-key" },
});
assert.equal(result.success, false);
assert.equal((result as { status: number }).status, 400);
assert.equal(calls.length, 0);
} finally {
globalThis.fetch = originalFetch;
}
}
});

View File

@@ -0,0 +1,825 @@
import test from "node:test";
import assert from "node:assert/strict";
// Naver CLOVA Studio "Chat Completions v3" translator pair.
//
// The guard that matters most here is the stream-duplication case: `event: token`
// carries an INCREMENTAL delta while the terminal `event: result` repeats the
// COMPLETE text. Concatenating both doubles the whole answer at the end of the
// stream, so the result event must contribute finish_reason + usage only.
//
// Wire docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3
const request = await import("../../open-sse/translator/request/openai-to-clova.ts");
const response = await import("../../open-sse/translator/response/clova-to-openai.ts");
const registry = await import("../../open-sse/translator/registry.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const { getExecutor } = await import("../../open-sse/executors/index.ts");
// ---------------------------------------------------------------------------
// Request: OpenAI → CLOVA v3
// ---------------------------------------------------------------------------
test("clova v3: registers the request and response translator pair", () => {
assert.ok(registry.getRequestTranslator(FORMATS.OPENAI, FORMATS.CLOVA));
assert.ok(registry.getResponseTranslator(FORMATS.CLOVA, FORMATS.OPENAI));
});
test("clova v3: its executor appends and URL-encodes the selected model", async () => {
const executor = await getExecutor("clova-studio");
assert.equal(
executor.buildUrl("HCX 005", true),
"https://clovastudio.stream.ntruss.com/v3/chat-completions/HCX%20005"
);
});
test("clova v3: string content becomes a typed text part", () => {
const body = { messages: [{ role: "user", content: "hello" }] };
const payload = request.buildClovaPayload("HCX-005", body, true, null);
assert.deepEqual(payload.messages[0], {
role: "user",
content: [{ type: "text", text: "hello" }],
});
});
test("clova v3: sampling params are camelCased", () => {
const payload = request.buildClovaPayload(
"HCX-005",
{
messages: [{ role: "user", content: "hi" }],
max_tokens: 512,
top_p: 0.8,
top_k: 4,
temperature: 0.5,
repetition_penalty: 1.15,
seed: 42,
stop: ["END"],
},
true,
null
);
assert.equal(payload.maxTokens, 512);
assert.equal(payload.topP, 0.8);
assert.equal(payload.topK, 4);
assert.equal(payload.temperature, 0.5);
assert.equal(payload.repetitionPenalty, 1.15);
assert.equal(payload.seed, 42);
assert.deepEqual(payload.stop, ["END"]);
// snake_case must not leak upstream.
assert.equal(payload.max_tokens, undefined);
assert.equal(payload.top_p, undefined);
});
test("clova v3: output tokens are clamped to the documented 4096 cap", () => {
const payload = request.buildClovaPayload(
"HCX-005",
{ messages: [{ role: "user", content: "hi" }], max_tokens: 100000 },
true,
null
);
assert.equal(payload.maxTokens, request.CLOVA_V3_MAX_OUTPUT_TOKENS);
});
test("clova v3: max_completion_tokens on a text model still maps to maxTokens", () => {
// Only reasoning models speak `maxCompletionTokens`; for text models the cap is
// `maxTokens` regardless of which OpenAI alias the client used.
const payload = request.buildClovaPayload(
"HCX-005",
{ messages: [{ role: "user", content: "hi" }], max_completion_tokens: 1024 },
true,
null
);
assert.equal(payload.maxTokens, 1024);
assert.equal(payload.maxCompletionTokens, undefined);
});
test("clova v3: model and stream are not sent in the body", () => {
const payload = request.buildClovaPayload(
"HCX-005",
{ model: "HCX-005", stream: true, messages: [{ role: "user", content: "hi" }] },
true,
null
);
// The model travels in the URL path and streaming is driven by Accept.
assert.equal(payload.model, undefined);
assert.equal(payload.stream, undefined);
});
// ---------------------------------------------------------------------------
// Function calling (v3-fc) — same endpoint, different body fields
// ---------------------------------------------------------------------------
test("clova v3: tools are translated and toolChoice auto is forwarded", () => {
const payload = request.buildClovaPayload(
"HCX-005",
{
messages: [{ role: "user", content: "Weather in Seoul?" }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get the weather for a city",
parameters: {
type: "object",
properties: { location: { type: "string" } },
required: ["location"],
},
},
},
],
tool_choice: "auto",
},
true,
null
);
assert.deepEqual(payload.tools, [
{
type: "function",
function: {
name: "get_weather",
description: "Get the weather for a city",
parameters: {
type: "object",
properties: { location: { type: "string" } },
required: ["location"],
},
},
},
]);
assert.equal(payload.toolChoice, "auto");
});
test("clova v3: toolChoice none is forwarded; a forced choice is dropped", () => {
// Live-verified: `toolChoice: {type:"function", function:{name}}` returns
// `40009 Unsupported function` — CLOVA only accepts "auto" and "none".
const none = request.buildClovaPayload(
"HCX-005",
{
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "f" } }],
tool_choice: "none",
},
true,
null
);
assert.equal(none.toolChoice, "none");
const forced = request.buildClovaPayload(
"HCX-005",
{
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "f" } }],
tool_choice: { type: "function", function: { name: "f" } },
},
true,
null
);
assert.equal(forced.toolChoice, undefined);
});
test("clova v3: function calling raises the cap to the documented 1024 minimum", () => {
// Live-verified: any cap below 1024 fails with
// `40001 Invalid parameter: tools, maxTokens`.
const below = request.buildClovaPayload(
"HCX-005",
{
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "f" } }],
max_tokens: 128,
},
true,
null
);
assert.equal(below.maxTokens, request.CLOVA_V3_MIN_TOOL_TOKENS);
const absent = request.buildClovaPayload(
"HCX-005",
{
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "f" } }],
},
true,
null
);
assert.equal(absent.maxTokens, request.CLOVA_V3_MIN_TOOL_TOKENS);
const above = request.buildClovaPayload(
"HCX-005",
{
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "f" } }],
max_tokens: 2048,
},
true,
null
);
assert.equal(above.maxTokens, 2048);
});
test("clova v3: function calling forces thinking.effort none on the reasoning model", () => {
// Live-verified: HCX-007 without it returns
// `40001 Invalid parameter: tools, thinking`.
const payload = request.buildClovaPayload(
"HCX-007",
{
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "f" } }],
max_tokens: 2048,
},
true,
null
);
assert.deepEqual(payload.thinking, { effort: "none" });
assert.equal(payload.maxCompletionTokens, 2048);
assert.equal(payload.maxTokens, undefined);
});
test("clova v3: non-reasoning models never receive a thinking field", () => {
// Regression guard: HCX-005 and HCX-DASH-002 reject `thinking` outright
// (live-verified: `40001 Invalid parameter: thinking`), even with tools.
for (const model of ["HCX-005", "HCX-DASH-002"]) {
const withTools = request.buildClovaPayload(
model,
{
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "f" } }],
},
true,
null
);
assert.equal(withTools.thinking, undefined, `${model} must not receive thinking`);
assert.ok(Array.isArray(withTools.tools));
const askingForReasoning = request.buildClovaPayload(
model,
{ messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
true,
null
);
assert.equal(askingForReasoning.thinking, undefined, `${model} ignores reasoning_effort`);
}
});
test("clova v3: images are dropped in function-calling mode", () => {
const payload = request.buildClovaPayload(
"HCX-005",
{
messages: [
{
role: "user",
content: [
{ type: "text", text: "describe" },
{ type: "image_url", image_url: { url: "https://example.com/a.png" } },
],
},
],
tools: [{ type: "function", function: { name: "f" } }],
},
true,
null
);
assert.deepEqual(payload.messages[0], { role: "user", content: "describe" });
assert.ok(!JSON.stringify(payload).includes("imageUrl"));
});
test("clova v3: a tool result round-trips as role tool with toolCallId", () => {
const payload = request.buildClovaPayload(
"HCX-005",
{
messages: [
{ role: "user", content: "Weather in Seoul?" },
{
role: "assistant",
content: "",
tool_calls: [
{
id: "call_abc",
type: "function",
function: { name: "get_weather", arguments: '{"location":"Seoul"}' },
},
],
},
{ role: "tool", tool_call_id: "call_abc", content: '{"temp":17}' },
],
tools: [{ type: "function", function: { name: "get_weather" } }],
},
true,
null
);
assert.deepEqual(payload.messages[1], {
role: "assistant",
content: "",
toolCalls: [
{
id: "call_abc",
type: "function",
function: { name: "get_weather", arguments: { location: "Seoul" } },
},
],
});
// CLOVA wants `arguments` as an object; OpenAI sends a JSON string.
assert.equal(typeof payload.messages[1].toolCalls[0].function.arguments, "object");
assert.deepEqual(payload.messages[2], {
role: "tool",
content: '{"temp":17}',
toolCallId: "call_abc",
});
});
// ---------------------------------------------------------------------------
// Structured Outputs (v3-so) — HCX-007 only
// ---------------------------------------------------------------------------
test("clova v3: json_schema maps onto responseFormat", () => {
const schema = {
type: "object",
properties: { temp_high_c: { type: "number" } },
required: ["temp_high_c"],
};
const payload = request.buildClovaPayload(
"HCX-007",
{
messages: [{ role: "user", content: "..." }],
response_format: { type: "json_schema", json_schema: { name: "weather", schema } },
},
true,
null
);
assert.deepEqual(payload.responseFormat, { type: "json", schema });
// Structured Outputs cannot be combined with reasoning (live-verified).
assert.deepEqual(payload.thinking, { effort: "none" });
});
test("clova v3: structured outputs are dropped off the HCX-007-only path", () => {
// HCX-005 rejects `thinking` outright, so SO is unavailable there
// (live-verified: `40001 Invalid parameter: thinking`).
const payload = request.buildClovaPayload(
"HCX-005",
{
messages: [{ role: "user", content: "..." }],
response_format: {
type: "json_schema",
json_schema: { name: "x", schema: { type: "object" } },
},
},
true,
null
);
assert.equal(payload.responseFormat, undefined);
assert.equal(payload.thinking, undefined);
});
test("clova v3: function calling wins over structured outputs", () => {
const payload = request.buildClovaPayload(
"HCX-007",
{
messages: [{ role: "user", content: "..." }],
tools: [{ type: "function", function: { name: "f" } }],
response_format: {
type: "json_schema",
json_schema: { name: "x", schema: { type: "object" } },
},
},
true,
null
);
assert.ok(Array.isArray(payload.tools));
assert.equal(payload.responseFormat, undefined);
});
test("clova v3: a public image URL maps to imageUrl.url", () => {
const payload = request.buildClovaPayload(
"HCX-005",
{
messages: [
{
role: "user",
content: [
{ type: "text", text: "describe" },
{ type: "image_url", image_url: { url: "https://example.com/a.png" } },
],
},
],
},
true,
null
);
const parts = payload.messages[0].content;
assert.deepEqual(parts[1], {
type: "image_url",
imageUrl: { url: "https://example.com/a.png" },
});
});
test("clova v3: base64 images keep their full data-URI prefix in dataUri.data", () => {
// Regression guard: the prefix MUST survive. Sending only the base64 payload
// (prefix stripped) makes CLOVA reject the whole request with
// `40001 Invalid parameter`, while the complete data-URI string is accepted.
// Live-verified 2026-09-01 with PNG and JPEG at 16x16, 64x64 and full size.
const payload = request.buildClovaPayload(
"HCX-005",
{
messages: [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAABBBB" } },
{ type: "text", text: "what is this" },
],
},
],
},
true,
null
);
assert.deepEqual(payload.messages[0].content[0], {
type: "image_url",
dataUri: { data: "data:image/png;base64,AAAABBBB" },
});
assert.deepEqual(payload.messages[0].content[1], { type: "text", text: "what is this" });
});
test("clova v3: a data: image never leaks into imageUrl.url", () => {
const payload = request.buildClovaPayload(
"HCX-005",
{
messages: [
{
role: "user",
content: [{ type: "image_url", image_url: { url: "data:image/jpeg;base64,ZZZZ" } }],
},
],
},
true,
null
);
const part = payload.messages[0].content[0];
assert.equal(part.imageUrl, undefined);
assert.deepEqual(part.dataUri, { data: "data:image/jpeg;base64,ZZZZ" });
});
test("clova v3: images are stripped for a text-only model", () => {
const payload = request.buildClovaPayload(
"HCX-DASH-002",
{
messages: [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: "https://example.com/a.png" } },
{ type: "text", text: "describe" },
],
},
],
},
true,
null
);
assert.deepEqual(payload.messages[0].content, [{ type: "text", text: "describe" }]);
});
// ---------------------------------------------------------------------------
// Reasoning model (HCX-007) contract
// ---------------------------------------------------------------------------
test("clova v3: reasoning models use maxCompletionTokens, never maxTokens", () => {
// Live-verified: HCX-007 answers 40001 "Invalid parameter: maxTokens" when the
// cap is sent as `maxTokens`, and succeeds with `maxCompletionTokens`.
const withMaxTokens = request.buildClovaPayload(
"HCX-007",
{ messages: [{ role: "user", content: "hi" }], max_tokens: 1024 },
true,
null
);
assert.equal(withMaxTokens.maxCompletionTokens, 1024);
assert.equal(withMaxTokens.maxTokens, undefined);
const withMaxCompletion = request.buildClovaPayload(
"HCX-007",
{ messages: [{ role: "user", content: "hi" }], max_completion_tokens: 2048 },
true,
null
);
assert.equal(withMaxCompletion.maxCompletionTokens, 2048);
});
test("clova v3: reasoning output cap is 32768, not the 4096 text-model cap", () => {
const payload = request.buildClovaPayload(
"HCX-007",
{ messages: [{ role: "user", content: "hi" }], max_tokens: 999999 },
true,
null
);
assert.equal(payload.maxCompletionTokens, request.CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS);
const textModel = request.buildClovaPayload(
"HCX-005",
{ messages: [{ role: "user", content: "hi" }], max_tokens: 999999 },
true,
null
);
assert.equal(textModel.maxTokens, request.CLOVA_V3_MAX_OUTPUT_TOKENS);
});
test("clova v3: stop is dropped for reasoning models", () => {
// The vendor docs state `stop` cannot be used while thinking.
const reasoning = request.buildClovaPayload(
"HCX-007",
{ messages: [{ role: "user", content: "hi" }], stop: ["END"] },
true,
null
);
assert.equal(reasoning.stop, undefined);
const text = request.buildClovaPayload(
"HCX-005",
{ messages: [{ role: "user", content: "hi" }], stop: ["END"] },
true,
null
);
assert.deepEqual(text.stop, ["END"]);
});
test("clova v3: images are stripped for the reasoning model (HCX-007 has no vision)", () => {
const payload = request.buildClovaPayload(
"HCX-007",
{
messages: [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: "https://example.com/a.png" } },
{ type: "text", text: "describe" },
],
},
],
},
true,
null
);
assert.deepEqual(payload.messages[0].content, [{ type: "text", text: "describe" }]);
});
test("clova v3: reasoning_effort maps onto thinking.effort", () => {
assert.equal(request.toClovaThinkingEffort("low"), "low");
assert.equal(request.toClovaThinkingEffort("high"), "high");
// OpenAI's `minimal` has no CLOVA equivalent; `low` is the closest.
assert.equal(request.toClovaThinkingEffort("minimal"), "low");
// Unknown values are omitted so CLOVA applies its own default.
assert.equal(request.toClovaThinkingEffort("bogus"), "");
const payload = request.buildClovaPayload(
"HCX-007",
{ messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
true,
null
);
assert.deepEqual(payload.thinking, { effort: "high" });
const noEffort = request.buildClovaPayload(
"HCX-007",
{ messages: [{ role: "user", content: "hi" }] },
true,
null
);
assert.equal(noEffort.thinking, undefined);
// Non-reasoning models must never receive the thinking envelope.
const textModel = request.buildClovaPayload(
"HCX-005",
{ messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
true,
null
);
assert.equal(textModel.thinking, undefined);
});
// ---------------------------------------------------------------------------
// Response: CLOVA v3 → OpenAI
// ---------------------------------------------------------------------------
function tokenFrame(text: string): string {
return (
`id: aabb\n` +
`event: token\n` +
`data: ${JSON.stringify({ message: { role: "assistant", content: text }, finishReason: null, created: 1 })}\n\n`
);
}
function resultFrame(fullText: string): string {
return (
`id: aabb\n` +
`event: result\n` +
`data: ${JSON.stringify({
message: { role: "assistant", content: fullText },
finishReason: "stop",
created: 1,
usage: { promptTokens: 20, completionTokens: 5, totalTokens: 25 },
})}\n\n`
);
}
test("clova v3: a token frame emits an incremental delta", () => {
const state = {};
const chunk = response.convertClovaToOpenAI(tokenFrame("안"), state);
assert.equal(chunk.choices[0].delta.content, "안");
// First chunk carries the assistant role, per OpenAI semantics.
assert.equal(chunk.choices[0].delta.role, "assistant");
assert.equal(chunk.choices[0].finish_reason, null);
});
test("clova v3: the result frame does NOT repeat the already-streamed text", () => {
const state = {};
response.convertClovaToOpenAI(tokenFrame("안"), state);
response.convertClovaToOpenAI(tokenFrame("녕"), state);
const terminal = response.convertClovaToOpenAI(resultFrame("안녕"), state);
// The snapshot text must not be re-emitted — this is the duplication guard.
assert.equal(terminal.choices[0].delta.content, undefined);
assert.deepEqual(terminal.choices[0].delta, {});
assert.equal(terminal.choices[0].finish_reason, "stop");
});
test("clova v3: a full token→result stream yields the answer exactly once", () => {
const state = {};
const frames = [tokenFrame("안"), tokenFrame("녕"), resultFrame("안녕")];
const text = frames
.map((frame) => response.convertClovaToOpenAI(frame, state))
.filter(Boolean)
.map((chunk) => chunk.choices?.[0]?.delta?.content ?? "")
.join("");
assert.equal(text, "안녕");
assert.notEqual(text, "안녕안녕");
assert.deepEqual(state.usage, {
prompt_tokens: 20,
completion_tokens: 5,
total_tokens: 25,
});
});
test("clova v3: an upstream status failure surfaces as state.upstreamError", () => {
const state = {};
const frame =
`id: aabb\n` +
`event: error\n` +
`data: ${JSON.stringify({ status: { code: "40100", message: "Invalid API key" } })}\n\n`;
assert.equal(response.convertClovaToOpenAI(frame, state), null);
assert.equal(state.upstreamError.status, 400);
assert.match(state.upstreamError.message, /Invalid API key/);
});
test("clova v3: a 5xxxx status maps to a 502 upstream error", () => {
const state = {};
const payload = {
status: { code: "50000", message: "Internal Server Error" },
result: null,
};
assert.equal(response.convertClovaToOpenAI(payload, state), null);
assert.equal(state.upstreamError.status, 502);
});
test("clova v3: a non-stream envelope replays its text once, then terminates", () => {
const state = {};
const out = response.convertClovaToOpenAI(
{
status: { code: "20000", message: "OK" },
result: {
message: { role: "assistant", content: "hello" },
usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 },
finishReason: "stop",
},
},
state
);
assert.ok(Array.isArray(out));
assert.equal(out[0].choices[0].delta.content, "hello");
assert.equal(out[1].choices[0].finish_reason, "stop");
assert.equal(state.usage.total_tokens, 3);
});
test("clova v3: thinkingContent is emitted as reasoning_content", () => {
const state = {};
const frame =
`id: aabb\n` +
`event: token\n` +
`data: ${JSON.stringify({ message: { role: "assistant", thinkingContent: "생각" }, finishReason: null })}\n\n`;
const chunk = response.convertClovaToOpenAI(frame, state);
assert.equal(chunk.choices[0].delta.reasoning_content, "생각");
assert.equal(chunk.choices[0].delta.content, undefined);
});
test("clova v3: reasoning and answer deltas stay on separate delta keys", () => {
const state = {};
const thinking = response.convertClovaToOpenAI(
`event: token\ndata: ${JSON.stringify({ message: { thinkingContent: "because" } })}\n\n`,
state
);
const answer = response.convertClovaToOpenAI(
`event: token\ndata: ${JSON.stringify({ message: { content: "391" } })}\n\n`,
state
);
assert.equal(thinking.choices[0].delta.reasoning_content, "because");
assert.equal(answer.choices[0].delta.content, "391");
assert.equal(answer.choices[0].delta.reasoning_content, undefined);
});
test("clova v3: a tool-call stream assembles partialJson fragments", () => {
const state = {};
const frame = (data: unknown, event = "token") =>
`id: x\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
// First frame carries id + name; the rest carry only JSON fragments.
const start = response.convertClovaToOpenAI(
frame({
message: {
role: "assistant",
content: "",
toolCalls: [{ id: "call_abc", type: "function", function: { name: "get_weather" } }],
},
finishReason: null,
}),
state
);
assert.deepEqual(start.choices[0].delta.tool_calls, [
{
index: 0,
id: "call_abc",
type: "function",
function: { name: "get_weather", arguments: "" },
},
]);
// Fragment order is taken verbatim from a live HCX-005 function-calling
// stream — note the space after the colon, which CLOVA emits as its own chunk.
let args = "";
for (const fragment of ['{"', "location", '":', ' "', "Se", "oul", '"}']) {
const chunk = response.convertClovaToOpenAI(
frame({
message: {
role: "assistant",
content: "",
toolCalls: [{ type: "function", function: { partialJson: fragment } }],
},
finishReason: null,
}),
state
);
args += chunk.choices[0].delta.tool_calls[0].function.arguments;
}
assert.equal(args, '{"location": "Seoul"}');
assert.deepEqual(JSON.parse(args), { location: "Seoul" });
});
test("clova v3: the terminal frame reports tool_calls without repeating the call", () => {
const state = {};
response.convertClovaToOpenAI(
`id: x\nevent: token\ndata: ${JSON.stringify({ message: { content: "", toolCalls: [{ id: "call_abc", type: "function", function: { name: "get_weather" } }] }, finishReason: null })}\n\n`,
state
);
const terminal = response.convertClovaToOpenAI(
`id: x\nevent: result\ndata: ${JSON.stringify({
message: {
role: "assistant",
content: "",
toolCalls: [
{
id: "call_abc",
type: "function",
function: { name: "get_weather", arguments: { location: "Seoul" } },
},
],
},
finishReason: "tool_calls",
usage: { promptTokens: 9, completionTokens: 47, totalTokens: 56 },
})}\n\n`,
state
);
// The finished call is a snapshot — it must not be emitted a second time.
assert.equal(terminal.choices[0].delta.tool_calls, undefined);
assert.deepEqual(terminal.choices[0].delta, {});
assert.equal(terminal.choices[0].finish_reason, "tool_calls");
assert.equal(terminal.usage.total_tokens, 56);
});
test("clova v3: the flush signal and unparseable frames return null", () => {
const state = {};
assert.equal(response.convertClovaToOpenAI(null, state), null);
assert.equal(response.convertClovaToOpenAI("id: aabb\nevent: ping\ndata: \n\n", state), null);
assert.equal(response.convertClovaToOpenAI("not json at all", state), null);
});
test("clova v3: an unknown event type is ignored", () => {
const state = {};
const frame = `event: signal\ndata: ${JSON.stringify({ data: "keepalive" })}\n\n`;
assert.equal(response.convertClovaToOpenAI(frame, state), null);
});