mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
12
open-sse/executors/clova-studio.ts
Normal file
12
open-sse/executors/clova-studio.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
375
open-sse/translator/request/openai-to-clova.ts
Normal file
375
open-sse/translator/request/openai-to-clova.ts
Normal 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);
|
||||
354
open-sse/translator/response/clova-to-openai.ts
Normal file
354
open-sse/translator/response/clova-to-openai.ts
Normal 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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
258
tests/unit/embedding-clova-v2.test.ts
Normal file
258
tests/unit/embedding-clova-v2.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
825
tests/unit/translator-clova-v3.test.ts
Normal file
825
tests/unit/translator-clova-v3.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user