Skip to content

Commit fcc2423

Browse files
author
backryun
committed
feat(providers): modernize CLOVA Studio chat and embeddings
1 parent f5e7095 commit fcc2423

17 files changed

Lines changed: 2626 additions & 558 deletions

File tree

open-sse/config/embeddingRegistry.ts

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
export type EmbeddingModality = "text" | "image" | "audio" | "video" | "document";
1212
export type StructuredEmbeddingProtocol = "jina-v1" | "gemini-embed-content";
13+
export type SingleTextEmbeddingProtocol = "clova-v2";
1314

1415
export interface EmbeddingModel {
1516
id: string;
@@ -34,6 +35,13 @@ export interface EmbeddingProvider {
3435
models: EmbeddingModel[];
3536
/** Provider-native serializer required for canonical structured input. */
3637
structuredInputProtocol?: StructuredEmbeddingProtocol;
38+
/**
39+
* Set when the endpoint embeds exactly ONE text per request (`{"text": …}` →
40+
* one vector) instead of accepting OpenAI's `input` array. A batched
41+
* `/v1/embeddings` call is then fanned out into N sequential upstream calls and
42+
* merged back into a single OpenAI list response.
43+
*/
44+
singleTextProtocol?: SingleTextEmbeddingProtocol;
3745
}
3846

3947
export interface EmbeddingProviderNodeRow {
@@ -297,6 +305,18 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
297305
],
298306
},
299307

308+
// Naver CLOVA Studio — embedding v2. The endpoint takes a single `{"text": …}`
309+
// body and returns `{status, result:{embedding:[…1024 floats], inputTokens}}`,
310+
// with no batch array and no `usage` object, hence `singleTextProtocol`.
311+
"clova-studio": {
312+
id: "clova-studio",
313+
baseUrl: "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2",
314+
authType: "apikey",
315+
authHeader: "bearer",
316+
singleTextProtocol: "clova-v2",
317+
models: [{ id: "clova-embedding-v2", name: "CLOVA Embedding v2", dimensions: 1024 }],
318+
},
319+
300320
"jina-ai": {
301321
id: "jina-ai",
302322
structuredInputProtocol: "jina-v1",
@@ -470,6 +490,57 @@ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | nu
470490
return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null;
471491
}
472492

