mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
feat(providers): add GitLab Duo, NLP Cloud, and enterprise gateways
Expand the provider catalog with GitLab Duo PAT and OAuth support, NLP Cloud, and new OpenAI-compatible gateways including Azure AI Foundry, Bedrock, DataRobot, watsonx, OCI, SAP, Modal, Reka, Clarifai, and Chutes. Add specialized GitLab and NLP Cloud executors, provider-specific URL normalization and validation flows, managed catalog/model discovery updates, and dashboard metadata for the new providers. Extend search support with You.com, including request building, response normalization, validation coverage, and route/schema registration.
This commit is contained in:
51
open-sse/config/azureAi.ts
Normal file
51
open-sse/config/azureAi.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export const AZURE_AI_DEFAULT_BASE_URL = "https://example-resource.services.ai.azure.com/openai/v1";
|
||||
|
||||
function normalizeBaseUrl(value: string | null | undefined): string {
|
||||
return (value || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function normalizeAzureAiBaseUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeBaseUrl(value || AZURE_AI_DEFAULT_BASE_URL);
|
||||
if (!normalized) return AZURE_AI_DEFAULT_BASE_URL;
|
||||
|
||||
if (
|
||||
normalized.endsWith("/chat/completions") ||
|
||||
normalized.endsWith("/responses") ||
|
||||
normalized.endsWith("/models")
|
||||
) {
|
||||
return normalized.replace(/\/(?:chat\/completions|responses|models)$/i, "");
|
||||
}
|
||||
|
||||
if (normalized.endsWith("/openai/v1") || normalized.endsWith("/v1")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized.endsWith("/openai")) {
|
||||
return `${normalized}/v1`;
|
||||
}
|
||||
|
||||
const parsed = new URL(normalized);
|
||||
if (
|
||||
parsed.hostname.endsWith(".services.ai.azure.com") ||
|
||||
parsed.hostname.endsWith(".openai.azure.com")
|
||||
) {
|
||||
if (!parsed.pathname || parsed.pathname === "/") {
|
||||
parsed.pathname = "/openai/v1";
|
||||
return parsed.toString().replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildAzureAiChatUrl(
|
||||
value: string | null | undefined,
|
||||
apiType: "chat" | "responses" = "chat"
|
||||
): string {
|
||||
const normalized = normalizeAzureAiBaseUrl(value);
|
||||
return `${normalized}/${apiType === "responses" ? "responses" : "chat/completions"}`;
|
||||
}
|
||||
|
||||
export function buildAzureAiModelsUrl(value: string | null | undefined): string {
|
||||
return `${normalizeAzureAiBaseUrl(value)}/models`;
|
||||
}
|
||||
82
open-sse/config/bedrock.ts
Normal file
82
open-sse/config/bedrock.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export const BEDROCK_DEFAULT_BASE_URL = "https://bedrock-mantle.us-east-1.api.aws/v1";
|
||||
|
||||
function normalizeBaseUrl(value: string | null | undefined): string {
|
||||
return (value || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isBedrockRuntimeHost(hostname: string): boolean {
|
||||
return hostname.startsWith("bedrock-runtime.") && hostname.endsWith(".amazonaws.com");
|
||||
}
|
||||
|
||||
function isBedrockMantleHost(hostname: string): boolean {
|
||||
return hostname.startsWith("bedrock-mantle.") && hostname.endsWith(".api.aws");
|
||||
}
|
||||
|
||||
export function isBedrockRuntimeBaseUrl(value: string | null | undefined): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeBaseUrl(value || BEDROCK_DEFAULT_BASE_URL));
|
||||
return isBedrockRuntimeHost(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBedrockMantleBaseUrl(value: string | null | undefined): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeBaseUrl(value || BEDROCK_DEFAULT_BASE_URL));
|
||||
return isBedrockMantleHost(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBedrockBaseUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeBaseUrl(value || BEDROCK_DEFAULT_BASE_URL);
|
||||
if (!normalized) return BEDROCK_DEFAULT_BASE_URL;
|
||||
|
||||
const stripped = normalized.replace(/\/(?:chat\/completions|responses|models)$/i, "");
|
||||
|
||||
try {
|
||||
const parsed = new URL(stripped);
|
||||
const pathname = parsed.pathname.replace(/\/+$/, "");
|
||||
|
||||
if (isBedrockMantleHost(parsed.hostname)) {
|
||||
if (!pathname || pathname === "/" || pathname === "/openai" || pathname === "/openai/v1") {
|
||||
parsed.pathname = "/v1";
|
||||
} else if (!pathname.endsWith("/v1")) {
|
||||
parsed.pathname = pathname;
|
||||
}
|
||||
} else if (isBedrockRuntimeHost(parsed.hostname)) {
|
||||
if (!pathname || pathname === "/" || pathname === "/openai" || pathname === "/v1") {
|
||||
parsed.pathname = "/openai/v1";
|
||||
} else if (!pathname.endsWith("/openai/v1")) {
|
||||
parsed.pathname = pathname;
|
||||
}
|
||||
} else if (pathname.endsWith("/openai")) {
|
||||
parsed.pathname = `${pathname}/v1`;
|
||||
} else if (!pathname) {
|
||||
parsed.pathname = "/v1";
|
||||
}
|
||||
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
if (stripped.endsWith("/openai")) {
|
||||
return `${stripped}/v1`;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBedrockChatUrl(value: string | null | undefined): string {
|
||||
return `${normalizeBedrockBaseUrl(value)}/chat/completions`;
|
||||
}
|
||||
|
||||
export function buildBedrockModelsUrl(value: string | null | undefined): string {
|
||||
return `${normalizeBedrockBaseUrl(value)}/models`;
|
||||
}
|
||||
|
||||
export function getBedrockValidationModelId(value: string | null | undefined): string {
|
||||
return isBedrockRuntimeBaseUrl(value) ? "openai.gpt-oss-120b-1:0" : "openai.gpt-oss-120b";
|
||||
}
|
||||
71
open-sse/config/datarobot.ts
Normal file
71
open-sse/config/datarobot.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
const DATAROBOT_API_V2_SEGMENT = "/api/v2";
|
||||
const DATAROBOT_LLMGW_CHAT_PATH = "/genai/llmgw/chat/completions/";
|
||||
const DATAROBOT_LLMGW_CATALOG_PATH = "/genai/llmgw/catalog/";
|
||||
|
||||
export const DATAROBOT_DEFAULT_BASE_URL = "https://app.datarobot.com";
|
||||
|
||||
function normalizeBaseUrl(value: string | null | undefined): string {
|
||||
return (value || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function normalizeDataRobotBaseUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeBaseUrl(value || DATAROBOT_DEFAULT_BASE_URL);
|
||||
return normalized || DATAROBOT_DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
export function isDataRobotDeploymentUrl(value: string | null | undefined): boolean {
|
||||
const normalized = normalizeDataRobotBaseUrl(value);
|
||||
return /\/api\/v2\/deployments\/[^/]+(?:\/chat\/completions)?$/i.test(normalized);
|
||||
}
|
||||
|
||||
export function buildDataRobotChatUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeDataRobotBaseUrl(value);
|
||||
|
||||
if (normalized.endsWith("/chat/completions")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (/\/api\/v2\/deployments\/[^/]+$/i.test(normalized)) {
|
||||
return `${normalized}/chat/completions`;
|
||||
}
|
||||
|
||||
if (/\/api\/v2\/genai\/llmgw$/i.test(normalized)) {
|
||||
return `${normalized}/chat/completions/`;
|
||||
}
|
||||
|
||||
if (/\/api\/v2\/genai\/llmgw\/chat$/i.test(normalized)) {
|
||||
return `${normalized}/completions/`;
|
||||
}
|
||||
|
||||
if (normalized.includes(DATAROBOT_API_V2_SEGMENT)) {
|
||||
return `${normalized}${DATAROBOT_LLMGW_CHAT_PATH}`;
|
||||
}
|
||||
|
||||
return `${normalized}${DATAROBOT_API_V2_SEGMENT}${DATAROBOT_LLMGW_CHAT_PATH}`;
|
||||
}
|
||||
|
||||
export function buildDataRobotCatalogUrl(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeDataRobotBaseUrl(value);
|
||||
|
||||
if (isDataRobotDeploymentUrl(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new URL(normalized);
|
||||
let basePath = parsed.pathname.replace(/\/+$/, "");
|
||||
|
||||
if (/\/api\/v2\/genai\/llmgw\/chat\/completions$/i.test(basePath)) {
|
||||
basePath = basePath.replace(/\/chat\/completions$/i, "");
|
||||
} else if (/\/api\/v2\/genai\/llmgw$/i.test(basePath)) {
|
||||
// Keep path as-is.
|
||||
} else if (basePath.includes(DATAROBOT_API_V2_SEGMENT)) {
|
||||
basePath = basePath.replace(/\/api\/v2.*$/i, "");
|
||||
}
|
||||
|
||||
const catalogPath = `${basePath}${DATAROBOT_LLMGW_CATALOG_PATH}`.replace(/\/{2,}/g, "/");
|
||||
parsed.pathname = catalogPath;
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
46
open-sse/config/oci.ts
Normal file
46
open-sse/config/oci.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export const OCI_DEFAULT_BASE_URL =
|
||||
"https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1";
|
||||
|
||||
function normalizeBaseUrl(value: string | null | undefined): string {
|
||||
return (value || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function normalizeOciBaseUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeBaseUrl(value || OCI_DEFAULT_BASE_URL);
|
||||
if (!normalized) return OCI_DEFAULT_BASE_URL;
|
||||
|
||||
const stripped = normalized.replace(/\/(?:chat\/completions|responses|models)$/i, "");
|
||||
|
||||
if (stripped.endsWith("/openai/v1")) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
if (stripped.endsWith("/openai")) {
|
||||
return `${stripped}/v1`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(stripped);
|
||||
if (!parsed.pathname || parsed.pathname === "/") {
|
||||
parsed.pathname = "/openai/v1";
|
||||
} else if (parsed.pathname.endsWith("/openai")) {
|
||||
parsed.pathname = `${parsed.pathname}/v1`;
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOciChatUrl(
|
||||
value: string | null | undefined,
|
||||
apiType: "chat" | "responses" = "chat"
|
||||
): string {
|
||||
return `${normalizeOciBaseUrl(value)}/${apiType === "responses" ? "responses" : "chat/completions"}`;
|
||||
}
|
||||
|
||||
export function buildOciModelsUrl(value: string | null | undefined): string {
|
||||
return `${normalizeOciBaseUrl(value)}/models`;
|
||||
}
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
GLM_SHARED_HEADERS,
|
||||
GLM_SHARED_MODELS,
|
||||
} from "./glmProvider.ts";
|
||||
import { DATAROBOT_DEFAULT_BASE_URL } from "./datarobot.ts";
|
||||
import { AZURE_AI_DEFAULT_BASE_URL } from "./azureAi.ts";
|
||||
import { BEDROCK_DEFAULT_BASE_URL } from "./bedrock.ts";
|
||||
import { WATSONX_DEFAULT_BASE_URL } from "./watsonx.ts";
|
||||
import { OCI_DEFAULT_BASE_URL } from "./oci.ts";
|
||||
import { SAP_DEFAULT_BASE_URL } from "./sap.ts";
|
||||
import {
|
||||
CURSOR_REGISTRY_VERSION,
|
||||
getAntigravityProviderHeaders,
|
||||
@@ -138,6 +144,9 @@ const KIMI_CODING_SHARED = {
|
||||
] as RegistryModel[],
|
||||
} as const;
|
||||
|
||||
const GITLAB_DUO_BASE_URL =
|
||||
process.env.GITLAB_DUO_BASE_URL || process.env.GITLAB_BASE_URL || "https://gitlab.com";
|
||||
|
||||
const buildModels = (ids: readonly string[]): RegistryModel[] =>
|
||||
ids.map((id) => ({ id, name: id }));
|
||||
|
||||
@@ -190,6 +199,14 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
|
||||
cablyai: buildModels(["gpt-4o", "gpt-4o-mini", "deepseek-chat"]),
|
||||
thebai: buildModels(["gpt-4o", "claude-3.5-sonnet", "llama-3.3-70b"]),
|
||||
fenayai: buildModels(["gpt-4o", "claude-3.5-sonnet", "deepseek-chat"]),
|
||||
gitlab: [{ id: "gitlab-duo-code-suggestions", name: "GitLab Duo Code Suggestions" }],
|
||||
"gitlab-duo": [{ id: "gitlab-duo-code-suggestions", name: "GitLab Duo Code Suggestions" }],
|
||||
chutes: buildModels([
|
||||
"Qwen/Qwen3-32B-TEE",
|
||||
"deepseek-ai/DeepSeek-V3.2-TEE",
|
||||
"openai/gpt-oss-120b-TEE",
|
||||
"moonshotai/Kimi-K2.6-TEE",
|
||||
]),
|
||||
moonshot: buildModels(["kimi-k2.5", "kimi-latest", "moonshot-v1-auto"]),
|
||||
"meta-llama": buildModels([
|
||||
"Llama-3.3-70B-Instruct",
|
||||
@@ -209,6 +226,47 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
|
||||
"databricks-claude-sonnet-4",
|
||||
"databricks-gemini-2-5-pro",
|
||||
]),
|
||||
datarobot: [
|
||||
{ id: "azure/gpt-5-mini-2025-08-07", name: "Azure GPT-5 Mini" },
|
||||
{ id: "azure/gpt-4o-mini", name: "Azure GPT-4o Mini" },
|
||||
],
|
||||
clarifai: [
|
||||
{ id: "openai/chat-completion/models/gpt-oss-120b", name: "GPT-OSS 120B" },
|
||||
{ id: "openai/chat-completion/models/gpt-4o", name: "GPT-4o" },
|
||||
{ id: "openai/chat-completion/models/o4-mini", name: "o4-mini" },
|
||||
{ id: "anthropic/completion/models/claude-sonnet-4", name: "Claude Sonnet 4" },
|
||||
{
|
||||
id: "deepseek-ai/deepseek-chat/models/DeepSeek-R1-0528-Qwen3-8B",
|
||||
name: "DeepSeek R1 Qwen3 8B",
|
||||
},
|
||||
{ id: "gcp/generate/models/gemini-2_5-flash", name: "Gemini 2.5 Flash" },
|
||||
],
|
||||
watsonx: buildModels([
|
||||
"ibm/granite-3-3-8b-instruct",
|
||||
"meta-llama/llama-3-3-70b-instruct",
|
||||
"openai/gpt-4o",
|
||||
]),
|
||||
oci: buildModels([
|
||||
"openai.gpt-oss-20b",
|
||||
"openai.gpt-oss-120b",
|
||||
"google.gemini-2.5-pro",
|
||||
"xai.grok-4",
|
||||
]),
|
||||
sap: buildModels(["gpt-4o", "gpt-5-mini", "mistralai--mistral-medium-instruct"]),
|
||||
modal: buildModels([
|
||||
"Qwen/Qwen3-4B-Thinking-2507-FP8",
|
||||
"google/gemma-4-26B-A4B-it",
|
||||
"gpt-oss-20B",
|
||||
]),
|
||||
reka: buildModels(["reka-core", "reka-flash", "reka-edge-2603"]),
|
||||
nlpcloud: buildModels([
|
||||
"gpt-oss-120b",
|
||||
"llama-3-1-405b",
|
||||
"finetuned-llama-3-70b",
|
||||
"chatdolphin",
|
||||
"dolphin-yi-34b",
|
||||
"dolphin-mixtral-8x7b",
|
||||
]),
|
||||
snowflake: buildModels(["llama3.1-70b", "llama3.3-70b", "deepseek-r1", "claude-3-5-sonnet"]),
|
||||
wandb: buildModels([
|
||||
"openai/gpt-oss-120b",
|
||||
@@ -647,6 +705,42 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
"azure-ai": {
|
||||
id: "azure-ai",
|
||||
alias: "azure-ai",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: AZURE_AI_DEFAULT_BASE_URL,
|
||||
authType: "apikey",
|
||||
authHeader: "api-key",
|
||||
models: [
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
|
||||
{ id: "grok-4", name: "Grok 4" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
bedrock: {
|
||||
id: "bedrock",
|
||||
alias: "bedrock",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: BEDROCK_DEFAULT_BASE_URL,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: buildModels([
|
||||
"openai.gpt-oss-20b",
|
||||
"openai.gpt-oss-120b",
|
||||
"openai.gpt-oss-safeguard-20b",
|
||||
"openai.gpt-oss-safeguard-120b",
|
||||
"mistral.mistral-large-3-675b-instruct",
|
||||
]),
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
anthropic: {
|
||||
id: "anthropic",
|
||||
alias: "anthropic",
|
||||
@@ -1960,6 +2054,50 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
gitlab: {
|
||||
id: "gitlab",
|
||||
alias: "gitlab",
|
||||
format: "openai",
|
||||
executor: "gitlab",
|
||||
baseUrl: "https://gitlab.com/api/v4/code_suggestions/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.gitlab,
|
||||
},
|
||||
|
||||
"gitlab-duo": {
|
||||
id: "gitlab-duo",
|
||||
alias: "gitlab-duo",
|
||||
format: "openai",
|
||||
executor: "gitlab-duo",
|
||||
baseUrl: `${GITLAB_DUO_BASE_URL.replace(/\/$/, "")}/api/v4/code_suggestions/completions`,
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
oauth: {
|
||||
clientIdEnv: "GITLAB_DUO_OAUTH_CLIENT_ID",
|
||||
clientIdDefault: process.env.GITLAB_OAUTH_CLIENT_ID || "",
|
||||
clientSecretEnv: "GITLAB_DUO_OAUTH_CLIENT_SECRET",
|
||||
clientSecretDefault: process.env.GITLAB_OAUTH_CLIENT_SECRET || "",
|
||||
tokenUrl: `${GITLAB_DUO_BASE_URL.replace(/\/$/, "")}/oauth/token`,
|
||||
refreshUrl: `${GITLAB_DUO_BASE_URL.replace(/\/$/, "")}/oauth/token`,
|
||||
authUrl: `${GITLAB_DUO_BASE_URL.replace(/\/$/, "")}/oauth/authorize`,
|
||||
},
|
||||
models: CHAT_OPENAI_COMPAT_MODELS["gitlab-duo"],
|
||||
},
|
||||
|
||||
chutes: {
|
||||
id: "chutes",
|
||||
alias: "chutes",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://llm.chutes.ai/v1/chat/completions",
|
||||
modelsUrl: "https://llm.chutes.ai/v1/models",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.chutes,
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
moonshot: {
|
||||
id: "moonshot",
|
||||
alias: "moonshot",
|
||||
@@ -2070,6 +2208,103 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.databricks,
|
||||
},
|
||||
|
||||
datarobot: {
|
||||
id: "datarobot",
|
||||
alias: "datarobot",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: DATAROBOT_DEFAULT_BASE_URL,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.datarobot,
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
clarifai: {
|
||||
id: "clarifai",
|
||||
alias: "clarifai",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.clarifai.com/v2/ext/openai/v1/chat/completions",
|
||||
modelsUrl: "https://api.clarifai.com/v2/ext/openai/v1/models",
|
||||
authType: "apikey",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Key ",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.clarifai,
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
watsonx: {
|
||||
id: "watsonx",
|
||||
alias: "watsonx",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: WATSONX_DEFAULT_BASE_URL,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.watsonx,
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
oci: {
|
||||
id: "oci",
|
||||
alias: "oci",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: OCI_DEFAULT_BASE_URL,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.oci,
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
sap: {
|
||||
id: "sap",
|
||||
alias: "sap",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: SAP_DEFAULT_BASE_URL,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.sap,
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
modal: {
|
||||
id: "modal",
|
||||
alias: "modal",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://example-user--example-app.modal.run/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.modal,
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
reka: {
|
||||
id: "reka",
|
||||
alias: "reka",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.reka.ai/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.reka,
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
nlpcloud: {
|
||||
id: "nlpcloud",
|
||||
alias: "nlpc",
|
||||
format: "openai",
|
||||
executor: "nlpcloud",
|
||||
baseUrl: "https://api.nlpcloud.io/v1/gpu",
|
||||
authType: "apikey",
|
||||
authHeader: "token",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.nlpcloud,
|
||||
},
|
||||
|
||||
snowflake: {
|
||||
id: "snowflake",
|
||||
alias: "snowflake",
|
||||
|
||||
60
open-sse/config/sap.ts
Normal file
60
open-sse/config/sap.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export const SAP_DEFAULT_BASE_URL =
|
||||
"https://example-aicore.cfapps.eu10.hana.ondemand.com/v2/lm/deployments/example-deployment";
|
||||
|
||||
function normalizeBaseUrl(value: string | null | undefined): string {
|
||||
return (value || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function sanitizeUrl(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSapBaseUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeBaseUrl(value || SAP_DEFAULT_BASE_URL);
|
||||
if (!normalized) return SAP_DEFAULT_BASE_URL;
|
||||
|
||||
return sanitizeUrl(normalized.replace(/\/chat\/completions$/i, ""));
|
||||
}
|
||||
|
||||
export function isSapDeploymentUrl(value: string | null | undefined): boolean {
|
||||
const normalized = normalizeSapBaseUrl(value);
|
||||
return /\/v2\/lm\/deployments\/[^/]+$/i.test(normalized);
|
||||
}
|
||||
|
||||
export function buildSapChatUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeSapBaseUrl(value);
|
||||
if (normalized.endsWith("/chat/completions")) return normalized;
|
||||
return `${normalized}/chat/completions`;
|
||||
}
|
||||
|
||||
export function buildSapModelsUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeSapBaseUrl(value);
|
||||
const root = normalized.replace(/\/v2\/lm\/deployments\/[^/]+$/i, "");
|
||||
return `${root}/v2/lm/scenarios/foundation-models/models`;
|
||||
}
|
||||
|
||||
export function getSapResourceGroup(
|
||||
providerSpecificData: Record<string, unknown> | null | undefined,
|
||||
fallback = "default"
|
||||
): string {
|
||||
const candidates = [
|
||||
providerSpecificData?.resourceGroup,
|
||||
providerSpecificData?.aiResourceGroup,
|
||||
providerSpecificData?.resource_group,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -155,6 +155,22 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
|
||||
cacheTTLMs: 5 * 60 * 1000,
|
||||
},
|
||||
|
||||
"youcom-search": {
|
||||
id: "youcom-search",
|
||||
name: "You.com Search",
|
||||
baseUrl: "https://ydc-index.io/v1/search",
|
||||
method: "GET",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
costPerQuery: 0.005,
|
||||
freeMonthlyQuota: 0,
|
||||
searchTypes: ["web", "news"],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 100,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 5 * 60 * 1000,
|
||||
},
|
||||
|
||||
"searxng-search": {
|
||||
id: "searxng-search",
|
||||
name: "SearXNG Search",
|
||||
|
||||
45
open-sse/config/watsonx.ts
Normal file
45
open-sse/config/watsonx.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const WATSONX_DEFAULT_BASE_URL = "https://ca-tor.ml.cloud.ibm.com/ml/gateway/v1";
|
||||
|
||||
function normalizeBaseUrl(value: string | null | undefined): string {
|
||||
return (value || "").trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function normalizeWatsonxBaseUrl(value: string | null | undefined): string {
|
||||
const normalized = normalizeBaseUrl(value || WATSONX_DEFAULT_BASE_URL);
|
||||
if (!normalized) return WATSONX_DEFAULT_BASE_URL;
|
||||
|
||||
const stripped = normalized.replace(
|
||||
/\/(?:chat\/completions|completions|embeddings|models)$/i,
|
||||
""
|
||||
);
|
||||
|
||||
if (stripped.endsWith("/ml/gateway/v1")) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
if (stripped.endsWith("/ml/gateway")) {
|
||||
return `${stripped}/v1`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(stripped);
|
||||
if (!parsed.pathname || parsed.pathname === "/") {
|
||||
parsed.pathname = "/ml/gateway/v1";
|
||||
} else if (parsed.pathname.endsWith("/ml/gateway")) {
|
||||
parsed.pathname = `${parsed.pathname}/v1`;
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWatsonxChatUrl(value: string | null | undefined): string {
|
||||
return `${normalizeWatsonxBaseUrl(value)}/chat/completions`;
|
||||
}
|
||||
|
||||
export function buildWatsonxModelsUrl(value: string | null | undefined): string {
|
||||
return `${normalizeWatsonxBaseUrl(value)}/models`;
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import { getGigachatAccessToken } from "../services/gigachatAuth.ts";
|
||||
import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
|
||||
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
|
||||
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
|
||||
import { buildDataRobotChatUrl } from "../config/datarobot.ts";
|
||||
import { buildAzureAiChatUrl } from "../config/azureAi.ts";
|
||||
import { buildBedrockChatUrl } from "../config/bedrock.ts";
|
||||
import { buildWatsonxChatUrl } from "../config/watsonx.ts";
|
||||
import { buildOciChatUrl } from "../config/oci.ts";
|
||||
import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts";
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return (baseUrl || "").trim().replace(/\/$/, "");
|
||||
@@ -34,6 +40,26 @@ function normalizeDatabricksChatUrl(baseUrl) {
|
||||
return `${normalized}/chat/completions`;
|
||||
}
|
||||
|
||||
function normalizeDataRobotChatUrl(baseUrl) {
|
||||
return buildDataRobotChatUrl(baseUrl);
|
||||
}
|
||||
|
||||
function normalizeAzureAiChatUrl(baseUrl, apiType = "chat") {
|
||||
return buildAzureAiChatUrl(baseUrl, apiType);
|
||||
}
|
||||
|
||||
function normalizeWatsonxChatUrl(baseUrl) {
|
||||
return buildWatsonxChatUrl(baseUrl);
|
||||
}
|
||||
|
||||
function normalizeOciChatUrl(baseUrl, apiType = "chat") {
|
||||
return buildOciChatUrl(baseUrl, apiType);
|
||||
}
|
||||
|
||||
function normalizeSapChatUrl(baseUrl) {
|
||||
return buildSapChatUrl(baseUrl);
|
||||
}
|
||||
|
||||
function normalizeXiaomiMimoChatUrl(baseUrl) {
|
||||
const normalized = normalizeBaseUrl(baseUrl).replace(/\/chat\/completions$/, "");
|
||||
return `${normalized}/chat/completions`;
|
||||
@@ -110,6 +136,34 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeDatabricksChatUrl(baseUrl);
|
||||
}
|
||||
case "datarobot": {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeDataRobotChatUrl(baseUrl);
|
||||
}
|
||||
case "azure-ai": {
|
||||
const apiType =
|
||||
credentials?.providerSpecificData?.apiType === "responses" ? "responses" : "chat";
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeAzureAiChatUrl(baseUrl, apiType);
|
||||
}
|
||||
case "bedrock": {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return buildBedrockChatUrl(baseUrl);
|
||||
}
|
||||
case "watsonx": {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeWatsonxChatUrl(baseUrl);
|
||||
}
|
||||
case "oci": {
|
||||
const apiType =
|
||||
credentials?.providerSpecificData?.apiType === "responses" ? "responses" : "chat";
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeOciChatUrl(baseUrl, apiType);
|
||||
}
|
||||
case "sap": {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeSapChatUrl(baseUrl);
|
||||
}
|
||||
case "xiaomi-mimo": {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl;
|
||||
return normalizeXiaomiMimoChatUrl(baseUrl);
|
||||
@@ -123,6 +177,8 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
return normalizeGigachatChatUrl(baseUrl);
|
||||
}
|
||||
case "lm-studio":
|
||||
case "modal":
|
||||
case "reka":
|
||||
case "vllm":
|
||||
case "llamafile":
|
||||
case "triton":
|
||||
@@ -180,6 +236,49 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
case "gigachat":
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken || effectiveKey}`;
|
||||
break;
|
||||
case "clarifai": {
|
||||
const clarifaiToken = effectiveKey || credentials.accessToken;
|
||||
if (clarifaiToken) {
|
||||
headers["Authorization"] = `Key ${clarifaiToken}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "azure-ai":
|
||||
if (effectiveKey || credentials.accessToken) {
|
||||
headers["api-key"] = effectiveKey || credentials.accessToken;
|
||||
}
|
||||
delete headers["Authorization"];
|
||||
break;
|
||||
case "oci": {
|
||||
const bearerToken = effectiveKey || credentials.accessToken;
|
||||
if (bearerToken) {
|
||||
headers["Authorization"] = `Bearer ${bearerToken}`;
|
||||
}
|
||||
const projectId =
|
||||
credentials.projectId ||
|
||||
credentials?.providerSpecificData?.projectId ||
|
||||
credentials?.providerSpecificData?.project;
|
||||
if (projectId) {
|
||||
headers["OpenAI-Project"] = projectId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sap": {
|
||||
const bearerToken = effectiveKey || credentials.accessToken;
|
||||
if (bearerToken) {
|
||||
headers["Authorization"] = `Bearer ${bearerToken}`;
|
||||
}
|
||||
headers["AI-Resource-Group"] = getSapResourceGroup(credentials?.providerSpecificData);
|
||||
break;
|
||||
}
|
||||
case "reka": {
|
||||
const bearerToken = effectiveKey || credentials.accessToken;
|
||||
if (bearerToken) {
|
||||
headers["Authorization"] = `Bearer ${bearerToken}`;
|
||||
headers["X-Api-Key"] = bearerToken;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "claude":
|
||||
case "anthropic":
|
||||
effectiveKey
|
||||
|
||||
695
open-sse/executors/gitlab.ts
Normal file
695
open-sse/executors/gitlab.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
BaseExecutor,
|
||||
mergeAbortSignals,
|
||||
mergeUpstreamExtraHeaders,
|
||||
type ExecuteInput,
|
||||
type ExecutorLog,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||
import {
|
||||
buildGitLabDirectGatewayUrl,
|
||||
buildGitLabOAuthEndpoints,
|
||||
getCachedGitLabDirectAccess,
|
||||
isGitLabDirectAccessDisabled,
|
||||
parseGitLabDirectAccessDetails,
|
||||
resolveGitLabOAuthBaseUrl,
|
||||
type GitLabDirectAccessDetails,
|
||||
} from "@/lib/oauth/gitlab";
|
||||
|
||||
type OpenAIMessage = {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type GitLabRequestTarget = {
|
||||
mode: "monolith" | "direct";
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const item = part as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
if (item.type === "input_text" && typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter((text) => text.trim().length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildPrompt(messages: OpenAIMessage[] | undefined): string {
|
||||
if (!Array.isArray(messages)) return "";
|
||||
|
||||
const systemParts: string[] = [];
|
||||
const userParts: string[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const role = String(message?.role || "user").toLowerCase();
|
||||
const text = extractTextContent(message?.content);
|
||||
if (!text) continue;
|
||||
|
||||
if (role === "system" || role === "developer") {
|
||||
systemParts.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
userParts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
const latestUserPrompt = userParts.at(-1) || "";
|
||||
if (!systemParts.length) {
|
||||
return latestUserPrompt;
|
||||
}
|
||||
|
||||
return `System instructions:\n${systemParts.join("\n\n")}\n\n${latestUserPrompt}`.trim();
|
||||
}
|
||||
|
||||
function toOpenAIError(status: number, message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
type:
|
||||
status === 401 || status === 403
|
||||
? "authentication_error"
|
||||
: status === 429
|
||||
? "rate_limit_error"
|
||||
: "api_error",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildSseChunk(data: unknown): string {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function buildStreamingResponse(
|
||||
content: string,
|
||||
model: string,
|
||||
id: string,
|
||||
created: number
|
||||
): Response {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (content) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content }, finish_reason: null }],
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
})
|
||||
)
|
||||
);
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
function buildJsonCompletion(
|
||||
content: string,
|
||||
model: string,
|
||||
id: string,
|
||||
created: number
|
||||
): Response {
|
||||
const estimated = Math.max(1, Math.ceil(content.length / 4));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: estimated,
|
||||
completion_tokens: estimated,
|
||||
total_tokens: estimated * 2,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function mergeCredentials(
|
||||
current: ProviderCredentials,
|
||||
patch: Partial<ProviderCredentials> | null | undefined
|
||||
): ProviderCredentials {
|
||||
if (!patch) return current;
|
||||
return {
|
||||
...current,
|
||||
...patch,
|
||||
providerSpecificData: {
|
||||
...(current.providerSpecificData || {}),
|
||||
...(patch.providerSpecificData || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGitLabRoot(credentials: ExecuteInput["credentials"]): string {
|
||||
return resolveGitLabOAuthBaseUrl(credentials?.providerSpecificData);
|
||||
}
|
||||
|
||||
function resolveResponseModel(payload: JsonRecord, fallbackModel: string): string {
|
||||
const modelField = payload.model;
|
||||
if (typeof modelField === "string" && modelField.trim().length > 0) {
|
||||
return modelField.trim();
|
||||
}
|
||||
|
||||
const modelRecord = asRecord(modelField);
|
||||
const modelName =
|
||||
typeof modelRecord.name === "string" && modelRecord.name.trim().length > 0
|
||||
? modelRecord.name.trim()
|
||||
: typeof modelRecord.id === "string" && modelRecord.id.trim().length > 0
|
||||
? modelRecord.id.trim()
|
||||
: null;
|
||||
if (modelName) {
|
||||
return modelName;
|
||||
}
|
||||
|
||||
const metadata = asRecord(payload.metadata);
|
||||
const metadataModelDetails = asRecord(metadata.model_details);
|
||||
const payloadModelDetails = asRecord(payload.model_details);
|
||||
const nestedCandidates = [metadataModelDetails, payloadModelDetails];
|
||||
for (const candidate of nestedCandidates) {
|
||||
const value =
|
||||
typeof candidate.model_name === "string" && candidate.model_name.trim().length > 0
|
||||
? candidate.model_name.trim()
|
||||
: typeof candidate.name === "string" && candidate.name.trim().length > 0
|
||||
? candidate.name.trim()
|
||||
: null;
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackModel;
|
||||
}
|
||||
|
||||
function buildMonolithHeaders(token: string | null): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectHeaders(directAccess: GitLabDirectAccessDetails): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${directAccess.token}`,
|
||||
...directAccess.headers,
|
||||
};
|
||||
}
|
||||
|
||||
function isGitLabDuoOAuthProvider(providerId: string): boolean {
|
||||
return providerId === "gitlab-duo";
|
||||
}
|
||||
|
||||
async function persistGitLabDirectAccessCache(
|
||||
input: ExecuteInput,
|
||||
credentials: ProviderCredentials,
|
||||
root: string,
|
||||
directAccess: GitLabDirectAccessDetails
|
||||
) {
|
||||
if (!input.onCredentialsRefreshed) return;
|
||||
|
||||
await input.onCredentialsRefreshed({
|
||||
providerSpecificData: {
|
||||
...(credentials.providerSpecificData || {}),
|
||||
baseUrl: root,
|
||||
gitlabDirectAccess: {
|
||||
token: directAccess.token,
|
||||
baseUrl: directAccess.baseUrl,
|
||||
expiresAt: directAccess.expiresAt,
|
||||
headers: directAccess.headers,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class GitlabExecutor extends BaseExecutor {
|
||||
constructor(providerId = "gitlab") {
|
||||
super(providerId, {
|
||||
id: providerId,
|
||||
baseUrl: "https://gitlab.com/api/v4/code_suggestions/completions",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
buildUrl(
|
||||
_model: string,
|
||||
_stream: boolean,
|
||||
_urlIndex = 0,
|
||||
credentials: ExecuteInput["credentials"] | null = null
|
||||
): string {
|
||||
const endpoints = buildGitLabOAuthEndpoints(resolveGitLabRoot(credentials || {}));
|
||||
return endpoints.publicCompletionsUrl;
|
||||
}
|
||||
|
||||
buildHeaders(credentials: ExecuteInput["credentials"], _stream = false): Record<string, string> {
|
||||
const token = credentials?.apiKey || credentials?.accessToken || null;
|
||||
return buildMonolithHeaders(token);
|
||||
}
|
||||
|
||||
transformRequest(
|
||||
_model: string,
|
||||
body: Record<string, unknown>,
|
||||
_stream: boolean,
|
||||
credentials: ExecuteInput["credentials"]
|
||||
): Record<string, unknown> {
|
||||
const prompt = buildPrompt(body.messages as OpenAIMessage[] | undefined);
|
||||
const providerData =
|
||||
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
|
||||
? credentials.providerSpecificData
|
||||
: {};
|
||||
|
||||
const projectPath =
|
||||
typeof providerData.projectPath === "string" && providerData.projectPath.trim().length > 0
|
||||
? providerData.projectPath.trim()
|
||||
: undefined;
|
||||
const fileName =
|
||||
typeof providerData.fileName === "string" && providerData.fileName.trim().length > 0
|
||||
? providerData.fileName.trim()
|
||||
: "snippet.txt";
|
||||
|
||||
return {
|
||||
current_file: {
|
||||
file_name: fileName,
|
||||
content_above_cursor: prompt,
|
||||
content_below_cursor: "",
|
||||
},
|
||||
intent: "generation",
|
||||
generation_type: "small_file",
|
||||
stream: false,
|
||||
...(projectPath ? { project_path: projectPath } : {}),
|
||||
...(prompt ? { user_instruction: prompt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async refreshCredentials(credentials: ProviderCredentials, log: ExecutorLog | null) {
|
||||
if (!isGitLabDuoOAuthProvider(this.provider) || !credentials.refreshToken) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await getAccessToken(this.provider, credentials, log);
|
||||
} catch (error) {
|
||||
log?.error?.(
|
||||
"TOKEN",
|
||||
`GitLab Duo refresh error: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
needsRefresh(credentials?: ProviderCredentials | null) {
|
||||
if (
|
||||
isGitLabDuoOAuthProvider(this.provider) &&
|
||||
!credentials?.accessToken &&
|
||||
credentials?.refreshToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return super.needsRefresh(credentials);
|
||||
}
|
||||
|
||||
private async fetchGitLabDirectAccess(
|
||||
root: string,
|
||||
accessToken: string,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<{
|
||||
directAccess: GitLabDirectAccessDetails | null;
|
||||
response: Response | null;
|
||||
bodyText: string;
|
||||
}> {
|
||||
const endpoints = buildGitLabOAuthEndpoints(root);
|
||||
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal;
|
||||
const response = await fetch(endpoints.directAccessUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: combinedSignal,
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
if (!response.ok) {
|
||||
return { directAccess: null, response, bodyText };
|
||||
}
|
||||
|
||||
const parsed = bodyText ? JSON.parse(bodyText) : {};
|
||||
return {
|
||||
directAccess: parseGitLabDirectAccessDetails(parsed),
|
||||
response,
|
||||
bodyText,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveRequestTarget(
|
||||
input: ExecuteInput,
|
||||
credentials: ProviderCredentials
|
||||
): Promise<{
|
||||
target: GitLabRequestTarget | null;
|
||||
credentials: ProviderCredentials;
|
||||
errorResponse: Response | null;
|
||||
}> {
|
||||
const root = resolveGitLabRoot(credentials);
|
||||
const endpoints = buildGitLabOAuthEndpoints(root);
|
||||
|
||||
if (!isGitLabDuoOAuthProvider(this.provider)) {
|
||||
return {
|
||||
target: {
|
||||
mode: "monolith",
|
||||
url: endpoints.publicCompletionsUrl,
|
||||
headers: buildMonolithHeaders(credentials.apiKey || credentials.accessToken || null),
|
||||
},
|
||||
credentials,
|
||||
errorResponse: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!credentials.accessToken) {
|
||||
return {
|
||||
target: null,
|
||||
credentials,
|
||||
errorResponse: toOpenAIError(401, "GitLab Duo OAuth connection is missing an access token"),
|
||||
};
|
||||
}
|
||||
|
||||
const cachedDirectAccess = getCachedGitLabDirectAccess(credentials.providerSpecificData);
|
||||
if (cachedDirectAccess) {
|
||||
return {
|
||||
target: {
|
||||
mode: "direct",
|
||||
url: buildGitLabDirectGatewayUrl(cachedDirectAccess.baseUrl),
|
||||
headers: buildDirectHeaders(cachedDirectAccess),
|
||||
},
|
||||
credentials,
|
||||
errorResponse: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { directAccess, response, bodyText } = await this.fetchGitLabDirectAccess(
|
||||
root,
|
||||
credentials.accessToken,
|
||||
input.signal
|
||||
);
|
||||
|
||||
if (directAccess) {
|
||||
await persistGitLabDirectAccessCache(input, credentials, root, directAccess);
|
||||
const mergedCredentials = mergeCredentials(credentials, {
|
||||
providerSpecificData: {
|
||||
...(credentials.providerSpecificData || {}),
|
||||
baseUrl: root,
|
||||
gitlabDirectAccess: {
|
||||
token: directAccess.token,
|
||||
baseUrl: directAccess.baseUrl,
|
||||
expiresAt: directAccess.expiresAt,
|
||||
headers: directAccess.headers,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
target: {
|
||||
mode: "direct",
|
||||
url: buildGitLabDirectGatewayUrl(directAccess.baseUrl),
|
||||
headers: buildDirectHeaders(directAccess),
|
||||
},
|
||||
credentials: mergedCredentials,
|
||||
errorResponse: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
target: {
|
||||
mode: "monolith",
|
||||
url: endpoints.publicCompletionsUrl,
|
||||
headers: buildMonolithHeaders(credentials.accessToken),
|
||||
},
|
||||
credentials,
|
||||
errorResponse: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
target: null,
|
||||
credentials,
|
||||
errorResponse: toOpenAIError(401, "GitLab Duo direct access token request was rejected"),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 403 && !isGitLabDirectAccessDisabled(response.status, bodyText)) {
|
||||
return {
|
||||
target: null,
|
||||
credentials,
|
||||
errorResponse: toOpenAIError(403, "GitLab Duo direct access scope is unavailable"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
target: {
|
||||
mode: "monolith",
|
||||
url: endpoints.publicCompletionsUrl,
|
||||
headers: buildMonolithHeaders(credentials.accessToken),
|
||||
},
|
||||
credentials,
|
||||
errorResponse: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
target: {
|
||||
mode: "monolith",
|
||||
url: endpoints.publicCompletionsUrl,
|
||||
headers: buildMonolithHeaders(credentials.accessToken),
|
||||
},
|
||||
credentials,
|
||||
errorResponse: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async performRequest(
|
||||
input: ExecuteInput,
|
||||
target: GitLabRequestTarget,
|
||||
transformedBody: Record<string, unknown>
|
||||
) {
|
||||
const headers = { ...target.headers };
|
||||
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
|
||||
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
const combinedSignal = input.signal
|
||||
? mergeAbortSignals(input.signal, timeoutSignal)
|
||||
: timeoutSignal;
|
||||
const response = await fetch(target.url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
return { response, headers };
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const prompt = buildPrompt(
|
||||
(input.body as Record<string, unknown>)?.messages as OpenAIMessage[]
|
||||
);
|
||||
if (!prompt) {
|
||||
return {
|
||||
response: toOpenAIError(400, "GitLab Duo requires at least one user message"),
|
||||
};
|
||||
}
|
||||
|
||||
let activeCredentials = input.credentials;
|
||||
if (this.needsRefresh(activeCredentials)) {
|
||||
const refreshed = await this.refreshCredentials(activeCredentials, input.log || null);
|
||||
if (refreshed) {
|
||||
activeCredentials = mergeCredentials(activeCredentials, refreshed);
|
||||
await input.onCredentialsRefreshed?.({
|
||||
...refreshed,
|
||||
providerSpecificData: {
|
||||
...(input.credentials.providerSpecificData || {}),
|
||||
...(refreshed.providerSpecificData || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const transformedBody = this.transformRequest(
|
||||
input.model,
|
||||
(input.body as Record<string, unknown>) || {},
|
||||
false,
|
||||
activeCredentials
|
||||
);
|
||||
|
||||
const {
|
||||
target,
|
||||
credentials: resolvedCredentials,
|
||||
errorResponse,
|
||||
} = await this.resolveRequestTarget(input, activeCredentials);
|
||||
if (errorResponse || !target) {
|
||||
return {
|
||||
response: errorResponse || toOpenAIError(500, "GitLab Duo target resolution failed"),
|
||||
};
|
||||
}
|
||||
activeCredentials = resolvedCredentials;
|
||||
|
||||
let upstream: Response;
|
||||
let requestHeaders: Record<string, string>;
|
||||
let activeTarget = target;
|
||||
try {
|
||||
const requestResult = await this.performRequest(input, target, transformedBody);
|
||||
upstream = requestResult.response;
|
||||
requestHeaders = requestResult.headers;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
response: toOpenAIError(502, `GitLab Duo connection failed: ${message}`),
|
||||
url: target.url,
|
||||
headers: target.headers,
|
||||
transformedBody,
|
||||
};
|
||||
}
|
||||
|
||||
if (!upstream.ok && target.mode === "direct") {
|
||||
const fallbackTarget: GitLabRequestTarget = {
|
||||
mode: "monolith",
|
||||
url: buildGitLabOAuthEndpoints(resolveGitLabRoot(activeCredentials)).publicCompletionsUrl,
|
||||
headers: buildMonolithHeaders(activeCredentials.accessToken || null),
|
||||
};
|
||||
|
||||
try {
|
||||
const fallbackResult = await this.performRequest(input, fallbackTarget, transformedBody);
|
||||
upstream = fallbackResult.response;
|
||||
requestHeaders = fallbackResult.headers;
|
||||
activeTarget = fallbackTarget;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
response: toOpenAIError(502, `GitLab Duo connection failed: ${message}`),
|
||||
url: fallbackTarget.url,
|
||||
headers: fallbackTarget.headers,
|
||||
transformedBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const text = await upstream.text();
|
||||
const message =
|
||||
upstream.status === 401 || upstream.status === 403
|
||||
? `GitLab Duo auth failed: ${upstream.status}`
|
||||
: upstream.status === 429
|
||||
? "GitLab Duo rate limited the request"
|
||||
: text || `GitLab Duo request failed: ${upstream.status}`;
|
||||
return {
|
||||
response: toOpenAIError(upstream.status, message),
|
||||
url: activeTarget.url,
|
||||
headers: requestHeaders,
|
||||
transformedBody,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = (await upstream.json()) as JsonRecord;
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const firstChoice =
|
||||
choices[0] && typeof choices[0] === "object" ? (choices[0] as JsonRecord) : {};
|
||||
const content =
|
||||
typeof firstChoice.text === "string"
|
||||
? firstChoice.text
|
||||
: typeof payload.content === "string"
|
||||
? payload.content
|
||||
: "";
|
||||
const resolvedModel = resolveResponseModel(payload, input.model);
|
||||
const responseId = `chatcmpl-gitlab-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
const response = input.stream
|
||||
? buildStreamingResponse(content, resolvedModel, responseId, created)
|
||||
: buildJsonCompletion(content, resolvedModel, responseId, created);
|
||||
|
||||
return { response, url: activeTarget.url, headers: requestHeaders, transformedBody };
|
||||
}
|
||||
}
|
||||
|
||||
export default GitlabExecutor;
|
||||
@@ -17,6 +17,8 @@ import { GrokWebExecutor } from "./grok-web.ts";
|
||||
import { BlackboxWebExecutor } from "./blackbox-web.ts";
|
||||
import { MuseSparkWebExecutor } from "./muse-spark-web.ts";
|
||||
import { AzureOpenAIExecutor } from "./azure-openai.ts";
|
||||
import { GitlabExecutor } from "./gitlab.ts";
|
||||
import { NlpCloudExecutor } from "./nlpcloud.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -29,6 +31,9 @@ const executors = {
|
||||
cursor: new CursorExecutor(),
|
||||
cu: new CursorExecutor(), // Alias for cursor
|
||||
"azure-openai": new AzureOpenAIExecutor(),
|
||||
gitlab: new GitlabExecutor(),
|
||||
"gitlab-duo": new GitlabExecutor("gitlab-duo"),
|
||||
nlpcloud: new NlpCloudExecutor(),
|
||||
pollinations: new PollinationsExecutor(),
|
||||
pol: new PollinationsExecutor(), // Alias
|
||||
"cloudflare-ai": new CloudflareAIExecutor(),
|
||||
@@ -82,3 +87,5 @@ export { GrokWebExecutor } from "./grok-web.ts";
|
||||
export { BlackboxWebExecutor } from "./blackbox-web.ts";
|
||||
export { MuseSparkWebExecutor } from "./muse-spark-web.ts";
|
||||
export { AzureOpenAIExecutor } from "./azure-openai.ts";
|
||||
export { GitlabExecutor } from "./gitlab.ts";
|
||||
export { NlpCloudExecutor } from "./nlpcloud.ts";
|
||||
|
||||
546
open-sse/executors/nlpcloud.ts
Normal file
546
open-sse/executors/nlpcloud.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
BaseExecutor,
|
||||
mergeUpstreamExtraHeaders,
|
||||
type ExecuteInput,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type OpenAIMessage = {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
type NlpCloudHistoryEntry = {
|
||||
input: string;
|
||||
response: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL = "chatdolphin";
|
||||
const DEFAULT_BASE_URL = "https://api.nlpcloud.io/v1/gpu";
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const item = part as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
if (item.type === "input_text" && typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter((text) => text.trim().length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string | null | undefined): string {
|
||||
const normalized = String(baseUrl || DEFAULT_BASE_URL)
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
if (normalized.endsWith("/chatbot")) {
|
||||
return normalized.replace(/\/[^/]+\/chatbot$/, "");
|
||||
}
|
||||
if (normalized.endsWith("/v1/gpu")) {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized.endsWith("/v1")) {
|
||||
return `${normalized}/gpu`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePrompt(messages: OpenAIMessage[] | undefined): {
|
||||
input: string;
|
||||
context: string | null;
|
||||
history: NlpCloudHistoryEntry[];
|
||||
} {
|
||||
if (!Array.isArray(messages)) {
|
||||
return { input: "", context: null, history: [] };
|
||||
}
|
||||
|
||||
const systemParts: string[] = [];
|
||||
const chatParts: Array<{ role: string; text: string }> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const role = String(message?.role || "user").toLowerCase();
|
||||
const text = extractTextContent(message?.content);
|
||||
if (!text) continue;
|
||||
|
||||
if (role === "system" || role === "developer") {
|
||||
systemParts.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "user" || role === "assistant") {
|
||||
chatParts.push({ role, text });
|
||||
}
|
||||
}
|
||||
|
||||
const lastUserIndex = [...chatParts].map((part) => part.role).lastIndexOf("user");
|
||||
if (lastUserIndex === -1) {
|
||||
return {
|
||||
input: "",
|
||||
context: systemParts.length > 0 ? systemParts.join("\n\n") : null,
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
const history: NlpCloudHistoryEntry[] = [];
|
||||
let pendingUser: string | null = null;
|
||||
|
||||
for (let index = 0; index < lastUserIndex; index += 1) {
|
||||
const part = chatParts[index];
|
||||
if (part.role === "user") {
|
||||
pendingUser = part.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.role === "assistant" && pendingUser) {
|
||||
history.push({ input: pendingUser, response: part.text });
|
||||
pendingUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
input: chatParts[lastUserIndex]?.text || "",
|
||||
context: systemParts.length > 0 ? systemParts.join("\n\n") : null,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.max(1, Math.ceil(text.length / 4));
|
||||
}
|
||||
|
||||
function buildSseChunk(data: unknown): string {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function buildOpenAiJsonCompletion(
|
||||
content: string,
|
||||
model: string,
|
||||
id: string,
|
||||
created: number
|
||||
): Response {
|
||||
const completionTokens = estimateTokens(content);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: completionTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: completionTokens * 2,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildSynthesizedStream(
|
||||
content: string,
|
||||
model: string,
|
||||
id: string,
|
||||
created: number
|
||||
): Response {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (content) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content }, finish_reason: null }],
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
})
|
||||
)
|
||||
);
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
function extractStreamDelta(data: string): string {
|
||||
const trimmed = data.trim();
|
||||
if (!trimmed || trimmed === "[DONE]") return "";
|
||||
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const payload = asRecord(JSON.parse(trimmed));
|
||||
const response =
|
||||
typeof payload.response === "string"
|
||||
? payload.response
|
||||
: typeof payload.content === "string"
|
||||
? payload.content
|
||||
: typeof payload.token === "string"
|
||||
? payload.token
|
||||
: "";
|
||||
return response.trim();
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function toOpenAiError(status: number, message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
type:
|
||||
status === 401 || status === 403
|
||||
? "authentication_error"
|
||||
: status === 429
|
||||
? "rate_limit_error"
|
||||
: "api_error",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export class NlpCloudExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("nlpcloud", PROVIDERS.nlpcloud || { format: "openai" });
|
||||
}
|
||||
|
||||
buildUrl(
|
||||
model: string,
|
||||
_stream: boolean,
|
||||
_urlIndex = 0,
|
||||
credentials: ProviderCredentials | null = null
|
||||
): string {
|
||||
const baseUrl = normalizeBaseUrl(
|
||||
typeof credentials?.providerSpecificData?.baseUrl === "string"
|
||||
? credentials.providerSpecificData.baseUrl
|
||||
: this.config.baseUrl
|
||||
);
|
||||
return `${baseUrl}/${encodeURIComponent(model || DEFAULT_MODEL)}/chatbot`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials: ProviderCredentials | null, stream = true): Record<string, string> {
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (key) {
|
||||
headers.Authorization = `Token ${key}`;
|
||||
}
|
||||
|
||||
headers.Accept = stream ? "text/event-stream" : "application/json";
|
||||
return headers;
|
||||
}
|
||||
|
||||
private buildRequestPayload(model: string, body: unknown, stream: boolean): JsonRecord | null {
|
||||
const payload = asRecord(body);
|
||||
const messages = Array.isArray(payload.messages) ? (payload.messages as OpenAIMessage[]) : [];
|
||||
const prompt = resolvePrompt(messages);
|
||||
|
||||
if (!prompt.input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
input: prompt.input,
|
||||
...(prompt.context ? { context: prompt.context } : {}),
|
||||
...(prompt.history.length > 0 ? { history: prompt.history } : {}),
|
||||
...(stream ? { stream: true } : {}),
|
||||
...(typeof payload.temperature === "number" ? { temperature: payload.temperature } : {}),
|
||||
...(typeof payload.top_p === "number" ? { top_p: payload.top_p } : {}),
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
private async transformEventStreamToSse(response: Response, model: string): Promise<Response> {
|
||||
const upstream = response.body;
|
||||
if (!upstream) {
|
||||
return buildSynthesizedStream(
|
||||
"",
|
||||
model,
|
||||
`chatcmpl-nlpcloud-${randomUUID()}`,
|
||||
Math.floor(Date.now() / 1000)
|
||||
);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const id = `chatcmpl-nlpcloud-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const reader = upstream.getReader();
|
||||
let buffer = "";
|
||||
let finished = false;
|
||||
|
||||
const emitDelta = (content: string) => {
|
||||
if (!content) return;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content }, finish_reason: null }],
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
buildSseChunk({
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
})
|
||||
)
|
||||
);
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
};
|
||||
|
||||
const processEvent = (eventText: string) => {
|
||||
const normalized = eventText.replace(/\r/g, "");
|
||||
const lines = normalized.split("\n");
|
||||
const dataParts: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (line.startsWith("data:")) {
|
||||
dataParts.push(line.slice(5).trimStart());
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith(":")) {
|
||||
continue;
|
||||
}
|
||||
dataParts.push(line.trim());
|
||||
}
|
||||
|
||||
const raw = dataParts.join("\n").trim();
|
||||
if (!raw) return;
|
||||
if (raw === "[DONE]") {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
emitDelta(extractStreamDelta(raw));
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
|
||||
let separatorIndex = buffer.indexOf("\n\n");
|
||||
while (separatorIndex !== -1) {
|
||||
const eventText = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
processEvent(eventText);
|
||||
separatorIndex = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
const tail = buffer.trim();
|
||||
if (tail) {
|
||||
processEvent(tail);
|
||||
}
|
||||
finish();
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) {
|
||||
const resolvedModel = model || DEFAULT_MODEL;
|
||||
const payload = this.buildRequestPayload(resolvedModel, body, stream);
|
||||
const url = this.buildUrl(resolvedModel, stream, 0, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
||||
|
||||
if (!payload) {
|
||||
return {
|
||||
response: toOpenAiError(400, "NLP Cloud requests require at least one user message."),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
response: toOpenAiError(
|
||||
response.status,
|
||||
`NLP Cloud API failed with status ${response.status}: ${errorText || "Unknown error"}`
|
||||
),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const json = asRecord(await response.json());
|
||||
const content = typeof json.response === "string" ? json.response : "";
|
||||
return {
|
||||
response: buildSynthesizedStream(
|
||||
content,
|
||||
resolvedModel,
|
||||
`chatcmpl-nlpcloud-${randomUUID()}`,
|
||||
Math.floor(Date.now() / 1000)
|
||||
),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: await this.transformEventStreamToSse(response, resolvedModel),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
|
||||
const json = asRecord(await response.json());
|
||||
const content = typeof json.response === "string" ? json.response : "";
|
||||
|
||||
return {
|
||||
response: buildOpenAiJsonCompletion(
|
||||
content,
|
||||
resolvedModel,
|
||||
`chatcmpl-nlpcloud-${randomUUID()}`,
|
||||
Math.floor(Date.now() / 1000)
|
||||
),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
return {
|
||||
response: toOpenAiError(502, `NLP Cloud fetch error: ${message}`),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default NlpCloudExecutor;
|
||||
@@ -3,9 +3,9 @@ import { randomUUID } from "crypto";
|
||||
* Search Handler
|
||||
*
|
||||
* Handles POST /v1/search requests.
|
||||
* Routes to 9 search providers with automatic failover:
|
||||
* Routes to 10 search providers with automatic failover:
|
||||
* serper-search, brave-search, perplexity-search, exa-search, tavily-search,
|
||||
* google-pse-search, linkup-search, searchapi-search, searxng-search
|
||||
* google-pse-search, linkup-search, searchapi-search, youcom-search, searxng-search
|
||||
*
|
||||
* Request format:
|
||||
* {
|
||||
@@ -271,6 +271,12 @@ interface SearchRequestParams {
|
||||
timeRange?: string;
|
||||
offset?: number;
|
||||
domainFilter?: string[];
|
||||
contentOptions?: {
|
||||
snippet?: boolean;
|
||||
full_page?: boolean;
|
||||
format?: string;
|
||||
max_characters?: number;
|
||||
};
|
||||
providerOptions?: Record<string, unknown>;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
}
|
||||
@@ -498,6 +504,52 @@ function buildSearchApiRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function buildYouComRequest(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): { url: string; init: RequestInit } {
|
||||
const apiKey = params.token;
|
||||
if (!apiKey) {
|
||||
throw new Error("You.com Search requires an API key");
|
||||
}
|
||||
|
||||
const { includes, excludes } = parseDomainFilter(params.domainFilter);
|
||||
const qp = new URLSearchParams({
|
||||
query: params.query,
|
||||
count: String(Math.min(params.maxResults, 100)),
|
||||
});
|
||||
|
||||
if (params.timeRange && params.timeRange !== "any") {
|
||||
qp.set("freshness", params.timeRange);
|
||||
}
|
||||
if (typeof params.offset === "number" && params.offset > 0 && params.maxResults > 0) {
|
||||
qp.set("offset", String(Math.min(Math.floor(params.offset / params.maxResults), 9)));
|
||||
}
|
||||
if (params.country) qp.set("country", params.country);
|
||||
if (params.language) qp.set("language", params.language);
|
||||
if (includes.length) qp.set("include_domains", includes.join(","));
|
||||
if (excludes.length) qp.set("exclude_domains", excludes.join(","));
|
||||
|
||||
if (params.contentOptions?.full_page) {
|
||||
qp.set("livecrawl", params.searchType === "news" ? "news" : "web");
|
||||
qp.append(
|
||||
"livecrawl_formats",
|
||||
params.contentOptions.format === "markdown" ? "markdown" : "html"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: `${resolveSearchBaseUrl(config, params)}?${qp}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-API-Key": apiKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSearxngRequest(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
@@ -537,6 +589,7 @@ function buildRequest(
|
||||
if (config.id === "google-pse-search") return buildGooglePseRequest(config, params);
|
||||
if (config.id === "linkup-search") return buildLinkupRequest(config, params);
|
||||
if (config.id === "searchapi-search") return buildSearchApiRequest(config, params);
|
||||
if (config.id === "youcom-search") return buildYouComRequest(config, params);
|
||||
if (config.id === "searxng-search") return buildSearxngRequest(config, params);
|
||||
// Fallback for future providers: POST with bearer auth
|
||||
return {
|
||||
@@ -744,6 +797,56 @@ function normalizeSearchApiResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeYouComResponse(
|
||||
data: any,
|
||||
_query: string,
|
||||
searchType: string
|
||||
): { results: SearchResult[]; totalResults: number | null } {
|
||||
const now = new Date().toISOString();
|
||||
const resultsContainer =
|
||||
data?.results && typeof data.results === "object" ? data.results : undefined;
|
||||
const section =
|
||||
searchType === "news" ? resultsContainer?.news || [] : resultsContainer?.web || [];
|
||||
const items = Array.isArray(section) ? section : [];
|
||||
|
||||
const results = items.map((item: any, idx: number) => {
|
||||
const firstSnippet = Array.isArray(item.snippets)
|
||||
? item.snippets.find((value: unknown) => typeof value === "string")
|
||||
: null;
|
||||
const livecrawlText =
|
||||
typeof item.markdown === "string"
|
||||
? item.markdown
|
||||
: typeof item.html === "string"
|
||||
? item.html
|
||||
: undefined;
|
||||
const livecrawlFormat = typeof item.markdown === "string" ? "markdown" : "html";
|
||||
|
||||
return makeResult(
|
||||
"youcom-search",
|
||||
{
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
snippet:
|
||||
typeof firstSnippet === "string"
|
||||
? firstSnippet
|
||||
: typeof item.description === "string"
|
||||
? item.description
|
||||
: "",
|
||||
published_at: item.page_age,
|
||||
favicon_url: item.favicon_url,
|
||||
image_url: item.thumbnail_url,
|
||||
source_type: searchType,
|
||||
full_text: livecrawlText,
|
||||
text_format: livecrawlText ? livecrawlFormat : undefined,
|
||||
},
|
||||
idx,
|
||||
now
|
||||
);
|
||||
});
|
||||
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeSearxngResponse(
|
||||
data: any,
|
||||
_query: string,
|
||||
@@ -789,6 +892,7 @@ function normalizeResponse(
|
||||
return normalizeGooglePseResponse(data, query, searchType);
|
||||
if (providerId === "linkup-search") return normalizeLinkupResponse(data, query, searchType);
|
||||
if (providerId === "searchapi-search") return normalizeSearchApiResponse(data, query, searchType);
|
||||
if (providerId === "youcom-search") return normalizeYouComResponse(data, query, searchType);
|
||||
if (providerId === "searxng-search") return normalizeSearxngResponse(data, query, searchType);
|
||||
return { results: [], totalResults: null };
|
||||
}
|
||||
@@ -806,6 +910,7 @@ export async function handleSearch(options: SearchHandlerOptions): Promise<Searc
|
||||
timeRange,
|
||||
offset,
|
||||
domainFilter,
|
||||
contentOptions,
|
||||
providerOptions,
|
||||
credentials,
|
||||
alternateProvider,
|
||||
@@ -842,6 +947,7 @@ export async function handleSearch(options: SearchHandlerOptions): Promise<Searc
|
||||
timeRange,
|
||||
offset,
|
||||
domainFilter,
|
||||
contentOptions,
|
||||
providerOptions,
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,26 @@ import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/an
|
||||
import { getGlmModelsUrl } from "@omniroute/open-sse/config/glmProvider.ts";
|
||||
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { resolveAntigravityVersion } from "@omniroute/open-sse/services/antigravityVersion.ts";
|
||||
import {
|
||||
AZURE_AI_DEFAULT_BASE_URL,
|
||||
buildAzureAiModelsUrl,
|
||||
} from "@omniroute/open-sse/config/azureAi.ts";
|
||||
import { normalizeBedrockBaseUrl } from "@omniroute/open-sse/config/bedrock.ts";
|
||||
import {
|
||||
DATAROBOT_DEFAULT_BASE_URL,
|
||||
buildDataRobotCatalogUrl,
|
||||
isDataRobotDeploymentUrl,
|
||||
} from "@omniroute/open-sse/config/datarobot.ts";
|
||||
import { OCI_DEFAULT_BASE_URL, buildOciModelsUrl } from "@omniroute/open-sse/config/oci.ts";
|
||||
import {
|
||||
SAP_DEFAULT_BASE_URL,
|
||||
buildSapModelsUrl,
|
||||
getSapResourceGroup,
|
||||
} from "@omniroute/open-sse/config/sap.ts";
|
||||
import {
|
||||
WATSONX_DEFAULT_BASE_URL,
|
||||
buildWatsonxModelsUrl,
|
||||
} from "@omniroute/open-sse/config/watsonx.ts";
|
||||
import {
|
||||
ANTIGRAVITY_PUBLIC_MODELS,
|
||||
getClientVisibleAntigravityModelName,
|
||||
@@ -59,6 +79,12 @@ function isLocalOpenAIStyleProvider(provider: string): boolean {
|
||||
return isSelfHostedChatProvider(provider);
|
||||
}
|
||||
|
||||
const NAMED_OPENAI_STYLE_PROVIDERS = new Set(["bedrock", "modal", "reka"]);
|
||||
|
||||
function isNamedOpenAIStyleProvider(provider: string): boolean {
|
||||
return NAMED_OPENAI_STYLE_PROVIDERS.has(provider);
|
||||
}
|
||||
|
||||
function buildOptionalBearerHeaders(token: string | null | undefined): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
@@ -66,6 +92,19 @@ function buildOptionalBearerHeaders(token: string | null | undefined): Record<st
|
||||
};
|
||||
}
|
||||
|
||||
function buildNamedOpenAiStyleHeaders(
|
||||
provider: string,
|
||||
token: string | null | undefined
|
||||
): Record<string, string> {
|
||||
const headers = buildOptionalBearerHeaders(token);
|
||||
|
||||
if (provider === "reka" && token) {
|
||||
headers["X-Api-Key"] = token;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function normalizeAntigravityModelsResponse(data: unknown): Array<{ id: string; name: string }> {
|
||||
const payload = asRecord(data).models;
|
||||
|
||||
@@ -118,6 +157,76 @@ function mapAntigravityModelForClient(model: { id: string; name: string }): {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDataRobotCatalogResponse(data: unknown): Array<{ id: string; name: string }> {
|
||||
const items = Array.isArray(asRecord(data).data) ? (asRecord(data).data as unknown[]) : [];
|
||||
|
||||
return items
|
||||
.map((value) => {
|
||||
const item = asRecord(value);
|
||||
const model =
|
||||
toNonEmptyString(item.model) || toNonEmptyString(item.id) || toNonEmptyString(item.name);
|
||||
if (!model) return null;
|
||||
if (item.isActive === false) return null;
|
||||
const name = toNonEmptyString(item.label) || toNonEmptyString(item.displayName) || model;
|
||||
return { id: model, name };
|
||||
})
|
||||
.filter((value): value is { id: string; name: string } => Boolean(value));
|
||||
}
|
||||
|
||||
function normalizeOpenAiLikeModelsResponse(
|
||||
data: unknown,
|
||||
fallbackOwner: string
|
||||
): Array<{ id: string; name: string; owned_by: string }> {
|
||||
const payload = asRecord(data);
|
||||
const items = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(payload.data)
|
||||
? (payload.data as unknown[])
|
||||
: Array.isArray(payload.models)
|
||||
? (payload.models as unknown[])
|
||||
: [];
|
||||
|
||||
return items
|
||||
.map((value) => {
|
||||
const item = asRecord(value);
|
||||
const id =
|
||||
toNonEmptyString(item.id) || toNonEmptyString(item.model) || toNonEmptyString(item.name);
|
||||
if (!id) return null;
|
||||
const name =
|
||||
toNonEmptyString(item.display_name) ||
|
||||
toNonEmptyString(item.displayName) ||
|
||||
toNonEmptyString(item.name) ||
|
||||
id;
|
||||
const ownedBy =
|
||||
toNonEmptyString(item.owned_by) || toNonEmptyString(item.provider) || fallbackOwner;
|
||||
return { id, name, owned_by: ownedBy };
|
||||
})
|
||||
.filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value));
|
||||
}
|
||||
|
||||
function normalizeSapModelsResponse(
|
||||
data: unknown
|
||||
): Array<{ id: string; name: string; owned_by: string }> {
|
||||
const payload = asRecord(data);
|
||||
const items = Array.isArray(payload.resources) ? (payload.resources as unknown[]) : [];
|
||||
|
||||
return items
|
||||
.map((value) => {
|
||||
const item = asRecord(value);
|
||||
const id =
|
||||
toNonEmptyString(item.model) || toNonEmptyString(item.id) || toNonEmptyString(item.name);
|
||||
if (!id) return null;
|
||||
const name =
|
||||
toNonEmptyString(item.displayName) ||
|
||||
toNonEmptyString(item.display_name) ||
|
||||
toNonEmptyString(item.name) ||
|
||||
id;
|
||||
const ownedBy = toNonEmptyString(item.provider) || "sap";
|
||||
return { id, name, owned_by: ownedBy };
|
||||
})
|
||||
.filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value));
|
||||
}
|
||||
|
||||
type ProviderModelsConfigEntry = {
|
||||
url: string;
|
||||
method: "GET" | "POST";
|
||||
@@ -181,6 +290,12 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: str
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
],
|
||||
gitlab: () => [{ id: "gitlab-duo-code-suggestions", name: "GitLab Duo Code Suggestions" }],
|
||||
nlpcloud: () =>
|
||||
getModelsByProviderId("nlpcloud").map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
})),
|
||||
qoder: () => getStaticQoderModels(),
|
||||
};
|
||||
|
||||
@@ -353,6 +468,22 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
chutes: {
|
||||
url: "https://llm.chutes.ai/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
clarifai: {
|
||||
url: "https://api.clarifai.com/v2/ext/openai/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Key ",
|
||||
parseResponse: (data) => normalizeOpenAiLikeModelsResponse(data, "clarifai"),
|
||||
},
|
||||
kimi: {
|
||||
url: "https://api.moonshot.ai/v1/models",
|
||||
method: "GET",
|
||||
@@ -676,19 +807,26 @@ export async function GET(
|
||||
});
|
||||
};
|
||||
|
||||
if (isOpenAICompatibleProvider(provider) || isLocalOpenAIStyleProvider(provider)) {
|
||||
if (
|
||||
isOpenAICompatibleProvider(provider) ||
|
||||
isLocalOpenAIStyleProvider(provider) ||
|
||||
isNamedOpenAIStyleProvider(provider)
|
||||
) {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const registryEntry = isLocalOpenAIStyleProvider(provider)
|
||||
? getRegistryEntry(provider)
|
||||
: null;
|
||||
const baseUrl =
|
||||
const registryEntry =
|
||||
isLocalOpenAIStyleProvider(provider) || isNamedOpenAIStyleProvider(provider)
|
||||
? getRegistryEntry(provider)
|
||||
: null;
|
||||
const rawBaseUrl =
|
||||
getProviderBaseUrl(connection.providerSpecificData) ||
|
||||
(typeof registryEntry?.baseUrl === "string" ? registryEntry.baseUrl : null);
|
||||
const baseUrl =
|
||||
provider === "bedrock" && rawBaseUrl ? normalizeBedrockBaseUrl(rawBaseUrl) : rawBaseUrl;
|
||||
if (!baseUrl) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "Base URL unavailable — using cached catalog",
|
||||
@@ -699,7 +837,9 @@ export async function GET(
|
||||
{
|
||||
error: isOpenAICompatibleProvider(provider)
|
||||
? "No base URL configured for OpenAI compatible provider"
|
||||
: "No base URL configured for local provider",
|
||||
: isLocalOpenAIStyleProvider(provider)
|
||||
? "No base URL configured for local provider"
|
||||
: "No base URL configured for provider",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -734,12 +874,16 @@ export async function GET(
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: buildOptionalBearerHeaders(token),
|
||||
headers: isNamedOpenAIStyleProvider(provider)
|
||||
? buildNamedOpenAiStyleHeaders(provider, token)
|
||||
: buildOptionalBearerHeaders(token),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
models = data.data || data.models || [];
|
||||
models = isNamedOpenAIStyleProvider(provider)
|
||||
? normalizeOpenAiLikeModelsResponse(data, provider)
|
||||
: data.data || data.models || [];
|
||||
break; // Success!
|
||||
}
|
||||
|
||||
@@ -790,6 +934,363 @@ export async function GET(
|
||||
return buildApiDiscoveryResponse(models);
|
||||
}
|
||||
|
||||
if (provider === "datarobot") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "No token configured — using cached catalog",
|
||||
localWarning: "No token configured — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No API key configured for this provider. Please add an API key in the provider settings.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const configuredBaseUrl =
|
||||
getProviderBaseUrl(connection.providerSpecificData) || DATAROBOT_DEFAULT_BASE_URL;
|
||||
|
||||
if (isDataRobotDeploymentUrl(configuredBaseUrl)) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "Deployment URL does not expose catalog — using cached catalog",
|
||||
localWarning: "Deployment URL does not expose catalog — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return buildResponse({
|
||||
provider,
|
||||
connectionId,
|
||||
models: toLocalCatalogModels(),
|
||||
source: "local_catalog",
|
||||
warning: "Deployment URL does not expose catalog — using local catalog",
|
||||
});
|
||||
}
|
||||
|
||||
const catalogUrl = buildDataRobotCatalogUrl(configuredBaseUrl);
|
||||
if (!catalogUrl) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "Invalid DataRobot base URL — using cached catalog",
|
||||
localWarning: "Invalid DataRobot base URL — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json({ error: "Invalid DataRobot base URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(catalogUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: buildOptionalBearerHeaders(token),
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "DataRobot catalog unavailable — using cached catalog",
|
||||
localWarning: "DataRobot catalog unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Catalog probe failed (${response.status}) — using cached catalog`,
|
||||
localWarning: `Catalog probe failed (${response.status}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const models = normalizeDataRobotCatalogResponse(await response.json());
|
||||
return buildApiDiscoveryResponse(
|
||||
models.map((model) => ({
|
||||
...model,
|
||||
owned_by: "datarobot",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "azure-ai") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "No token configured — using cached catalog",
|
||||
localWarning: "No token configured — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No API key configured for this provider. Please add an API key in the provider settings.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
getProviderBaseUrl(connection.providerSpecificData) || AZURE_AI_DEFAULT_BASE_URL;
|
||||
const modelsUrl = buildAzureAiModelsUrl(baseUrl);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(modelsUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"api-key": token,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "Azure AI models API unavailable — using cached catalog",
|
||||
localWarning: "Azure AI models API unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Models probe failed (${response.status}) — using cached catalog`,
|
||||
localWarning: `Models probe failed (${response.status}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = (data.data || data.models || []).map((model: Record<string, unknown>) => ({
|
||||
id:
|
||||
(typeof model.id === "string" && model.id) ||
|
||||
(typeof model.name === "string" && model.name) ||
|
||||
"",
|
||||
name:
|
||||
(typeof model.display_name === "string" && model.display_name) ||
|
||||
(typeof model.name === "string" && model.name) ||
|
||||
(typeof model.id === "string" && model.id) ||
|
||||
"",
|
||||
owned_by: "azure-ai",
|
||||
}));
|
||||
|
||||
return buildApiDiscoveryResponse(models.filter((model) => model.id));
|
||||
}
|
||||
|
||||
if (provider === "watsonx") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "No token configured — using cached catalog",
|
||||
localWarning: "No token configured — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No API key configured for this provider. Please add an API key in the provider settings.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
getProviderBaseUrl(connection.providerSpecificData) || WATSONX_DEFAULT_BASE_URL;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(buildWatsonxModelsUrl(baseUrl), {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: buildOptionalBearerHeaders(token),
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "watsonx models API unavailable — using cached catalog",
|
||||
localWarning: "watsonx models API unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Models probe failed (${response.status}) — using cached catalog`,
|
||||
localWarning: `Models probe failed (${response.status}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
return buildApiDiscoveryResponse(
|
||||
normalizeOpenAiLikeModelsResponse(await response.json(), "watsonx")
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "oci") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "No token configured — using cached catalog",
|
||||
localWarning: "No token configured — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No API key configured for this provider. Please add an API key in the provider settings.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const psd = asRecord(connection.providerSpecificData);
|
||||
const baseUrl = getProviderBaseUrl(psd) || OCI_DEFAULT_BASE_URL;
|
||||
const projectId =
|
||||
connection.projectId || toNonEmptyString(psd.projectId) || toNonEmptyString(psd.project);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(buildOciModelsUrl(baseUrl), {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
...buildOptionalBearerHeaders(token),
|
||||
...(projectId ? { "OpenAI-Project": projectId } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "OCI models API unavailable — using cached catalog",
|
||||
localWarning: "OCI models API unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Models probe failed (${response.status}) — using cached catalog`,
|
||||
localWarning: `Models probe failed (${response.status}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
return buildApiDiscoveryResponse(
|
||||
normalizeOpenAiLikeModelsResponse(await response.json(), "oci")
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "sap") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "No token configured — using cached catalog",
|
||||
localWarning: "No token configured — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No API key configured for this provider. Please add an API key in the provider settings.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const psd = asRecord(connection.providerSpecificData);
|
||||
const baseUrl = getProviderBaseUrl(psd) || SAP_DEFAULT_BASE_URL;
|
||||
const resourceGroup = getSapResourceGroup(psd);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(buildSapModelsUrl(baseUrl), {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
...buildOptionalBearerHeaders(token),
|
||||
"AI-Resource-Group": resourceGroup,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "SAP models API unavailable — using cached catalog",
|
||||
localWarning: "SAP models API unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Models probe failed (${response.status}) — using cached catalog`,
|
||||
localWarning: `Models probe failed (${response.status}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
return buildApiDiscoveryResponse(normalizeSapModelsResponse(await response.json()));
|
||||
}
|
||||
|
||||
if (provider === "claude") {
|
||||
return buildResponse({
|
||||
provider,
|
||||
|
||||
@@ -16,6 +16,11 @@ import { getAccessToken } from "@omniroute/open-sse/services/tokenRefresh.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { logProxyEvent } from "@/lib/proxyLogger";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import {
|
||||
buildGitLabOAuthEndpoints,
|
||||
isGitLabDirectAccessDisabled,
|
||||
resolveGitLabOAuthBaseUrl,
|
||||
} from "@/lib/oauth/gitlab";
|
||||
|
||||
// OAuth provider test endpoints
|
||||
const OAUTH_TEST_CONFIG = {
|
||||
@@ -52,6 +57,15 @@ const OAUTH_TEST_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
|
||||
},
|
||||
"gitlab-duo": {
|
||||
getUrl: (connection: any) =>
|
||||
buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData))
|
||||
.directAccessUrl,
|
||||
method: "POST",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
refreshable: true,
|
||||
},
|
||||
qwen: {
|
||||
// DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues.
|
||||
// Use checkExpiry instead — actual connectivity is validated via real requests.
|
||||
@@ -400,7 +414,8 @@ async function testOAuthConnection(connection: any) {
|
||||
...config.extraHeaders,
|
||||
};
|
||||
|
||||
const res = await fetch(config.url, {
|
||||
const url = typeof config.getUrl === "function" ? config.getUrl(connection) : config.url;
|
||||
const res = await fetch(url, {
|
||||
method: config.method,
|
||||
headers,
|
||||
});
|
||||
@@ -415,6 +430,19 @@ async function testOAuthConnection(connection: any) {
|
||||
};
|
||||
}
|
||||
|
||||
if (connection.provider === "gitlab-duo") {
|
||||
const gitlabText = await res.text();
|
||||
if (isGitLabDirectAccessDisabled(res.status, gitlabText)) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
refreshed,
|
||||
newTokens,
|
||||
diagnosis: makeDiagnosis("ok", "upstream", null, null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If 401/403 and we haven't tried refresh yet, only attempt refresh
|
||||
// if the token is actually expired. This prevents corrupting valid tokens
|
||||
// when the upstream returns transient 401/403 errors (rate-limiting, etc.).
|
||||
@@ -428,7 +456,7 @@ async function testOAuthConnection(connection: any) {
|
||||
const tokens = await refreshOAuthToken(connection);
|
||||
if (tokens) {
|
||||
// Retry with new token
|
||||
const retryRes = await fetch(config.url, {
|
||||
const retryRes = await fetch(url, {
|
||||
method: config.method,
|
||||
headers: {
|
||||
[config.authHeader]: `${config.authPrefix}${tokens.accessToken}`,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GITHUB_COPILOT_CHAT_USER_AGENT,
|
||||
GITHUB_COPILOT_EDITOR_VERSION,
|
||||
} from "@omniroute/open-sse/config/providerHeaderProfiles.ts";
|
||||
import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab";
|
||||
|
||||
/**
|
||||
* OAuth Configuration Constants
|
||||
@@ -188,6 +189,21 @@ export const GITHUB_CONFIG = {
|
||||
editorPluginVersion: GITHUB_COPILOT_CHAT_PLUGIN_VERSION,
|
||||
};
|
||||
|
||||
const GITLAB_DUO_ENDPOINTS = buildGitLabOAuthEndpoints(GITLAB_DUO_DEFAULT_BASE_URL);
|
||||
|
||||
export const GITLAB_DUO_CONFIG = {
|
||||
baseUrl: GITLAB_DUO_ENDPOINTS.root,
|
||||
clientId: process.env.GITLAB_DUO_OAUTH_CLIENT_ID || process.env.GITLAB_OAUTH_CLIENT_ID || "",
|
||||
clientSecret:
|
||||
process.env.GITLAB_DUO_OAUTH_CLIENT_SECRET || process.env.GITLAB_OAUTH_CLIENT_SECRET || "",
|
||||
authorizeUrl: GITLAB_DUO_ENDPOINTS.authorizeUrl,
|
||||
tokenUrl: GITLAB_DUO_ENDPOINTS.tokenUrl,
|
||||
userInfoUrl: GITLAB_DUO_ENDPOINTS.userUrl,
|
||||
directAccessUrl: GITLAB_DUO_ENDPOINTS.directAccessUrl,
|
||||
scope: "ai_features read_user",
|
||||
codeChallengeMethod: "S256",
|
||||
};
|
||||
|
||||
// Kiro OAuth Configuration
|
||||
// Supports multiple auth methods:
|
||||
// 1. AWS Builder ID (Device Code Flow)
|
||||
@@ -259,6 +275,7 @@ export const PROVIDERS = {
|
||||
KIMI_CODING: "kimi-coding",
|
||||
OPENAI: "openai",
|
||||
GITHUB: "github",
|
||||
GITLAB_DUO: "gitlab-duo",
|
||||
KIRO: "kiro",
|
||||
AMAZON_Q: "amazon-q",
|
||||
CURSOR: "cursor",
|
||||
|
||||
103
src/lib/oauth/gitlab.ts
Normal file
103
src/lib/oauth/gitlab.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type GitLabDirectAccessDetails = {
|
||||
token: string;
|
||||
baseUrl: string;
|
||||
expiresAt: string | null;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export const GITLAB_DUO_DEFAULT_BASE_URL =
|
||||
process.env.GITLAB_DUO_BASE_URL || process.env.GITLAB_BASE_URL || "https://gitlab.com";
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
export function normalizeGitLabBaseUrl(baseUrl?: unknown): string {
|
||||
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
||||
return (raw || GITLAB_DUO_DEFAULT_BASE_URL).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function resolveGitLabOAuthBaseUrl(providerSpecificData?: unknown): string {
|
||||
const data = asRecord(providerSpecificData);
|
||||
return normalizeGitLabBaseUrl(data.baseUrl);
|
||||
}
|
||||
|
||||
export function buildGitLabOAuthEndpoints(baseUrl?: unknown) {
|
||||
const root = normalizeGitLabBaseUrl(baseUrl);
|
||||
return {
|
||||
root,
|
||||
authorizeUrl: `${root}/oauth/authorize`,
|
||||
tokenUrl: `${root}/oauth/token`,
|
||||
userUrl: `${root}/api/v4/user`,
|
||||
directAccessUrl: `${root}/api/v4/code_suggestions/direct_access`,
|
||||
publicCompletionsUrl: `${root}/api/v4/code_suggestions/completions`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGitLabDirectGatewayUrl(baseUrl: string): string {
|
||||
const normalized = normalizeGitLabBaseUrl(baseUrl);
|
||||
if (normalized.endsWith("/ai/v2/completions")) {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized.endsWith("/ai/v2")) {
|
||||
return `${normalized}/completions`;
|
||||
}
|
||||
return `${normalized}/ai/v2/completions`;
|
||||
}
|
||||
|
||||
export function parseGitLabDirectAccessDetails(payload: unknown): GitLabDirectAccessDetails | null {
|
||||
const data = asRecord(payload);
|
||||
const token = typeof data.token === "string" ? data.token.trim() : "";
|
||||
const baseUrl = typeof data.base_url === "string" ? data.base_url.trim() : "";
|
||||
if (!token || !baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawHeaders = asRecord(data.headers);
|
||||
const headers = Object.fromEntries(
|
||||
Object.entries(rawHeaders).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" && typeof entry[1] === "string"
|
||||
)
|
||||
);
|
||||
|
||||
const expiresAt =
|
||||
typeof data.expires_at === "number" && Number.isFinite(data.expires_at)
|
||||
? new Date(data.expires_at * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
return {
|
||||
token,
|
||||
baseUrl: normalizeGitLabBaseUrl(baseUrl),
|
||||
expiresAt,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCachedGitLabDirectAccess(
|
||||
providerSpecificData?: unknown,
|
||||
minValidityMs = 60_000
|
||||
): GitLabDirectAccessDetails | null {
|
||||
const data = asRecord(providerSpecificData);
|
||||
const cache = data.gitlabDirectAccess ?? data.directAccessCache;
|
||||
const parsed = parseGitLabDirectAccessDetails(cache);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.expiresAt) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const expiresAtMs = new Date(parsed.expiresAt).getTime();
|
||||
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + minValidityMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function isGitLabDirectAccessDisabled(status: number, bodyText: string): boolean {
|
||||
return status === 403 && bodyText.toLowerCase().includes("direct connections are disabled");
|
||||
}
|
||||
122
src/lib/oauth/providers/gitlab-duo.ts
Normal file
122
src/lib/oauth/providers/gitlab-duo.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { GITLAB_DUO_CONFIG } from "../constants/oauth";
|
||||
import { buildGitLabOAuthEndpoints, parseGitLabDirectAccessDetails } from "../gitlab";
|
||||
|
||||
function getGitLabUserEmail(userInfo: Record<string, unknown>): string | null {
|
||||
const candidates = [userInfo.email, userInfo.public_email];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGitLabUserName(userInfo: Record<string, unknown>): string | null {
|
||||
const candidates = [userInfo.name, userInfo.username, userInfo.email, userInfo.public_email];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const gitlabDuo = {
|
||||
config: GITLAB_DUO_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
if (!config.clientId) {
|
||||
throw new Error(
|
||||
"GitLab Duo OAuth requires GITLAB_DUO_OAUTH_CLIENT_ID (or GITLAB_OAUTH_CLIENT_ID) to be configured."
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
state,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
});
|
||||
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
if (config.clientSecret) {
|
||||
body.set("client_secret", config.clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`GitLab Duo token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
const endpoints = buildGitLabOAuthEndpoints(GITLAB_DUO_CONFIG.baseUrl);
|
||||
const headers = {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const userRes = await fetch(endpoints.userUrl, { headers });
|
||||
const userInfo = userRes.ok ? ((await userRes.json()) as Record<string, unknown>) : {};
|
||||
|
||||
let directAccess = null;
|
||||
try {
|
||||
const directRes = await fetch(endpoints.directAccessUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (directRes.ok) {
|
||||
directAccess = parseGitLabDirectAccessDetails(await directRes.json());
|
||||
}
|
||||
} catch {
|
||||
// Direct access is optional at login time; executor will retry later.
|
||||
}
|
||||
|
||||
return { userInfo, directAccess };
|
||||
},
|
||||
mapTokens: (tokens, extra) => {
|
||||
const userInfo =
|
||||
extra?.userInfo && typeof extra.userInfo === "object"
|
||||
? (extra.userInfo as Record<string, unknown>)
|
||||
: {};
|
||||
const directAccess = extra?.directAccess ?? null;
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
email: getGitLabUserEmail(userInfo),
|
||||
name: getGitLabUserName(userInfo),
|
||||
providerSpecificData: {
|
||||
baseUrl: GITLAB_DUO_CONFIG.baseUrl,
|
||||
gitlabUserId: userInfo.id,
|
||||
gitlabUsername: userInfo.username,
|
||||
gitlabName: userInfo.name,
|
||||
...(directAccess ? { gitlabDirectAccess: directAccess } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { qoder } from "./qoder";
|
||||
import { qwen } from "./qwen";
|
||||
import { kimiCoding } from "./kimi-coding";
|
||||
import { github } from "./github";
|
||||
import { gitlabDuo } from "./gitlab-duo";
|
||||
import { kiro } from "./kiro";
|
||||
import { cursor } from "./cursor";
|
||||
import { kilocode } from "./kilocode";
|
||||
@@ -32,6 +33,7 @@ export const PROVIDERS = {
|
||||
qwen,
|
||||
"kimi-coding": kimiCoding,
|
||||
github,
|
||||
"gitlab-duo": gitlabDuo,
|
||||
kiro,
|
||||
"amazon-q": kiro,
|
||||
cursor,
|
||||
|
||||
@@ -27,6 +27,40 @@ import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth";
|
||||
import { getGigachatAccessToken } from "@omniroute/open-sse/services/gigachatAuth.ts";
|
||||
import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts";
|
||||
import {
|
||||
AZURE_AI_DEFAULT_BASE_URL,
|
||||
buildAzureAiChatUrl,
|
||||
buildAzureAiModelsUrl,
|
||||
} from "@omniroute/open-sse/config/azureAi.ts";
|
||||
import {
|
||||
BEDROCK_DEFAULT_BASE_URL,
|
||||
buildBedrockModelsUrl,
|
||||
getBedrockValidationModelId,
|
||||
normalizeBedrockBaseUrl,
|
||||
} from "@omniroute/open-sse/config/bedrock.ts";
|
||||
import {
|
||||
DATAROBOT_DEFAULT_BASE_URL,
|
||||
buildDataRobotCatalogUrl,
|
||||
buildDataRobotChatUrl,
|
||||
isDataRobotDeploymentUrl,
|
||||
} from "@omniroute/open-sse/config/datarobot.ts";
|
||||
import {
|
||||
OCI_DEFAULT_BASE_URL,
|
||||
buildOciChatUrl,
|
||||
buildOciModelsUrl,
|
||||
} from "@omniroute/open-sse/config/oci.ts";
|
||||
import {
|
||||
SAP_DEFAULT_BASE_URL,
|
||||
buildSapChatUrl,
|
||||
buildSapModelsUrl,
|
||||
getSapResourceGroup,
|
||||
isSapDeploymentUrl,
|
||||
} from "@omniroute/open-sse/config/sap.ts";
|
||||
import {
|
||||
WATSONX_DEFAULT_BASE_URL,
|
||||
buildWatsonxChatUrl,
|
||||
buildWatsonxModelsUrl,
|
||||
} from "@omniroute/open-sse/config/watsonx.ts";
|
||||
|
||||
const OPENAI_LIKE_FORMATS = new Set(["openai", "openai-responses"]);
|
||||
const GEMINI_LIKE_FORMATS = new Set(["gemini", "gemini-cli"]);
|
||||
@@ -162,6 +196,43 @@ function buildBearerHeaders(apiKey: string, providerSpecificData: any = {}) {
|
||||
return applyCustomUserAgent(headers, providerSpecificData);
|
||||
}
|
||||
|
||||
function buildRekaHeaders(apiKey: string, providerSpecificData: any = {}) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
headers["X-Api-Key"] = apiKey;
|
||||
}
|
||||
|
||||
return applyCustomUserAgent(headers, providerSpecificData);
|
||||
}
|
||||
|
||||
function buildClarifaiHeaders(apiKey: string, providerSpecificData: any = {}) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Key ${apiKey}`;
|
||||
}
|
||||
|
||||
return applyCustomUserAgent(headers, providerSpecificData);
|
||||
}
|
||||
|
||||
function buildTokenHeaders(apiKey: string, providerSpecificData: any = {}) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Token ${apiKey}`;
|
||||
}
|
||||
|
||||
return applyCustomUserAgent(headers, providerSpecificData);
|
||||
}
|
||||
|
||||
async function validationRead(url: string, init: RequestInit) {
|
||||
return safeOutboundFetch(url, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.validationRead,
|
||||
@@ -294,6 +365,59 @@ async function validateDirectChatProvider({ url, headers, body, providerSpecific
|
||||
}
|
||||
}
|
||||
|
||||
async function validateClarifaiProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl =
|
||||
normalizeBaseUrl(providerSpecificData.baseUrl) || "https://api.clarifai.com/v2/ext/openai/v1";
|
||||
const modelsUrl = addModelsSuffix(baseUrl);
|
||||
|
||||
try {
|
||||
const modelsRes = await validationRead(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: buildClarifaiHeaders(apiKey, providerSpecificData),
|
||||
});
|
||||
|
||||
if (modelsRes.ok) {
|
||||
return { valid: true, error: null, method: "clarifai_models" };
|
||||
}
|
||||
|
||||
if (modelsRes.status === 401 || modelsRes.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
const chatUrl = resolveChatUrl("clarifai", baseUrl, providerSpecificData);
|
||||
const chatRes = await validationWrite(chatUrl, {
|
||||
method: "POST",
|
||||
headers: buildClarifaiHeaders(apiKey, providerSpecificData),
|
||||
body: JSON.stringify({
|
||||
model:
|
||||
providerSpecificData?.validationModelId || "openai/chat-completion/models/gpt-oss-120b",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (chatRes.ok || chatRes.status === 400 || chatRes.status === 422 || chatRes.status === 429) {
|
||||
return { valid: true, error: null, method: "clarifai_chat_probe" };
|
||||
}
|
||||
|
||||
if (chatRes.status === 401 || chatRes.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (chatRes.status === 404 || chatRes.status === 405) {
|
||||
return { valid: false, error: "Provider validation endpoint not supported" };
|
||||
}
|
||||
|
||||
if (chatRes.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${chatRes.status})` };
|
||||
}
|
||||
|
||||
return { valid: true, error: null, method: "clarifai_chat_probe" };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateEmbeddingApiProvider({
|
||||
apiKey,
|
||||
providerSpecificData = {},
|
||||
@@ -712,6 +836,61 @@ async function validateDatabricksProvider({ apiKey, providerSpecificData = {} }:
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDataRobotProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const configuredBaseUrl =
|
||||
normalizeBaseUrl(providerSpecificData.baseUrl) || DATAROBOT_DEFAULT_BASE_URL;
|
||||
|
||||
if (isDataRobotDeploymentUrl(configuredBaseUrl)) {
|
||||
return validateDirectChatProvider({
|
||||
url: buildDataRobotChatUrl(configuredBaseUrl),
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "datarobot-deployed-llm",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
const catalogUrl = buildDataRobotCatalogUrl(configuredBaseUrl);
|
||||
if (!catalogUrl) {
|
||||
return { valid: false, error: "Invalid DataRobot base URL" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await validationRead(catalogUrl, {
|
||||
method: "GET",
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "gateway_catalog" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "gateway_catalog",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return { valid: true, error: null, method: "gateway_catalog" };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSnowflakeProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
@@ -869,6 +1048,496 @@ async function validateAzureOpenAIProvider({ apiKey, providerSpecificData = {} }
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
}
|
||||
|
||||
async function validateAzureAiProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || AZURE_AI_DEFAULT_BASE_URL;
|
||||
const modelsUrl = buildAzureAiModelsUrl(rawBaseUrl);
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"api-key": apiKey,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(modelsUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "azure_ai_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "azure_ai_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string"
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "";
|
||||
|
||||
if (!validationModelId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Endpoint /models unavailable. Provide a Model ID to validate via /chat/completions.",
|
||||
};
|
||||
}
|
||||
|
||||
const chatUrl = buildAzureAiChatUrl(
|
||||
rawBaseUrl,
|
||||
providerSpecificData.apiType === "responses" ? "responses" : "chat"
|
||||
);
|
||||
const chatBody =
|
||||
providerSpecificData.apiType === "responses"
|
||||
? {
|
||||
model: validationModelId,
|
||||
input: "test",
|
||||
max_output_tokens: 1,
|
||||
}
|
||||
: {
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await validationWrite(chatUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(chatBody),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true, error: null, method: "azure_ai_chat_probe" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing Azure AI Foundry" };
|
||||
}
|
||||
|
||||
async function validateWatsonxProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || WATSONX_DEFAULT_BASE_URL;
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildWatsonxModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "watsonx_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "watsonx_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "ibm/granite-3-3-8b-instruct";
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildWatsonxChatUrl(rawBaseUrl), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "watsonx_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "watsonx credentials are valid, but the requested model is not enabled." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing watsonx.ai" };
|
||||
}
|
||||
|
||||
async function validateOciProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || OCI_DEFAULT_BASE_URL;
|
||||
const projectId =
|
||||
typeof providerSpecificData.projectId === "string" && providerSpecificData.projectId.trim()
|
||||
? providerSpecificData.projectId.trim()
|
||||
: typeof providerSpecificData.project === "string" && providerSpecificData.project.trim()
|
||||
? providerSpecificData.project.trim()
|
||||
: "";
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...(projectId ? { "OpenAI-Project": projectId } : {}),
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildOciModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "oci_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "oci_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat/responses probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "openai.gpt-oss-20b";
|
||||
const apiType = providerSpecificData.apiType === "responses" ? "responses" : "chat";
|
||||
const body =
|
||||
apiType === "responses"
|
||||
? {
|
||||
model: validationModelId,
|
||||
input: "test",
|
||||
max_output_tokens: 1,
|
||||
}
|
||||
: {
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildOciChatUrl(rawBaseUrl, apiType), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: apiType === "responses" ? "oci_responses_probe" : "oci_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "OCI credentials are valid, but the requested model was not found." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing OCI Generative AI" };
|
||||
}
|
||||
|
||||
async function validateSapProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || SAP_DEFAULT_BASE_URL;
|
||||
const resourceGroup = getSapResourceGroup(providerSpecificData);
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"AI-Resource-Group": resourceGroup,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildSapModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "sap_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "sap_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to deployment probe when the discovery API is unavailable.
|
||||
}
|
||||
|
||||
const canProbeChat =
|
||||
isSapDeploymentUrl(rawBaseUrl) || /\/chat\/completions$/i.test(normalizeBaseUrl(rawBaseUrl));
|
||||
if (!canProbeChat) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"SAP validation needs either a reachable AI_API_URL or a deployment URL in providerSpecificData.baseUrl",
|
||||
};
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "gpt-4o";
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildSapChatUrl(rawBaseUrl), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "sap_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "SAP credentials are valid, but the deployment URL or model was not found." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing SAP Generative AI Hub" };
|
||||
}
|
||||
|
||||
async function validateRekaProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || "https://api.reka.ai/v1";
|
||||
const headers = buildRekaHeaders(apiKey, providerSpecificData);
|
||||
|
||||
try {
|
||||
const response = await validationRead(`${baseUrl}/models`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "reka_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "reka_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the chat probe when /models is unavailable.
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await validationWrite(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: providerSpecificData.validationModelId || "reka-flash",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true, error: null, method: "reka_chat_probe" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing Reka" };
|
||||
}
|
||||
|
||||
async function validateNlpCloudProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || "https://api.nlpcloud.io/v1";
|
||||
const baseUrl = rawBaseUrl.endsWith("/gpu") ? rawBaseUrl : `${rawBaseUrl.replace(/\/$/, "")}/gpu`;
|
||||
const modelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "chatdolphin";
|
||||
const headers = buildTokenHeaders(apiKey, providerSpecificData);
|
||||
|
||||
try {
|
||||
const response = await validationWrite(`${baseUrl}/${modelId}/chatbot`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
input: "test",
|
||||
context: "You are a concise assistant.",
|
||||
history: [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "nlpcloud_chatbot",
|
||||
...(response.status === 429 ? { warning: "Rate limited, but credentials are valid" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing NLP Cloud" };
|
||||
}
|
||||
|
||||
async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
@@ -1259,6 +1928,13 @@ const SEARCH_VALIDATOR_CONFIGS: Record<
|
||||
headers: { Accept: "application/json" },
|
||||
},
|
||||
}),
|
||||
"youcom-search": (apiKey) => ({
|
||||
url: "https://ydc-index.io/v1/search?query=test&count=1",
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "X-API-Key": apiKey },
|
||||
},
|
||||
}),
|
||||
"searxng-search": (_apiKey, providerSpecificData = {}) => {
|
||||
const baseUrl =
|
||||
typeof providerSpecificData?.baseUrl === "string" && providerSpecificData.baseUrl.trim()
|
||||
@@ -1769,6 +2445,34 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
"bailian-coding-plan": validateBailianCodingPlanProvider,
|
||||
heroku: validateHerokuProvider,
|
||||
databricks: validateDatabricksProvider,
|
||||
datarobot: validateDataRobotProvider,
|
||||
watsonx: validateWatsonxProvider,
|
||||
oci: validateOciProvider,
|
||||
sap: validateSapProvider,
|
||||
bedrock: ({ apiKey, providerSpecificData }: any) => {
|
||||
const baseUrl = normalizeBedrockBaseUrl(
|
||||
providerSpecificData?.baseUrl || BEDROCK_DEFAULT_BASE_URL
|
||||
);
|
||||
return validateOpenAILikeProvider({
|
||||
provider: "bedrock",
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
baseUrl,
|
||||
modelId: getBedrockValidationModelId(baseUrl),
|
||||
modelsUrl: buildBedrockModelsUrl(baseUrl),
|
||||
});
|
||||
},
|
||||
modal: ({ apiKey, providerSpecificData }: any) =>
|
||||
validateOpenAILikeProvider({
|
||||
provider: "modal",
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
baseUrl: normalizeBaseUrl(providerSpecificData?.baseUrl || ""),
|
||||
modelId: "Qwen/Qwen3-4B-Thinking-2507-FP8",
|
||||
}),
|
||||
clarifai: validateClarifaiProvider,
|
||||
reka: validateRekaProvider,
|
||||
nlpcloud: validateNlpCloudProvider,
|
||||
snowflake: validateSnowflakeProvider,
|
||||
gigachat: validateGigachatProvider,
|
||||
"grok-web": validateGrokWebProvider,
|
||||
@@ -1776,6 +2480,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
"blackbox-web": validateBlackboxWebProvider,
|
||||
"muse-spark-web": validateMuseSparkWebProvider,
|
||||
"azure-openai": validateAzureOpenAIProvider,
|
||||
"azure-ai": validateAzureAiProvider,
|
||||
"voyage-ai": ({ apiKey, providerSpecificData }: any) => {
|
||||
const embeddingProvider = getEmbeddingProvider("voyage-ai");
|
||||
return validateEmbeddingApiProvider({
|
||||
@@ -1794,6 +2499,26 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
modelId: rerankProvider?.models?.[0]?.id || "jina-reranker-v3",
|
||||
});
|
||||
},
|
||||
gitlab: async ({ apiKey, providerSpecificData }: any) => {
|
||||
try {
|
||||
const configuredBaseUrl =
|
||||
typeof providerSpecificData?.baseUrl === "string"
|
||||
? providerSpecificData.baseUrl.trim()
|
||||
: "";
|
||||
const root = (configuredBaseUrl || "https://gitlab.com").replace(/\/$/, "");
|
||||
const res = await validationWrite(`${root}/api/v4/code_suggestions/direct_access`, {
|
||||
method: "POST",
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: "{}",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
return { valid: true, error: null };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
},
|
||||
vertex: async ({ apiKey }: any) => {
|
||||
try {
|
||||
const { parseSAFromApiKey, getAccessToken } =
|
||||
|
||||
@@ -55,6 +55,17 @@ export const OAUTH_PROVIDERS = {
|
||||
},
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
|
||||
"gitlab-duo": {
|
||||
id: "gitlab-duo",
|
||||
alias: "gitlab-duo",
|
||||
name: "GitLab Duo",
|
||||
icon: "hub",
|
||||
color: "#FC6D26",
|
||||
textIcon: "GL",
|
||||
website: "https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/",
|
||||
authHint:
|
||||
"OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance.",
|
||||
},
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
|
||||
"kimi-coding": {
|
||||
id: "kimi-coding",
|
||||
@@ -252,6 +263,122 @@ export const APIKEY_PROVIDERS = {
|
||||
"Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
"azure-ai": {
|
||||
id: "azure-ai",
|
||||
alias: "azure-ai",
|
||||
name: "Azure AI Foundry",
|
||||
icon: "cloud",
|
||||
color: "#2563EB",
|
||||
textIcon: "AF",
|
||||
website: "https://learn.microsoft.com/azure/ai-foundry/",
|
||||
authHint:
|
||||
"Use your Azure AI Foundry key. Base URL can be https://<resource>.services.ai.azure.com/openai/v1/ or https://<resource>.openai.azure.com/openai/v1/.",
|
||||
apiHint:
|
||||
"Foundry uses the OpenAI v1 surface with deployment names as models. OmniRoute normalizes root resource URLs to the v1 chat and /models endpoints.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
bedrock: {
|
||||
id: "bedrock",
|
||||
alias: "bedrock",
|
||||
name: "Amazon Bedrock",
|
||||
icon: "cloud",
|
||||
color: "#FF9900",
|
||||
textIcon: "BR",
|
||||
website: "https://aws.amazon.com/bedrock/",
|
||||
authHint:
|
||||
"Use your Amazon Bedrock API key in Authorization: Bearer <key>. OmniRoute defaults to the OpenAI-compatible bedrock-mantle endpoint in us-east-1; set a regional base URL if your account uses another region or the bedrock-runtime /openai/v1 path.",
|
||||
apiHint:
|
||||
"This integration targets Amazon Bedrock's current OpenAI-compatible surface. bedrock-mantle is the default for /models and chat; advanced users can also point baseUrl to bedrock-runtime/.../openai/v1 for runtime-specific model IDs.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
watsonx: {
|
||||
id: "watsonx",
|
||||
alias: "watsonx",
|
||||
name: "IBM watsonx.ai Gateway",
|
||||
icon: "hub",
|
||||
color: "#0F62FE",
|
||||
textIcon: "WX",
|
||||
website: "https://www.ibm.com/products/watsonx-ai",
|
||||
authHint:
|
||||
"Use your watsonx bearer token. Base URL can be https://<region>.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint.",
|
||||
apiHint:
|
||||
"The watsonx model gateway exposes OpenAI-compatible /chat/completions and /models under /ml/gateway/v1.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
oci: {
|
||||
id: "oci",
|
||||
alias: "oci",
|
||||
name: "OCI Generative AI",
|
||||
icon: "cloud",
|
||||
color: "#C74634",
|
||||
textIcon: "OCI",
|
||||
website: "https://www.oracle.com/artificial-intelligence/generative-ai/",
|
||||
authHint:
|
||||
"Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai.<region>.oci.oraclecloud.com/openai/v1/.",
|
||||
apiHint:
|
||||
"OCI exposes OpenAI-compatible chat and responses endpoints. Project ID is optional in OmniRoute but may be required for Responses and agentic workflows.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
sap: {
|
||||
id: "sap",
|
||||
alias: "sap",
|
||||
name: "SAP Generative AI Hub",
|
||||
icon: "business",
|
||||
color: "#0FAAFF",
|
||||
textIcon: "SAP",
|
||||
website:
|
||||
"https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core",
|
||||
authHint:
|
||||
"Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub.",
|
||||
apiHint:
|
||||
"Model discovery uses /v2/lm/scenarios/foundation-models/models on AI_API_URL. Chat requests use deploymentUrl/chat/completions and require AI-Resource-Group.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
modal: {
|
||||
id: "modal",
|
||||
alias: "mdl",
|
||||
name: "Modal",
|
||||
icon: "cloud_queue",
|
||||
color: "#7C3AED",
|
||||
textIcon: "MDL",
|
||||
website: "https://modal.com/docs",
|
||||
authHint:
|
||||
"Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://<workspace>--<app>.modal.run/v1.",
|
||||
apiHint:
|
||||
"Modal commonly serves user-hosted OpenAI-compatible apps on /v1. OmniRoute will probe /v1/models and route chat traffic to /v1/chat/completions.",
|
||||
hasFree: true,
|
||||
freeNote: "$30/month free credits for new accounts",
|
||||
passthroughModels: true,
|
||||
},
|
||||
reka: {
|
||||
id: "reka",
|
||||
alias: "reka",
|
||||
name: "Reka",
|
||||
icon: "auto_awesome",
|
||||
color: "#111827",
|
||||
textIcon: "RK",
|
||||
website: "https://docs.reka.ai/chat/overview",
|
||||
authHint:
|
||||
"Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility.",
|
||||
apiHint:
|
||||
"Reka Chat is OpenAI-compatible on /v1. OmniRoute probes /v1/models and routes chat traffic to /v1/chat/completions.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
nlpcloud: {
|
||||
id: "nlpcloud",
|
||||
alias: "nlpc",
|
||||
name: "NLP Cloud",
|
||||
icon: "psychology",
|
||||
color: "#2196F3",
|
||||
textIcon: "NLPC",
|
||||
website: "https://docs.nlpcloud.com",
|
||||
authHint:
|
||||
"Use your NLP Cloud API key in Authorization: Token <key>. OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu/<model>/chatbot by default.",
|
||||
apiHint:
|
||||
"NLP Cloud uses a proprietary chatbot API instead of OpenAI chat/completions. OmniRoute adapts OpenAI messages to input/context/history and exposes a local catalog of supported chatbot models.",
|
||||
hasFree: true,
|
||||
freeNote: "Trial credits for new accounts",
|
||||
},
|
||||
anthropic: {
|
||||
id: "anthropic",
|
||||
alias: "anthropic",
|
||||
@@ -736,6 +863,34 @@ export const APIKEY_PROVIDERS = {
|
||||
textIcon: "DB",
|
||||
website: "https://www.databricks.com",
|
||||
},
|
||||
datarobot: {
|
||||
id: "datarobot",
|
||||
alias: "datarobot",
|
||||
name: "DataRobot",
|
||||
icon: "precision_manufacturing",
|
||||
color: "#6D28D9",
|
||||
textIcon: "DR",
|
||||
website: "https://docs.datarobot.com",
|
||||
authHint:
|
||||
"Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/<id>.",
|
||||
apiHint:
|
||||
"The default gateway catalogs active models from /genai/llmgw/catalog/. Deployment URLs are also supported for direct OpenAI-compatible chat requests.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
clarifai: {
|
||||
id: "clarifai",
|
||||
alias: "clarifai",
|
||||
name: "Clarifai",
|
||||
icon: "hub",
|
||||
color: "#7C3AED",
|
||||
textIcon: "CF",
|
||||
website: "https://docs.clarifai.com",
|
||||
authHint:
|
||||
"Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key <token>.",
|
||||
apiHint:
|
||||
"Clarifai exposes OpenAI-compatible chat, responses and /models on /v2/ext/openai/v1. Public/community models typically require a PAT; app-scoped keys only work for resources inside that app.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
snowflake: {
|
||||
id: "snowflake",
|
||||
alias: "snowflake",
|
||||
@@ -959,6 +1114,28 @@ export const APIKEY_PROVIDERS = {
|
||||
authHint: "Bearer API key for the FenayAI OpenAI-compatible gateway.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
gitlab: {
|
||||
id: "gitlab",
|
||||
alias: "gitlab",
|
||||
name: "GitLab Duo PAT",
|
||||
icon: "hub",
|
||||
color: "#FC6D26",
|
||||
textIcon: "GL",
|
||||
website: "https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/",
|
||||
authHint:
|
||||
"GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com.",
|
||||
},
|
||||
chutes: {
|
||||
id: "chutes",
|
||||
alias: "chutes",
|
||||
name: "Chutes.ai",
|
||||
icon: "hub",
|
||||
color: "#06B6D4",
|
||||
textIcon: "CH",
|
||||
website: "https://chutes.ai",
|
||||
authHint: "Bearer API key for the Chutes OpenAI-compatible gateway.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
"voyage-ai": {
|
||||
id: "voyage-ai",
|
||||
alias: "voyage",
|
||||
@@ -1227,6 +1404,16 @@ export const SEARCH_PROVIDERS = {
|
||||
website: "https://www.searchapi.io/docs",
|
||||
authHint: "API key from SearchAPI (query param or Bearer auth)",
|
||||
},
|
||||
"youcom-search": {
|
||||
id: "youcom-search",
|
||||
alias: "youcom-search",
|
||||
name: "You.com Search",
|
||||
icon: "travel_explore",
|
||||
color: "#2563EB",
|
||||
textIcon: "YOU",
|
||||
website: "https://you.com/docs/search/overview",
|
||||
authHint: "X-API-Key from the You.com platform dashboard",
|
||||
},
|
||||
"searxng-search": {
|
||||
id: "searxng-search",
|
||||
alias: "searxng",
|
||||
|
||||
@@ -1747,6 +1747,7 @@ export const v1SearchSchema = z
|
||||
"google-pse-search",
|
||||
"linkup-search",
|
||||
"searchapi-search",
|
||||
"youcom-search",
|
||||
"searxng-search",
|
||||
])
|
||||
.optional(),
|
||||
|
||||
@@ -14,6 +14,7 @@ const CHAT_OPENAI_COMPAT_PROVIDER_IDS = [
|
||||
"ovhcloud",
|
||||
"baseten",
|
||||
"publicai",
|
||||
"chutes",
|
||||
"moonshot",
|
||||
"meta-llama",
|
||||
"v0-vercel",
|
||||
@@ -24,6 +25,15 @@ const CHAT_OPENAI_COMPAT_PROVIDER_IDS = [
|
||||
"heroku",
|
||||
"galadriel",
|
||||
"databricks",
|
||||
"datarobot",
|
||||
"clarifai",
|
||||
"azure-ai",
|
||||
"bedrock",
|
||||
"watsonx",
|
||||
"oci",
|
||||
"sap",
|
||||
"modal",
|
||||
"reka",
|
||||
"snowflake",
|
||||
"wandb",
|
||||
"volcengine",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../open-sse/executors/base.ts";
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
|
||||
import { PROVIDERS } from "../../open-sse/config/constants.ts";
|
||||
import { BEDROCK_DEFAULT_BASE_URL } from "../../open-sse/config/bedrock.ts";
|
||||
import {
|
||||
CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
@@ -161,6 +162,15 @@ test("DefaultExecutor.buildUrl normalizes configurable chat-openai-compat base U
|
||||
const bailian = new DefaultExecutor("bailian-coding-plan");
|
||||
const heroku = new DefaultExecutor("heroku");
|
||||
const databricks = new DefaultExecutor("databricks");
|
||||
const datarobot = new DefaultExecutor("datarobot");
|
||||
const clarifai = new DefaultExecutor("clarifai");
|
||||
const azureAi = new DefaultExecutor("azure-ai");
|
||||
const bedrock = new DefaultExecutor("bedrock");
|
||||
const watsonx = new DefaultExecutor("watsonx");
|
||||
const oci = new DefaultExecutor("oci");
|
||||
const sap = new DefaultExecutor("sap");
|
||||
const modal = new DefaultExecutor("modal");
|
||||
const reka = new DefaultExecutor("reka");
|
||||
const snowflake = new DefaultExecutor("snowflake");
|
||||
const gigachat = new DefaultExecutor("gigachat");
|
||||
|
||||
@@ -186,6 +196,84 @@ test("DefaultExecutor.buildUrl normalizes configurable chat-openai-compat base U
|
||||
}),
|
||||
"https://adb-1234567890123456.7.azuredatabricks.net/serving-endpoints/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
datarobot.buildUrl("azure/gpt-5-mini-2025-08-07", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://app.datarobot.com" },
|
||||
}),
|
||||
"https://app.datarobot.com/api/v2/genai/llmgw/chat/completions/"
|
||||
);
|
||||
assert.equal(
|
||||
datarobot.buildUrl("datarobot-deployed-llm", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://app.datarobot.com/api/v2/deployments/65f5b2b7c8f8c4b257e0d123",
|
||||
},
|
||||
}),
|
||||
"https://app.datarobot.com/api/v2/deployments/65f5b2b7c8f8c4b257e0d123/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
clarifai.buildUrl("openai/chat-completion/models/gpt-oss-120b", true),
|
||||
"https://api.clarifai.com/v2/ext/openai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
azureAi.buildUrl("DeepSeek-V3.1", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://my-foundry.services.ai.azure.com" },
|
||||
}),
|
||||
"https://my-foundry.services.ai.azure.com/openai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
bedrock.buildUrl("openai.gpt-oss-120b", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://bedrock-mantle.us-east-1.api.aws" },
|
||||
}),
|
||||
"https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
bedrock.buildUrl("openai.gpt-oss-120b-1:0", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com" },
|
||||
}),
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
bedrock.buildUrl("openai.gpt-oss-120b", true),
|
||||
`${BEDROCK_DEFAULT_BASE_URL}/chat/completions`
|
||||
);
|
||||
assert.equal(
|
||||
watsonx.buildUrl("ibm/granite-3-3-8b-instruct", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://ca-tor.ml.cloud.ibm.com" },
|
||||
}),
|
||||
"https://ca-tor.ml.cloud.ibm.com/ml/gateway/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
oci.buildUrl("openai.gpt-oss-20b", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://inference.generativeai.us-ashburn-1.oci.oraclecloud.com",
|
||||
},
|
||||
}),
|
||||
"https://inference.generativeai.us-ashburn-1.oci.oraclecloud.com/openai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
sap.buildUrl("gpt-4o", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://sap.example.com/v2/lm/deployments/demo-deployment",
|
||||
},
|
||||
}),
|
||||
"https://sap.example.com/v2/lm/deployments/demo-deployment/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
modal.buildUrl("Qwen/Qwen3-4B-Thinking-2507-FP8", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://alice--demo.modal.run/v1",
|
||||
},
|
||||
}),
|
||||
"https://alice--demo.modal.run/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
reka.buildUrl("reka-core", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.reka.ai/v1",
|
||||
},
|
||||
}),
|
||||
"https://api.reka.ai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
snowflake.buildUrl("llama3.3-70b", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://account.snowflakecomputing.com" },
|
||||
@@ -209,11 +297,52 @@ test("DefaultExecutor.buildUrl falls back to OpenAI config for unknown providers
|
||||
test("DefaultExecutor.buildHeaders handles Gemini and Claude auth modes", () => {
|
||||
const gemini = new DefaultExecutor("gemini");
|
||||
const claude = new DefaultExecutor("claude");
|
||||
const clarifai = new DefaultExecutor("clarifai");
|
||||
const azureAi = new DefaultExecutor("azure-ai");
|
||||
const oci = new DefaultExecutor("oci");
|
||||
const sap = new DefaultExecutor("sap");
|
||||
const modal = new DefaultExecutor("modal");
|
||||
const reka = new DefaultExecutor("reka");
|
||||
|
||||
const geminiApiKeyHeaders = gemini.buildHeaders({ apiKey: "gem-key" }, true);
|
||||
const geminiOAuthHeaders = gemini.buildHeaders({ accessToken: "gem-token" }, false);
|
||||
const claudeApiKeyHeaders = claude.buildHeaders({ apiKey: "claude-key" }, true);
|
||||
const claudeOAuthHeaders = claude.buildHeaders({ accessToken: "claude-token" }, false);
|
||||
const azureAiHeaders = azureAi.buildHeaders({ apiKey: "azure-ai-key" }, true);
|
||||
const ociHeaders = oci.buildHeaders(
|
||||
{
|
||||
apiKey: "oci-key",
|
||||
projectId: "ocid1.generativeaiproject.oc1.us-chicago-1.example",
|
||||
},
|
||||
true
|
||||
);
|
||||
const sapHeaders = sap.buildHeaders(
|
||||
{
|
||||
apiKey: "sap-key",
|
||||
providerSpecificData: {
|
||||
resourceGroup: "shared",
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
const modalHeaders = modal.buildHeaders(
|
||||
{
|
||||
apiKey: "modal-key",
|
||||
},
|
||||
true
|
||||
);
|
||||
const rekaHeaders = reka.buildHeaders(
|
||||
{
|
||||
apiKey: "reka-key",
|
||||
},
|
||||
true
|
||||
);
|
||||
const clarifaiHeaders = clarifai.buildHeaders(
|
||||
{
|
||||
apiKey: "clarifai-pat",
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(geminiApiKeyHeaders["x-goog-api-key"], "gem-key");
|
||||
assert.equal(geminiApiKeyHeaders.Accept, "text/event-stream");
|
||||
@@ -223,6 +352,16 @@ test("DefaultExecutor.buildHeaders handles Gemini and Claude auth modes", () =>
|
||||
assert.equal(claudeApiKeyHeaders.Accept, "text/event-stream");
|
||||
assert.equal(claudeOAuthHeaders.Authorization, "Bearer claude-token");
|
||||
assert.equal(claudeOAuthHeaders["x-api-key"], undefined);
|
||||
assert.equal(azureAiHeaders["api-key"], "azure-ai-key");
|
||||
assert.equal(azureAiHeaders.Authorization, undefined);
|
||||
assert.equal(ociHeaders.Authorization, "Bearer oci-key");
|
||||
assert.equal(ociHeaders["OpenAI-Project"], "ocid1.generativeaiproject.oc1.us-chicago-1.example");
|
||||
assert.equal(sapHeaders.Authorization, "Bearer sap-key");
|
||||
assert.equal(sapHeaders["AI-Resource-Group"], "shared");
|
||||
assert.equal(modalHeaders.Authorization, "Bearer modal-key");
|
||||
assert.equal(rekaHeaders.Authorization, "Bearer reka-key");
|
||||
assert.equal(rekaHeaders["X-Api-Key"], "reka-key");
|
||||
assert.equal(clarifaiHeaders.Authorization, "Key clarifai-pat");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildHeaders handles GLM, default auth and anthropic-compatible headers", () => {
|
||||
|
||||
263
tests/unit/executor-gitlab.test.ts
Normal file
263
tests/unit/executor-gitlab.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GitlabExecutor } from "../../open-sse/executors/gitlab.ts";
|
||||
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
test("GitlabExecutor is registered in the executor index", () => {
|
||||
assert.equal(hasSpecializedExecutor("gitlab"), true);
|
||||
assert.ok(getExecutor("gitlab") instanceof GitlabExecutor);
|
||||
assert.equal(hasSpecializedExecutor("gitlab-duo"), true);
|
||||
assert.ok(getExecutor("gitlab-duo") instanceof GitlabExecutor);
|
||||
});
|
||||
|
||||
test("GitlabExecutor posts PAT-backed code suggestion requests to the configured instance", async () => {
|
||||
const executor = new GitlabExecutor();
|
||||
const calls: Array<{
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init.body || "{}")),
|
||||
headers: init.headers as Record<string, string>,
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
id: "gitlab-response-1",
|
||||
model: { name: "code-gecko", engine: "vertex-ai" },
|
||||
choices: [{ text: "def hello():\n return 'world'", finish_reason: "stop" }],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gitlab-duo-code-suggestions",
|
||||
body: {
|
||||
messages: [
|
||||
{ role: "system", content: "Return Python code only." },
|
||||
{ role: "user", content: "Write a hello world function" },
|
||||
],
|
||||
},
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "glpat-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://gitlab.example.com",
|
||||
projectPath: "group/project",
|
||||
fileName: "app.py",
|
||||
},
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "https://gitlab.example.com/api/v4/code_suggestions/completions");
|
||||
assert.equal(calls[0].headers.Authorization, "Bearer glpat-test");
|
||||
assert.equal(calls[0].body.project_path, "group/project");
|
||||
assert.equal(calls[0].body.current_file.file_name, "app.py");
|
||||
assert.equal(calls[0].body.intent, "generation");
|
||||
assert.match(String(calls[0].body.user_instruction), /Write a hello world function/);
|
||||
assert.match(String(calls[0].body.current_file.content_above_cursor), /System instructions:/);
|
||||
|
||||
const body = (await result.response.json()) as any;
|
||||
assert.equal(body.object, "chat.completion");
|
||||
assert.equal(body.choices[0].message.role, "assistant");
|
||||
assert.match(body.choices[0].message.content, /hello/);
|
||||
assert.equal(body.model, "code-gecko");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GitlabExecutor synthesizes SSE responses from non-streaming upstream completions", async () => {
|
||||
const executor = new GitlabExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
jsonResponse({
|
||||
model: { name: "code-gecko" },
|
||||
choices: [{ text: "console.log('hi');" }],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gitlab-duo-code-suggestions",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "Write a JS hello world" }],
|
||||
},
|
||||
stream: true,
|
||||
credentials: { apiKey: "glpat-test" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
|
||||
const text = await result.response.text();
|
||||
assert.match(text, /data: \{\"id\":\"chatcmpl-gitlab-/);
|
||||
assert.match(text, /console\.log\('hi'\);/);
|
||||
assert.match(text, /data: \[DONE\]/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GitlabExecutor maps upstream auth failures to OpenAI-style errors", async () => {
|
||||
const executor = new GitlabExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () => jsonResponse({ message: "forbidden" }, 403);
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gitlab-duo-code-suggestions",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "glpat-test" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 403);
|
||||
const body = (await result.response.json()) as any;
|
||||
assert.match(body.error.message, /auth failed/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GitlabExecutor uses GitLab direct_access for gitlab-duo and persists the cache", async () => {
|
||||
const executor = getExecutor("gitlab-duo") as GitlabExecutor;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
|
||||
const refreshedPatches: Array<Record<string, unknown>> = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
headers: (init.headers || {}) as Record<string, string>,
|
||||
});
|
||||
|
||||
if (String(url) === "https://gitlab.example.com/api/v4/code_suggestions/direct_access") {
|
||||
return jsonResponse({
|
||||
token: "direct-token",
|
||||
base_url: "https://cloud.gitlab.com",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 1800,
|
||||
headers: {
|
||||
"x-gitlab-feature-enabled": "true",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
metadata: {
|
||||
model_details: {
|
||||
model_name: "GitLab Duo Claude Sonnet",
|
||||
},
|
||||
},
|
||||
choices: [{ text: "print('gitlab duo')" }],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gitlab-duo-code-suggestions",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "Write a hello world in Python" }],
|
||||
},
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "oauth-access",
|
||||
refreshToken: "oauth-refresh",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://gitlab.example.com",
|
||||
},
|
||||
},
|
||||
onCredentialsRefreshed: async (patch) => {
|
||||
refreshedPatches.push(patch as Record<string, unknown>);
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(calls[0].url, "https://gitlab.example.com/api/v4/code_suggestions/direct_access");
|
||||
assert.equal(calls[0].headers.Authorization, "Bearer oauth-access");
|
||||
assert.equal(calls[1].url, "https://cloud.gitlab.com/ai/v2/completions");
|
||||
assert.equal(calls[1].headers.Authorization, "Bearer direct-token");
|
||||
assert.equal(calls[1].headers["x-gitlab-feature-enabled"], "true");
|
||||
assert.equal(refreshedPatches.length, 1);
|
||||
assert.equal(
|
||||
(
|
||||
(refreshedPatches[0].providerSpecificData as Record<string, unknown>)
|
||||
?.gitlabDirectAccess as Record<string, unknown>
|
||||
)?.token,
|
||||
"direct-token"
|
||||
);
|
||||
|
||||
const body = (await result.response.json()) as any;
|
||||
assert.equal(body.model, "GitLab Duo Claude Sonnet");
|
||||
assert.match(body.choices[0].message.content, /gitlab duo/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GitlabExecutor falls back to the public Code Suggestions endpoint when direct_access is disabled", async () => {
|
||||
const executor = getExecutor("gitlab-duo") as GitlabExecutor;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
|
||||
if (String(url) === "https://gitlab.example.com/api/v4/code_suggestions/direct_access") {
|
||||
return jsonResponse({ message: "Direct connections are disabled" }, 403);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
model: { name: "code-gecko" },
|
||||
choices: [{ text: "fallback path works" }],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gitlab-duo-code-suggestions",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "Say hello" }],
|
||||
},
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "oauth-access",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://gitlab.example.com",
|
||||
},
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"https://gitlab.example.com/api/v4/code_suggestions/direct_access",
|
||||
"https://gitlab.example.com/api/v4/code_suggestions/completions",
|
||||
]);
|
||||
|
||||
const body = (await result.response.json()) as any;
|
||||
assert.equal(body.model, "code-gecko");
|
||||
assert.match(body.choices[0].message.content, /fallback path/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
149
tests/unit/executor-nlpcloud.test.ts
Normal file
149
tests/unit/executor-nlpcloud.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
|
||||
import { NlpCloudExecutor } from "../../open-sse/executors/nlpcloud.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function sseResponse(events: string[]) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const event of events) {
|
||||
controller.enqueue(encoder.encode(event));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test("NlpCloudExecutor is registered in the executor index", () => {
|
||||
assert.equal(hasSpecializedExecutor("nlpcloud"), true);
|
||||
assert.ok(getExecutor("nlpcloud") instanceof NlpCloudExecutor);
|
||||
});
|
||||
|
||||
test("NlpCloudExecutor converts OpenAI messages into chatbot input/context/history and wraps JSON responses", async () => {
|
||||
const executor = new NlpCloudExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}> = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init.body || "{}")),
|
||||
headers: init.headers as Record<string, string>,
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
response: "Hi back from NLP Cloud.",
|
||||
history: [
|
||||
{ input: "Hello", response: "Hi there!" },
|
||||
{ input: "How are you?", response: "Hi back from NLP Cloud." },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "chatdolphin",
|
||||
body: {
|
||||
messages: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
{ role: "user", content: "How are you?" },
|
||||
],
|
||||
},
|
||||
stream: false,
|
||||
credentials: { apiKey: "nlpc-key" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "https://api.nlpcloud.io/v1/gpu/chatdolphin/chatbot");
|
||||
assert.equal(calls[0].headers.Authorization, "Token nlpc-key");
|
||||
assert.equal(calls[0].body.input, "How are you?");
|
||||
assert.equal(calls[0].body.context, "You are concise.");
|
||||
assert.deepEqual(calls[0].body.history, [{ input: "Hello", response: "Hi there!" }]);
|
||||
|
||||
const body = (await result.response.json()) as any;
|
||||
assert.equal(body.object, "chat.completion");
|
||||
assert.equal(body.choices[0].message.role, "assistant");
|
||||
assert.equal(body.choices[0].message.content, "Hi back from NLP Cloud.");
|
||||
assert.equal(body.model, "chatdolphin");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("NlpCloudExecutor converts raw NLP Cloud SSE text events into OpenAI chat chunks", async () => {
|
||||
const executor = new NlpCloudExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
sseResponse(["data: Hello \n\n", "data: world\n\n", "data: [DONE]\n\n"]);
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "chatdolphin",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "Say hello" }],
|
||||
},
|
||||
stream: true,
|
||||
credentials: { apiKey: "nlpc-key" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.headers.get("Content-Type"), "text/event-stream");
|
||||
const text = await result.response.text();
|
||||
assert.match(text, /data: \{\"id\":\"chatcmpl-nlpcloud-/);
|
||||
assert.match(text, /Hello/);
|
||||
assert.match(text, /world/);
|
||||
assert.match(text, /data: \[DONE\]/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("NlpCloudExecutor maps upstream auth failures to OpenAI-style errors", async () => {
|
||||
const executor = new NlpCloudExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () => jsonResponse({ detail: "forbidden" }, 403);
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "chatdolphin",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "nlpc-key" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 403);
|
||||
const body = (await result.response.json()) as any;
|
||||
assert.match(body.error.message, /status 403/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -78,3 +78,122 @@ test("Kiro registry exposes the current CLI model lineup with context windows",
|
||||
assert.equal(byId.get("deepseek-3.2")?.contextLength, 128000);
|
||||
assert.equal(byId.get("qwen3-coder-next")?.contextLength, 256000);
|
||||
});
|
||||
|
||||
test("Chutes registry exposes a current TEE-heavy public lineup", () => {
|
||||
const chutesModels = getProviderModels("chutes");
|
||||
const ids = new Set(chutesModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("Qwen/Qwen3-32B-TEE"));
|
||||
assert.ok(ids.has("deepseek-ai/DeepSeek-V3.2-TEE"));
|
||||
assert.ok(ids.has("openai/gpt-oss-120b-TEE"));
|
||||
assert.ok(ids.has("moonshotai/Kimi-K2.6-TEE"));
|
||||
});
|
||||
|
||||
test("DataRobot registry exposes gateway-friendly fallback examples", () => {
|
||||
const datarobotModels = getProviderModels("datarobot");
|
||||
const ids = new Set(datarobotModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("azure/gpt-5-mini-2025-08-07"));
|
||||
assert.ok(ids.has("azure/gpt-4o-mini"));
|
||||
});
|
||||
|
||||
test("Clarifai registry exposes current OpenAI-compatible examples", () => {
|
||||
const clarifaiModels = getProviderModels("clarifai");
|
||||
const ids = new Set(clarifaiModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("openai/chat-completion/models/gpt-oss-120b"));
|
||||
assert.ok(ids.has("openai/chat-completion/models/gpt-4o"));
|
||||
assert.ok(ids.has("anthropic/completion/models/claude-sonnet-4"));
|
||||
assert.ok(ids.has("gcp/generate/models/gemini-2_5-flash"));
|
||||
});
|
||||
|
||||
test("Azure AI Foundry registry exposes fallback marketplace examples", () => {
|
||||
const azureAiModels = getProviderModels("azure-ai");
|
||||
const ids = new Set(azureAiModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("claude-opus-4-6"));
|
||||
assert.ok(ids.has("deepseek-v3.2"));
|
||||
assert.ok(ids.has("kimi-k2.5"));
|
||||
});
|
||||
|
||||
test("Bedrock registry exposes current OpenAI-compatible mantle examples", () => {
|
||||
const bedrockModels = getProviderModels("bedrock");
|
||||
const ids = new Set(bedrockModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("openai.gpt-oss-20b"));
|
||||
assert.ok(ids.has("openai.gpt-oss-120b"));
|
||||
assert.ok(ids.has("mistral.mistral-large-3-675b-instruct"));
|
||||
});
|
||||
|
||||
test("watsonx registry exposes gateway-friendly fallback examples", () => {
|
||||
const watsonxModels = getProviderModels("watsonx");
|
||||
const ids = new Set(watsonxModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("ibm/granite-3-3-8b-instruct"));
|
||||
assert.ok(ids.has("meta-llama/llama-3-3-70b-instruct"));
|
||||
assert.ok(ids.has("openai/gpt-4o"));
|
||||
});
|
||||
|
||||
test("OCI registry exposes current OpenAI-compatible enterprise examples", () => {
|
||||
const ociModels = getProviderModels("oci");
|
||||
const ids = new Set(ociModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("openai.gpt-oss-20b"));
|
||||
assert.ok(ids.has("openai.gpt-oss-120b"));
|
||||
assert.ok(ids.has("google.gemini-2.5-pro"));
|
||||
});
|
||||
|
||||
test("SAP registry exposes current Generative AI Hub examples", () => {
|
||||
const sapModels = getProviderModels("sap");
|
||||
const ids = new Set(sapModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("gpt-4o"));
|
||||
assert.ok(ids.has("gpt-5-mini"));
|
||||
assert.ok(ids.has("mistralai--mistral-medium-instruct"));
|
||||
});
|
||||
|
||||
test("Modal registry exposes current OpenAI-compatible deployment examples", () => {
|
||||
const modalModels = getProviderModels("modal");
|
||||
const ids = new Set(modalModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("Qwen/Qwen3-4B-Thinking-2507-FP8"));
|
||||
assert.ok(ids.has("google/gemma-4-26B-A4B-it"));
|
||||
assert.ok(ids.has("gpt-oss-20B"));
|
||||
});
|
||||
|
||||
test("Reka registry exposes current OpenAI-compatible chat examples", () => {
|
||||
const rekaModels = getProviderModels("reka");
|
||||
const ids = new Set(rekaModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("reka-core"));
|
||||
assert.ok(ids.has("reka-flash"));
|
||||
assert.ok(ids.has("reka-edge-2603"));
|
||||
});
|
||||
|
||||
test("NLP Cloud registry exposes the current chatbot model lineup", () => {
|
||||
const nlpCloudModels = getModelsByProviderId("nlpcloud");
|
||||
const ids = new Set(nlpCloudModels.map((model) => model.id));
|
||||
|
||||
assert.ok(ids.has("gpt-oss-120b"));
|
||||
assert.ok(ids.has("llama-3-1-405b"));
|
||||
assert.ok(ids.has("finetuned-llama-3-70b"));
|
||||
assert.ok(ids.has("chatdolphin"));
|
||||
assert.ok(ids.has("dolphin-yi-34b"));
|
||||
assert.ok(ids.has("dolphin-mixtral-8x7b"));
|
||||
});
|
||||
|
||||
test("GitLab registry exposes the public code suggestions fallback model", () => {
|
||||
const gitlabModels = getProviderModels("gitlab");
|
||||
|
||||
assert.deepEqual(gitlabModels, [
|
||||
{ id: "gitlab-duo-code-suggestions", name: "GitLab Duo Code Suggestions" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("GitLab Duo OAuth registry reuses the same fallback model catalog", () => {
|
||||
const gitlabDuoModels = getModelsByProviderId("gitlab-duo");
|
||||
|
||||
assert.deepEqual(gitlabDuoModels, [
|
||||
{ id: "gitlab-duo-code-suggestions", name: "GitLab Duo Code Suggestions" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -208,6 +208,43 @@ test("provider models route returns static catalog entries for providers with ha
|
||||
assert.equal(body.models.length, 8);
|
||||
});
|
||||
|
||||
test("provider models route returns the local catalog for GitLab Duo fallback models", async () => {
|
||||
const connection = await seedConnection("gitlab", {
|
||||
apiKey: "glpat-test",
|
||||
});
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "gitlab");
|
||||
assert.equal(body.source, "local_catalog");
|
||||
assert.deepEqual(body.models, [
|
||||
{ id: "gitlab-duo-code-suggestions", name: "GitLab Duo Code Suggestions", owned_by: "gitlab" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route returns the local catalog for GitLab Duo OAuth fallback models", async () => {
|
||||
const connection = await seedConnection("gitlab-duo", {
|
||||
authType: "oauth",
|
||||
accessToken: "oauth-access",
|
||||
});
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "gitlab-duo");
|
||||
assert.equal(body.source, "local_catalog");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "gitlab-duo-code-suggestions",
|
||||
name: "GitLab Duo Code Suggestions",
|
||||
owned_by: "gitlab-duo",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers local OpenAI-style models without requiring an API key", async () => {
|
||||
process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true";
|
||||
|
||||
@@ -298,6 +335,12 @@ test("provider models route fetches remote catalogs for new OpenAI-compatible ga
|
||||
expectedUrl: "https://fenayai.com/v1/models",
|
||||
model: { id: "deepseek-chat", name: "DeepSeek Chat via FenayAI" },
|
||||
},
|
||||
{
|
||||
provider: "chutes",
|
||||
apiKey: "chutes-key",
|
||||
expectedUrl: "https://llm.chutes.ai/v1/models",
|
||||
model: { id: "Qwen/Qwen3-32B-TEE", name: "Qwen3 32B via Chutes" },
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
@@ -354,6 +397,22 @@ test("provider models route returns the local catalog for embedding and rerank p
|
||||
assert.ok(jinaBody.models.some((model) => model.id === "jina-reranker-v2-base-multilingual"));
|
||||
});
|
||||
|
||||
test("provider models route returns the local catalog for NLP Cloud", async () => {
|
||||
const connection = await seedConnection("nlpcloud", {
|
||||
apiKey: "nlpc-key",
|
||||
});
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "nlpcloud");
|
||||
assert.equal(body.source, "local_catalog");
|
||||
assert.ok(body.models.some((model) => model.id === "chatdolphin"));
|
||||
assert.ok(body.models.some((model) => model.id === "gpt-oss-120b"));
|
||||
assert.ok(body.models.some((model) => model.id === "dolphin-mixtral-8x7b"));
|
||||
});
|
||||
|
||||
test("provider models route returns the local catalog for amazon-q via the kiro-compatible registry", async () => {
|
||||
const connection = await seedConnection("amazon-q", {
|
||||
authType: "oauth",
|
||||
@@ -875,6 +934,368 @@ test("provider models route rejects generic providers without any configured tok
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("provider models route discovers active DataRobot gateway models from the catalog endpoint", async () => {
|
||||
const connection = await seedConnection("datarobot", {
|
||||
apiKey: "dr-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://app.datarobot.com",
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://app.datarobot.com/genai/llmgw/catalog/");
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers.Authorization, "Bearer dr-key");
|
||||
|
||||
return Response.json({
|
||||
data: [
|
||||
{ model: "azure/gpt-5-mini-2025-08-07", isActive: true },
|
||||
{ model: "azure/gpt-4o-mini", label: "Azure GPT-4o Mini", isActive: true },
|
||||
{ model: "anthropic/claude-sonnet-4-6", isActive: false },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "datarobot");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "azure/gpt-5-mini-2025-08-07",
|
||||
name: "azure/gpt-5-mini-2025-08-07",
|
||||
owned_by: "datarobot",
|
||||
},
|
||||
{
|
||||
id: "azure/gpt-4o-mini",
|
||||
name: "Azure GPT-4o Mini",
|
||||
owned_by: "datarobot",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers Clarifai OpenAI-compatible models with Key auth", async () => {
|
||||
const connection = await seedConnection("clarifai", {
|
||||
apiKey: "clarifai-pat",
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://api.clarifai.com/v2/ext/openai/v1/models");
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers.Authorization, "Key clarifai-pat");
|
||||
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "openai/chat-completion/models/gpt-oss-120b",
|
||||
display_name: "GPT-OSS 120B",
|
||||
},
|
||||
{ id: "anthropic/completion/models/claude-sonnet-4" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "clarifai");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "openai/chat-completion/models/gpt-oss-120b",
|
||||
name: "GPT-OSS 120B",
|
||||
owned_by: "clarifai",
|
||||
},
|
||||
{
|
||||
id: "anthropic/completion/models/claude-sonnet-4",
|
||||
name: "anthropic/completion/models/claude-sonnet-4",
|
||||
owned_by: "clarifai",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers Azure AI Foundry deployments through the v1 models endpoint", async () => {
|
||||
const connection = await seedConnection("azure-ai", {
|
||||
apiKey: "azure-ai-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://my-foundry.services.ai.azure.com",
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://my-foundry.services.ai.azure.com/openai/v1/models");
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers["api-key"], "azure-ai-key");
|
||||
|
||||
return Response.json({
|
||||
data: [{ id: "DeepSeek-V3.1", display_name: "DeepSeek V3.1" }, { name: "Claude-Opus-4.6" }],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "azure-ai");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{ id: "DeepSeek-V3.1", name: "DeepSeek V3.1", owned_by: "azure-ai" },
|
||||
{ id: "Claude-Opus-4.6", name: "Claude-Opus-4.6", owned_by: "azure-ai" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers Bedrock mantle models from the OpenAI-compatible models endpoint", async () => {
|
||||
const connection = await seedConnection("bedrock", {
|
||||
apiKey: "bedrock-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws",
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://bedrock-mantle.us-east-1.api.aws/v1/models");
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers.Authorization, "Bearer bedrock-key");
|
||||
|
||||
return Response.json({
|
||||
data: [
|
||||
{ id: "openai.gpt-oss-120b", display_name: "OpenAI GPT-OSS 120B" },
|
||||
{ id: "mistral.mistral-large-3-675b-instruct" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "bedrock");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "openai.gpt-oss-120b",
|
||||
name: "OpenAI GPT-OSS 120B",
|
||||
owned_by: "bedrock",
|
||||
},
|
||||
{
|
||||
id: "mistral.mistral-large-3-675b-instruct",
|
||||
name: "mistral.mistral-large-3-675b-instruct",
|
||||
owned_by: "bedrock",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers watsonx gateway models from the v1 models endpoint", async () => {
|
||||
const connection = await seedConnection("watsonx", {
|
||||
apiKey: "watsonx-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://ca-tor.ml.cloud.ibm.com",
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://ca-tor.ml.cloud.ibm.com/ml/gateway/v1/models");
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers.Authorization, "Bearer watsonx-key");
|
||||
|
||||
return Response.json({
|
||||
data: [
|
||||
{ id: "ibm/granite-3-3-8b-instruct", display_name: "Granite 3.3 8B Instruct" },
|
||||
{ model: "openai/gpt-4o", provider: "openai" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "watsonx");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "ibm/granite-3-3-8b-instruct",
|
||||
name: "Granite 3.3 8B Instruct",
|
||||
owned_by: "watsonx",
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-4o",
|
||||
name: "openai/gpt-4o",
|
||||
owned_by: "openai",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers OCI OpenAI-compatible models and forwards the project header", async () => {
|
||||
const connection = await seedConnection("oci", {
|
||||
apiKey: "oci-key",
|
||||
projectId: "ocid1.generativeaiproject.oc1.us-chicago-1.demo",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com",
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(
|
||||
String(url),
|
||||
"https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1/models"
|
||||
);
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers.Authorization, "Bearer oci-key");
|
||||
assert.equal(init.headers["OpenAI-Project"], "ocid1.generativeaiproject.oc1.us-chicago-1.demo");
|
||||
|
||||
return Response.json({
|
||||
data: [
|
||||
{ id: "openai.gpt-oss-20b", display_name: "OpenAI GPT-OSS 20B" },
|
||||
{ id: "google.gemini-2.5-pro" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "oci");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "openai.gpt-oss-20b",
|
||||
name: "OpenAI GPT-OSS 20B",
|
||||
owned_by: "oci",
|
||||
},
|
||||
{
|
||||
id: "google.gemini-2.5-pro",
|
||||
name: "google.gemini-2.5-pro",
|
||||
owned_by: "oci",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers Modal models from the configured OpenAI-compatible /v1 endpoint", async () => {
|
||||
const connection = await seedConnection("modal", {
|
||||
apiKey: "modal-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://alice--demo.modal.run/v1",
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://alice--demo.modal.run/v1/models");
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers.Authorization, "Bearer modal-key");
|
||||
|
||||
return Response.json({
|
||||
data: [
|
||||
{ id: "Qwen/Qwen3-4B-Thinking-2507-FP8", display_name: "Qwen3 4B Thinking FP8" },
|
||||
{ id: "google/gemma-4-26B-A4B-it" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "modal");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "Qwen/Qwen3-4B-Thinking-2507-FP8",
|
||||
name: "Qwen3 4B Thinking FP8",
|
||||
owned_by: "modal",
|
||||
},
|
||||
{
|
||||
id: "google/gemma-4-26B-A4B-it",
|
||||
name: "google/gemma-4-26B-A4B-it",
|
||||
owned_by: "modal",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers Reka models from the named OpenAI-compatible /v1 endpoint", async () => {
|
||||
const connection = await seedConnection("reka", {
|
||||
apiKey: "reka-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.reka.ai/v1",
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://api.reka.ai/v1/models");
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers.Authorization, "Bearer reka-key");
|
||||
assert.equal(init.headers["X-Api-Key"], "reka-key");
|
||||
|
||||
return Response.json([{ id: "reka-core", name: "Reka Core" }, { id: "reka-flash" }]);
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "reka");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "reka-core",
|
||||
name: "Reka Core",
|
||||
owned_by: "reka",
|
||||
},
|
||||
{
|
||||
id: "reka-flash",
|
||||
name: "reka-flash",
|
||||
owned_by: "reka",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route discovers SAP models from AI_API_URL derived from deploymentUrl", async () => {
|
||||
const connection = await seedConnection("sap", {
|
||||
apiKey: "sap-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://sap.example.com/v2/lm/deployments/demo-deployment",
|
||||
resourceGroup: "shared",
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://sap.example.com/v2/lm/scenarios/foundation-models/models");
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(init.headers.Authorization, "Bearer sap-key");
|
||||
assert.equal(init.headers["AI-Resource-Group"], "shared");
|
||||
|
||||
return Response.json({
|
||||
resources: [
|
||||
{ model: "gpt-4o", displayName: "GPT-4o", provider: "OpenAI" },
|
||||
{ model: "mistralai--mistral-medium-instruct", provider: "Mistral AI" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const response = await callRoute(connection.id);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.provider, "sap");
|
||||
assert.equal(body.source, "api");
|
||||
assert.deepEqual(body.models, [
|
||||
{
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
owned_by: "OpenAI",
|
||||
},
|
||||
{
|
||||
id: "mistralai--mistral-medium-instruct",
|
||||
name: "mistralai--mistral-medium-instruct",
|
||||
owned_by: "Mistral AI",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("provider models route rejects unsupported providers without a models config", async () => {
|
||||
const connection = await seedConnection("unsupported-provider", {
|
||||
apiKey: "sk-unsupported",
|
||||
|
||||
@@ -29,6 +29,23 @@ test("azure-openai validation accepts a successful deployments probe", async ()
|
||||
assert.equal(result.method, "azure_probe");
|
||||
});
|
||||
|
||||
test("azure-ai validation accepts a successful v1 models probe", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://my-foundry.services.ai.azure.com/openai/v1/models");
|
||||
assert.equal((init.headers as Record<string, string>)["api-key"], "azure-ai-key");
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "azure-ai",
|
||||
apiKey: "azure-ai-key",
|
||||
providerSpecificData: { baseUrl: "https://my-foundry.services.ai.azure.com" },
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.method, "azure_ai_models");
|
||||
});
|
||||
|
||||
test("vertex-partner validation reuses the Vertex service account branch", async () => {
|
||||
const invalid = await validateProviderApiKey({
|
||||
provider: "vertex-partner",
|
||||
|
||||
@@ -156,6 +156,25 @@ test("embedding and rerank specialty validators surface auth failures for Voyage
|
||||
assert.equal(jina.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("gitlab specialty validator accepts PAT auth on the direct access endpoint", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(String(url), "https://gitlab.com/api/v4/code_suggestions/direct_access");
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer glpat-test");
|
||||
return new Response(JSON.stringify({ token: "short-lived" }), { status: 200 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({ provider: "gitlab", apiKey: "glpat-test" });
|
||||
assert.equal(result.valid, true);
|
||||
});
|
||||
|
||||
test("gitlab specialty validator treats 401 as invalid PAT", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
|
||||
const result = await validateProviderApiKey({ provider: "gitlab", apiKey: "glpat-bad" });
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("web-cookie provider validators accept valid Grok, Perplexity, Blackbox and Muse Spark session cookies", async () => {
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
@@ -346,7 +365,7 @@ test("search provider validators cover success, client errors, server errors and
|
||||
assert.equal(calls[0].init.headers["User-Agent"], "SearchSuite/1.0");
|
||||
});
|
||||
|
||||
test("extended search provider validators cover Google PSE, Linkup, SearchAPI and SearXNG", async () => {
|
||||
test("extended search provider validators cover Google PSE, Linkup, SearchAPI, You.com and SearXNG", async () => {
|
||||
const originalAllowPrivateProviderUrls = process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
|
||||
process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true";
|
||||
const calls = [];
|
||||
@@ -363,6 +382,9 @@ test("extended search provider validators cover Google PSE, Linkup, SearchAPI an
|
||||
if (target.startsWith("https://www.searchapi.io/api/v1/search")) {
|
||||
return new Response(JSON.stringify({ organic_results: [] }), { status: 200 });
|
||||
}
|
||||
if (target.startsWith("https://ydc-index.io/v1/search")) {
|
||||
return new Response(JSON.stringify({ results: { web: [] } }), { status: 200 });
|
||||
}
|
||||
if (target.startsWith("http://localhost:9999/search")) {
|
||||
return new Response(JSON.stringify({ results: [] }), { status: 200 });
|
||||
}
|
||||
@@ -382,6 +404,10 @@ test("extended search provider validators cover Google PSE, Linkup, SearchAPI an
|
||||
provider: "searchapi-search",
|
||||
apiKey: "searchapi-key",
|
||||
});
|
||||
const youcom = await validateProviderApiKey({
|
||||
provider: "youcom-search",
|
||||
apiKey: "you-key",
|
||||
});
|
||||
const searxng = await validateProviderApiKey({
|
||||
provider: "searxng-search",
|
||||
providerSpecificData: { baseUrl: "http://localhost:9999/search" },
|
||||
@@ -390,10 +416,12 @@ test("extended search provider validators cover Google PSE, Linkup, SearchAPI an
|
||||
assert.equal(google.valid, true);
|
||||
assert.equal(linkup.valid, true);
|
||||
assert.equal(searchapi.valid, true);
|
||||
assert.equal(youcom.valid, true);
|
||||
assert.equal(searxng.valid, true);
|
||||
assert.match(calls[0].url, /cx=engine-id/);
|
||||
assert.equal(calls[1].init.headers.Authorization, "Bearer linkup-key");
|
||||
assert.match(calls[2].url, /api_key=searchapi-key/);
|
||||
assert.equal(calls[3].init.headers["X-API-Key"], "you-key");
|
||||
} finally {
|
||||
if (originalAllowPrivateProviderUrls === undefined) {
|
||||
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;
|
||||
@@ -869,3 +897,430 @@ test("specialty validators surface missing base URLs and invalid auth for Heroku
|
||||
assert.equal(snowflakeInvalid.error, "Invalid API key");
|
||||
assert.equal(gigachatInvalid.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("specialty validator accepts DataRobot gateway and deployment credentials", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://app.datarobot.com/genai/llmgw/catalog/") {
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer dr-key");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [{ model: "azure/gpt-5-mini-2025-08-07", isActive: true }],
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
target ===
|
||||
"https://app.datarobot.com/api/v2/deployments/65f5b2b7c8f8c4b257e0d123/chat/completions"
|
||||
) {
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer dr-deploy-key");
|
||||
const body = JSON.parse(String(init.body));
|
||||
assert.equal(body.model, "datarobot-deployed-llm");
|
||||
return new Response(JSON.stringify({ error: "bad request" }), { status: 400 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const gateway = await validateProviderApiKey({
|
||||
provider: "datarobot",
|
||||
apiKey: "dr-key",
|
||||
});
|
||||
const deployment = await validateProviderApiKey({
|
||||
provider: "datarobot",
|
||||
apiKey: "dr-deploy-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://app.datarobot.com/api/v2/deployments/65f5b2b7c8f8c4b257e0d123",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(gateway.valid, true);
|
||||
assert.equal(deployment.valid, true);
|
||||
});
|
||||
|
||||
test("specialty validator rejects invalid DataRobot credentials", async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://app.datarobot.com/genai/llmgw/catalog/") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
target ===
|
||||
"https://app.datarobot.com/api/v2/deployments/65f5b2b7c8f8c4b257e0d123/chat/completions"
|
||||
) {
|
||||
return new Response(JSON.stringify({ error: "forbidden" }), { status: 403 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const gateway = await validateProviderApiKey({
|
||||
provider: "datarobot",
|
||||
apiKey: "dr-key",
|
||||
});
|
||||
const deployment = await validateProviderApiKey({
|
||||
provider: "datarobot",
|
||||
apiKey: "dr-deploy-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://app.datarobot.com/api/v2/deployments/65f5b2b7c8f8c4b257e0d123",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(gateway.error, "Invalid API key");
|
||||
assert.equal(deployment.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("specialty validators accept watsonx, OCI and SAP enterprise gateways", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://ca-tor.ml.cloud.ibm.com/ml/gateway/v1/models") {
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer watsonx-key");
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}
|
||||
|
||||
if (
|
||||
target === "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1/models"
|
||||
) {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Bearer oci-key");
|
||||
assert.equal(headers["OpenAI-Project"], "ocid1.generativeaiproject.oc1.us-chicago-1.demo");
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}
|
||||
|
||||
if (target === "https://sap.example.com/v2/lm/scenarios/foundation-models/models") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Bearer sap-key");
|
||||
assert.equal(headers["AI-Resource-Group"], "shared");
|
||||
return new Response(JSON.stringify({ resources: [] }), { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const watsonx = await validateProviderApiKey({
|
||||
provider: "watsonx",
|
||||
apiKey: "watsonx-key",
|
||||
providerSpecificData: { baseUrl: "https://ca-tor.ml.cloud.ibm.com" },
|
||||
});
|
||||
const oci = await validateProviderApiKey({
|
||||
provider: "oci",
|
||||
apiKey: "oci-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com",
|
||||
projectId: "ocid1.generativeaiproject.oc1.us-chicago-1.demo",
|
||||
},
|
||||
});
|
||||
const sap = await validateProviderApiKey({
|
||||
provider: "sap",
|
||||
apiKey: "sap-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://sap.example.com/v2/lm/deployments/demo-deployment",
|
||||
resourceGroup: "shared",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(watsonx.valid, true);
|
||||
assert.equal(watsonx.method, "watsonx_models");
|
||||
assert.equal(oci.valid, true);
|
||||
assert.equal(oci.method, "oci_models");
|
||||
assert.equal(sap.valid, true);
|
||||
assert.equal(sap.method, "sap_models");
|
||||
});
|
||||
|
||||
test("specialty validator accepts Bedrock mantle discovery and runtime chat fallback", async () => {
|
||||
let runtimeChatProbed = false;
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://bedrock-mantle.us-east-1.api.aws/v1/models") {
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer bedrock-key");
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}
|
||||
|
||||
if (target === "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1/models") {
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer runtime-key");
|
||||
return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
|
||||
}
|
||||
|
||||
if (target === "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1/chat/completions") {
|
||||
runtimeChatProbed = true;
|
||||
const body = JSON.parse(String(init.body || "{}"));
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer runtime-key");
|
||||
assert.equal(body.model, "openai.gpt-oss-120b-1:0");
|
||||
return new Response(JSON.stringify({ error: "bad request" }), { status: 400 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const mantle = await validateProviderApiKey({
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-key",
|
||||
});
|
||||
const runtime = await validateProviderApiKey({
|
||||
provider: "bedrock",
|
||||
apiKey: "runtime-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(mantle.valid, true);
|
||||
assert.equal(runtime.valid, true);
|
||||
assert.equal(runtimeChatProbed, true);
|
||||
});
|
||||
|
||||
test("specialty validator rejects invalid Bedrock credentials", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://bedrock-mantle.us-east-1.api.aws/v1/models") {
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer bedrock-key");
|
||||
return new Response(JSON.stringify({ error: "forbidden" }), { status: 403 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const bedrock = await validateProviderApiKey({
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-key",
|
||||
});
|
||||
|
||||
assert.equal(bedrock.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("specialty validators reject invalid watsonx, OCI and SAP credentials", async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://ca-tor.ml.cloud.ibm.com/ml/gateway/v1/models") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
target === "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1/models"
|
||||
) {
|
||||
return new Response(JSON.stringify({ error: "forbidden" }), { status: 403 });
|
||||
}
|
||||
|
||||
if (target === "https://sap.example.com/v2/lm/scenarios/foundation-models/models") {
|
||||
return new Response(JSON.stringify({ error: "forbidden" }), { status: 403 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const watsonx = await validateProviderApiKey({
|
||||
provider: "watsonx",
|
||||
apiKey: "watsonx-key",
|
||||
providerSpecificData: { baseUrl: "https://ca-tor.ml.cloud.ibm.com" },
|
||||
});
|
||||
const oci = await validateProviderApiKey({
|
||||
provider: "oci",
|
||||
apiKey: "oci-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com",
|
||||
},
|
||||
});
|
||||
const sap = await validateProviderApiKey({
|
||||
provider: "sap",
|
||||
apiKey: "sap-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://sap.example.com/v2/lm/deployments/demo-deployment",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(watsonx.error, "Invalid API key");
|
||||
assert.equal(oci.error, "Invalid API key");
|
||||
assert.equal(sap.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("specialty validator accepts Modal OpenAI-compatible deployments", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://alice--demo.modal.run/v1/models") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Bearer modal-key");
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const modal = await validateProviderApiKey({
|
||||
provider: "modal",
|
||||
apiKey: "modal-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://alice--demo.modal.run/v1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(modal.valid, true);
|
||||
});
|
||||
|
||||
test("specialty validator rejects invalid Modal credentials", async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://alice--demo.modal.run/v1/models") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const modal = await validateProviderApiKey({
|
||||
provider: "modal",
|
||||
apiKey: "modal-key",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://alice--demo.modal.run/v1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(modal.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("specialty validator accepts Clarifai credentials through the OpenAI-compatible models probe", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://api.clarifai.com/v2/ext/openai/v1/models") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Key clarifai-pat");
|
||||
return new Response(
|
||||
JSON.stringify({ data: [{ id: "openai/chat-completion/models/gpt-oss-120b" }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const clarifai = await validateProviderApiKey({
|
||||
provider: "clarifai",
|
||||
apiKey: "clarifai-pat",
|
||||
});
|
||||
|
||||
assert.equal(clarifai.valid, true);
|
||||
assert.equal(clarifai.method, "clarifai_models");
|
||||
});
|
||||
|
||||
test("specialty validator rejects invalid Clarifai credentials", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://api.clarifai.com/v2/ext/openai/v1/models") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Key clarifai-bad");
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const clarifai = await validateProviderApiKey({
|
||||
provider: "clarifai",
|
||||
apiKey: "clarifai-bad",
|
||||
});
|
||||
|
||||
assert.equal(clarifai.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("specialty validator accepts Reka credentials through the models probe with dual auth headers", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://api.reka.ai/v1/models") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Bearer reka-key");
|
||||
assert.equal(headers["X-Api-Key"], "reka-key");
|
||||
return new Response(JSON.stringify([{ id: "reka-core" }]), { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const reka = await validateProviderApiKey({
|
||||
provider: "reka",
|
||||
apiKey: "reka-key",
|
||||
});
|
||||
|
||||
assert.equal(reka.valid, true);
|
||||
assert.equal(reka.method, "reka_models");
|
||||
});
|
||||
|
||||
test("specialty validator rejects invalid Reka credentials", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://api.reka.ai/v1/models") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Bearer reka-bad");
|
||||
assert.equal(headers["X-Api-Key"], "reka-bad");
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const reka = await validateProviderApiKey({
|
||||
provider: "reka",
|
||||
apiKey: "reka-bad",
|
||||
});
|
||||
|
||||
assert.equal(reka.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("specialty validator accepts NLP Cloud credentials on the chatbot endpoint", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://api.nlpcloud.io/v1/gpu/chatdolphin/chatbot") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
const body = JSON.parse(String(init.body));
|
||||
assert.equal(headers.Authorization, "Token nlpc-key");
|
||||
assert.equal(body.input, "test");
|
||||
return new Response(JSON.stringify({ response: "ok" }), { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const nlpCloud = await validateProviderApiKey({
|
||||
provider: "nlpcloud",
|
||||
apiKey: "nlpc-key",
|
||||
});
|
||||
|
||||
assert.equal(nlpCloud.valid, true);
|
||||
assert.equal(nlpCloud.method, "nlpcloud_chatbot");
|
||||
});
|
||||
|
||||
test("specialty validator rejects invalid NLP Cloud credentials", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://api.nlpcloud.io/v1/gpu/chatdolphin/chatbot") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Token nlpc-bad");
|
||||
return new Response(JSON.stringify({ detail: "forbidden" }), { status: 403 });
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const nlpCloud = await validateProviderApiKey({
|
||||
provider: "nlpcloud",
|
||||
apiKey: "nlpc-bad",
|
||||
});
|
||||
|
||||
assert.equal(nlpCloud.error, "Invalid API key");
|
||||
});
|
||||
|
||||
@@ -219,9 +219,22 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre
|
||||
const localProvider = providerPageUtils.resolveDashboardProviderInfo("sdwebui");
|
||||
const localChatProvider = providerPageUtils.resolveDashboardProviderInfo("lm-studio");
|
||||
const searchProvider = providerPageUtils.resolveDashboardProviderInfo("brave-search");
|
||||
const youcomSearchProvider = providerPageUtils.resolveDashboardProviderInfo("youcom-search");
|
||||
const audioProvider = providerPageUtils.resolveDashboardProviderInfo("assemblyai");
|
||||
const webCookieProvider = providerPageUtils.resolveDashboardProviderInfo("grok-web");
|
||||
const apiKeyProvider = providerPageUtils.resolveDashboardProviderInfo("glhf");
|
||||
const gitlabProvider = providerPageUtils.resolveDashboardProviderInfo("gitlab");
|
||||
const gitlabDuoProvider = providerPageUtils.resolveDashboardProviderInfo("gitlab-duo");
|
||||
const chutesProvider = providerPageUtils.resolveDashboardProviderInfo("chutes");
|
||||
const datarobotProvider = providerPageUtils.resolveDashboardProviderInfo("datarobot");
|
||||
const clarifaiProvider = providerPageUtils.resolveDashboardProviderInfo("clarifai");
|
||||
const azureAiProvider = providerPageUtils.resolveDashboardProviderInfo("azure-ai");
|
||||
const watsonxProvider = providerPageUtils.resolveDashboardProviderInfo("watsonx");
|
||||
const ociProvider = providerPageUtils.resolveDashboardProviderInfo("oci");
|
||||
const sapProvider = providerPageUtils.resolveDashboardProviderInfo("sap");
|
||||
const modalProvider = providerPageUtils.resolveDashboardProviderInfo("modal");
|
||||
const rekaProvider = providerPageUtils.resolveDashboardProviderInfo("reka");
|
||||
const nlpCloudProvider = providerPageUtils.resolveDashboardProviderInfo("nlpcloud");
|
||||
const embeddingProvider = providerPageUtils.resolveDashboardProviderInfo("voyage-ai");
|
||||
const rerankProvider = providerPageUtils.resolveDashboardProviderInfo("jina-ai");
|
||||
const perplexityWebProvider = providerPageUtils.resolveDashboardProviderInfo("perplexity-web");
|
||||
@@ -239,12 +252,38 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre
|
||||
|
||||
assert.equal(searchProvider?.category, "search");
|
||||
assert.equal(searchProvider?.name, providers.SEARCH_PROVIDERS["brave-search"].name);
|
||||
assert.equal(youcomSearchProvider?.category, "search");
|
||||
assert.equal(youcomSearchProvider?.name, providers.SEARCH_PROVIDERS["youcom-search"].name);
|
||||
|
||||
assert.equal(audioProvider?.category, "audio");
|
||||
assert.equal(audioProvider?.name, providers.AUDIO_ONLY_PROVIDERS.assemblyai.name);
|
||||
|
||||
assert.equal(apiKeyProvider?.category, "apikey");
|
||||
assert.equal(apiKeyProvider?.name, providers.APIKEY_PROVIDERS.glhf.name);
|
||||
assert.equal(gitlabProvider?.category, "apikey");
|
||||
assert.equal(gitlabProvider?.name, providers.APIKEY_PROVIDERS.gitlab.name);
|
||||
assert.equal(gitlabDuoProvider?.category, "oauth");
|
||||
assert.equal(gitlabDuoProvider?.name, providers.OAUTH_PROVIDERS["gitlab-duo"].name);
|
||||
assert.equal(chutesProvider?.category, "apikey");
|
||||
assert.equal(chutesProvider?.name, providers.APIKEY_PROVIDERS.chutes.name);
|
||||
assert.equal(datarobotProvider?.category, "apikey");
|
||||
assert.equal(datarobotProvider?.name, providers.APIKEY_PROVIDERS.datarobot.name);
|
||||
assert.equal(clarifaiProvider?.category, "apikey");
|
||||
assert.equal(clarifaiProvider?.name, providers.APIKEY_PROVIDERS.clarifai.name);
|
||||
assert.equal(azureAiProvider?.category, "apikey");
|
||||
assert.equal(azureAiProvider?.name, providers.APIKEY_PROVIDERS["azure-ai"].name);
|
||||
assert.equal(watsonxProvider?.category, "apikey");
|
||||
assert.equal(watsonxProvider?.name, providers.APIKEY_PROVIDERS.watsonx.name);
|
||||
assert.equal(ociProvider?.category, "apikey");
|
||||
assert.equal(ociProvider?.name, providers.APIKEY_PROVIDERS.oci.name);
|
||||
assert.equal(sapProvider?.category, "apikey");
|
||||
assert.equal(sapProvider?.name, providers.APIKEY_PROVIDERS.sap.name);
|
||||
assert.equal(modalProvider?.category, "apikey");
|
||||
assert.equal(modalProvider?.name, providers.APIKEY_PROVIDERS.modal.name);
|
||||
assert.equal(rekaProvider?.category, "apikey");
|
||||
assert.equal(rekaProvider?.name, providers.APIKEY_PROVIDERS.reka.name);
|
||||
assert.equal(nlpCloudProvider?.category, "apikey");
|
||||
assert.equal(nlpCloudProvider?.name, providers.APIKEY_PROVIDERS.nlpcloud.name);
|
||||
|
||||
assert.equal(embeddingProvider?.category, "apikey");
|
||||
assert.equal(embeddingProvider?.name, providers.APIKEY_PROVIDERS["voyage-ai"].name);
|
||||
@@ -274,9 +313,21 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre
|
||||
test("managed provider connection ids include supported static categories and exclude upstream proxy", () => {
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("qoder"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("glhf"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("gitlab"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("cablyai"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("thebai"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("fenayai"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("chutes"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("datarobot"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("clarifai"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("azure-ai"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("bedrock"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("watsonx"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("oci"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("sap"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("modal"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("reka"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("nlpcloud"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("voyage-ai"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("jina-ai"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("sdwebui"), true);
|
||||
@@ -288,6 +339,7 @@ test("managed provider connection ids include supported static categories and ex
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("blackbox-web"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("muse-spark-web"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("brave-search"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("youcom-search"), true);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("cliproxyapi"), false);
|
||||
assert.equal(providerCatalog.isManagedProviderConnectionId("claude"), false);
|
||||
});
|
||||
@@ -308,9 +360,22 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent
|
||||
assert.equal("muse-spark-web" in providers.APIKEY_PROVIDERS, false);
|
||||
assert.equal("muse-spark-web" in providers.WEB_COOKIE_PROVIDERS, true);
|
||||
assert.equal("glhf" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("gitlab" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("gitlab-duo" in providers.OAUTH_PROVIDERS, true);
|
||||
assert.equal("cablyai" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("thebai" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("fenayai" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("chutes" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("datarobot" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("clarifai" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("azure-ai" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("bedrock" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("watsonx" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("oci" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("sap" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("modal" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("reka" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("nlpcloud" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("voyage-ai" in providers.APIKEY_PROVIDERS, true);
|
||||
assert.equal("jina-ai" in providers.APIKEY_PROVIDERS, true);
|
||||
|
||||
@@ -348,6 +413,10 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent
|
||||
apiKeyEntries.some((entry) => entry.providerId === "glhf"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "gitlab"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "cablyai"),
|
||||
true
|
||||
@@ -360,6 +429,50 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent
|
||||
apiKeyEntries.some((entry) => entry.providerId === "fenayai"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "chutes"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "datarobot"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "clarifai"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "azure-ai"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "bedrock"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "watsonx"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "oci"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "sap"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "modal"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "reka"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "nlpcloud"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
apiKeyEntries.some((entry) => entry.providerId === "voyage-ai"),
|
||||
true
|
||||
|
||||
@@ -38,6 +38,14 @@ test("providers route accepts managed local, audio, web-cookie and search provid
|
||||
name: "GLHF Chat",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "gitlab",
|
||||
body: {
|
||||
provider: "gitlab",
|
||||
apiKey: "glpat-test",
|
||||
name: "GitLab Duo PAT",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "cablyai",
|
||||
body: {
|
||||
@@ -62,6 +70,100 @@ test("providers route accepts managed local, audio, web-cookie and search provid
|
||||
name: "FenayAI Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "chutes",
|
||||
body: {
|
||||
provider: "chutes",
|
||||
apiKey: "chutes-key",
|
||||
name: "Chutes Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "datarobot",
|
||||
body: {
|
||||
provider: "datarobot",
|
||||
apiKey: "datarobot-key",
|
||||
name: "DataRobot Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "clarifai",
|
||||
body: {
|
||||
provider: "clarifai",
|
||||
apiKey: "clarifai-pat",
|
||||
name: "Clarifai Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "azure-ai",
|
||||
body: {
|
||||
provider: "azure-ai",
|
||||
apiKey: "azure-ai-key",
|
||||
name: "Azure AI Foundry Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "bedrock",
|
||||
body: {
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-key",
|
||||
name: "Bedrock Mantle Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "watsonx",
|
||||
body: {
|
||||
provider: "watsonx",
|
||||
apiKey: "watsonx-key",
|
||||
name: "watsonx Gateway Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "oci",
|
||||
body: {
|
||||
provider: "oci",
|
||||
apiKey: "oci-key",
|
||||
name: "OCI GenAI Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "sap",
|
||||
body: {
|
||||
provider: "sap",
|
||||
apiKey: "sap-key",
|
||||
name: "SAP GenAI Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "modal",
|
||||
body: {
|
||||
provider: "modal",
|
||||
apiKey: "modal-key",
|
||||
name: "Modal Primary",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://alice--demo.modal.run/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "reka",
|
||||
body: {
|
||||
provider: "reka",
|
||||
apiKey: "reka-key",
|
||||
name: "Reka Primary",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.reka.ai/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "nlpcloud",
|
||||
body: {
|
||||
provider: "nlpcloud",
|
||||
apiKey: "nlpc-key",
|
||||
name: "NLP Cloud Primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "voyage-ai",
|
||||
body: {
|
||||
@@ -209,6 +311,14 @@ test("providers route accepts managed local, audio, web-cookie and search provid
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "youcom-search",
|
||||
body: {
|
||||
provider: "youcom-search",
|
||||
apiKey: "you-key",
|
||||
name: "You.com Search",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "searxng-search",
|
||||
body: {
|
||||
|
||||
@@ -284,6 +284,7 @@ test("OAuth test config covers all expected providers", () => {
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"github",
|
||||
"gitlab-duo",
|
||||
"qoder",
|
||||
"qwen",
|
||||
"cursor",
|
||||
@@ -302,6 +303,7 @@ test("OAuth test config covers all expected providers", () => {
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"github",
|
||||
"gitlab-duo",
|
||||
"qoder",
|
||||
"qwen",
|
||||
"cursor",
|
||||
@@ -325,6 +327,7 @@ test("Refreshable OAuth providers are correctly identified", () => {
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"gitlab-duo",
|
||||
"qoder",
|
||||
"qwen",
|
||||
"kimi-coding",
|
||||
@@ -336,6 +339,6 @@ test("Refreshable OAuth providers are correctly identified", () => {
|
||||
|
||||
// Verify these two sets are mutually exclusive and cover all providers
|
||||
const allProviders = [...refreshable, ...nonRefreshable];
|
||||
assert.equal(allProviders.length, 13);
|
||||
assert.equal(new Set(allProviders).size, 13);
|
||||
assert.equal(allProviders.length, 14);
|
||||
assert.equal(new Set(allProviders).size, 14);
|
||||
});
|
||||
|
||||
@@ -449,6 +449,76 @@ test("handleSearch builds SearchAPI requests and normalizes organic results", as
|
||||
}
|
||||
});
|
||||
|
||||
test("handleSearch builds You.com requests with livecrawl and normalizes unified response sections", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedUrl;
|
||||
let capturedHeaders;
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
capturedUrl = String(url);
|
||||
capturedHeaders = init.headers;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: {
|
||||
web: [
|
||||
{
|
||||
title: "You.com result",
|
||||
url: "https://you.example.com/page",
|
||||
description: "Fallback description",
|
||||
snippets: ["Primary snippet"],
|
||||
page_age: "2026-04-23T10:00:00Z",
|
||||
favicon_url: "https://you.example.com/favicon.ico",
|
||||
thumbnail_url: "https://you.example.com/thumb.png",
|
||||
markdown: "# Full page markdown",
|
||||
},
|
||||
],
|
||||
news: [],
|
||||
},
|
||||
metadata: { search_uuid: "uuid-1", latency: 0.5 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleSearch({
|
||||
query: "you search",
|
||||
provider: "youcom-search",
|
||||
maxResults: 2,
|
||||
searchType: "web",
|
||||
country: "US",
|
||||
language: "en",
|
||||
timeRange: "week",
|
||||
offset: 4,
|
||||
domainFilter: ["docs.you.com"],
|
||||
contentOptions: { full_page: true, format: "markdown" },
|
||||
credentials: { apiKey: "you-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
const url = new URL(capturedUrl);
|
||||
assert.equal(url.origin + url.pathname, "https://ydc-index.io/v1/search");
|
||||
assert.equal(url.searchParams.get("query"), "you search");
|
||||
assert.equal(url.searchParams.get("count"), "2");
|
||||
assert.equal(url.searchParams.get("freshness"), "week");
|
||||
assert.equal(url.searchParams.get("offset"), "2");
|
||||
assert.equal(url.searchParams.get("country"), "US");
|
||||
assert.equal(url.searchParams.get("language"), "en");
|
||||
assert.equal(url.searchParams.get("include_domains"), "docs.you.com");
|
||||
assert.equal(url.searchParams.get("livecrawl"), "web");
|
||||
assert.equal(url.searchParams.get("livecrawl_formats"), "markdown");
|
||||
assert.equal(capturedHeaders["X-API-Key"], "you-key");
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data.provider, "youcom-search");
|
||||
assert.equal(result.data.results[0].snippet, "Primary snippet");
|
||||
assert.equal(result.data.results[0].content?.format, "markdown");
|
||||
assert.equal(result.data.results[0].content?.text, "# Full page markdown");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleSearch builds SearXNG requests with custom baseUrl and no apiKey", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedUrl;
|
||||
|
||||
@@ -19,7 +19,7 @@ const { computeCacheKey, getOrCoalesce, getCacheStats, SEARCH_CACHE_DEFAULT_TTL_
|
||||
|
||||
// ─── Registry Tests ──────────────────────────────────────────
|
||||
|
||||
test("SEARCH_PROVIDERS has all 9 providers", () => {
|
||||
test("SEARCH_PROVIDERS has all 10 providers", () => {
|
||||
assert.ok(SEARCH_PROVIDERS["serper-search"], "serper should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["brave-search"], "brave should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["perplexity-search"], "perplexity-search should exist");
|
||||
@@ -28,8 +28,9 @@ test("SEARCH_PROVIDERS has all 9 providers", () => {
|
||||
assert.ok(SEARCH_PROVIDERS["google-pse-search"], "google-pse should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["linkup-search"], "linkup should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["searchapi-search"], "searchapi should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["youcom-search"], "youcom should exist");
|
||||
assert.ok(SEARCH_PROVIDERS["searxng-search"], "searxng should exist");
|
||||
assert.equal(Object.keys(SEARCH_PROVIDERS).length, 9);
|
||||
assert.equal(Object.keys(SEARCH_PROVIDERS).length, 10);
|
||||
});
|
||||
|
||||
test("serper-search config is correct", () => {
|
||||
@@ -108,6 +109,16 @@ test("searchapi-search config is correct", () => {
|
||||
assert.deepEqual(s.searchTypes, ["web", "news"]);
|
||||
});
|
||||
|
||||
test("youcom-search config is correct", () => {
|
||||
const y = SEARCH_PROVIDERS["youcom-search"];
|
||||
assert.equal(y.id, "youcom-search");
|
||||
assert.equal(y.method, "GET");
|
||||
assert.equal(y.authHeader, "x-api-key");
|
||||
assert.equal(y.baseUrl, "https://ydc-index.io/v1/search");
|
||||
assert.equal(y.costPerQuery, 0.005);
|
||||
assert.deepEqual(y.searchTypes, ["web", "news"]);
|
||||
});
|
||||
|
||||
test("searxng-search config is correct", () => {
|
||||
const s = SEARCH_PROVIDERS["searxng-search"];
|
||||
assert.equal(s.id, "searxng-search");
|
||||
@@ -119,7 +130,7 @@ test("searxng-search config is correct", () => {
|
||||
|
||||
test("getAllSearchProviders returns flat list", () => {
|
||||
const all = getAllSearchProviders();
|
||||
assert.equal(all.length, 9);
|
||||
assert.equal(all.length, 10);
|
||||
assert.ok(all.some((p) => p.id === "serper-search"));
|
||||
assert.ok(all.some((p) => p.id === "brave-search"));
|
||||
assert.ok(all.some((p) => p.id === "perplexity-search"));
|
||||
@@ -128,6 +139,7 @@ test("getAllSearchProviders returns flat list", () => {
|
||||
assert.ok(all.some((p) => p.id === "google-pse-search"));
|
||||
assert.ok(all.some((p) => p.id === "linkup-search"));
|
||||
assert.ok(all.some((p) => p.id === "searchapi-search"));
|
||||
assert.ok(all.some((p) => p.id === "youcom-search"));
|
||||
assert.ok(all.some((p) => p.id === "searxng-search"));
|
||||
// Each entry should have id, name, searchTypes
|
||||
for (const p of all) {
|
||||
@@ -323,6 +335,7 @@ test("v1SearchSchema accepts new search providers", async () => {
|
||||
"google-pse-search",
|
||||
"linkup-search",
|
||||
"searchapi-search",
|
||||
"youcom-search",
|
||||
"searxng-search",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -45,14 +45,14 @@ test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("v1 search GET lists all 9 search providers", async () => {
|
||||
test("v1 search GET lists all 10 search providers", async () => {
|
||||
const response = await searchRoute.GET();
|
||||
const body = (await response.json()) as any;
|
||||
const ids = body.data.map((item: { id: string }) => item.id);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.object, "list");
|
||||
assert.equal(body.data.length, 9);
|
||||
assert.equal(body.data.length, 10);
|
||||
assert.deepEqual(ids, [
|
||||
"serper-search",
|
||||
"brave-search",
|
||||
@@ -62,6 +62,7 @@ test("v1 search GET lists all 9 search providers", async () => {
|
||||
"google-pse-search",
|
||||
"linkup-search",
|
||||
"searchapi-search",
|
||||
"youcom-search",
|
||||
"searxng-search",
|
||||
]);
|
||||
});
|
||||
@@ -126,6 +127,73 @@ test("v1 search POST uses stored Linkup credentials and returns normalized resul
|
||||
}
|
||||
});
|
||||
|
||||
test("v1 search POST uses stored You.com credentials and returns unified news results", async () => {
|
||||
await seedConnection("youcom-search", { apiKey: "you-key" });
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedUrl = "";
|
||||
let capturedInit: RequestInit | undefined;
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
capturedUrl = String(url);
|
||||
capturedInit = init;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: {
|
||||
web: [],
|
||||
news: [
|
||||
{
|
||||
title: "You.com news result",
|
||||
description: "Breaking update",
|
||||
page_age: "2026-04-23T12:00:00Z",
|
||||
url: "https://news.example.com/you",
|
||||
thumbnail_url: "https://news.example.com/thumb.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: { search_uuid: "uuid-1", latency: 0.42 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await searchRoute.POST(
|
||||
new Request("http://localhost/api/v1/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: "latest ai regulation",
|
||||
provider: "youcom-search",
|
||||
max_results: 1,
|
||||
search_type: "news",
|
||||
time_range: "week",
|
||||
content: { full_page: true, format: "markdown" },
|
||||
}),
|
||||
})
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
const url = new URL(capturedUrl);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(url.origin + url.pathname, "https://ydc-index.io/v1/search");
|
||||
assert.equal(url.searchParams.get("query"), "latest ai regulation");
|
||||
assert.equal(url.searchParams.get("count"), "1");
|
||||
assert.equal(url.searchParams.get("freshness"), "week");
|
||||
assert.equal(url.searchParams.get("livecrawl"), "news");
|
||||
assert.equal(url.searchParams.get("livecrawl_formats"), "markdown");
|
||||
assert.equal((capturedInit?.headers as Record<string, string>)["X-API-Key"], "you-key");
|
||||
assert.equal(body.provider, "youcom-search");
|
||||
assert.equal(body.results.length, 1);
|
||||
assert.equal(body.results[0].title, "You.com news result");
|
||||
assert.equal(body.results[0].snippet, "Breaking update");
|
||||
assert.equal(body.results[0].citation.provider, "youcom-search");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("v1 search POST accepts authless SearXNG with provider_options baseUrl", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedUrl = "";
|
||||
|
||||
Reference in New Issue
Block a user