Skip to content

Commit d166362

Browse files
authored
feat(api): support more OpenAI types (image, embeddings, audio-transcriptions, audio-speech) (#1297)
Integrated into release/v3.6.6 — adds embeddings, audio-transcriptions, audio-speech, and images-generations support for custom OpenAI-compatible providers, plus Pollinations image registry
1 parent 578004c commit d166362

12 files changed

Lines changed: 294 additions & 32 deletions

File tree

open-sse/config/imageRegistry.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,26 @@ export const IMAGE_PROVIDERS = {
150150
],
151151
supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "256x256", "512x512"],
152152
},
153+
154+
pollinations: {
155+
id: "pollinations",
156+
alias: "pol",
157+
baseUrl: "https://gen.pollinations.ai/v1/images/generations",
158+
authType: "apikey",
159+
authHeader: "bearer",
160+
format: "openai",
161+
models: [
162+
{ id: "flux", name: "Flux Schnell" },
163+
{ id: "zimage", name: "Z-Image Turbo" },
164+
{ id: "klein", name: "FLUX.2 Klein 4B" },
165+
{ id: "gptimage", name: "GPT Image 1 Mini" },
166+
{ id: "qwen-image", name: "Qwen Image Plus" },
167+
{ id: "wan-image", name: "Wan 2.7 Image" },
168+
{ id: "kontext", name: "FLUX.1 Kontext" },
169+
{ id: "gptimage-large", name: "GPT Image 1.5" },
170+
],
171+
supportedSizes: ["1024x1024", "512x512"],
172+
},
153173
};
154174

