Your First API Call
ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។
Table of Contents
- From curl to Code
- The Bare fetch Version
- What Can Go Wrong With the Bare Version
- Settings Belong in One Place
- Errors as a Class: ApiError
- Building the Request in One Function
- The chat Function
- Using the Client
- System Messages, max_tokens and temperature
- Handling Errors
- Keeping Keys Out of Your Code: .env
- Pointing the Client at a Real Provider
- Recipe: The config and client Modules
- Try It Yourself
- Quick Review: Common Questions
1. From curl to Code
Last chapter you called the mock server with curl. An app needs the
same call inside its own code. You will use fetch, which is built into
Node.js and every browser, so there is nothing to install.
You will go in two steps: a bare call first, then a proper
client module, src/client.js, that every later chapter reuses.
Make sure the mock from
chapter 1
is running in your second terminal and that you are in chatlab.
2. The Bare fetch Version
Create demo/bare.js. It is the curl command from chapter 1, translated
into JavaScript line for line:
// demo/bare.js
const res = await fetch("http://localhost:8787/v1/chat/completions", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer test-key",
},
body: JSON.stringify({
model: "mock-1",
messages: [{ role: "user", content: "Hello!" }],
}),
});
console.log(res.status, res.ok);
const data = await res.json();
console.log(data);
console.log(data.choices[0].message.content);
Run it:
node demo/bare.js
# output
# 200 true
# {
# id: 'mock-1',
# model: 'mock-1',
# choices: [ { index: 0, message: [Object], finish_reason: 'stop' } ],
# usage: { prompt_tokens: 2, completion_tokens: 8, total_tokens: 10 }
# }
# Hello! How can I help you today?
Read it piece by piece. fetch(url, options) returns a promise for
a Response, and top-level await works because the project is an ES
module. JSON.stringify turns the object into the JSON text the server
expects. res.ok is true for any 2xx status. res.json() reads
and parses the body (also a promise). console.log(data) shows
[Object] for the nested message because Node prints only a few levels
deep, so the last line digs out the text.
The id differs if the mock has already served other requests; the
reply text and usage numbers are stable. You have made your first
API call from code. Now let us make it fit for a real app.
3. What Can Go Wrong With the Bare Version
The bare script works on a good day. Real programs meet bad days, and this version has weaknesses:
- Everything is hard-coded. To point at another server you must edit the program, and a real key typed into a file is easy to commit to Git by accident.
- It ignores failures. On a
401or500the body is an error object, sodata.choices[0]crashes with a confusingTypeError. - It cannot be reused. A chatbot sends many requests.
- Network errors look different. With no server,
fetchthrows.
Here is a first refactor that fixes the third problem and starts on
the second. Keep it as a throwaway file, not in src/.
// a first attempt at a reusable function (we will improve it)
const baseUrl = "http://localhost:8787";
const apiKey = "test-key";
export async function chat(messages) {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model: "mock-1", messages }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.choices[0].message.content;
}
Used like this:
console.log(await chat([{ role: "user", content: "Hello!" }]));
try {
await chat([]);
} catch (err) {
console.log(err.message);
}
// output
// Hello! How can I help you today?
// HTTP 400
Better, but HTTP 400 is not helpful: the server told us why
("model and messages are required") and we threw that away. The URL,
key and model are also still hard-coded. The rest of the chapter fixes
both.
4. Settings Belong in One Place
Create src/config.js. It holds the three settings that change between
environments, read from environment variables, with defaults that
work for the mock server:
// src/config.js
// Settings come from environment variables, with safe local defaults.
export const baseUrl = process.env.LLM_BASE_URL ?? "http://localhost:8787";
export const apiKey = process.env.LLM_API_KEY ?? "test-key";
export const defaultModel = process.env.LLM_MODEL ?? "mock-1";
An environment variable is a named value that the operating system
passes to a program when it starts. In Node you read them from
process.env. The ?? operator means "use the right side only if the
left side is undefined", which is how the defaults work.
| Variable | Meaning | Default |
|---|---|---|
LLM_BASE_URL | Which server to call | http://localhost:8787 (the mock) |
LLM_API_KEY | Your secret key | test-key (the mock's key) |
LLM_MODEL | Which model to ask for | mock-1 |
Now the same code runs against the mock on your laptop and a real
provider in production, and nothing secret is written in the code.
You can also use another mock port without editing a file:
PORT=8800 node mock-llm.js in one terminal and
LLM_BASE_URL=http://localhost:8800 node demo/first.js in the other.
(On Windows PowerShell, set variables first: $env:NAME="value".)
The defaults are safe only because the default key is fake. Never use a
real key as a fallback.
5. Errors as a Class: ApiError
When the API says no, we want a JavaScript error that carries the
status code and the server's message, so calling code can
decide what to do. Make src/client.js and start it with the error
class and a helper that builds one from a failed response:
// src/client.js (part 1)
import { baseUrl, apiKey, defaultModel } from "./config.js";
export class ApiError extends Error {
constructor(status, message, retryAfter = null) {
super(message);
this.name = "ApiError";
this.status = status;
this.retryAfter = retryAfter; // seconds, or null
}
}
export async function readError(res) {
let message = res.statusText;
try {
const body = await res.json();
message = body.error?.message ?? message;
} catch {}
const retryAfter = res.headers.get("retry-after");
return new ApiError(res.status, message, retryAfter === null ? null : Number(retryAfter));
}
Four details to notice:
class ApiError extends Erroris a real error type with.messageand a stack trace, plus.statusand.retryAfter.readErrorpulls outerror.message, the shape you saw in chapter 1. If the body is not JSON (a proxy might return an HTML page), thecatch {}falls back to the status text. An error handler must never crash while handling an error.body.error?.messageis optional chaining:undefinedinstead of a crash whenerroris missing.- Busy servers often send a
retry-afterheader (seconds to wait). We store it, ornull, for chapter 7. This client only understands the plain number of seconds the mock sends; some APIs use a date. Check your provider's documentation.
6. Building the Request in One Function
Next, a function that builds the fetch options. We separate it from
the sending code because chapter 4 (streaming) needs the very same
request with one extra flag.
// src/client.js (part 2)
export function requestInit({ messages, model, maxTokens, temperature, stream, signal }) {
return {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: model ?? defaultModel,
messages,
...(maxTokens && { max_tokens: maxTokens }),
...(temperature !== undefined && { temperature }),
...(stream && { stream: true }),
}),
signal,
};
}
It takes one options object; only messages is required.
modeldefaults to the configured model.maxTokensbecomes the JSON fieldmax_tokens(camelCase in JavaScript, snake_case on the wire).temperatureis included only when passed. We test!== undefinedbecause0is a valid value that a plaintemperature && ...check would wrongly drop.streamadds"stream": true(chapter 4). Until then it is unused.signalis anAbortSignalfor cancelling or timing out the request (chapters 4 and 7).fetchaccepts it directly.
The spread trick ...(maxTokens && { max_tokens: maxTokens }) adds the
field only when the condition is truthy. Spreading undefined or false
into an object adds nothing. You can see what it builds without sending anything: call
requestInit({ messages, maxTokens: 20, temperature: 0.2 }) and print
JSON.parse(init.body). You get model, messages, max_tokens: 20
and temperature: 0.2. Be careful when you print the headers in real
projects: they contain your key, so never paste them into a chat or a
bug report.
7. The chat Function
Finally the function you will call all the time. Add it to the bottom of
src/client.js:
// src/client.js (part 3)
export async function chat(options) {
const res = await fetch(`${baseUrl}/v1/chat/completions`, requestInit(options));
if (!res.ok) throw await readError(res);
const data = await res.json();
return { text: data.choices[0].message.content, usage: data.usage, raw: data };
}
Three lines of logic: send the request to baseUrl plus the path; on a
non-2xx status throw await readError(res) (we await because
reading the body is asynchronous); otherwise return the text, the
usage and the raw response. Most callers need only text.
usage lets you track cost (chapters 5 and 7), and raw holds anything
we did not think of, such as finish_reason.
There is no retry, logging or clever behaviour here. A client should be small and boring; chapter 7 wraps it with retries instead of stuffing them inside. If your provider uses a different path, the line that builds the URL is the only place to change.
8. Using the Client
Write demo/first.js:
// demo/first.js
import { chat } from "../src/client.js";
const { text, usage } = await chat({
messages: [{ role: "user", content: "Hello!" }],
});
console.log(text);
console.log(usage);
node demo/first.js
# output
# Hello! How can I help you today?
# { prompt_tokens: 2, completion_tokens: 8, total_tokens: 10 }
Both lines are stable. The program is now four lines and knows nothing about URLs, headers or JSON.
Add scripts to package.json for short commands:
"scripts": {
"mock": "node mock-llm.js",
"demo:first": "node demo/first.js",
"demo:errors": "node demo/errors.js"
}
Now npm run mock starts the server and npm run demo:first runs the
demo (npm run also prints the command name first; we leave that out).
9. System Messages, max_tokens and temperature
The client takes the options you will use most. Try all of them together:
// demo/options.js
import { chat } from "../src/client.js";
const messages = [
{ role: "system", content: "You are a friendly tutor." },
{ role: "user", content: "Is Phnom Penh a big city?" },
];
const result = await chat({ messages, maxTokens: 50, temperature: 0 });
console.log(result.text);
console.log(result.usage);
console.log(Object.keys(result.raw));
console.log(result.raw.choices[0].finish_reason);
// output
// Phnom Penh is the capital of Cambodia.
// { prompt_tokens: 13, completion_tokens: 10, total_tokens: 23 }
// [ 'id', 'model', 'choices', 'usage' ]
// stop
All four lines of output are stable here, since the mock has no randomness. (The mock's answer is canned: it matched on "Phnom Penh" and ignored the question. A real model would answer it.)
What the options do on a real model (the mock ignores them):
maxTokenslimits how long the reply may be, which protects you from runaway cost. If the model hits the limit the reply is cut off mid-sentence, andfinish_reasonsays so (commonly"length"; check your provider).temperaturecontrols randomness: near0answers become more repeatable, higher values are more varied. Valid ranges differ between providers, and even0rarely guarantees identical output. See Probabilities, Temperature and Sampling.modelpicks who answers: per call withchat({ messages, model })or for the whole program withLLM_MODEL.
10. Handling Errors
Now the payoff for ApiError. This script triggers three different
failures and shows that each one is easy to identify:
// demo/errors.js
import { chat, ApiError } from "../src/client.js";
const ask = { messages: [{ role: "user", content: "Hello!" }] };
// 1. The server breaks (mock-error always returns 500)
try {
await chat({ ...ask, model: "mock-error" });
} catch (err) {
console.log(err instanceof ApiError, err.status, err.message);
}
// 2. A bad request: an empty messages array
try {
await chat({ messages: [] });
} catch (err) {
console.log(err.name, err.status, err.message);
}
// 3. Rate limited: mock-flaky says 429 and "retry after 0 seconds"
try {
await chat({ ...ask, model: "mock-flaky" });
} catch (err) {
console.log(err.status, err.message, err.retryAfter);
}
Restart the mock first (Ctrl+C, then node mock-llm.js), because
mock-flaky fails depending on how many requests the server has served
since it started. Then:
node demo/errors.js
# output
# true 500 server exploded
# ApiError 400 model and messages are required
# 429 rate limit, slow down 0
On a freshly restarted server all three lines are stable. mock-error
sends 500, the empty array is a 400, and the third call is the
second request that counts for mock-flaky, so it gets 429 (and
retry-after: 0, which became err.retryAfter). Good code branches on
err.status: a 400 or 401 means your request is wrong and retrying
will not help; a 429 or 5xx might work if you try again later.
Chapter 7 does exactly that.
Two more failures. First, the wrong key, set for one run:
// demo/wrongkey.js
import { chat } from "../src/client.js";
try {
await chat({ messages: [{ role: "user", content: "Hello!" }] });
console.log("worked");
} catch (err) {
console.log(err.name, err.status, err.message);
}
LLM_API_KEY=nope node demo/wrongkey.js
# output
# ApiError 401 invalid api key
Second, the server is not running (a port where nothing listens):
// demo/down.js
import { chat } from "../src/client.js";
try {
await chat({ messages: [{ role: "user", content: "Hello!" }] });
} catch (err) {
console.log(err.name, err.message);
console.log(err.cause?.code);
}
LLM_BASE_URL=http://localhost:8809 node demo/down.js
# output
# TypeError fetch failed
# ECONNREFUSED
This is not an ApiError:
there was no HTTP response at all, so fetch itself threw a
TypeError, and the real reason sits in err.cause. An ApiError
means the server answered and said no; a plain TypeError from fetch
usually means the network or server is unreachable. Both can be worth
retrying (chapter 7). Catch errors where you can do something sensible,
such as showing a friendly message.
11. Keeping Keys Out of Your Code: .env
For the mock, test-key is harmless. A real key is a secret that
costs money. Rules from day one:
- Never write a real key in a source file. Anything in your code ends up in Git, and Git history lasts forever.
- Never put a key in front-end (browser) code. Anyone can read it. Chapter 6 covers this in depth.
- Do not paste keys into chats, screenshots or bug reports.
The standard approach is a file called .env next to package.json:
LLM_BASE_URL=http://localhost:8787
LLM_API_KEY=test-key
LLM_MODEL=mock-1
Node 20.6 and newer can load it without any package:
node --env-file=.env demo/first.js
# output
# Hello! How can I help you today?
# { prompt_tokens: 2, completion_tokens: 8, total_tokens: 10 }
(Same output, since the file repeats the defaults; use your mock's port
if it differs.) Node reads each NAME=value line into process.env
before your program starts, so config.js needs no changes.
Then tell Git to ignore it. Create .gitignore with:
.env
node_modules
Without that, one careless git add . publishes your key. Many people
also commit a .env.example with fake placeholders so teammates know
which variables to set.
If a key is ever exposed, treat it as stolen: revoke it in the provider's dashboard and create a new one. Deleting the commit is not enough, because the key may already have been copied.
12. Pointing the Client at a Real Provider
To try a real model you change the three variables, not the code. In
.env:
LLM_BASE_URL=https://api.example.com
LLM_API_KEY=paste-your-real-key-here
LLM_MODEL=model-name-from-your-provider-docs
Every value above is a placeholder. Get the real ones from your provider: the base URL of their chat endpoint, an API key you create in your own account, and a model name they list. Then:
node --env-file=.env demo/first.js
Be honest about what may differ, because this is where tutorials often fail:
- The path. Our client calls
${baseUrl}/v1/chat/completions. Many providers offer an "OpenAI-compatible" endpoint with that shape. Others use a different path or put the version in the base URL. On a404, compare the full URL with the docs, and edit the line inchat()that builds it. - The fields. Some providers use a different JSON shape: a separate
field for the system instruction, another name for
max_tokens, a differently nested reply. ChangerequestInit(the body) andchat(the line readingdata.choices[0].message.content). The idea is the same; the names differ. - The auth header.
authorization: Bearer <key>is common, not universal. - Limits, prices and privacy. Check rate limits and cost before looping over many requests, keep first experiments small, and do not send other people's private data without reading the privacy terms.
Start with one short prompt and watch the usage. The rest of this
series keeps using the mock so every example stays free and repeatable.
13. Recipe: The config and client Modules
These two files are the entire public surface of this chapter. Later chapters import from them and never rewrite them.
src/config.js:
// Settings come from environment variables, with safe local defaults.
export const baseUrl = process.env.LLM_BASE_URL ?? "http://localhost:8787";
export const apiKey = process.env.LLM_API_KEY ?? "test-key";
export const defaultModel = process.env.LLM_MODEL ?? "mock-1";
src/client.js:
import { baseUrl, apiKey, defaultModel } from "./config.js";
export class ApiError extends Error {
constructor(status, message, retryAfter = null) {
super(message);
this.name = "ApiError";
this.status = status;
this.retryAfter = retryAfter; // seconds, or null
}
}
export async function readError(res) {
let message = res.statusText;
try {
const body = await res.json();
message = body.error?.message ?? message;
} catch {}
const retryAfter = res.headers.get("retry-after");
return new ApiError(res.status, message, retryAfter === null ? null : Number(retryAfter));
}
export function requestInit({ messages, model, maxTokens, temperature, stream, signal }) {
return {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: model ?? defaultModel,
messages,
...(maxTokens && { max_tokens: maxTokens }),
...(temperature !== undefined && { temperature }),
...(stream && { stream: true }),
}),
signal,
};
}
export async function chat(options) {
const res = await fetch(`${baseUrl}/v1/chat/completions`, requestInit(options));
if (!res.ok) throw await readError(res);
const data = await res.json();
return { text: data.choices[0].message.content, usage: data.usage, raw: data };
}
What each export is for:
| Export | Purpose |
|---|---|
baseUrl, apiKey, defaultModel | Settings read from the environment |
ApiError | An error with status, message and retryAfter |
readError(res) | Turn a failed Response into an ApiError |
requestInit(options) | Build the fetch options for a chat request |
chat(options) | Send a chat, return { text, usage, raw } |
Your chatlab folder now has .env, .gitignore, src/config.js,
src/client.js, and the demo/ scripts from this chapter.
14. Try It Yourself
- Read the raw response. Print
result.rawfully withconsole.log(JSON.stringify(result.raw, null, 2)). Findid,finish_reasonandusage. Which of them change between two calls? - A friendlier error. In
demo/errors.js, write a functionexplain(err)that returns a one-line human message: for401, "check your API key"; for400, "the request is wrong"; for429, "slow down"; for5xx, "the server has a problem"; otherwise the server's own message. Test it with all three cases. - Catch the network error. Extend
demo/down.jsto print a different sentence for anApiErrorand for a network failure. - Fail loudly on a missing key. Write
requireKey()in a demo file that throws a clear message ifLLM_API_KEYis empty. Why is failing at startup better than failing at the first request? - Time it. Wrap
chatinperformance.now()before and after and print the milliseconds. Run it five times and describe the spread. Which part of the number would grow with a real provider?
15. Quick Review: Common Questions
Questions beginners often ask about this chapter. Try answering each one yourself first, then click the question to open the answer.
Why use fetch and not an official SDK?
An SDK (a ready-made library from a provider) is often a good choice in
production. But it hides the HTTP request, and this series is about
understanding that request. Once you know the plain fetch version, any
SDK is easy to read.
Why keep the URL, key and model in environment variables?
So the same code runs everywhere and no secret sits in a file that may be shared. The mock, your laptop and a production server just use different values.
What does res.ok check?
It is true for statuses 200 to 299. A 404 or 500 is still a
completed HTTP exchange for fetch, so it does not throw. That is
why the client checks res.ok and throws an ApiError itself. Only
network-level failures make fetch throw.
Will my code work with a real provider unchanged?
Often, if the provider offers a compatible endpoint, but not always. Providers differ in paths, field names and headers. Read the docs, change the lines that build the URL and parse the reply, and test with a small request first.
Is it safe to put the API key in a .env file?
Far better than source code, as long as .env is in .gitignore and
never leaves your machine or server. It is not a vault: anyone with
access to the computer can read it. In production use your host's secret
store, and keep keys out of browser code.
Where to go next. You can now send one message and read the reply, with settings in the environment and errors you can name. But a chatbot must handle many turns, and the model remembers nothing between them. In Conversation and Memory you will see that stateless behaviour with your own eyes, then build the class that keeps and trims the history.
ទិញកាហ្វេជូនខ្ញុំ
ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!