DEV Community

Jia
Jia

Posted on

OpenAI-Compatible API Gateway: Base URL, API Keys, and Model Routing for Dify, Cursor, and Node.js

When a team starts using LLM APIs, the first implementation usually feels simple: copy one Base URL into Dify, paste another API key into Cursor, hardcode a model name in a Node.js service, then repeat the same settings in an internal tool.

That works for a prototype. It becomes fragile as soon as more people, tools, and models are involved.

The common problems are not mysterious:

  • the Base URL is missing /v1, or has /chat/completions twice
  • the model name in code does not match the provider dashboard
  • an API key is shared across too many tools
  • model_not_found is treated as a provider outage instead of a configuration problem
  • cost and usage cannot be traced back to a feature, user, or environment

This guide shows a practical way to configure an OpenAI-compatible API setup across Dify, Cursor, and Node.js. I will use Vector Engine as the example OpenAI-compatible AI API gateway, but the same pattern applies to any provider that follows the OpenAI-style /v1 API shape.

The three URLs you should not mix up

Most configuration mistakes come from mixing up three different layers of the API URL.

URL What it means Where it usually belongs
https://api.vectorengine.cn Service root Documentation, connectivity checks, dashboard references
https://api.vectorengine.cn/v1 OpenAI-compatible Base URL Dify, Cursor, Chatbox, SDK clients
https://api.vectorengine.cn/v1/chat/completions Full chat completions endpoint Raw curl, fetch, requests, or HTTP clients

The important rule is simple:

If a tool asks for a Base URL, use the /v1 URL.

If you are writing a raw HTTP request, use the full endpoint.

Do not put the full endpoint into a field that expects only the Base URL. That is how you end up with requests like:

https://api.vectorengine.cn/v1/chat/completions/chat/completions
Enter fullscreen mode Exit fullscreen mode

That kind of bug often looks like a model issue, an auth issue, or a provider outage. In reality, it is just a path composition problem.

A clean mental model for tools and code

Think of your LLM configuration in four layers:

Layer Example Should business code know it?
Provider vectorengine Sometimes
Base URL https://api.vectorengine.cn/v1 No
API key VECTORENGINE_API_KEY No
Model alias chat-default, summary-fast Yes

Business code should not care about the exact provider URL or the raw API key. It should ask for a capability:

  • "use the default chat model"
  • "use a cheaper summarization model"
  • "use the long-context model"
  • "use the model approved for production"

The provider layer then maps that capability to the real provider, Base URL, API key, and model ID.

That gives you room to change models later without editing every feature that calls an LLM.

Dify configuration

For Dify, the main fields usually map like this:

Dify field Value pattern
Provider type OpenAI-compatible or custom OpenAI provider
Base URL https://api.vectorengine.cn/v1
API key A server-side key created for Dify only
Model name The exact model ID shown by your provider dashboard

Two details matter in production:

First, do not reuse the same key for Dify, Cursor, backend services, and experiments. A shared key makes it difficult to revoke access or identify which tool caused an unexpected usage spike.

Second, test the exact model name from the provider dashboard. Many model_not_found errors come from assuming a model ID instead of copying the actual one.

A minimal Dify validation checklist:

  • The provider test request returns a non-empty response.
  • The configured Base URL ends with /v1.
  • The configured model ID exists in the provider dashboard.
  • The key is scoped to Dify or to a specific environment.
  • Logs do not expose the full API key or sensitive user input.

Cursor configuration

Cursor and similar developer tools are different from backend services because they run close to the developer workflow.

That makes key management more important.

Use a separate key for Cursor instead of pasting a production backend key into a local tool. If someone leaves the team, changes machines, or accidentally exposes a config file, you want to rotate only that tool-specific key.

A practical Cursor setup:

Cursor setting Value pattern
Custom provider OpenAI-compatible
API key A dedicated developer-tool key
Base URL https://api.vectorengine.cn/v1
Model A model ID confirmed in the provider dashboard

If Cursor returns an authentication error, check the key first. If it returns a model error, check the model ID next. If it returns a network or timeout error, check whether the Base URL was copied with extra path segments.

Node.js provider layer

For application code, avoid scattering provider details across routes, jobs, and services.

Create a provider layer.

Here is a minimal example using the built-in fetch API available in modern Node.js.

// providers.js
export const providers = {
  vectorengine: {
    name: "vectorengine",
    baseURL: "https://api.vectorengine.cn/v1",
    apiKey: process.env.VECTORENGINE_API_KEY,
    timeoutMs: 30_000
  }
};

export const modelMap = {
  "chat-default": {
    provider: "vectorengine",
    model: process.env.CHAT_DEFAULT_MODEL || "your-chat-model"
  },
  "summary-fast": {
    provider: "vectorengine",
    model: process.env.SUMMARY_MODEL || "your-summary-model"
  }
};
Enter fullscreen mode Exit fullscreen mode

The business code should call model aliases like chat-default, not hardcoded provider model IDs.

Now add a small client:

// llmClient.js
import { providers, modelMap } from "./providers.js";

