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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 78 additions & 50 deletions open-sse/config/embeddingRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -297,6 +305,18 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
],
},

// Naver CLOVA Studio — embedding v2. The endpoint takes a single `{"text": …}`
// body and returns `{status, result:{embedding:[…1024 floats], inputTokens}}`,
// with no batch array and no `usage` object, hence `singleTextProtocol`.
"clova-studio": {
id: "clova-studio",
baseUrl: "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2",
authType: "apikey",
authHeader: "bearer",
singleTextProtocol: "clova-v2",
models: [{ id: "clova-embedding-v2", name: "CLOVA Embedding v2", dimensions: 1024 }],
},

"jina-ai": {
id: "jina-ai",
structuredInputProtocol: "jina-v1",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}

/**
Expand Down
72 changes: 65 additions & 7 deletions open-sse/config/providers/registry/clova-studio/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,75 @@
import type { RegistryEntry } from "../../shared.ts";

/**
* Naver CLOVA Studio — Chat Completions **v3** (native API).
*
* Previously this entry pointed at Naver's OpenAI-compatibility shim
* (`/v1/openai/chat/completions`), which meant `format: "openai"` and a
* pass-through `DefaultExecutor`. The v3 API is Naver's own wire format, so the
* entry now uses `format: "clova"` and the translator pair
* (`openai-to-clova` / `clova-to-openai`).
*
* v3 moves the model into the URL path (`/v3/chat-completions/{modelName}`), uses
* camelCase sampling params, and returns a `{status, result}` envelope instead of
* an OpenAI `choices[]` body — see the translators for the exact mapping.
*
* All three v3 models are live-verified against the real API (2026-09-01):
*
* | Model | Surface | Notes |
* | ------------- | -------- | -------------------------------------------------------- |
* | HCX-007 | thinking | rejects `maxTokens` (use `maxCompletionTokens`); no vision |
* | HCX-005 | text+img | vision via public URL **or** inline base64 data URI |
* | HCX-DASH-002 | text | lightweight, text only |
*
* Docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3
* https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-thinking
*/
export const clova_studioProvider: RegistryEntry = {
id: "clova-studio",
alias: "clova",
format: "openai",
executor: "default",
baseUrl: "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions",
format: "clova",
executor: "clova-studio",
baseUrl: "https://clovastudio.stream.ntruss.com/v3/chat-completions",
authType: "apikey",
authHeader: "bearer",
/**
* The v3 API does answer non-streaming requests (`Accept: application/json`),
* but only the streaming surface is expressed in the translator: CLOVA's SSE
* frames carry incremental `token` events plus a terminal `result` event that
* repeats the full text. Forcing the upstream stream lets OmniRoute consume
* that single, well-tested path and accumulate it into a JSON body for
* non-streaming clients, instead of maintaining a second parser for the
* `{status, result}` envelope.
*/
forceStream: true,
models: [
// HCX-007 stays first so it remains the provider default (deep-reasoning
// flagship); HCX-005 is the multimodal option.
{ id: "HCX-007", name: "HCX-007" },
{ id: "HCX-005", name: "HCX-005" },
{
// Reasoning flagship. Input+output ≤ 128000 tokens; the output cap counts
// thinking tokens too, so `maxCompletionTokens` may be up to 32768.
id: "HCX-007",
name: "HCX-007",
contextLength: 128000,
maxOutputTokens: 32768,
supportsReasoning: true,
},
{
// HyperCLOVA X vision model. Input+output ≤ 128000 tokens, output ≤ 4096,
// up to 5 images per request (1 per turn). Accepts a public URL or an
// inline base64 data URI — the data URI must keep its
// `data:<mime>;base64,` prefix inside `dataUri.data` or the request is
// rejected with `40001 Invalid parameter`.
id: "HCX-005",
name: "HCX-005",
contextLength: 128000,
maxOutputTokens: 4096,
supportsVision: true,
},
{
// Lightweight model. Input+output ≤ 32000 tokens, output ≤ 4096, text only.
id: "HCX-DASH-002",
name: "HCX-DASH-002",
contextLength: 32000,
maxOutputTokens: 4096,
},
],
};
12 changes: 12 additions & 0 deletions open-sse/executors/clova-studio.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
}
1 change: 1 addition & 0 deletions open-sse/executors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
xai: () => import("./xai.ts").then((m) => new m.XaiExecutor()),
"xai-oauth": () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
xao: () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
"clova-studio": () => import("./clova-studio.ts").then((m) => new m.ClovaStudioExecutor()),
"conol-web": () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()),
cnl: () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()), // Alias
};
Expand Down
52 changes: 43 additions & 9 deletions open-sse/handlers/embeddingStructuredInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -163,7 +160,9 @@ function embeddingValues(entry: unknown): unknown[] {
return Array.isArray(values) ? values : [];
}

function normalizeGeminiEmbedContentResponse(data: Record<string, unknown>): Record<string, unknown> {
function normalizeGeminiEmbedContentResponse(
data: Record<string, unknown>
): Record<string, unknown> {
return {
object: "list",
data: [{ object: "embedding", embedding: embeddingValues(data.embedding), index: 0 }],
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -346,3 +342,41 @@ export async function prepareStructuredEmbeddingRequest(
}
throw new Error(`Provider ${provider.id} has no structured embedding input translator`);
}

/**
* Normalize a single-text embedding endpoint's response into OpenAI's
* `/v1/embeddings` list shape.
*
* CLOVA Studio's embedding v2 answers:
*
* ```
* {"status":{"code":"20000","message":"OK"},
* "result":{"embedding":[…1024 floats],"inputTokens":4}}
* ```
*
* There is no `data[]` and no `usage` object, so both are synthesized. `index` is
* left at 0 here — the batching loop in `embeddings.ts` rewrites it to the
* caller's position before the response is returned.
*
* A non-20000 status or malformed success envelope throws so an HTTP-200 error
* envelope can never be exposed as an empty successful embedding response.
*/
export function normalizeClovaEmbeddingV2Response(
rawData: Record<string, unknown>
): Record<string, unknown> {
const statusCode = (rawData?.status as { code?: unknown } | undefined)?.code;
if (String(statusCode) !== "20000") {
throw new Error("CLOVA Studio embedding v2 returned an unsuccessful status");
}

const result = (rawData?.result ?? {}) as Record<string, unknown>;
if (!Array.isArray(result.embedding)) {
throw new Error("CLOVA Studio embedding v2 response is missing an embedding vector");
}

const inputTokens = Number(result.inputTokens) || 0;
return {
data: [{ object: "embedding", index: 0, embedding: result.embedding }],
usage: { prompt_tokens: inputTokens, total_tokens: inputTokens },
};
}
Loading
Loading