Calling a model through an API
In short: Calling a language model through an API is a request with a model, a system prompt and messages with roles; the response carries the text, the stop reason and the token count.
Problem
The assistant goes in front of customers
So far the models were small and lived inside the program. The support assistant has to answer real customers, and that takes a large model at a provider. The program asks it through an API: it sends a request over the network and waits for an answer.
On the first day of the sale customers see "Error 429" instead of an answer. Another customer is asked by the assistant for the order number he has just given.
What does a request look like, what does the model return, and what do you do when the answer is an error?
The idea
Request and response
Calling a model is an HTTP request with JSON. The request carries:
model: which model;max_tokens: at most how many tokens the answer may be;system: the system prompt, the instruction for the role and the tone;messages: the messages with their roles,user(the customer) andassistant(the model's earlier answers).
The response carries:
content: a list of blocks with atype; the text is in atextblock;stop_reason: why the model stopped,end_turn(it finished) ormax_tokens(it hit the ceiling);usage: how many tokens went in (input_tokens) and how many came out (output_tokens). You pay by them.
The API has no memory. Every request stands on its own and carries everything the model has to see, as in the lesson on the context window. The APIs of different providers have the same shape: messages with roles, tokens, tools.
See it
A fake client with recorded answers
The programs in the lesson do not call a real model: their check has no network, and the real answer is different every time (the lesson on limits). So instead of the provider's client there is a stand-in with the same shape: it takes model, max_tokens, system and messages and returns content, stop_reason and usage. The answers are recorded in advance and picked by a word in the last message. The tokens are counted roughly, 4 per 3 words.
record Message(string Role, string Content);
record Block(string Type, string Text);
record Usage(int InputTokens, int OutputTokens);
record Response(List<Block> Content, string StopReason, Usage Usage);
class ApiException(int status, string message) : Exception(message){ public int Status { get; } = status;}// a stand-in for the real client: recorded answers, no networkclass FakeClient(List<int> script){ readonly Queue<int> failures = new(script);
public Response Create(string model, int maxTokens, string system, List<Message> messages) { if (failures.Count > 0) { var status = failures.Dequeue(); throw new ApiException(status, Reason(status)); } var text = Answer(messages); var stopReason = "end_turn"; if (Tokens(text) > maxTokens) { // max_tokens: the answer stops in the middle var words = text.Split(' '); text = string.Join(" ", words.Take(maxTokens * 3 / 4)); stopReason = "max_tokens"; } var input = Tokens(system) + messages.Sum(m => Tokens(m.Content)); var usage = new Usage(input, Tokens(text)); return new Response([new Block("text", text)], stopReason, usage); }
// the recorded answer: picked by a word in the last message static string Answer(List<Message> messages) { var last = messages[^1].Content; var hasOrder = messages.Any(m => m.Content.Contains("1042")); if (last.Contains("return")) return "You can return the item within 14 days of delivery."; if (last.Contains("arrive")) { return hasOrder ? "Order 1042 is with the courier and arrives tomorrow by 6 pm." : "What is your order number?"; } return "How can I help: order status, delivery or returns?"; }
// the error text for its code static string Reason(int status) => status switch { 400 => "bad request", 401 => "invalid key", 429 => "too many requests", _ => "server error", };
// rough: 4 tokens per 3 words (the tokenizer gives the real count) public static int Tokens(string text) { var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; return (words * 4 + 2) / 3; }}One call, then the same with max_tokens 8:
// the text blocks, then why it stopped and how many tokensstatic void Show(Response response){ foreach (var block in response.Content) { if (block.Type == "text") Console.WriteLine(block.Text); } var u = response.Usage; Console.WriteLine($"stop_reason: {response.StopReason}, input_tokens: {u.InputTokens}, output_tokens: {u.OutputTokens}");}var client = new FakeClient([]);var system = "You are a support assistant for an online shop. Answer briefly.";List<Message> messages = [new Message("user", "When will order 1042 arrive?")];
var response = client.Create(Model, 1024, system, messages);Show(response);
response = client.Create(Model, 8, system, messages);Show(response);Order 1042 is with the courier and arrives tomorrow by 6 pm.stop_reason: end_turn, input_tokens: 22, output_tokens: 16Order 1042 is with the courierstop_reason: max_tokens, input_tokens: 22, output_tokens: 8With 8 tokens the answer stops in the middle of the sentence, and stop_reason is max_tokens. The program has to check it: a cut answer shown to a customer looks like a mistake of the shop.
See it
Run it at your place: the real call
The same call to a real model. It is not one of the checked programs and does not run here: you need an account at a provider, a key in the environment, and every call costs money.
What goes over the network is JSON. The request:
{ "model": "<model name>", "max_tokens": 1024, "system": "You are a support assistant for an online shop. Answer briefly.", "messages": [ { "role": "user", "content": "When will order 1042 arrive?" } ]}And the response (the numbers here are from the fake client):
{ "content": [ { "type": "text", "text": "Order 1042 is with the courier and arrives tomorrow by 6 pm." } ], "stop_reason": "end_turn", "usage": { "input_tokens": 22, "output_tokens": 16 }}With the standard HTTP client of the language this is one POST request. The address, the model name and the header with the key are placeholders: the real ones are in the provider's documentation. The key is read from the variable LLM_API_KEY.
using System.Net.Http.Json;
var key = Environment.GetEnvironmentVariable("LLM_API_KEY");using var http = new HttpClient();using var request = new HttpRequestMessage(HttpMethod.Post, "https://<provider>/messages");request.Headers.Add("Authorization", $"Bearer {key}");request.Content = JsonContent.Create(new{ model = "<model name>", max_tokens = 1024, system = "You are a support assistant for an online shop. Answer briefly.", messages = new[] { new { role = "user", content = "When will order 1042 arrive?" } },});var response = await http.SendAsync(request);Console.WriteLine((int)response.StatusCode);Console.WriteLine(await response.Content.ReadAsStringAsync()); // JSON: content, stop_reason, usageProviders also ship official libraries (SDKs) for Java, C#, Python and JavaScript with the same shape: messages with roles, tokens, tools. The exact class and method names are in their documentation. await: the call goes over the network and is asynchronous.
The idea
The key is in the environment, not in the code
The API key is like a password for the account: whoever has it spends from it. In the code it reaches git, every colleague and sometimes a public repository. That is why it is read from an environment variable, here LLM_API_KEY.
var key = Environment.GetEnvironmentVariable("LLM_API_KEY");if (string.IsNullOrWhiteSpace(key)){ Console.WriteLine("No key: set LLM_API_KEY in the environment.");}else{ Console.WriteLine("The key is loaded from the environment. Do not print it or write it in the code.");}No key: set LLM_API_KEY in the environment.The program says whether the key is there, but never prints it. The file with local secrets (for example .env) is in .gitignore.
See it
An API with no memory
ask adds the question to the history, sends the whole history and keeps the answer with the role assistant. At the end the same question, sent alone:
// adds the question, sends the whole history and keeps the answerstatic string Ask(FakeClient client, string system, List<Message> history, string text){ history.Add(new Message("user", text)); var r = client.Create(Model, 1024, system, history); var reply = r.Content[0].Text; history.Add(new Message("assistant", reply)); return reply;}var client = new FakeClient([]);var system = "You are a support assistant for an online shop. Answer briefly.";var history = new List<Message>();foreach (var text in new[] { "Hello, I have order 1042.", "When will it arrive?" }){ Console.WriteLine($"customer: {text}"); Console.WriteLine($"assistant: {Ask(client, system, history, text)}");}
var alone = client.Create(Model, 1024, system, [new Message("user", "When will it arrive?")]);Console.WriteLine($"the question alone, without the history: {alone.Content[0].Text}");customer: Hello, I have order 1042.assistant: How can I help: order status, delivery or returns?customer: When will it arrive?assistant: Order 1042 is with the courier and arrives tomorrow by 6 pm.the question alone, without the history: What is your order number?With the history the model sees number 1042 from the first message. Without it, it asks for it. The memory of the conversation is in the program, not in the model, and the history grows and is paid for on every turn.
Try it
Find the bug
A colleague writes ask. On the second question the assistant asks for the order number again. Which line has the bug?
Pick the line with the bug.
The history is kept but not sent: the request carries only the last message. The model does not remember earlier requests, so it sees a question with no number. The whole history has to be sent.
Try it
Fill in
With which role does the model's answer go into the history?
static string Ask(FakeClient client, string system,
List<Message> history, string text)
{
history.Add(new Message("user", text));
var r = client.Create(Model, 1024, system, history);
var reply = r.Content[0].Text;
history.Add(new Message(, reply));
return reply;
}The model's answers have the role "assistant", and the customer's messages "user". So in the next request the model knows what it said and what the customer said.
The idea
The errors of the API
The network and the provider's server fail like any outside service. The errors come with an HTTP code and fall into two groups:
- Temporary: 429 (too many requests, a limit reached), 500 and the other 5xx (the server has a problem or is overloaded), 408 and 409. A moment later the same request may pass. These are worth retrying.
- Permanent: 400 (bad request), 401 (invalid key), 404. A retry gives the same. The program reports these at once.
A retry waits between attempts, and the pause doubles: 1, 2, 4 seconds. A short problem passes quickly, and an overloaded server does not get a stream of new requests. This is exponential backoff. After a set number of attempts the program gives up.
See it
Retries with a pause that doubles
// retries the temporary errors, the pause doublesstatic Response CreateWithRetry(FakeClient client, string system, List<Message> messages){ var maxAttempts = 4; var delay = 1; for (var attempt = 1; ; attempt++) { try { return client.Create(Model, 1024, system, messages); } catch (ApiException e) { var last = attempt == maxAttempts; if (last || !Retryable(e.Status)) throw; Console.WriteLine($" attempt {attempt}: " + $"{e.Status}, waiting {delay} s"); // a real program waits here: // Thread.Sleep(delay * 1000) delay *= 2; } }}
// temporary: 408, 409, 429 and 5xxstatic bool Retryable(int status) => status == 408 || status == 409 || status == 429 || status >= 500;Step by step, in words
- The request: the model, the system prompt and the messages with their roles. Here there is one, from the customer (user).
- The first attempt returns 429: "too many requests". A temporary error.
- 429 is in the temporary list, so the program waits 1 s and tries again.
- The second attempt: 500, "server error". Also temporary.
- The pause doubles: 2 s. An overloaded server gets room to breathe.
- The third attempt goes through.
- The answer: a text block, stop_reason end_turn and usage: 22 input and 16 output tokens.
- 401 and 400 are not retried: waiting will not fix a wrong key. After 4 attempts the program gives up.
// retries the temporary errors, the pause doublesstatic Response CreateWithRetry(FakeClient client, string system, List<Message> messages){ var maxAttempts = 4; var delay = 1; for (var attempt = 1; ; attempt++) { try { return client.Create(Model, 1024, system, messages); } catch (ApiException e) { var last = attempt == maxAttempts; if (last || !Retryable(e.Status)) throw; Console.WriteLine($" attempt {attempt}: " + $"{e.Status}, waiting {delay} s"); // a real program waits here: // Thread.Sleep(delay * 1000) delay *= 2; } }}
// temporary: 408, 409, 429 and 5xxstatic bool Retryable(int status) => status == 408 || status == 409 || status == 429 || status >= 500;Three scenarios: 429 and 500, then an answer; 401, no retry; four times 429, giving up after the fourth attempt. The pause is only printed, so that the program runs at once.
var system = "You are a support assistant for an online shop. Answer briefly.";List<Message> messages = [new Message("user", "When will order 1042 arrive?")];List<List<int>> scenarios = [[429, 500], [401], [429, 429, 429, 429]];foreach (var failures in scenarios){ Console.WriteLine($"scenario [{string.Join(", ", failures)}]:"); try { var r = CreateWithRetry(new FakeClient(failures), system, messages); Console.WriteLine($" answer: {r.Content[0].Text}"); } catch (ApiException e) { Console.WriteLine($" gave up: {e.Status} {e.Message}"); }}scenario [429, 500]: attempt 1: 429, waiting 1 s attempt 2: 500, waiting 2 s answer: Order 1042 is with the courier and arrives tomorrow by 6 pm.scenario [401]: gave up: 401 invalid keyscenario [429, 429, 429, 429]: attempt 1: 429, waiting 1 s attempt 2: 429, waiting 2 s attempt 3: 429, waiting 4 s gave up: 429 too many requestsThe providers' official SDKs usually do this inside: they retry the temporary errors themselves, with a growing pause. Check the documentation and do not put your own loop on top: the attempts multiply. You write your own loop when you call the API directly over HTTP, as in the "run it at your place" block.
Timeouts, retries with jitter and the circuit breaker are in the lesson on resilience.Try it
Predict the output
The fake client returns 429 first, then 400. system and messages are as above. What will the program print?
var client = new FakeClient([429, 400]);try{ var r = CreateWithRetry(client, system, messages); Console.WriteLine($"answer: {r.Content[0].Text}");}catch (ApiException e){ Console.WriteLine($"gave up: {e.Status}");}429 is temporary: the program prints the first attempt, waits 1 s and tries again. 400 is permanent: retryable(400) is false, the error leaves createWithRetry at once, with no second line about waiting, and the program prints that it gave up with code 400. It never gets to an answer.
The idea
What the customer sees
Retries do not always help. When the last attempt fails too, the customer must not see "ApiException: 503". The program catches the error and says something polite and true: "I cannot answer right now. I will connect you with a colleague." The error goes into the log for the team.
More precisely, if you are curious
Jitter. When thousands of programs fail together and wait exactly 1, 2, 4 seconds, they try again together. A random part (jitter) is added to the pause to spread them out.
Timeout. The request may also hang, so every call over the network has a ceiling on the time for an answer. A long answer is slow, so max_tokens guards both time and money. More on slowness and cost is in the lesson on cost and latency.
Limits. 429 means you reached the limit of requests or tokens per minute. A retry helps at a peak. If you reach it all the time, the fix is fewer requests, shorter requests or a higher limit, not more retries.
What comes next. The system prompt is the first step towards the prompt as code. An answer the program reads, not only shows, is structured output, and a model that asks the program for data itself is tool use. Text from a user or a document that tries to change the instructions (prompt injection) is in the module on agents later in the course.
Exercise
An answer or a polite refusal
Solve it in your editor.
Write askSupport(client, system, question): it calls createWithRetry and returns the text of the answer, and if the last attempt fails too (or the error is permanent), it returns "I cannot answer right now. I will connect you with a colleague.". Run it with the question "Can I return the jacket?" and four scenarios of the fake client: no errors; four times 503; 401; one 429.
scenario []: You can return the item within 14 days of delivery.scenario [503, 503, 503, 503]: attempt 1: 503, waiting 1 s attempt 2: 503, waiting 2 s attempt 3: 503, waiting 4 s I cannot answer right now. I will connect you with a colleague.scenario [401]: I cannot answer right now. I will connect you with a colleague.scenario [429]: attempt 1: 429, waiting 1 s You can return the item within 14 days of delivery.More tasks
- A log for the team. On a refusal print a separate line with the code and the text of the error before the polite answer. The customer sees only the polite one.
- The cut answer. Add a parameter for
max_tokenstocreateWithRetry. Ifstop_reasonismax_tokens, return the answer with "…" at the end and log a warning. Try it with 8.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Text in pieces: tokens · Predicting the next token · The context window · The limits of the model
- Continues in: The prompt is code · Structured output: JSON by a schema · Tool use: the model asks, the program acts · Evaluation: a test set for the assistant
- Tasks on this topic: Apps with LLMs