From 3a0bc955bece32d79ca1093c479d92d2b7dc55ae Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 24 Apr 2026 07:00:21 -0300 Subject: [PATCH] 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. --- open-sse/config/azureAi.ts | 51 ++ open-sse/config/bedrock.ts | 82 ++ open-sse/config/datarobot.ts | 71 ++ open-sse/config/oci.ts | 46 ++ open-sse/config/providerRegistry.ts | 235 ++++++ open-sse/config/sap.ts | 60 ++ open-sse/config/searchRegistry.ts | 16 + open-sse/config/watsonx.ts | 45 ++ open-sse/executors/default.ts | 99 +++ open-sse/executors/gitlab.ts | 695 +++++++++++++++++ open-sse/executors/index.ts | 7 + open-sse/executors/nlpcloud.ts | 546 +++++++++++++ open-sse/handlers/search.ts | 110 ++- src/app/api/providers/[id]/models/route.ts | 517 ++++++++++++- src/app/api/providers/[id]/test/route.ts | 32 +- src/lib/oauth/constants/oauth.ts | 17 + src/lib/oauth/gitlab.ts | 103 +++ src/lib/oauth/providers/gitlab-duo.ts | 122 +++ src/lib/oauth/providers/index.ts | 2 + src/lib/providers/validation.ts | 725 ++++++++++++++++++ src/shared/constants/providers.ts | 187 +++++ src/shared/validation/schemas.ts | 1 + .../unit/chat-openai-compat-providers.test.ts | 10 + tests/unit/executor-default-base.test.ts | 139 ++++ tests/unit/executor-gitlab.test.ts | 263 +++++++ tests/unit/executor-nlpcloud.test.ts | 149 ++++ tests/unit/provider-models-config.test.ts | 119 +++ tests/unit/provider-models-route.test.ts | 421 ++++++++++ .../provider-validation-azure-vertex.test.ts | 17 + .../provider-validation-specialty.test.ts | 457 ++++++++++- tests/unit/providers-page-utils.test.ts | 113 +++ .../providers-route-managed-catalog.test.ts | 110 +++ tests/unit/proxy-connection-test.test.ts | 7 +- tests/unit/search-handler-extended.test.ts | 70 ++ tests/unit/search-registry.test.ts | 19 +- tests/unit/search-route.test.ts | 72 +- 36 files changed, 5715 insertions(+), 20 deletions(-) create mode 100644 open-sse/config/azureAi.ts create mode 100644 open-sse/config/bedrock.ts create mode 100644 open-sse/config/datarobot.ts create mode 100644 open-sse/config/oci.ts create mode 100644 open-sse/config/sap.ts create mode 100644 open-sse/config/watsonx.ts create mode 100644 open-sse/executors/gitlab.ts create mode 100644 open-sse/executors/nlpcloud.ts create mode 100644 src/lib/oauth/gitlab.ts create mode 100644 src/lib/oauth/providers/gitlab-duo.ts create mode 100644 tests/unit/executor-gitlab.test.ts create mode 100644 tests/unit/executor-nlpcloud.test.ts diff --git a/open-sse/config/azureAi.ts b/open-sse/config/azureAi.ts new file mode 100644 index 0000000000..96fb85635d --- /dev/null +++ b/open-sse/config/azureAi.ts @@ -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`; +} diff --git a/open-sse/config/bedrock.ts b/open-sse/config/bedrock.ts new file mode 100644 index 0000000000..3862bbaa8a --- /dev/null +++ b/open-sse/config/bedrock.ts @@ -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"; +} diff --git a/open-sse/config/datarobot.ts b/open-sse/config/datarobot.ts new file mode 100644 index 0000000000..2bc0200523 --- /dev/null +++ b/open-sse/config/datarobot.ts @@ -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(); +} diff --git a/open-sse/config/oci.ts b/open-sse/config/oci.ts new file mode 100644 index 0000000000..379adb9b18 --- /dev/null +++ b/open-sse/config/oci.ts @@ -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`; +} diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 43ab59fc2f..7ede79d326 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -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 = { 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 = { "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 = { 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 = { 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 = { 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", diff --git a/open-sse/config/sap.ts b/open-sse/config/sap.ts new file mode 100644 index 0000000000..e59a1853cd --- /dev/null +++ b/open-sse/config/sap.ts @@ -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 | 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; +} diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts index 2851d2bb0c..c092d53d7e 100644 --- a/open-sse/config/searchRegistry.ts +++ b/open-sse/config/searchRegistry.ts @@ -155,6 +155,22 @@ export const SEARCH_PROVIDERS: Record = { 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", diff --git a/open-sse/config/watsonx.ts b/open-sse/config/watsonx.ts new file mode 100644 index 0000000000..82ecc7a373 --- /dev/null +++ b/open-sse/config/watsonx.ts @@ -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`; +} diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 2ff1d9e3d2..366808ee9a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -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 diff --git a/open-sse/executors/gitlab.ts b/open-sse/executors/gitlab.ts new file mode 100644 index 0000000000..9ccec9a46b --- /dev/null +++ b/open-sse/executors/gitlab.ts @@ -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; + +type GitLabRequestTarget = { + mode: "monolith" | "direct"; + url: string; + headers: Record; +}; + +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; + 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({ + 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 | 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 { + return { + "Content-Type": "application/json", + Accept: "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} + +function buildDirectHeaders(directAccess: GitLabDirectAccessDetails): Record { + 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 { + const token = credentials?.apiKey || credentials?.accessToken || null; + return buildMonolithHeaders(token); + } + + transformRequest( + _model: string, + body: Record, + _stream: boolean, + credentials: ExecuteInput["credentials"] + ): Record { + 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 + ) { + 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)?.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) || {}, + 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; + 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; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index b04cf5c7c4..d941229962 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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"; diff --git a/open-sse/executors/nlpcloud.ts b/open-sse/executors/nlpcloud.ts new file mode 100644 index 0000000000..d413b5a683 --- /dev/null +++ b/open-sse/executors/nlpcloud.ts @@ -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; +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; + 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({ + 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 { + const key = credentials?.apiKey || credentials?.accessToken; + const headers: Record = { + "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 { + 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({ + 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; diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index 42e88c3969..e3e4d875af 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -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; providerSpecificData?: Record; } @@ -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 { return { "Content-Type": "application/json", @@ -66,6 +92,19 @@ function buildOptionalBearerHeaders(token: string | null | undefined): Record { + 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 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 = { 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) => ({ + 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, diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 292faa56d1..d1e7ab0b98 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -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}`, diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 3745bcf2d3..202d477a32 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -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", diff --git a/src/lib/oauth/gitlab.ts b/src/lib/oauth/gitlab.ts new file mode 100644 index 0000000000..cbc7cbf1b4 --- /dev/null +++ b/src/lib/oauth/gitlab.ts @@ -0,0 +1,103 @@ +type JsonRecord = Record; + +export type GitLabDirectAccessDetails = { + token: string; + baseUrl: string; + expiresAt: string | null; + headers: Record; +}; + +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"); +} diff --git a/src/lib/oauth/providers/gitlab-duo.ts b/src/lib/oauth/providers/gitlab-duo.ts new file mode 100644 index 0000000000..7f911dcecc --- /dev/null +++ b/src/lib/oauth/providers/gitlab-duo.ts @@ -0,0 +1,122 @@ +import { GITLAB_DUO_CONFIG } from "../constants/oauth"; +import { buildGitLabOAuthEndpoints, parseGitLabDirectAccessDetails } from "../gitlab"; + +function getGitLabUserEmail(userInfo: Record): 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 | 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) : {}; + + 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) + : {}; + 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 } : {}), + }, + }; + }, +}; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 17738d76a7..5c13cb3c79 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -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, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index c72995c799..2e6fac32ea 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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 = { + "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 = { + "Content-Type": "application/json", + }; + + if (apiKey) { + headers.Authorization = `Key ${apiKey}`; + } + + return applyCustomUserAgent(headers, providerSpecificData); +} + +function buildTokenHeaders(apiKey: string, providerSpecificData: any = {}) { + const headers: Record = { + "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 } = diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index ba235e68df..a90977ec47 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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://.services.ai.azure.com/openai/v1/ or https://.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 . 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://.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..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://--.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 . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//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/.", + 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 .", + 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", diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 69da538de1..6737a33906 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1747,6 +1747,7 @@ export const v1SearchSchema = z "google-pse-search", "linkup-search", "searchapi-search", + "youcom-search", "searxng-search", ]) .optional(), diff --git a/tests/unit/chat-openai-compat-providers.test.ts b/tests/unit/chat-openai-compat-providers.test.ts index 53fa044e7f..0ca61df745 100644 --- a/tests/unit/chat-openai-compat-providers.test.ts +++ b/tests/unit/chat-openai-compat-providers.test.ts @@ -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", diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index 40fbc1df16..b851cffb2c 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -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", () => { diff --git a/tests/unit/executor-gitlab.test.ts b/tests/unit/executor-gitlab.test.ts new file mode 100644 index 0000000000..af4a0753f8 --- /dev/null +++ b/tests/unit/executor-gitlab.test.ts @@ -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; + headers: Record; + }> = []; + 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, + }); + + 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 }> = []; + const refreshedPatches: Array> = []; + + globalThis.fetch = async (url, init = {}) => { + calls.push({ + url: String(url), + headers: (init.headers || {}) as Record, + }); + + 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); + }, + 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) + ?.gitlabDirectAccess as Record + )?.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; + } +}); diff --git a/tests/unit/executor-nlpcloud.test.ts b/tests/unit/executor-nlpcloud.test.ts new file mode 100644 index 0000000000..dd94e0da72 --- /dev/null +++ b/tests/unit/executor-nlpcloud.test.ts @@ -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; + headers: Record; + }> = []; + + globalThis.fetch = async (url, init = {}) => { + calls.push({ + url: String(url), + body: JSON.parse(String(init.body || "{}")), + headers: init.headers as Record, + }); + + 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; + } +}); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index 37166a83fb..e3c722cbfb 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -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" }, + ]); +}); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index d1dcc5b3b2..90541bb807 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -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", diff --git a/tests/unit/provider-validation-azure-vertex.test.ts b/tests/unit/provider-validation-azure-vertex.test.ts index efed885d29..9caaf2ee51 100644 --- a/tests/unit/provider-validation-azure-vertex.test.ts +++ b/tests/unit/provider-validation-azure-vertex.test.ts @@ -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)["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", diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 1b9a736df8..c50f59896c 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -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).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).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).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).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; + 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; + 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).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).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).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).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; + 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; + 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; + 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; + 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; + 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; + 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; + 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"); +}); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index f8f3703ce2..9f65833d20 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -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 diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index e35f1d84ff..112d8f9c87 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -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: { diff --git a/tests/unit/proxy-connection-test.test.ts b/tests/unit/proxy-connection-test.test.ts index 46896eb7c9..3d07ad1c99 100644 --- a/tests/unit/proxy-connection-test.test.ts +++ b/tests/unit/proxy-connection-test.test.ts @@ -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); }); diff --git a/tests/unit/search-handler-extended.test.ts b/tests/unit/search-handler-extended.test.ts index a725580595..c295560179 100644 --- a/tests/unit/search-handler-extended.test.ts +++ b/tests/unit/search-handler-extended.test.ts @@ -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; diff --git a/tests/unit/search-registry.test.ts b/tests/unit/search-registry.test.ts index dfac5537a9..aa77867a0e 100644 --- a/tests/unit/search-registry.test.ts +++ b/tests/unit/search-registry.test.ts @@ -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; diff --git a/tests/unit/search-route.test.ts b/tests/unit/search-route.test.ts index 69d957bc76..36ca742fc7 100644 --- a/tests/unit/search-route.test.ts +++ b/tests/unit/search-route.test.ts @@ -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)["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 = "";