493+
function findDynamicEmbeddingProvider(
494+
modelStr: string,
495+
dynamicProviders: EmbeddingProvider[] | undefined
496+
): { provider: string; model: string } | null {
497+
const match = dynamicProviders?.find((provider) => modelStr.startsWith(`${provider.id}/`));
498+
return match ? { provider: match.id, model: modelStr.slice(match.id.length + 1) } : null;
499+
}
500+
501+
function parsePrefixedEmbeddingModel(
502+
modelStr: string,
503+
slashIdx: number,
504+
dynamicProviders: EmbeddingProvider[] | undefined
505+
): { provider: string; model: string } {
506+
const rawProvider = modelStr.slice(0, slashIdx);
507+
const resolvedProvider = resolveEmbeddingProviderId(rawProvider);
508+
if (EMBEDDING_PROVIDERS[resolvedProvider]) {
509+
return {
510+
provider: resolvedProvider,
511+
model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)),
512+
};
513+
}
514+
515+
const hardcodedProvider = Object.keys(EMBEDDING_PROVIDERS).find((providerId) =>
516+
modelStr.startsWith(`${providerId}/`)
517+
);
518+
if (hardcodedProvider) {
519+
return {
520+
provider: hardcodedProvider,
521+
model: normalizeProviderScopedModelId(
522+
hardcodedProvider,
523+
modelStr.slice(hardcodedProvider.length + 1)
524+
),
525+
};
526+
}
527+
528+
return (
529+
findDynamicEmbeddingProvider(modelStr, dynamicProviders) ?? {
530+
provider: rawProvider,
531+
model: modelStr.slice(slashIdx + 1),
532+
}
533+
);
534+
}
535+
536+
function findEmbeddingModelProvider(modelStr: string): string | null {
537+
return (
538+
Object.entries(EMBEDDING_PROVIDERS).find(([, config]) =>
539+
config.models.some((model) => model.id === modelStr)
540+
)?.[0] ?? null
541+
);
542+
}
543+
473544
/**
474545
* Parse embedding model string (format: "provider/model" or just "model")
475546
* Returns { provider, model }
@@ -484,47 +555,11 @@ export function parseEmbeddingModel(
484555
// Check for "provider/model" format
485556
const slashIdx = modelStr.indexOf("/");
486557
if (slashIdx > 0) {
487-
const rawProvider = modelStr.slice(0, slashIdx);
488-
const resolvedProvider = resolveEmbeddingProviderId(rawProvider);
489-
490-
if (EMBEDDING_PROVIDERS[resolvedProvider]) {
491-
return {
492-
provider: resolvedProvider,
493-
model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)),
494-
};
495-
}
496-
497-
// Phase 1: Try each hardcoded provider prefix
498-
for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) {
499-
if (modelStr.startsWith(providerId + "/")) {
500-
return {
501-
provider: providerId,
502-
model: normalizeProviderScopedModelId(providerId, modelStr.slice(providerId.length + 1)),
503-
};
504-
}
505-
}
506-
// Phase 2: Try dynamic provider_nodes prefix
507-
if (dynamicProviders) {
508-
for (const dp of dynamicProviders) {
509-
if (modelStr.startsWith(dp.id + "/")) {
510-
return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) };
511-
}
512-
}
513-
}
514-
// Phase 3: Fallback — first segment is provider
515-
const provider = modelStr.slice(0, slashIdx);
516-
const model = modelStr.slice(slashIdx + 1);
517-
return { provider, model };
558+
return parsePrefixedEmbeddingModel(modelStr, slashIdx, dynamicProviders);
518559
}
519560

520561
// No provider prefix — search hardcoded providers for the model
521-
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
522-
if (config.models.some((m) => m.id === modelStr)) {
523-
return { provider: providerId, model: modelStr };
524-
}
525-
}
526-
527-
return { provider: null, model: modelStr };
562+
return { provider: findEmbeddingModelProvider(modelStr), model: modelStr };
528563
}
529564

530565
/**
Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,75 @@
11
import type { RegistryEntry } from "../../shared.ts";
22

3+
/**
4+
* Naver CLOVA Studio — Chat Completions **v3** (native API).
5+
*
6+
* Previously this entry pointed at Naver's OpenAI-compatibility shim
7+
* (`/v1/openai/chat/completions`), which meant `format: "openai"` and a
8+
* pass-through `DefaultExecutor`. The v3 API is Naver's own wire format, so the
9+
* entry now uses `format: "clova"` and the translator pair
10+
* (`openai-to-clova` / `clova-to-openai`).
11+
*
12+
* v3 moves the model into the URL path (`/v3/chat-completions/{modelName}`), uses
13+
* camelCase sampling params, and returns a `{status, result}` envelope instead of
14+
* an OpenAI `choices[]` body — see the translators for the exact mapping.
15+
*
16+
* All three v3 models are live-verified against the real API (2026-09-01):
17+
*
18+
* | Model | Surface | Notes |
19+
* | ------------- | -------- | -------------------------------------------------------- |
20+
* | HCX-007 | thinking | rejects `maxTokens` (use `maxCompletionTokens`); no vision |
21+
* | HCX-005 | text+img | vision via public URL **or** inline base64 data URI |
22+
* | HCX-DASH-002 | text | lightweight, text only |
23+
*
24+
* Docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3
25+
* https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-thinking
26+
*/
327
export const clova_studioProvider: RegistryEntry = {
428
id: "clova-studio",
529
alias: "clova",
6-
format: "openai",
7-
executor: "default",
8-
baseUrl: "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions",
30+
format: "clova",
31+
executor: "clova-studio",
32+
baseUrl: "https://clovastudio.stream.ntruss.com/v3/chat-completions",
933
authType: "apikey",
1034
authHeader: "bearer",
35+
/**
36+
* The v3 API does answer non-streaming requests (`Accept: application/json`),
37+
* but only the streaming surface is expressed in the translator: CLOVA's SSE
38+
* frames carry incremental `token` events plus a terminal `result` event that
39+
* repeats the full text. Forcing the upstream stream lets OmniRoute consume
40+
* that single, well-tested path and accumulate it into a JSON body for
41+
* non-streaming clients, instead of maintaining a second parser for the
42+
* `{status, result}` envelope.
43+
*/
44+
forceStream: true,
1145
models: [
12-
// HCX-007 stays first so it remains the provider default (deep-reasoning
13-
// flagship); HCX-005 is the multimodal option.
14-
{ id: "HCX-007", name: "HCX-007" },
15-
{ id: "HCX-005", name: "HCX-005" },
46+
{
47+
// Reasoning flagship. Input+output ≤ 128000 tokens; the output cap counts
48+
// thinking tokens too, so `maxCompletionTokens` may be up to 32768.
49+
id: "HCX-007",
50+
name: "HCX-007",
51+
contextLength: 128000,
52+
maxOutputTokens: 32768,
53+
supportsReasoning: true,
54+
},
55+
{
56+
// HyperCLOVA X vision model. Input+output ≤ 128000 tokens, output ≤ 4096,
57+
// up to 5 images per request (1 per turn). Accepts a public URL or an
58+
// inline base64 data URI — the data URI must keep its
59+
// `data:<mime>;base64,` prefix inside `dataUri.data` or the request is
60+
// rejected with `40001 Invalid parameter`.
61+
id: "HCX-005",
62+
name: "HCX-005",
63+
contextLength: 128000,
64+
maxOutputTokens: 4096,
65+
supportsVision: true,
66+
},
67+
{
68+
// Lightweight model. Input+output ≤ 32000 tokens, output ≤ 4096, text only.
69+
id: "HCX-DASH-002",
70+
name: "HCX-DASH-002",
71+
contextLength: 32000,
72+
maxOutputTokens: 4096,
73+
},
1674
],
1775
};

