diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index c4ab2dc2fd..562d2f63c8 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -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 = { ], }, + // 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 }; } /** diff --git a/open-sse/config/providers/registry/clova-studio/index.ts b/open-sse/config/providers/registry/clova-studio/index.ts index 8344ebe911..dd371a0336 100644 --- a/open-sse/config/providers/registry/clova-studio/index.ts +++ b/open-sse/config/providers/registry/clova-studio/index.ts @@ -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:;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, + }, ], }; diff --git a/open-sse/executors/clova-studio.ts b/open-sse/executors/clova-studio.ts new file mode 100644 index 0000000000..28997a78b9 --- /dev/null +++ b/open-sse/executors/clova-studio.ts @@ -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)}`; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d1c3b9ced0..fed0a651c1 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -180,6 +180,7 @@ const lazyExecutors: Record Promise> = { 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 }; diff --git a/open-sse/handlers/embeddingStructuredInput.ts b/open-sse/handlers/embeddingStructuredInput.ts index 1183c9e5a3..1cd7571a38 100644 --- a/open-sse/handlers/embeddingStructuredInput.ts +++ b/open-sse/handlers/embeddingStructuredInput.ts @@ -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): Record { +function normalizeGeminiEmbedContentResponse( + data: Record +): Record { 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 +): Record { + 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; + 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 }, + }; +} diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index 7cf322610d..c60842b09f 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -1,16 +1,8 @@ /** * Embedding Handler * - * Handles POST /v1/embeddings requests. - * Proxies to upstream embedding providers using OpenAI-compatible format. - * - * Request format (OpenAI-compatible): - * { - * "model": "nebius/Qwen/Qwen3-Embedding-8B", - * "input": "text" | ["text1", "text2"], - * "dimensions": 4096, // optional - * "encoding_format": "float" // optional - * } + * Handles POST /v1/embeddings requests and normalizes provider responses to the + * OpenAI embedding shape. */ import { @@ -32,6 +24,7 @@ import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { hasStructuredEmbeddingInput, + normalizeClovaEmbeddingV2Response, prepareJinaMixedEmbeddingInput, prepareStructuredEmbeddingRequest, } from "./embeddingStructuredInput.ts"; @@ -53,17 +46,82 @@ interface ClientRawRequest { headers: Record; } -/** - * Flatten a single embedding item's vector to the OpenAI-spec `number[]` shape. - * - * Some OpenAI-compatible embedding backends — notably a llama.cpp - * `llama-server --embedding --pooling ...` instance — return each vector wrapped in one - * extra array level: `[[...floats]]` instead of `[...floats]` for a single input. That - * extra level is silently spec-breaking, since a standard OpenAI-SDK consumer reading - * `response.data[i].embedding` gets a length-1 array holding the real vector instead of - * the vector itself. Unwrap only that single redundant level; vectors that are already - * flat (or genuinely multi-row) are left untouched. See issue #9089. - */ +interface EmbeddingCredentials { + apiKey?: string | null; + accessToken?: string | null; + providerSpecificData?: Record | null; +} + +interface EmbeddingLog { + info: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +interface HandleEmbeddingParams { + body: Record; + credentials: EmbeddingCredentials | null; + log?: EmbeddingLog; + resolvedProvider?: EmbeddingProvider | null; + resolvedModel?: string | null; + clientRawRequest?: ClientRawRequest | null; + apiKeyId?: string | null; + apiKeyName?: string | null; + connectionId?: string | null; +} + +interface EmbeddingFailure { + success: false; + status: number; + error: string; + headers?: Headers; + data?: never; +} + +interface EmbeddingSuccess { + success: true; + data: Record; + headers: Headers; + status?: never; + error?: never; +} + +type EmbeddingResult = EmbeddingSuccess | EmbeddingFailure; + +interface ResolvedEmbedding { + provider: string | null; + model: string | null; + providerConfig: EmbeddingProvider | null; +} + +type RequestLogger = Awaited>; +type ProviderResponseNormalizer = + ((data: Record) => Record) | null; + +interface EmbeddingRuntime extends HandleEmbeddingParams { + provider: string; + model: string | null; + providerConfig: EmbeddingProvider; + startTime: number; + detailedLoggingEnabled: boolean; + reqLogger: RequestLogger; + logRequestBody: Record; +} + +interface PreparedEmbeddingRequest { + upstreamBody: Record; + upstreamUrl: string; + headers: Record; + normalizeProviderResponse: ProviderResponseNormalizer; +} + +interface ParsedEmbeddingResponse { + data?: unknown[] | unknown; + usage?: { prompt_tokens?: number; total_tokens?: number }; +} + +const KNOWN_EMBEDDING_FIELDS = new Set(["model", "input", "dimensions", "encoding_format"]); + +/** Unwrap one redundant row around an otherwise flat vector. */ function flattenSingleRowEmbedding(item: unknown): void { if (!item || typeof item !== "object" || !("embedding" in item)) return; const record = item as { embedding: unknown }; @@ -78,103 +136,81 @@ function flattenSingleRowEmbedding(item: unknown): void { } } -/** - * Handle embedding request. - * Supports both hardcoded cloud providers and dynamic local provider_nodes. - * When resolvedProvider is passed, uses it directly (injection pattern from route handler). - * Falls back to hardcoded registry lookup for backward compatibility. - */ -export async function handleEmbedding({ - body, - credentials, - log, - resolvedProvider = null, - resolvedModel = null, - clientRawRequest = null, - apiKeyId = null, - apiKeyName = null, - connectionId = null, -}: { - body: Record; - credentials: { - apiKey?: string | null; - accessToken?: string | null; - providerSpecificData?: Record | null; - } | null; - log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; - resolvedProvider?: EmbeddingProvider | null; - resolvedModel?: string | null; - clientRawRequest?: ClientRawRequest | null; - apiKeyId?: string | null; - apiKeyName?: string | null; - connectionId?: string | null; -}) { - // Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes). - let provider: string | null; - let model: string | null; - let providerConfig: EmbeddingProvider | null; +function failure(status: number, error: string, headers?: Headers): EmbeddingFailure { + return { success: false, status, error, ...(headers ? { headers } : {}) }; +} - if (resolvedProvider) { - provider = resolvedProvider.id; - model = resolvedModel; - providerConfig = resolvedProvider; - } else { - const parsed = parseEmbeddingModel(body.model as string); - provider = parsed.provider; - model = parsed.model; - providerConfig = provider ? getEmbeddingProvider(provider) : null; +function resolveEmbedding(params: HandleEmbeddingParams): ResolvedEmbedding { + if (params.resolvedProvider) { + return { + provider: params.resolvedProvider.id, + model: params.resolvedModel ?? null, + providerConfig: params.resolvedProvider, + }; } + const parsed = parseEmbeddingModel(params.body.model as string); + return { + provider: parsed.provider, + model: parsed.model, + providerConfig: parsed.provider ? getEmbeddingProvider(parsed.provider) : null, + }; +} - const startTime = Date.now(); - - // Set up request logger for pipeline artifact capture +async function createEmbeddingRuntime( + params: HandleEmbeddingParams, + resolved: ResolvedEmbedding +): Promise { const detailedLoggingEnabled = await isDetailedLoggingEnabled(); - const captureStreamChunks = getCallLogPipelineCaptureStreamChunks(); const reqLogger = await createRequestLogger( - provider || "openai", + resolved.provider || "openai", "openai", - body.model as string, + params.body.model as string, { enabled: detailedLoggingEnabled, - captureStreamChunks, - connectionId: connectionId || undefined, - model: model || (body.model as string), - provider: provider || undefined, + captureStreamChunks: getCallLogPipelineCaptureStreamChunks(), + connectionId: params.connectionId || undefined, + model: resolved.model || (params.body.model as string), + provider: resolved.provider || undefined, } ); - // Log client raw request - if (clientRawRequest) { + if (params.clientRawRequest) { reqLogger.logClientRawRequest( - clientRawRequest.endpoint, - clientRawRequest.body, - clientRawRequest.headers + params.clientRawRequest.endpoint, + params.clientRawRequest.body, + params.clientRawRequest.headers ); } + if (!resolved.provider) { + return failure( + 400, + `Invalid embedding model: ${params.body.model}. Use format: provider/model` + ); + } + if (!resolved.providerConfig) { + return failure(400, `Unknown embedding provider: ${resolved.provider}`); + } - // Summarized request body for call log (avoid storing large embedding input arrays) - const logRequestBody = { - model: body.model, - input_count: Array.isArray(body.input) ? body.input.length : 1, - dimensions: body.dimensions || undefined, + return { + ...params, + provider: resolved.provider, + model: resolved.model, + providerConfig: resolved.providerConfig, + startTime: Date.now(), + detailedLoggingEnabled, + reqLogger, + logRequestBody: { + model: params.body.model, + input_count: Array.isArray(params.body.input) ? params.body.input.length : 1, + dimensions: params.body.dimensions || undefined, + }, }; +} - if (!provider) { - return { - success: false, - status: 400, - error: `Invalid embedding model: ${body.model}. Use format: provider/model`, - }; - } - - if (!providerConfig) { - return { - success: false, - status: 400, - error: `Unknown embedding provider: ${provider}`, - }; - } - +function collectRequestedModalities(body: Record): { + structuredItems: Array<{ type: EmbeddingModality }>; + nativeModalities: EmbeddingModality[]; +} { const structuredItems = Array.isArray(body.input) ? body.input.filter( (item): item is { type: EmbeddingModality } => @@ -184,409 +220,493 @@ export async function handleEmbedding({ const nativeModalities = [ ...(isJinaNativeEmbeddingInput(body.input) ? collectJinaNativeModalities(body.input) : []), ...(isGeminiNativeEmbeddingInput(body.input) ? collectGeminiNativeModalities(body.input) : []), - ].filter((modality) => modality !== "text"); - if (structuredItems.length > 0 || nativeModalities.length > 0) { - const supportedModalities = getEmbeddingModelModalities(providerConfig, model); - if (!supportedModalities) { - return { - success: false, - status: 400, - error: `Embedding model ${body.model} does not advertise structured embedding input support`, - }; - } - const unsupportedCanonical = structuredItems.find( - (item) => !supportedModalities.includes(item.type) + ].filter((modality): modality is EmbeddingModality => modality !== "text"); + return { structuredItems, nativeModalities }; +} + +function validateRequestedModalities(runtime: EmbeddingRuntime): EmbeddingFailure | null { + const { structuredItems, nativeModalities } = collectRequestedModalities(runtime.body); + if (structuredItems.length === 0 && nativeModalities.length === 0) return null; + + const supported = getEmbeddingModelModalities(runtime.providerConfig, runtime.model); + if (!supported) { + return failure( + 400, + `Embedding model ${runtime.body.model} does not advertise structured embedding input support` ); - if (unsupportedCanonical) { - return { - success: false, - status: 400, - error: `Embedding model ${body.model} does not support ${unsupportedCanonical.type} input`, - }; - } - const unsupportedNative = nativeModalities.find( - (modality) => !supportedModalities.includes(modality) - ); - if (unsupportedNative) { - return { - success: false, - status: 400, - error: `Embedding model ${body.model} does not support ${unsupportedNative} input`, - }; - } } + const unsupportedCanonical = structuredItems.find((item) => !supported.includes(item.type)); + if (unsupportedCanonical) { + return failure( + 400, + `Embedding model ${runtime.body.model} does not support ${unsupportedCanonical.type} input` + ); + } + const unsupportedNative = nativeModalities.find((modality) => !supported.includes(modality)); + return unsupportedNative + ? failure( + 400, + `Embedding model ${runtime.body.model} does not support ${unsupportedNative} input` + ) + : null; +} - // Build upstream request — start with standard fields, then forward extra fields - // the client sent (e.g. input_type, user, truncate for NVIDIA NIM asymmetric models). - const KNOWN_FIELDS = new Set(["model", "input", "dimensions", "encoding_format"]); - - let upstreamBody: Record = { - model: model, - input: body.input, +function buildUpstreamBody(runtime: EmbeddingRuntime): Record { + const upstreamBody: Record = { + model: runtime.model, + input: runtime.body.input, }; - - if (body.dimensions !== undefined) upstreamBody.dimensions = body.dimensions; - if (body.encoding_format !== undefined) upstreamBody.encoding_format = body.encoding_format; - - for (const [key, value] of Object.entries(body)) { - if (!KNOWN_FIELDS.has(key) && value !== undefined) { - upstreamBody[key] = value; - } + if (runtime.body.dimensions !== undefined) upstreamBody.dimensions = runtime.body.dimensions; + if (runtime.body.encoding_format !== undefined) { + upstreamBody.encoding_format = runtime.body.encoding_format; + } + for (const [key, value] of Object.entries(runtime.body)) { + if (!KNOWN_EMBEDDING_FIELDS.has(key) && value !== undefined) upstreamBody[key] = value; } - // Gemini embedding models (gemini-embedding-001 / -2-preview / text-embedding-004) - // default to 3072-dim vectors. Clients targeting pgvector-style schemas typically - // request a smaller size (e.g. 1536) via OpenAI's `dimensions` field, but Google's - // OpenAI-compatibility shim at /v1beta/openai/embeddings does not document the - // `dimensions` → `outputDimensionality` translation. Mirror the request value into - // the Gemini-native `outputDimensionality` field so the upstream actually returns - // the requested vector size. Ported from upstream decolua/9router#1366. - if (provider === "gemini" && upstreamBody.outputDimensionality === undefined) { - const outputDimensionality = Number(body.dimensions); + if (runtime.provider === "gemini" && upstreamBody.outputDimensionality === undefined) { + const outputDimensionality = Number(runtime.body.dimensions); if (Number.isFinite(outputDimensionality) && outputDimensionality > 0) { upstreamBody.outputDimensionality = outputDimensionality; } } - - // Inject model-level default params (e.g. NVIDIA NIM asymmetric models require - // `input_type`) only for keys the client did not already supply, so a - // client-sent value is never overwritten. Symmetric models carry no defaults - // and are unaffected. See issue #1378. - const defaultParams = getEmbeddingModelDefaultParams(providerConfig, model); - if (defaultParams) { - for (const [key, value] of Object.entries(defaultParams)) { - if (upstreamBody[key] === undefined) { - upstreamBody[key] = value; - } - } + const defaultParams = getEmbeddingModelDefaultParams(runtime.providerConfig, runtime.model); + for (const [key, value] of Object.entries(defaultParams ?? {})) { + if (upstreamBody[key] === undefined) upstreamBody[key] = value; } + return upstreamBody; +} - let upstreamUrl = providerConfig.baseUrl; - if (provider === "ollama-local" || provider === "lmstudio") { - // Keyless local servers (#2824 ollama-local, #11233 lmstudio): honor the - // configured connection's baseUrl when one was hydrated, and fall back to - // the static localhost registry default otherwise. - const configuredBaseUrl = credentials?.providerSpecificData?.baseUrl; - const rawBaseUrl = - typeof configuredBaseUrl === "string" && configuredBaseUrl.trim().length > 0 - ? configuredBaseUrl - : providerConfig.baseUrl; - // Use the shared O(n) helper instead of `/\/+$/` — that regex is - // vulnerable to polynomial backtracking on adversarial input - // (CodeQL js/polynomial-redos) since baseUrl is operator-configured - // per-connection data. See open-sse/utils/urlSanitize.ts. - const normalizedBaseUrl = stripTrailingSlashes(rawBaseUrl.trim()); - const localServerHost = normalizedBaseUrl - .replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "") - .replace(/\/api\/chat$/i, "") - .replace(/\/v1$/i, ""); - upstreamUrl = `${localServerHost}/v1/embeddings`; - } - let normalizeProviderResponse: - ((data: Record) => Record) | null = null; +function resolveLocalEmbeddingUrl(runtime: EmbeddingRuntime): string { + const configuredBaseUrl = runtime.credentials?.providerSpecificData?.baseUrl; + const rawBaseUrl = + typeof configuredBaseUrl === "string" && configuredBaseUrl.trim() + ? configuredBaseUrl + : runtime.providerConfig.baseUrl; + const localServerHost = stripTrailingSlashes(rawBaseUrl.trim()) + .replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "") + .replace(/\/api\/chat$/i, "") + .replace(/\/v1$/i, ""); + return `${localServerHost}/v1/embeddings`; +} - // Build headers - const headers: Record = { - "Content-Type": "application/json", - }; +function resolveUpstreamUrl(runtime: EmbeddingRuntime): string { + return runtime.provider === "ollama-local" || runtime.provider === "lmstudio" + ? resolveLocalEmbeddingUrl(runtime) + : runtime.providerConfig.baseUrl; +} - // Skip credential injection for local providers (authType: "none") +function buildAuth( + runtime: EmbeddingRuntime +): { headers: Record; token: string | null } | EmbeddingFailure { + const headers: Record = { "Content-Type": "application/json" }; const token = - providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken; - if (token) { - if (providerConfig.authHeader === "bearer") { - headers["Authorization"] = `Bearer ${token}`; - } else if (providerConfig.authHeader === "x-api-key") { - headers["x-api-key"] = token; - } - } else if (providerConfig.authType !== "none") { - return { - success: false, - status: 401, - error: `No valid authentication token for provider ${provider}. Check provider credentials.`, - }; - } - - // Jina v5 Omni native docs ({ text }, { image: url|base64 }, { content: [...] }) - // must reach api.jina.ai unchanged. Do not fetch those image URLs or collapse - // to string[]. Canonical { type, source } items still go through the translator. - const jinaNative = isJinaNativeEmbeddingInput(body.input); - const geminiNative = isGeminiNativeEmbeddingInput(body.input); - const canonicalStructured = hasStructuredEmbeddingInput(body.input); - const passThroughJinaNative = - providerConfig.structuredInputProtocol === "jina-v1" && jinaNative && !canonicalStructured; - // gemini-embedding-2 aggregates a string[] on Google's OpenAI shim into one - // vector. Always use embedContent / batchEmbedContents so N input items - // become N embeddings. Native multimodal parts take the same path. - const useGeminiNativeTransport = - providerConfig.structuredInputProtocol === "gemini-embed-content" && - (isGeminiEmbedding2Family(model) || canonicalStructured || geminiNative || jinaNative); - - if (providerConfig.structuredInputProtocol === "jina-v1" && jinaNative && canonicalStructured) { - try { - const mixed = Array.isArray(body.input) ? body.input : [body.input]; - upstreamBody.input = await prepareJinaMixedEmbeddingInput(mixed, async (url) => { - const result = await fetchRemoteImage(url, { - guard: "public-only", - maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES, - pinDns: true, - }); - return { buffer: result.buffer, contentType: result.contentType || null }; - }); - } catch (error) { - return { success: false, status: 400, error: sanitizeErrorMessage(error) }; - } - } else if (useGeminiNativeTransport || (!passThroughJinaNative && canonicalStructured)) { - if (!model) { - return { - success: false, - status: 400, - error: `Invalid embedding model: ${body.model}. Use format: provider/model`, - }; - } - try { - const prepared = await prepareStructuredEmbeddingRequest( - providerConfig, - model, - body, - token ?? "", - { - fetchMedia: async (url) => { - const result = await fetchRemoteImage(url, { - guard: "public-only", - maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES, - pinDns: true, - }); - return { buffer: result.buffer, contentType: result.contentType || null }; - }, - } - ); - upstreamBody = prepared.body; - upstreamUrl = prepared.url; - normalizeProviderResponse = prepared.normalizeResponse ?? null; - if (prepared.authHeader) { - delete headers.Authorization; - delete headers["x-api-key"]; - headers[prepared.authHeader.name] = prepared.authHeader.value; - } - } catch (error) { - return { success: false, status: 400, error: sanitizeErrorMessage(error) }; - } - } - - if (log) { - log.info( - "EMBED", - `${provider}/${model} | input: ${Array.isArray(body.input) ? body.input.length + " items" : "1 item"}` + runtime.providerConfig.authType === "none" + ? null + : runtime.credentials?.apiKey || runtime.credentials?.accessToken || null; + if (!token && runtime.providerConfig.authType !== "none") { + return failure( + 401, + `No valid authentication token for provider ${runtime.provider}. Check provider credentials.` ); } + if (token && runtime.providerConfig.authHeader === "bearer") { + headers.Authorization = `Bearer ${token}`; + } else if (token && runtime.providerConfig.authHeader === "x-api-key") { + headers["x-api-key"] = token; + } + return { headers, token }; +} - try { - // Quota share enforcement (fail-open: errors allow the request through) - if (apiKeyId && connectionId && provider) { - try { - const { enforceQuotaShare } = await import("@/lib/quota/enforce"); - const quotaDecision = await enforceQuotaShare({ - apiKeyId, - connectionId, - provider, - // Per-(key,model) cap — resolved embedding model id (same scope used in logs/routing). - model: model || undefined, - }); - if (quotaDecision.kind === "block") { - return { - success: false, - status: quotaDecision.httpStatus ?? 429, - error: quotaDecision.reason || "Quota share limit reached", - }; - } - } catch { - // fail-open per B16 - } - } +async function fetchEmbeddingMedia( + url: string +): Promise<{ buffer: Buffer; contentType: string | null }> { + const result = await fetchRemoteImage(url, { + guard: "public-only", + maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES, + pinDns: true, + }); + return { buffer: result.buffer, contentType: result.contentType || null }; +} - // Log provider request - reqLogger.logTargetRequest(upstreamUrl, headers, upstreamBody); +async function prepareMixedJinaInput( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest +): Promise { + const mixed = Array.isArray(runtime.body.input) ? runtime.body.input : [runtime.body.input]; + prepared.upstreamBody.input = await prepareJinaMixedEmbeddingInput(mixed, fetchEmbeddingMedia); +} - const response = await fetch(upstreamUrl, { - method: "POST", - headers, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) { - log.error("EMBED", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - } - - // Log provider response - reqLogger.logProviderResponse(response.status, "", response.headers, errorText.slice(0, 500)); - - // Build client error response - const clientErrorBody = toJsonErrorPayload( - errorText.slice(0, 500), - "Embedding provider error" - ); - reqLogger.logConvertedResponse(clientErrorBody); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save error call log for Logger panel - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: response.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - requestBody: logRequestBody, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - // #10347 — persist a connection-level failure marker on a hard upstream failure so - // the dead account is not re-selected and re-hit on the next embed request (chat - // parity). markAccountUnavailable classifies the status via checkFallbackError: a - // payment-required 402 becomes the TERMINAL state credits_exhausted (the terminal - // marker excludes the account from selection until an operator resets it), benign - // 4xx are a no-op, and terminal statuses are never overwritten. honors per-connection - // disableCooling. The write must never break the error response path, so it is - // best-effort. - if (connectionId) { - try { - await markAccountUnavailable(connectionId, response.status, errorText, provider, model); - } catch { - // swallow — the upstream error response takes priority - } - } - - return { - success: false, - status: response.status, - error: errorText, - headers: stripStaleEncodingHeaders(response.headers), - }; - } - - const rawData = (await response.json()) as Record; - const data = (normalizeProviderResponse ? normalizeProviderResponse(rawData) : rawData) as { - data?: unknown[] | unknown; - usage?: { prompt_tokens?: number; total_tokens?: number }; - }; - - // Log provider response - reqLogger.logProviderResponse(response.status, "", response.headers, data); - - // OpenAI-spec compliance (#9089): each item's `embedding` must be a flat number[]. - // Some OpenAI-compatible backends (e.g. a llama.cpp `llama-server --embedding` - // instance) return the vector wrapped in one extra array level — `[[...floats]]` - // instead of `[...floats]` — for a single input, which silently breaks any standard - // OpenAI-SDK consumer doing `response.data[i].embedding`. Flatten that one redundant - // level without touching providers that already return flat vectors. - const responseItems = data.data || data; - if (Array.isArray(responseItems)) { - for (const item of responseItems) { - flattenSingleRowEmbedding(item); - } - } - - // Normalize response to OpenAI format - const normalizedResponse = { - object: "list", - data: data.data || data, - model: `${provider}/${model}`, - usage: data.usage || { prompt_tokens: 0, total_tokens: 0 }, - }; - - // Log client response - reqLogger.logConvertedResponse(normalizedResponse); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save success call log for Logger panel - // Embeddings only have input tokens (prompt_tokens + total_tokens), no output/completion tokens - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - tokens: { - prompt_tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, - completion_tokens: 0, - }, - requestBody: logRequestBody, - responseBody: { - usage: data.usage || null, - object: "list", - data_count: Array.isArray(data.data) ? data.data.length : 0, - }, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - // Record quota consumption (fire-and-forget, never blocks) - if (apiKeyId && connectionId && provider) { - try { - const { scheduleRecordConsumption } = await import("@/lib/quota/spendRecorder"); - scheduleRecordConsumption({ - apiKeyId, - connectionId, - provider, - // Per-(key,model) cap accounting — same resolved model id used at enforce time. - model: model || undefined, - cost: { - tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, - requests: 1, - }, - }); - } catch { - // fail-open per B29 - } - } - - return { - success: true, - data: normalizedResponse, - headers: stripStaleEncodingHeaders(response.headers), - }; - } catch (err) { - if (log) { - log.error("EMBED", `${provider} fetch error: ${err.message}`); - } - - // Log error - reqLogger.logError(err, upstreamBody); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save exception call log for Logger panel - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - requestBody: logRequestBody, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - return { - success: false, - status: 502, - error: `Embedding provider error: ${sanitizeErrorMessage(err.message)}`, - }; +async function prepareNativeTransport( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + token: string | null +): Promise { + if (!runtime.model) { + throw new Error(`Invalid embedding model: ${runtime.body.model}. Use format: provider/model`); + } + const native = await prepareStructuredEmbeddingRequest( + runtime.providerConfig, + runtime.model, + runtime.body, + token ?? "", + { fetchMedia: fetchEmbeddingMedia } + ); + prepared.upstreamBody = native.body; + prepared.upstreamUrl = native.url; + prepared.normalizeProviderResponse = native.normalizeResponse ?? null; + if (native.authHeader) { + delete prepared.headers.Authorization; + delete prepared.headers["x-api-key"]; + prepared.headers[native.authHeader.name] = native.authHeader.value; } } + +async function applyStructuredTransport( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + token: string | null +): Promise { + const jinaNative = isJinaNativeEmbeddingInput(runtime.body.input); + const geminiNative = isGeminiNativeEmbeddingInput(runtime.body.input); + const canonical = hasStructuredEmbeddingInput(runtime.body.input); + const isJinaProtocol = runtime.providerConfig.structuredInputProtocol === "jina-v1"; + const passThroughJina = isJinaProtocol && jinaNative && !canonical; + const useGeminiNative = + runtime.providerConfig.structuredInputProtocol === "gemini-embed-content" && + (isGeminiEmbedding2Family(runtime.model) || canonical || geminiNative || jinaNative); + + if (isJinaProtocol && jinaNative && canonical) { + await prepareMixedJinaInput(runtime, prepared); + } else if (useGeminiNative || (!passThroughJina && canonical)) { + await prepareNativeTransport(runtime, prepared, token); + } +} + +async function prepareEmbeddingRequest( + runtime: EmbeddingRuntime +): Promise { + const auth = buildAuth(runtime); + if ("success" in auth) return auth; + const prepared: PreparedEmbeddingRequest = { + upstreamBody: buildUpstreamBody(runtime), + upstreamUrl: resolveUpstreamUrl(runtime), + headers: auth.headers, + normalizeProviderResponse: null, + }; + try { + await applyStructuredTransport(runtime, prepared, auth.token); + return prepared; + } catch (error) { + return failure(400, sanitizeErrorMessage(error)); + } +} + +async function enforceEmbeddingQuota(runtime: EmbeddingRuntime): Promise { + if (!runtime.apiKeyId || !runtime.connectionId) return null; + try { + const { enforceQuotaShare } = await import("@/lib/quota/enforce"); + const decision = await enforceQuotaShare({ + apiKeyId: runtime.apiKeyId, + connectionId: runtime.connectionId, + provider: runtime.provider, + model: runtime.model || undefined, + }); + return decision.kind === "block" + ? failure(decision.httpStatus ?? 429, decision.reason || "Quota share limit reached") + : null; + } catch { + return null; + } +} + +function resolveSingleTexts(runtime: EmbeddingRuntime): string[] | EmbeddingFailure | null { + if (runtime.providerConfig.singleTextProtocol !== "clova-v2") return null; + const input = Array.isArray(runtime.body.input) ? runtime.body.input : [runtime.body.input]; + if ( + input.length === 0 || + input.some((item) => typeof item !== "string" || item.trim().length === 0) + ) { + return failure(400, "CLOVA Studio embedding v2 accepts non-empty text strings only"); + } + if (runtime.body.encoding_format === "base64") { + return failure(400, "CLOVA Studio embedding v2 supports float encoding only"); + } + if (runtime.body.dimensions !== undefined && Number(runtime.body.dimensions) !== 1024) { + return failure(400, "CLOVA Studio embedding v2 has a fixed dimension of 1024"); + } + return input as string[]; +} + +function appendClovaEmbedding( + parsed: ParsedEmbeddingResponse, + embeddings: Array>, + usage: { prompt_tokens: number; total_tokens: number } +): void { + if (!Array.isArray(parsed.data)) { + throw new Error("CLOVA Studio embedding v2 returned an invalid data list"); + } + for (const item of parsed.data) { + flattenSingleRowEmbedding(item); + if (!item || typeof item !== "object") { + throw new Error("CLOVA Studio embedding v2 returned an invalid embedding item"); + } + (item as { index?: number }).index = embeddings.length; + embeddings.push(item as Record); + } + usage.prompt_tokens += parsed.usage?.prompt_tokens || parsed.usage?.total_tokens || 0; + usage.total_tokens += parsed.usage?.total_tokens || parsed.usage?.prompt_tokens || 0; +} + +async function fetchClovaEmbeddingBatch( + prepared: PreparedEmbeddingRequest, + texts: string[], + reqLogger: RequestLogger +): Promise { + const embeddings: Array> = []; + const usage = { prompt_tokens: 0, total_tokens: 0 }; + let lastHeaders = new Headers(); + for (const text of texts) { + const requestBody = { text }; + reqLogger.logTargetRequest(prepared.upstreamUrl, prepared.headers, requestBody); + const response = await fetch(prepared.upstreamUrl, { + method: "POST", + headers: prepared.headers, + body: JSON.stringify(requestBody), + }); + lastHeaders = response.headers; + if (!response.ok) return response; + const rawData = (await response.json()) as Record; + appendClovaEmbedding(normalizeClovaEmbeddingV2Response(rawData), embeddings, usage); + } + return new Response(JSON.stringify({ data: embeddings, usage }), { + status: 200, + headers: lastHeaders, + }); +} + +async function dispatchEmbeddingRequest( + prepared: PreparedEmbeddingRequest, + singleTexts: string[] | null, + reqLogger: RequestLogger +): Promise { + if (singleTexts) return fetchClovaEmbeddingBatch(prepared, singleTexts, reqLogger); + reqLogger.logTargetRequest(prepared.upstreamUrl, prepared.headers, prepared.upstreamBody); + return fetch(prepared.upstreamUrl, { + method: "POST", + headers: prepared.headers, + body: JSON.stringify(prepared.upstreamBody), + }); +} + +function pipelinePayloads( + runtime: EmbeddingRuntime +): ReturnType | null { + return runtime.detailedLoggingEnabled ? runtime.reqLogger.getPipelinePayloads() : null; +} + +async function handleUpstreamFailure( + runtime: EmbeddingRuntime, + response: Response +): Promise { + const errorText = await response.text(); + runtime.log?.error( + "EMBED", + `${runtime.provider} error ${response.status}: ${errorText.slice(0, 200)}` + ); + runtime.reqLogger.logProviderResponse( + response.status, + "", + response.headers, + errorText.slice(0, 500) + ); + runtime.reqLogger.logConvertedResponse( + toJsonErrorPayload(errorText.slice(0, 500), "Embedding provider error") + ); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: response.status, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + error: errorText.slice(0, 500), + requestBody: runtime.logRequestBody, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); + if (runtime.connectionId) { + try { + await markAccountUnavailable( + runtime.connectionId, + response.status, + errorText, + runtime.provider, + runtime.model + ); + } catch { + // The upstream response has priority over a best-effort cooldown write. + } + } + return failure(response.status, errorText, stripStaleEncodingHeaders(response.headers)); +} + +function normalizeEmbeddingData( + runtime: EmbeddingRuntime, + response: Response, + rawData: Record, + normalizer: ProviderResponseNormalizer +): { data: ParsedEmbeddingResponse; normalizedResponse: Record } { + const data = (normalizer ? normalizer(rawData) : rawData) as ParsedEmbeddingResponse; + runtime.reqLogger.logProviderResponse(response.status, "", response.headers, data); + const responseItems = data.data || data; + if (Array.isArray(responseItems)) responseItems.forEach(flattenSingleRowEmbedding); + return { + data, + normalizedResponse: { + object: "list", + data: data.data || data, + model: `${runtime.provider}/${runtime.model}`, + usage: data.usage || { prompt_tokens: 0, total_tokens: 0 }, + }, + }; +} + +function recordEmbeddingSuccess( + runtime: EmbeddingRuntime, + data: ParsedEmbeddingResponse, + normalizedResponse: Record +): void { + runtime.reqLogger.logConvertedResponse(normalizedResponse); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: 200, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + tokens: { + prompt_tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, + completion_tokens: 0, + }, + requestBody: runtime.logRequestBody, + responseBody: { + usage: data.usage || null, + object: "list", + data_count: Array.isArray(data.data) ? data.data.length : 0, + }, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); +} + +async function recordEmbeddingConsumption( + runtime: EmbeddingRuntime, + data: ParsedEmbeddingResponse, + requestCount: number +): Promise { + if (!runtime.apiKeyId || !runtime.connectionId) return; + try { + const { scheduleRecordConsumption } = await import("@/lib/quota/spendRecorder"); + scheduleRecordConsumption({ + apiKeyId: runtime.apiKeyId, + connectionId: runtime.connectionId, + provider: runtime.provider, + model: runtime.model || undefined, + cost: { + tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, + requests: requestCount, + }, + }); + } catch { + // Quota accounting is fail-open. + } +} + +async function handleUpstreamSuccess( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + response: Response, + requestCount: number +): Promise { + const rawData = (await response.json()) as Record; + const { data, normalizedResponse } = normalizeEmbeddingData( + runtime, + response, + rawData, + prepared.normalizeProviderResponse + ); + recordEmbeddingSuccess(runtime, data, normalizedResponse); + await recordEmbeddingConsumption(runtime, data, requestCount); + return { + success: true, + data: normalizedResponse, + headers: stripStaleEncodingHeaders(response.headers), + }; +} + +function handleEmbeddingException( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + error: unknown +): EmbeddingFailure { + const message = error instanceof Error ? error.message : String(error); + runtime.log?.error("EMBED", `${runtime.provider} fetch error: ${message}`); + runtime.reqLogger.logError(error, prepared.upstreamBody); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: 502, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + error: message, + requestBody: runtime.logRequestBody, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); + return failure(502, `Embedding provider error: ${sanitizeErrorMessage(message)}`); +} + +async function executeEmbedding( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest +): Promise { + const quotaFailure = await enforceEmbeddingQuota(runtime); + if (quotaFailure) return quotaFailure; + const singleTextsOrFailure = resolveSingleTexts(runtime); + if (singleTextsOrFailure && !Array.isArray(singleTextsOrFailure)) return singleTextsOrFailure; + const singleTexts = Array.isArray(singleTextsOrFailure) ? singleTextsOrFailure : null; + try { + const response = await dispatchEmbeddingRequest(prepared, singleTexts, runtime.reqLogger); + return response.ok + ? handleUpstreamSuccess(runtime, prepared, response, singleTexts?.length ?? 1) + : handleUpstreamFailure(runtime, response); + } catch (error) { + return handleEmbeddingException(runtime, prepared, error); + } +} + +/** Handle one OpenAI-compatible embedding request. */ +export async function handleEmbedding(params: HandleEmbeddingParams): Promise { + const resolved = resolveEmbedding(params); + const runtime = await createEmbeddingRuntime(params, resolved); + if ("success" in runtime) return runtime; + const modalityFailure = validateRequestedModalities(runtime); + if (modalityFailure) return modalityFailure; + const prepared = await prepareEmbeddingRequest(runtime); + if ("success" in prepared) return prepared; + runtime.log?.info( + "EMBED", + `${runtime.provider}/${runtime.model} | input: ${ + Array.isArray(runtime.body.input) ? `${runtime.body.input.length} items` : "1 item" + }` + ); + return executeEmbedding(runtime, prepared); +} diff --git a/open-sse/translator/bootstrap.ts b/open-sse/translator/bootstrap.ts index df852d483c..bd870e934a 100644 --- a/open-sse/translator/bootstrap.ts +++ b/open-sse/translator/bootstrap.ts @@ -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"; diff --git a/open-sse/translator/formats.ts b/open-sse/translator/formats.ts index 4e0bd391f0..1349d30963 100644 --- a/open-sse/translator/formats.ts +++ b/open-sse/translator/formats.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", diff --git a/open-sse/translator/request/openai-to-clova.ts b/open-sse/translator/request/openai-to-clova.ts new file mode 100644 index 0000000000..62bd519c66 --- /dev/null +++ b/open-sse/translator/request/openai-to-clova.ts @@ -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 = new Set(["HCX-007"]); + +export const CLOVA_V3_VISION_MODELS: ReadonlySet = 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 = 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 = new Set(["HCX-007"]); + +const CLOVA_THINKING_EFFORTS: ReadonlySet = new Set(["none", "low", "medium", "high"]); + +type JsonRecord = Record; + +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:;base64,` prefix or CLOVA rejects the request with + * `40001 Invalid parameter`. + */ +export function toClovaContent( + content: unknown, + supportsImages: boolean +): Array> { + 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 { + if (raw == null) return {}; + if (typeof raw === "object") return raw as Record; + if (typeof raw !== "string" || !raw.trim()) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } 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> { + 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 +): "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, + stream: boolean, + credentials?: Record | null +): Record { + 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); diff --git a/open-sse/translator/response/clova-to-openai.ts b/open-sse/translator/response/clova-to-openai.ts new file mode 100644 index 0000000000..4d2b9cee54 --- /dev/null +++ b/open-sse/translator/response/clova-to-openai.ts @@ -0,0 +1,354 @@ +/** + * Naver CLOVA Studio "Chat Completions v3" → OpenAI response translator. + * + * CLOVA v3 streams as SSE with **named events**: + * + * ``` + * id: + * event: token + * data: {"message":{"role":"assistant","content":"안"},"finishReason":null,...} + * + * id: + * 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; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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 | Array> | 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 => chunk !== null); + if (out.length === 0) return null; + return out.length === 1 ? out[0] : out; +} + +function convertTokenEvent( + state: ClovaStreamState, + data: JsonRecord +): Record | Array> | 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 | Array> | 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 +): Record | Array> | 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); diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts index e655c169bb..769afa5493 100644 --- a/scripts/check/check-known-symbols.ts +++ b/scripts/check/check-known-symbols.ts @@ -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", diff --git a/src/shared/constants/providers/apikey/regional.ts b/src/shared/constants/providers/apikey/regional.ts index a9c5701dfd..2c437cc4b3 100644 --- a/src/shared/constants/providers/apikey/regional.ts +++ b/src/shared/constants/providers/apikey/regional.ts @@ -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", diff --git a/src/shared/constants/visionModels.ts b/src/shared/constants/visionModels.ts index da7b755490..bcad514058 100644 --- a/src/shared/constants/visionModels.ts +++ b/src/shared/constants/visionModels.ts @@ -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; diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 94d70fb770..8f4956e814 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -135,6 +135,11 @@ "configSource": "", "provider": "cloudflare-playground" }, + "clova-studio": { + "className": "ClovaStudioExecutor", + "configSource": "clova-studio", + "provider": "clova-studio" + }, "cmd": { "className": "CommandCodeExecutor", "configSource": "", @@ -671,6 +676,6 @@ "provider": "zai-web" } }, - "keyCount": 134, + "keyCount": 135, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 43150e2b22..64fd60cfa2 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -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": { diff --git a/tests/unit/embedding-clova-v2.test.ts b/tests/unit/embedding-clova-v2.test.ts new file mode 100644 index 0000000000..9a97a1c5ee --- /dev/null +++ b/tests/unit/embedding-clova-v2.test.ts @@ -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>): 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>, + envelope: Record +): 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> = []; + 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 }).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> = []; + 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> = []; + 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> = []; + 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 }).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> = []; + 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> = []; + 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> = []; + 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; + } + } +}); diff --git a/tests/unit/translator-clova-v3.test.ts b/tests/unit/translator-clova-v3.test.ts new file mode 100644 index 0000000000..fb9851f7d3 --- /dev/null +++ b/tests/unit/translator-clova-v3.test.ts @@ -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); +});