Your First API Call

ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។

Table of Contents

  1. From curl to Code
  2. The Bare fetch Version
  3. What Can Go Wrong With the Bare Version
  4. Settings Belong in One Place
  5. Errors as a Class: ApiError
  6. Building the Request in One Function
  7. The chat Function
  8. Using the Client
  9. System Messages, max_tokens and temperature
  10. Handling Errors
  11. Keeping Keys Out of Your Code: .env
  12. Pointing the Client at a Real Provider
  13. Recipe: The config and client Modules
  14. Try It Yourself
  15. 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:

  1. 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.
  2. It ignores failures. On a 401 or 500 the body is an error object, so data.choices[0] crashes with a confusing TypeError.
  3. It cannot be reused. A chatbot sends many requests.
  4. Network errors look different. With no server, fetch throws.

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.

VariableMeaningDefault
LLM_BASE_URLWhich server to callhttp://localhost:8787 (the mock)
LLM_API_KEYYour secret keytest-key (the mock's key)
LLM_MODELWhich model to ask formock-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 Error is a real error type with .message and a stack trace, plus .status and .retryAfter.
  • readError pulls out error.message, the shape you saw in chapter 1. If the body is not JSON (a proxy might return an HTML page), the catch {} falls back to the status text. An error handler must never crash while handling an error.
  • body.error?.message is optional chaining: undefined instead of a crash when error is missing.
  • Busy servers often send a retry-after header (seconds to wait). We store it, or null, 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.

  • model defaults to the configured model.
  • maxTokens becomes the JSON field max_tokens (camelCase in JavaScript, snake_case on the wire).
  • temperature is included only when passed. We test !== undefined because 0 is a valid value that a plain temperature && ... check would wrongly drop.
  • stream adds "stream": true (chapter 4). Until then it is unused.
  • signal is an AbortSignal for cancelling or timing out the request (chapters 4 and 7). fetch accepts 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):

  • maxTokens limits 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, and finish_reason says so (commonly "length"; check your provider).
  • temperature controls randomness: near 0 answers become more repeatable, higher values are more varied. Valid ranges differ between providers, and even 0 rarely guarantees identical output. See Probabilities, Temperature and Sampling.
  • model picks who answers: per call with chat({ messages, model }) or for the whole program with LLM_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:

  1. Never write a real key in a source file. Anything in your code ends up in Git, and Git history lasts forever.
  2. Never put a key in front-end (browser) code. Anyone can read it. Chapter 6 covers this in depth.
  3. 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 a 404, compare the full URL with the docs, and edit the line in chat() 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. Change requestInit (the body) and chat (the line reading data.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:

ExportPurpose
baseUrl, apiKey, defaultModelSettings read from the environment
ApiErrorAn 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

  1. Read the raw response. Print result.raw fully with console.log(JSON.stringify(result.raw, null, 2)). Find id, finish_reason and usage. Which of them change between two calls?
  2. A friendlier error. In demo/errors.js, write a function explain(err) that returns a one-line human message: for 401, "check your API key"; for 400, "the request is wrong"; for 429, "slow down"; for 5xx, "the server has a problem"; otherwise the server's own message. Test it with all three cases.
  3. Catch the network error. Extend demo/down.js to print a different sentence for an ApiError and for a network failure.
  4. Fail loudly on a missing key. Write requireKey() in a demo file that throws a clear message if LLM_API_KEY is empty. Why is failing at startup better than failing at the first request?
  5. Time it. Wrap chat in performance.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.

ទិញកាហ្វេជូនខ្ញុំ

ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!