155175
/**
@@ -171,6 +191,10 @@ export function parseImageModel(modelStr) {
171191
if (modelStr.startsWith(providerId + "/")) {
172192
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
173193
}
194+
// Check alias if available
195+
if (config.alias && modelStr.startsWith(config.alias + "/")) {
196+
return { provider: providerId, model: modelStr.slice(config.alias.length + 1) };
197+
}
174198
}
175199

176200
// No provider prefix — try to find the model in every provider

open-sse/services/provider.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,38 @@ export function getOpenAICompatibleType(provider, providerSpecificData = null) {
3737
typeof providerSpecificData.apiType === "string"
3838
? providerSpecificData.apiType
3939
: null;
40-
if (configuredType === "responses" || configuredType === "chat") {
40+
if (
41+
configuredType === "responses" ||
42+
configuredType === "chat" ||
43+
configuredType === "embeddings" ||
44+
configuredType === "audio-transcriptions" ||
45+
configuredType === "audio-speech" ||
46+
configuredType === "images-generations"
47+
) {
4148
return configuredType;
4249
}
43-
return provider.includes("responses") ? "responses" : "chat";
50+
if (provider.includes("responses")) return "responses";
51+
if (provider.includes("embeddings")) return "embeddings";
52+
if (provider.includes("audio-transcriptions")) return "audio-transcriptions";
53+
if (provider.includes("audio-speech")) return "audio-speech";
54+
if (provider.includes("images-generations")) return "images-generations";
55+
return "chat";
4456
}
4557

4658
function buildOpenAICompatibleUrl(baseUrl, apiType) {
4759
const normalized = baseUrl.replace(/\/$/, "");
48-
const path = apiType === "responses" ? "/responses" : "/chat/completions";
60+
let path = "/chat/completions";
61+
if (apiType === "responses") {
62+
path = "/responses";
63+
} else if (apiType === "embeddings") {
64+
path = "/embeddings";
65+
} else if (apiType === "audio-transcriptions") {
66+
path = "/audio/transcriptions";
67+
} else if (apiType === "audio-speech") {
68+
path = "/audio/speech";
69+
} else if (apiType === "images-generations") {
70+
path = "/images/generations";
71+
}
4972
return `${normalized}${path}`;
5073
}
5174

src/app/(dashboard)/dashboard/providers/[id]/page.tsx

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,50 @@ export default function ProviderDetailPage() {
10041004
const providerStorageAlias = isCompatible ? providerId : providerAlias;
10051005
const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias;
10061006

1007+
const getApiLabel = () => {
1008+
if (isAnthropicProtocolCompatible) return t("messagesApi");
1009+
const type = providerNode?.apiType;
1010+
switch (type) {
1011+
case "responses":
1012+
return t("responsesApi");
1013+
case "embeddings":
1014+
return t("embeddings");
1015+
case "audio-transcriptions":
1016+
return t("audioTranscriptions");
1017+
case "audio-speech":
1018+
return t("audioSpeech");
1019+
case "images-generations":
1020+
return t("imagesGenerations");
1021+
default:
1022+
return t("chatCompletions");
1023+
}
1024+
};
1025+
1026+
const getApiDefaultPath = () => {
1027+
if (isCcCompatible) return CC_COMPATIBLE_DEFAULT_CHAT_PATH;
1028+
if (isAnthropicCompatible) return "/messages";
1029+
const type = providerNode?.apiType;
1030+
switch (type) {
1031+
case "responses":
1032+
return "/responses";
1033+
case "embeddings":
1034+
return "/embeddings";
1035+
case "audio-transcriptions":
1036+
return "/audio/transcriptions";
1037+
case "audio-speech":
1038+
return "/audio/speech";
1039+
case "images-generations":
1040+
return "/images/generations";
1041+
default:
1042+
return "/chat/completions";
1043+
}
1044+
};
1045+
1046+
const getApiPath = () => {
1047+
const defaultPath = getApiDefaultPath();
1048+
return (providerNode?.chatPath || defaultPath).replace(/^\//, "");
1049+
};
1050+
10071051
// Define callbacks BEFORE the useEffect that uses them
10081052
const fetchAliases = useCallback(async () => {
10091053
try {
@@ -2495,19 +2539,7 @@ export default function ProviderDetailPage() {
24952539
: t("openaiCompatibleDetails")}
24962540
</h2>
24972541
<p className="text-sm text-text-muted">
2498-
{isAnthropicProtocolCompatible
2499-
? t("messagesApi")
2500-
: providerNode.apiType === "responses"
2501-
? t("responsesApi")
2502-
: t("chatCompletions")}{" "}
2503-
· {(providerNode.baseUrl || "").replace(/\/$/, "")}/
2504-
{isCcCompatible
2505-
? (providerNode.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH).replace(/^\//, "")
2506-
: isAnthropicCompatible
2507-
? (providerNode.chatPath || "/messages").replace(/^\//, "")
2508-
: providerNode.apiType === "responses"
2509-
? (providerNode.chatPath || "/responses").replace(/^\//, "")
2510-
: (providerNode.chatPath || "/chat/completions").replace(/^\//, "")}
2542+
{getApiLabel()} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/{getApiPath()}
25112543
</p>
25122544
</div>
25132545
<div className="flex items-center gap-2">
@@ -3845,8 +3877,12 @@ function CustomModelsSection({
38453877
onChange={(e) => setNewApiFormat(e.target.value)}
38463878
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
38473879
>
3848-
<option value="chat-completions">Chat Completions</option>
3849-
<option value="responses">Responses API</option>
3880+
<option value="chat-completions">{t("chatCompletions")}</option>
3881+
<option value="responses">{t("responsesApi")}</option>
3882+
<option value="embeddings">{t("embeddings")}</option>
3883+
<option value="audio-transcriptions">{t("audioTranscriptions")}</option>
3884+
<option value="audio-speech">{t("audioSpeech")}</option>
3885+
<option value="images-generations">{t("imagesGenerations")}</option>
38503886
</select>
38513887
</div>
38523888
<div className="flex-1">
@@ -3972,8 +4008,12 @@ function CustomModelsSection({
39724008
onChange={(e) => setEditingApiFormat(e.target.value)}
39734009
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary"
39744010
>
3975-
<option value="chat-completions">Chat Completions</option>
3976-
<option value="responses">Responses API</option>
4011+
<option value="chat-completions">{t("chatCompletions")}</option>
4012+
<option value="responses">{t("responsesApi")}</option>
4013+
<option value="embeddings">{t("embeddings")}</option>
4014+
<option value="audio-transcriptions">{t("audioTranscriptions")}</option>
4015+
<option value="audio-speech">{t("audioSpeech")}</option>
4016+
<option value="images-generations">{t("imagesGenerations")}</option>
39774017
</select>
39784018
</div>
39794019
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-visible [scrollbar-width:thin]">
@@ -6092,6 +6132,10 @@ function EditCompatibleNodeModal({
60926132
const apiTypeOptions = [
60936133
{ value: "chat", label: t("chatCompletions") },
60946134
{ value: "responses", label: t("responsesApi") },
6135+
{ value: "embeddings", label: t("embeddings") },
6136+
{ value: "audio-transcriptions", label: t("audioTranscriptions") },
6137+
{ value: "audio-speech", label: t("audioSpeech") },
6138+
{ value: "images-generations", label: t("imagesGenerations") },
60956139
];
60966140

60976141
const handleSubmit = async () => {

src/app/(dashboard)/dashboard/providers/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,10 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
11491149
const apiTypeOptions = [
11501150
{ value: "chat", label: t("chatCompletions") },
11511151
{ value: "responses", label: t("responsesApi") },
1152+
{ value: "embeddings", label: t("embeddings") },
1153+
{ value: "audio-transcriptions", label: t("audioTranscriptions") },
1154+
{ value: "audio-speech", label: t("audioSpeech") },
1155+
{ value: "images-generations", label: t("imagesGenerations") },
11521156
];
11531157

11541158
useEffect(() => {

src/app/api/provider-nodes/[id]/route.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,15 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
6262
}
6363

6464
// Only validate apiType for OpenAI Compatible nodes
65-
if (
66-
node.type === "openai-compatible" &&
67-
(!apiType || !["chat", "responses"].includes(apiType))
68-
) {
65+
const validApiTypes = [
66+
"chat",
67+
"responses",
68+
"embeddings",
69+
"audio-transcriptions",
70+
"audio-speech",
71+
"images-generations",
72+
];
73+
if (node.type === "openai-compatible" && (!apiType || !validApiTypes.includes(apiType))) {
6974
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
7075
}
7176

src/app/api/v1/embeddings/route.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,10 @@ export async function POST(request) {
120120
const nodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[];
121121
dynamicProviders = (Array.isArray(nodes) ? nodes : [])
122122
.filter((n) => {
123-
// provider_nodes apiType is "chat" or "responses" (not "embeddings") — local OpenAI-compatible
123+
// provider_nodes apiType is "chat", "responses" or "embeddings" — local OpenAI-compatible
124124
// backends expose /embeddings under the same base URL as chat, so we build the URL as baseUrl + /embeddings.
125-
if (n.apiType !== "chat" && n.apiType !== "responses") return false;
125+
const validTypes = ["chat", "responses", "embeddings"];
126+
if (!validTypes.includes(n.apiType || "")) return false;
126127
try {
127128
const hostname = new URL(n.baseUrl).hostname;
128129
// Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening
@@ -170,7 +171,9 @@ export async function POST(request) {
170171
const allNodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[];
171172
const matchingNode = (Array.isArray(allNodes) ? allNodes : []).find(
172173
(n) =>
173-
n.prefix === provider && (n.apiType === "chat" || n.apiType === "responses") && n.baseUrl
174+
n.prefix === provider &&
175+
(n.apiType === "chat" || n.apiType === "responses" || n.apiType === "embeddings") &&
176+
n.baseUrl
174177
);
175178
if (matchingNode) {
176179
const baseUrl = String(matchingNode.baseUrl).replace(/\/+$/, "");

src/i18n/messages/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,6 +1704,10 @@
17041704
"openaiCompatibleDetails": "OpenAI Compatible Details",
17051705
"messagesApi": "Messages API",
17061706
"responsesApi": "Responses API",
1707+
"embeddings": "Embeddings",
1708+
"audioTranscriptions": "Audio Transcriptions",
1709+
"audioSpeech": "Audio Speech",
1710+
"imagesGenerations": "Images Generations",
17071711
"chatCompletions": "Chat Completions",
17081712
"importingModels": "Importing...",
17091713
"importFromModels": "Import from /models",

src/lib/db/models.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,13 @@ export async function addCustomModel(
343343
modelId: string,
344344
modelName?: string,
345345
source = "manual",
346-
apiFormat: "chat-completions" | "responses" = "chat-completions",
346+
apiFormat:
347+
| "chat-completions"
348+
| "responses"
349+
| "embeddings"
350+
| "audio-transcriptions"
351+
| "audio-speech"
352+
| "images-generations" = "chat-completions",
347353
supportedEndpoints: string[] = ["chat"]
348354
) {
349355
const db = getDbInstance();

src/shared/validation/schemas.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -516,8 +516,8 @@ export const providerModelMutationSchema = z.object({
516516
modelId: z.string().trim().min(1, "modelId is required").max(240),
517517
modelName: z.string().trim().max(240).optional(),
518518
source: z.string().trim().max(80).optional(),
519-
apiFormat: z.enum(["chat-completions", "responses"]).default("chat-completions"),
520-
supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]),
519+
apiFormat: z.enum(["chat-completions", "responses", "embeddings", "audio-transcriptions", "audio-speech", "images-generations"]).default("chat-completions"),
520+
supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio", "audio-transcriptions", "audio-speech", "images-generations"])).default(["chat"]),
521521
normalizeToolCallId: z.boolean().optional(),
522522
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
523523
upstreamHeaders: upstreamHeadersRecordSchema.nullable().optional(),
@@ -1124,7 +1124,7 @@ export const createProviderNodeSchema = z
11241124
.object({
11251125
name: z.string().trim().min(1, "Name is required"),
11261126
prefix: z.string().trim().min(1, "Prefix is required"),
1127-
apiType: z.enum(["chat", "responses"]).optional(),
1127+
apiType: z.enum(["chat", "responses", "embeddings", "audio-transcriptions", "audio-speech", "images-generations"]).optional(),
11281128
baseUrl: z.string().trim().min(1).optional(),
11291129
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
11301130
compatMode: z.enum(["cc"]).optional(),
@@ -1145,7 +1145,7 @@ export const createProviderNodeSchema = z
11451145
export const updateProviderNodeSchema = z.object({
11461146
name: z.string().trim().min(1, "Name is required"),
11471147
prefix: z.string().trim().min(1, "Prefix is required"),
1148-
apiType: z.enum(["chat", "responses"]).optional(),
1148+
apiType: z.enum(["chat", "responses", "embeddings", "audio-transcriptions", "audio-speech", "images-generations"]).optional(),
11491149
baseUrl: z.string().trim().min(1, "Base URL is required"),
11501150
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
11511151
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),

tests/manual/embedding.http

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
###
2+
# @name Embedding - Test embedding response
3+
POST {{omniroute-address}}/v1/embeddings
4+
Authorization: Bearer {{OMNIROUTE_API_KEY}}
5+
Content-Type: application/json
6+
7+
{
8+
"model": "pinecone/llama-text-embed-v2",
9+
"input": "The food was delicious and the waiter was very attentive."
10+
}
11+

0 commit comments

Comments
 (0)