open-sse/executors/clova-studio.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { DefaultExecutor } from "./default.ts";
2+
3+
/** CLOVA Chat Completions v3 places the selected model in the URL path. */
4+
export class ClovaStudioExecutor extends DefaultExecutor {
5+
constructor() {
6+
super("clova-studio");
7+
}
8+
9+
buildUrl(model: string): string {
10+
return `${this.config.baseUrl}/${encodeURIComponent(model)}`;
11+
}
12+
}

open-sse/executors/index.ts

Lines changed: 15 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts";
22
import { assertMicrosoftDesignerWebProviderAvailable } from "@/shared/constants/designerWebRetirement";
33
import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
44
import { assertCommonChatGptWebProviderAvailable } from "@/shared/constants/chatgptWebRetirement";
5-
import {
6-
registerLazyExecutor,
7-
loadRegisteredExecutor,
8-
hasRegisteredExecutor,
9-
} from "./registry.ts";
5+
import { registerLazyExecutor, loadRegisteredExecutor, hasRegisteredExecutor } from "./registry.ts";
106
// Type-only: pulls no runtime code, keeps DefaultExecutor the only eager class.
117
import type { BaseExecutor } from "./base.ts";
128
import { getDefaultExecutor } from "./defaultResolver.ts";
@@ -46,8 +42,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
4642
),
4743
"chatgpt-web-codex": () =>
4844
import("./chatgpt-web-codex.ts").then((m) => new m.ChatGptWebCodexExecutor()),
49-
"cgpt-codex": () =>
50-
import("./chatgpt-web-codex.ts").then((m) => new m.ChatGptWebCodexExecutor()),
45+
"cgpt-codex": () => import("./chatgpt-web-codex.ts").then((m) => new m.ChatGptWebCodexExecutor()),
5146
cursor: () => import("./cursor.ts").then((m) => new m.CursorExecutor()),
5247
trae: () => import("./trae.ts").then((m) => new m.TraeExecutor()),
5348
glm: () => import("./glm.ts").then((m) => new m.GlmExecutor("glm")),
@@ -71,12 +66,9 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
7166
cf: () => import("./cloudflare-ai.ts").then((m) => new m.CloudflareAIExecutor()), // Alias
7267
freebuff: () => import("./freebuff.ts").then((m) => new m.FreebuffExecutor()),
7368
fb: () => import("./freebuff.ts").then((m) => new m.FreebuffExecutor()), // Alias
74-
"opencode-zen": () =>
75-
import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")),
76-
"opencode-go": () =>
77-
import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-go")),
78-
opencode: () =>
79-
import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")), // Alias for opencode-zen
69+
"opencode-zen": () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")),
70+
"opencode-go": () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-go")),
71+
opencode: () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")), // Alias for opencode-zen
8072
vertex: () => import("./vertex.ts").then((m) => new m.VertexExecutor()),
8173
"vertex-partner": () => import("./vertex.ts").then((m) => new m.VertexExecutor()),
8274
cliproxyapi: () => import("./cliproxyapi.ts").then((m) => new m.CliproxyapiExecutor()),
@@ -85,23 +77,19 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
8577
dr: () => import("./dario.ts").then((m) => new m.DarioExecutor()), // Alias
8678
"9router": () => import("./ninerouter.ts").then((m) => new m.NineRouterExecutor()),
8779
nr: () => import("./ninerouter.ts").then((m) => new m.NineRouterExecutor()), // Alias
88-
"perplexity-web": () =>
89-
import("./perplexity-web.ts").then((m) => new m.PerplexityWebExecutor()),
90-
"pplx-web": () =>
91-
import("./perplexity-web.ts").then((m) => new m.PerplexityWebExecutor()), // Alias
80+
"perplexity-web": () => import("./perplexity-web.ts").then((m) => new m.PerplexityWebExecutor()),
81+
"pplx-web": () => import("./perplexity-web.ts").then((m) => new m.PerplexityWebExecutor()), // Alias
9282
"grok-web": () => import("./grok-web.ts").then((m) => new m.GrokWebExecutor()),
9383
"claude-web": () => import("./claude-web.ts").then((m) => new m.ClaudeWebExecutor()),
9484
"cw-web": () => import("./claude-web.ts").then((m) => new m.ClaudeWebExecutor()), // Alias
9585
"gemini-web": () => import("./gemini-web.ts").then((m) => new m.GeminiWebExecutor()),
9686
gweb: () => import("./gemini-web.ts").then((m) => new m.GeminiWebExecutor()), // Alias
9787
"gemini-business": () =>
9888
import("./gemini-business.ts").then((m) => new m.GeminiBusinessExecutor()),
99-
gembiz: () =>
100-
import("./gemini-business.ts").then((m) => new m.GeminiBusinessExecutor()), // Alias
89+
gembiz: () => import("./gemini-business.ts").then((m) => new m.GeminiBusinessExecutor()), // Alias
10190
"blackbox-web": () => import("./blackbox-web.ts").then((m) => new m.BlackboxWebExecutor()),
10291
"bb-web": () => import("./blackbox-web.ts").then((m) => new m.BlackboxWebExecutor()), // Alias
103-
"muse-spark-web": () =>
104-
import("./muse-spark-web.ts").then((m) => new m.MuseSparkWebExecutor()),
92+
"muse-spark-web": () => import("./muse-spark-web.ts").then((m) => new m.MuseSparkWebExecutor()),
10593
"ms-web": () => import("./muse-spark-web.ts").then((m) => new m.MuseSparkWebExecutor()), // Alias
10694
"devin-desktop": () => import("./devin-desktop.ts").then((m) => new m.DevinDesktopExecutor()),
10795
"zed-hosted": () => import("./zed-hosted.ts").then((m) => new m.ZedHostedExecutor()),
@@ -129,8 +117,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
129117
firefly: () => import("./adobe-firefly.ts").then((m) => new m.AdobeFireflyExecutor()), // Alias
130118
"veoaifree-web": () => import("./veoaifree-web.ts").then((m) => new m.VeoAIFreeWebExecutor()),
131119
"veo-free": () => import("./veoaifree-web.ts").then((m) => new m.VeoAIFreeWebExecutor()), // Alias
132-
"duckduckgo-web": () =>
133-
import("./duckduckgo-web.ts").then((m) => new m.DuckDuckGoWebExecutor()),
120+
"duckduckgo-web": () => import("./duckduckgo-web.ts").then((m) => new m.DuckDuckGoWebExecutor()),
134121
ddgw: () => import("./duckduckgo-web.ts").then((m) => new m.DuckDuckGoWebExecutor()), // Alias
135122
"t3-web": () => import("./t3-chat-web.ts").then((m) => new m.T3ChatWebExecutor()),
136123
t3chat: () => import("./t3-chat-web.ts").then((m) => new m.T3ChatWebExecutor()), // Alias
@@ -141,8 +128,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
141128
"yuanbao-web": () => import("./yuanbao-web.ts").then((m) => new m.YuanbaoWebExecutor()),
142129
"tencent-aistudio-web": () =>
143130
import("./tencent-aistudio-web.ts").then((m) => new m.TencentAIStudioWebExecutor()),
144-
tasw: () =>
145-
import("./tencent-aistudio-web.ts").then((m) => new m.TencentAIStudioWebExecutor()), // Alias
131+
tasw: () => import("./tencent-aistudio-web.ts").then((m) => new m.TencentAIStudioWebExecutor()), // Alias
146132
ybw: () => import("./yuanbao-web.ts").then((m) => new m.YuanbaoWebExecutor()), // Alias
147133
"poe-web": () => import("./poe-web.ts").then((m) => new m.PoeWebExecutor()),
148134
// #8969: do NOT alias canonical `poe` (API-key / api.poe.com) to PoeWebExecutor.
@@ -165,9 +151,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
165151
cheaperinference: () =>
166152
import("./cheaperinference.ts").then((m) => new m.CheaperInferenceExecutor()),
167153
cinf: () =>
168-
import("./cheaperinference.ts").then(
169-
(m) => new m.CheaperInferenceExecutor("cheaperinference")
170-
), // Alias
154+
import("./cheaperinference.ts").then((m) => new m.CheaperInferenceExecutor("cheaperinference")), // Alias
171155
"doubao-web": () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()),
172156
db: () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()), // Alias
173157
"zai-web": () => import("./zai-web.ts").then((m) => new m.ZaiWebExecutor()),
@@ -185,8 +169,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
185169
"zenmux-free": () => import("./zenmux-free.ts").then((m) => new m.ZenmuxFreeExecutor()),
186170
"cloudflare-playground": () =>
187171
import("./cloudflare-playground.ts").then((m) => new m.CloudflarePlaygroundExecutor()),
188-
cfp: () =>
189-
import("./cloudflare-playground.ts").then((m) => new m.CloudflarePlaygroundExecutor()), // Alias for cloudflare-playground
172+
cfp: () => import("./cloudflare-playground.ts").then((m) => new m.CloudflarePlaygroundExecutor()), // Alias for cloudflare-playground
190173
"tinycms-web": () => import("./tinycms.ts").then((m) => new m.TinyCmsExecutor()),
191174
tcw: () => import("./tinycms.ts").then((m) => new m.TinyCmsExecutor()), // Alias
192175
hyperagent: () => import("./hyperagent.ts").then((m) => new m.HyperAgentExecutor()),
@@ -196,6 +179,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
196179
xai: () => import("./xai.ts").then((m) => new m.XaiExecutor()),
197180
"xai-oauth": () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
198181
xao: () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
182+
"clova-studio": () => import("./clova-studio.ts").then((m) => new m.ClovaStudioExecutor()),
199183
"conol-web": () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()),
200184
cnl: () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()), // Alias
201185
};
@@ -256,11 +240,7 @@ export function hasSpecializedExecutor(provider: string): boolean {
256240
return hasRegisteredExecutor(provider);
257241
}
258242

259-
export {
260-
registerExecutor,
261-
registerLazyExecutor,
262-
listExecutorAliases,
263-
} from "./registry.ts";
243+
export { registerExecutor, registerLazyExecutor, listExecutorAliases } from "./registry.ts";
264244
// Value re-export: base.ts is already eager (DefaultExecutor extends it), and
265245
// scripts/check/check-known-symbols.ts reads this export from the module.
266246
export { BaseExecutor } from "./base.ts";

0 commit comments

Comments
 (0)