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/completionstwice - the model name in code does not match the provider dashboard
- an API key is shared across too many tools
-
model_not_foundis 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
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"
}
};
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;
}
The key line is this one:
fetch(`${provider.baseURL}/chat/completions`, ...)
That only works because provider.baseURL is already:
https://api.vectorengine.cn/v1
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");
});
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
});
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)