export async function chat(alias, messages) {
  const target = modelMap[alias];

  if (!target) {
    throw new Error(`unknown_model_alias:${alias}`);
  }

  const provider = providers[target.provider];

  if (!provider) {
    throw new Error(`unknown_provider:${target.provider}`);
  }

  if (!provider.apiKey) {
    throw new Error(`provider_api_key_missing:${target.provider}`);
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), provider.timeoutMs);

  try {
    const response = await fetch(`${provider.baseURL}/chat/completions`, {
      method: "POST",
      signal: controller.signal,
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${provider.apiKey}`
      },
      body: JSON.stringify({
        model: target.model,
        messages,
        temperature: 0.2
      })
    });

    const data = await response.json().catch(() => ({}));

    if (!response.ok) {
      throw normalizeProviderError(response.status, data, target);
    }

    return {
      provider: target.provider,
      model: target.model,
      content: data.choices?.[0]?.message?.content ?? "",
      usage: data.usage ?? null
    };
  } finally {
    clearTimeout(timer);
  }
}

function normalizeProviderError(status, data, target) {
  const message =
    data?.error?.message ||
    data?.message ||
    JSON.stringify(data);

  const error = new Error(message);
  error.status = status;
  error.provider = target.provider;
  error.model = target.model;

  if (status === 401 || /api key|unauthorized/i.test(message)) {
    error.code = "invalid_api_key";
  } else if (/model/i.test(message) && /not|invalid|missing/i.test(message)) {
    error.code = "model_not_found";
  } else if (status === 429 || /rate|limit|quota/i.test(message)) {
    error.code = "rate_limited";
  } else {
    error.code = "provider_request_failed";
  }

  return error;
}
Enter fullscreen mode Exit fullscreen mode

The key line is this one:

fetch(`${provider.baseURL}/chat/completions`, ...)
Enter fullscreen mode Exit fullscreen mode

That only works because provider.baseURL is already:

https://api.vectorengine.cn/v1
Enter fullscreen mode Exit fullscreen mode

If you store the full endpoint in baseURL, this code will build the wrong URL.

Expose a simple backend endpoint

Your application can now expose a clean internal API without leaking provider details to the frontend.

// server.js
import express from "express";
import { chat } from "./llmClient.js";

const app = express();
app.use(express.json());

app.post("/api/ask", async (req, res) => {
  try {
    const question = String(req.body.question || "").trim();

    if (!question) {
      return res.status(400).json({ error: "question_required" });
    }

    const result = await chat("chat-default", [
      {
        role: "system",
        content: "Answer clearly and prefer practical implementation details."
      },
      {
        role: "user",
        content: question
      }
    ]);

    res.json({
      answer: result.content,
      provider: result.provider,
      model: result.model,
      usage: result.usage
    });
  } catch (error) {
    res.status(error.status || 500).json({
      error: error.code || "internal_error",
      provider: error.provider,
      model: error.model,
      message: error.message
    });
  }
});

app.listen(3000, () => {
  console.log("LLM proxy listening on http://localhost:3000");
});
Enter fullscreen mode Exit fullscreen mode

This gives you several operational benefits:

  • The frontend never receives the API key.
  • The application uses model aliases instead of provider-specific model IDs.
  • Errors can be grouped by provider, model, and failure type.
  • You can later add retries, fallback models, budgets, or audit logs in one place.

Troubleshooting common errors

Here is how I usually debug OpenAI-compatible API issues.

Symptom Likely cause First check
invalid_api_key Wrong key, revoked key, missing environment variable Print only whether the env var exists, never the full key
model_not_found Wrong model ID or model not enabled for the account Copy the model ID from the provider dashboard
rate_limited Quota, concurrency, or billing limit Check usage dashboard and retry policy
Timeout Network issue, slow upstream model, missing timeout handling Add explicit timeout and request logging
404 or route error Wrong Base URL path Confirm whether the tool expects /v1 or the full endpoint
Empty answer Response shape mismatch Inspect choices[0].message.content and raw response safely

The most useful habit is to log structured metadata for each request:

console.log({
  provider: target.provider,
  model: target.model,
  alias,
  status: response.status,
  usage: data.usage ?? null
});
Enter fullscreen mode Exit fullscreen mode

Do not log the full API key. Do not log long raw user prompts by default. If you need debugging traces, redact them or sample them in a controlled environment.

Production checklist

Before moving real traffic to any OpenAI-compatible API gateway, run a small acceptance test.

Check Passing condition
Base URL Tool config uses /v1; raw HTTP uses the full endpoint
API key Separate keys exist for Dify, Cursor, backend, and experiments
Model ID Every model name is copied from the provider dashboard
Error handling invalid_api_key, model_not_found, rate_limited, and timeout are separated
Logging Provider, model, alias, status, and usage are recorded
Secrets Keys are stored in environment variables or secret managers
Cost tracking Usage can be traced to a feature, environment, or team
Rollback You can disable one model alias without changing business code

This checklist is more valuable than a one-time successful curl. A single request proves that the connection works. A provider layer proves that the integration can be operated.

Where Vector Engine fits

Vector Engine API can be used as an OpenAI-compatible provider when you need a unified API entry for multiple LLM tools, experiments, and backend services.

The practical way to evaluate it is not to replace everything on day one. Start with one low-risk workflow:

  • configure Dify with https://api.vectorengine.cn/v1
  • configure one developer tool with a separate key
  • run one Node.js backend route through a provider layer
  • record model ID, response status, usage, and error type
  • rotate the test key after the evaluation if you do not continue

That gives you a clean answer to the question that matters:

Can this provider become a stable part of your LLM configuration, not just a URL that worked once?

Final thought

The hardest part of using LLM APIs at scale is rarely the first request. The hard part is keeping Base URLs, keys, model names, errors, and usage understandable after several tools and teams are involved.

If you treat an OpenAI-compatible API as a provider layer instead of a one-off endpoint, the system becomes easier to debug, safer to operate, and much easier to change later.

Top comments (0)