To call a language model from code, install the official SDK (@anthropic-ai/sdk or openai), keep the key in a server-side environment variable and send a request: at Anthropic that is client.messages.create with model, max_tokens, system and messages; at OpenAI it is client.responses.create with model, instructions and input. Interfaces use streaming, and the SDKs retry 429 and 5xx errors on their own by default; your job is to set limits and a timeout and handle the remaining errors by type.
After this you will be able to
- send a request to the Anthropic Messages API and the OpenAI Responses API through the official SDKs, with the key taken from an environment variable
- pass a system prompt and conversation history and extract the answer text without relying on block position
- enable streaming with both providers and handle an error that arrives mid-stream
- configure
maxRetriesandtimeout, tell a transient 429 from an exhausted quota and compute usage fromusage
Know beforehand
Verified with
Node.js 24 LTS (24.18)TypeScript 7.0 (tsc 7.0.2)@anthropic-ai/sdk 0.133.0openai (npm) 7.32.0@types/node 24claude-opus-5-5, gpt-6-astra (2026-10-12)
Checked against docs: October 12, 2026
Contents
What you need and where the key lives
This lesson is for a developer who already writes TypeScript and uses async/await. You need Node.js 24, accounts in the Anthropic Console and on the OpenAI platform, and one API key for each. A key is the password to your bill: anyone who has it spends your money. So there is one rule: the key lives only on the server, in an environment variable, and never ends up in a browser, a mobile app or a repository.
mkdir llm-first-request
cd llm-first-request
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk openai
npm install -D typescript @types/node@24
mkdir src{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}# Copy to .env and put your own keys there. Never commit .env.
ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
OPENAI_API_KEY=YOUR_OPENAI_API_KEYFirst request to Claude: the Messages API
At Anthropic everything goes through one endpoint, the Messages API. A minimal request has a model ID, an output limit max_tokens and a messages array; the system prompt goes into the separate system field. Take the model ID from the models page: as of 2026-10-12 the documentation suggests starting with claude-opus-5-5, and for high-volume simple tasks there is the faster and cheaper claude-haiku-5-5.
import Anthropic from "@anthropic-ai/sdk";
// The SDK reads ANTHROPIC_API_KEY from the environment; fail fast with a clear message.
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error("ANTHROPIC_API_KEY is not set: copy .env.example to .env");
}
const client = new Anthropic();
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 2048,
system: "You are a concise assistant for a web studio. Answer in two sentences.",
messages: [{ role: "user", content: "What is an HTTP 429 status code?" }],
});
// content is an array of blocks (text, thinking, tool_use...): take only text blocks.
const text = message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
console.log(text);
console.log("stop_reason:", message.stop_reason);
console.log("tokens in/out:", message.usage.input_tokens, message.usage.output_tokens);npx tsc -p .
node --env-file=.env src/anthropic-hello.ts
node --env-file=.env src/openai-hello.tscontentis an array of blocks, not a string. Current Claude models always reason, so athinkingblock may come before the text; never readcontent[0].text— filter bytype === "text"max_tokensis a hard ceiling for the answer, and the model's reasoning counts toward it. If you getstop_reason: "max_tokens", the answer was cut offstop_reason: "end_turn"means the model finished on its own;"refusal"means it declined the request, and such an answer must not be shown as a normal oneusage.input_tokensandusage.output_tokensare the actual consumption you compute cost from
First request to OpenAI: the Responses API
OpenAI has two text APIs: the older Chat Completions and Responses. OpenAI's documentation recommends the Responses API for new applications, especially with reasoning models. The request is simpler: input can be a string, the system prompt goes into instructions, and the SDK assembles the final text in response.output_text. As of 2026-10-12 OpenAI's documentation recommends gpt-6-astra for complex work, gpt-6.1-sol as a balance of cost and quality, and gpt-6-luna for high-volume tasks.
import OpenAI from "openai";
// The SDK reads OPENAI_API_KEY from the environment; fail fast with a clear message.
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is not set: copy .env.example to .env");
}
const client = new OpenAI();
const response = await client.responses.create({
model: "gpt-6-astra",
reasoning: { effort: "low" },
instructions: "You are a concise assistant for a web studio. Answer in two sentences.",
input: "What is an HTTP 429 status code?",
});
// output_text joins every text item from response.output into one string.
console.log(response.output_text);
console.log("status:", response.status);
console.log("tokens in/out:", response.usage?.input_tokens, response.usage?.output_tokens);Message roles and the system prompt
An LLM API does not keep the conversation: every request carries all the history the model should consider. History is a list of messages with the roles user and assistant (the model's earlier answers). The system prompt sets the role, rules and format for the whole conversation and outranks individual messages. At Anthropic it is the system field, at OpenAI it is instructions (or a message with the developer role in input).
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
type Turn = { role: "user" | "assistant"; content: string };
const system = "You are a support assistant of a coffee shop. Answer briefly.";
// The API is stateless: every request carries the whole conversation.
const history: Turn[] = [
{ role: "user", content: "Do you have oat milk?" },
{ role: "assistant", content: "Yes, oat and almond milk are available." },
{ role: "user", content: "Is it extra?" },
];
const anthropic = new Anthropic();
const claude = await anthropic.messages.create({
model: "claude-opus-5-5",
max_tokens: 2048,
system, // Anthropic: the system prompt is a separate top-level field
messages: history,
});
const claudeText = claude.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
console.log("Claude:", claudeText);
const openai = new OpenAI();
const gpt = await openai.responses.create({
model: "gpt-6-astra",
instructions: system, // OpenAI: instructions outrank messages in input
input: history,
});
console.log("GPT:", gpt.output_text);System prompt
- Anthropic Messages API
system- OpenAI Responses API
instructionsor thedeveloperrole
Conversation history
- Anthropic Messages API
messages:user/assistant- OpenAI Responses API
input: a string or an array of messages
Output limit
- Anthropic Messages API
max_tokens(required)- OpenAI Responses API
max_output_tokens(optional)
Answer text
- Anthropic Messages API
contentblocks withtype: "text"- OpenAI Responses API
output_text
Why it stopped
- Anthropic Messages API
stop_reason- OpenAI Responses API
statusandincomplete_details
Usage
- Anthropic Messages API
usage.input_tokens/output_tokens- OpenAI Responses API
usage.input_tokens/output_tokens
Streaming the response
Without streaming the user waits until the model has generated the whole answer. With streaming (server-sent events, SSE — a stream of events over one HTTP response) the text appears as it is generated. In the Anthropic SDK the most convenient option is the messages.stream() helper: the text event delivers text chunks and finalMessage() returns the assembled message with stop_reason and usage. In the OpenAI SDK a request with stream: true returns an async stream of typed events: text arrives in response.output_text.delta, the end in response.completed.
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
const question = "List three checks before deploying a website.";
// Anthropic: the stream helper emits text chunks and assembles the final message.
const anthropic = new Anthropic();
const stream = anthropic.messages
.stream({
model: "claude-opus-5-5",
max_tokens: 2048,
messages: [{ role: "user", content: question }],
})
.on("text", (chunk) => process.stdout.write(chunk));
const final = await stream.finalMessage();
console.log("\n[claude] stop_reason:", final.stop_reason, "output tokens:", final.usage.output_tokens);
// OpenAI: stream: true returns an async iterable of typed events.
const openai = new OpenAI();
const events = await openai.responses.create({
model: "gpt-6-astra",
input: question,
stream: true,
});
for await (const event of events) {
if (event.type === "response.output_text.delta") {
process.stdout.write(event.delta);
} else if (event.type === "response.completed") {
console.log("\n[gpt] status:", event.response.status, "output tokens:", event.response.usage?.output_tokens);
} else if (event.type === "error") {
console.error("\n[gpt] stream error:", event.message);
}
}Errors, limits and retries
Both SDKs throw typed errors — subclasses of APIError with a status field: AuthenticationError (401), RateLimitError (429), InternalServerError (5xx), APIConnectionError and APIConnectionTimeoutError for the network and timeouts. Transient failures — network errors, 408, 409, 429 and 5xx — are retried by the SDK twice by default with exponential backoff, honoring the retry-after header. maxRetries sets the number of retries and timeout the timeout in milliseconds (10 minutes by default).
400
- What happened
- Invalid request: a parameter, format or unsupported value
- What to do
- Do not retry, fix the request; log the
requestID
401 / 403
- What happened
- Wrong or revoked key, or no permission
- What to do
- Do not retry, check the key and access
429 with retry-after
- What happened
- Requests or tokens per minute exceeded
- What to do
- Wait the given time, slow down or queue the work
429 without retry-after or with a quota code
- What happened
- Budget, credit or spend limit exhausted
- What to do
- Retrying will not help: top up or raise the limit
500, 503, 529
- What happened
- Failure or overload on the provider's side
- What to do
- Retry with a pause; the SDK does it for you
Network or timeout
- What happened
- No response in the allotted time
- What to do
- Retry; use streaming for long answers
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
export type Provider = "anthropic" | "openai";
export type AskInput = {
system: string;
prompt: string;
maxOutputTokens?: number;
};
export type AskResult = {
provider: Provider;
model: string;
text: string;
inputTokens: number;
outputTokens: number;
complete: boolean; // false when the answer was cut off by the token limit
};
export class LLMError extends Error {
readonly provider: Provider;
readonly status: number | undefined;
readonly retryable: boolean; // temporary problem: try later or put the job in a queue
constructor(provider: Provider, message: string, options: { status?: number; retryable: boolean; cause?: unknown }) {
super(message, { cause: options.cause });
this.name = "LLMError";
this.provider = provider;
this.status = options.status;
this.retryable = options.retryable;
}
}
// Model IDs: copy them from the providers' model pages, do not guess.
const MODELS = { anthropic: "claude-opus-5-5", openai: "gpt-6-astra" } as const;
// SDK-level retries with exponential backoff (429, 408, 409, 5xx, network) and a request timeout.
const CLIENT_OPTIONS = { maxRetries: 3, timeout: 60_000 };
function requireKey(name: "ANTHROPIC_API_KEY" | "OPENAI_API_KEY"): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set: copy .env.example to .env`);
return value;
}
// Clients are created on first use, so a missing key of one provider does not break the other.
let anthropic: Anthropic | undefined;
let openai: OpenAI | undefined;
async function askAnthropic(input: AskInput): Promise<AskResult> {
anthropic ??= new Anthropic({ apiKey: requireKey("ANTHROPIC_API_KEY"), ...CLIENT_OPTIONS });
const message = await anthropic.messages.create({
model: MODELS.anthropic,
max_tokens: input.maxOutputTokens ?? 2048,
system: input.system,
messages: [{ role: "user", content: input.prompt }],
});
if (message.stop_reason === "refusal") {
throw new LLMError("anthropic", "anthropic: the model declined the request", { retryable: false });
}
return {
provider: "anthropic",
model: message.model,
text: message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join(""),
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
complete: message.stop_reason !== "max_tokens",
};
}
async function askOpenAI(input: AskInput): Promise<AskResult> {
openai ??= new OpenAI({ apiKey: requireKey("OPENAI_API_KEY"), ...CLIENT_OPTIONS });
const response = await openai.responses.create({
model: MODELS.openai,
reasoning: { effort: "low" },
instructions: input.system,
input: input.prompt,
max_output_tokens: input.maxOutputTokens ?? 2048,
});
return {
provider: "openai",
model: response.model,
text: response.output_text,
inputTokens: response.usage?.input_tokens ?? 0,
outputTokens: response.usage?.output_tokens ?? 0,
complete: response.status === "completed",
};
}
const QUOTA_CODES = new Set([
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
]);
// Both SDKs throw the same family of typed errors: check specific classes first.
function toLLMError(provider: Provider, err: unknown): unknown {
if (err instanceof Anthropic.AuthenticationError || err instanceof OpenAI.AuthenticationError) {
return new LLMError(provider, `${provider}: invalid or revoked API key`, { status: 401, retryable: false, cause: err });
}
if (err instanceof Anthropic.RateLimitError || err instanceof OpenAI.RateLimitError) {
const wait = err.headers.get("retry-after");
// A spend or quota limit is not temporary: Anthropic sends that 429 without retry-after,
// OpenAI marks it with a code such as credit_balance_exhausted.
const quota = err instanceof OpenAI.RateLimitError ? QUOTA_CODES.has(err.code ?? "") : wait === null;
const message = quota ? `${provider}: spend or quota limit reached` : `${provider}: rate limit, retry after ${wait ?? "?"} s`;
return new LLMError(provider, message, { status: 429, retryable: !quota, cause: err });
}
if (err instanceof Anthropic.APIConnectionError || err instanceof OpenAI.APIConnectionError) {
return new LLMError(provider, `${provider}: network error or timeout`, { retryable: true, cause: err });
}
if (err instanceof Anthropic.APIError || err instanceof OpenAI.APIError) {
const status = err.status;
return new LLMError(provider, `${provider}: HTTP ${status}, request ${err.requestID ?? "-"}`, {
status,
retryable: status !== undefined && status >= 500,
cause: err,
});
}
return err;
}
export async function ask(provider: Provider, input: AskInput): Promise<AskResult> {
try {
return provider === "anthropic" ? await askAnthropic(input) : await askOpenAI(input);
} catch (err) {
throw toLLMError(provider, err);
}
}- Retries with backoff are already built into the SDKs: your own retry loop on top multiplies the number of requests, just like retries at several layers of a system
retryableonLLMErrortells the caller whether the job can be queued for later or the error must be reported right away- Anthropic counts limits in requests and input and output tokens per minute, OpenAI in requests and tokens per minute and per day; current values are in the console and in response headers (
anthropic-ratelimit-*,x-ratelimit-*) - To work with support, log the request ID:
requestIDon errors,_request_idon Anthropic responses
A common interface, token accounting and verification
The script below calls both providers through one ask function and prints the answer and usage. Cost is computed from usage and the prices on the pricing pages; prices change, so keep them in configuration rather than in code. Remember that model reasoning is billed as output tokens, and conversation history is billed again as input on every request.
import { ask, LLMError, type Provider } from "./llm.ts";
const providers: Provider[] = ["anthropic", "openai"];
const input = {
system: "You are a concise assistant for a web studio. Answer in two sentences.",
prompt: "Why should an API key never be stored in frontend code?",
};
for (const provider of providers) {
try {
const result = await ask(provider, input);
console.log(`[${result.provider}] ${result.model}: ${result.text}`);
console.log(` tokens in/out: ${result.inputTokens}/${result.outputTokens}${result.complete ? "" : " (cut off)"}`);
} catch (err) {
if (!(err instanceof LLMError)) throw err;
console.error(`[${provider}] ${err.message} (retryable: ${err.retryable})`);
process.exitCode = 1;
}
}// Prices change: take them from the provider's pricing page and keep them in config, not in code.
export type Price = {
inputPerMTok: number; // USD per 1M input tokens
outputPerMTok: number; // USD per 1M output tokens (reasoning tokens are billed as output too)
};
export type Usage = { inputTokens: number; outputTokens: number };
export function costUSD(usage: Usage, price: Price): number {
return (usage.inputTokens * price.inputPerMTok + usage.outputTokens * price.outputPerMTok) / 1_000_000;
}
// Running total for a batch of requests, e.g. one night of ticket triage.
export function totalUsage(items: Usage[]): Usage {
return items.reduce(
(sum, u) => ({ inputTokens: sum.inputTokens + u.inputTokens, outputTokens: sum.outputTokens + u.outputTokens }),
{ inputTokens: 0, outputTokens: 0 },
);
}You can verify the code without real keys and without spending anything. Both SDKs read the API address from ANTHROPIC_BASE_URL and OPENAI_BASE_URL, so we pointed them at a local mock server that answers in the documented formats: plain JSON, an SSE stream and 401, 429 and 529 errors. All the lesson scripts and 14 node:test tests passed against it: retries after 429 and 529, no retries on 401, cut-off answers, an error in the middle of a stream.
$ node --env-file=.env.mock src/compare.ts
[anthropic] claude-opus-5-5: MOCK CLAUDE: Why should an API key never be stored in frontend code?
tokens in/out: 24/13
[openai] gpt-6-astra: MOCK GPT: Why should an API key never be stored in frontend code?
tokens in/out: 24/13npx tsc -p .passes without errors- With an empty key variable the script fails with a clear message and sends no request
- The Claude answer is built only from
textblocks, the OpenAI answer fromoutput_text - A cut-off answer is flagged:
stop_reason: "max_tokens"orstatus: "incomplete" - A 429 with
retry-afteris retried, 401 and 400 are not
How this was verified3 · passed3 · partialchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.