AI APIs with .NET

A deep-dive walkthrough of calling hosted AI models from .NET — covering OpenAI API integration with the official .NET library, Azure OpenAI and how it differs, the chat/completions model and why conversations are stateless, streaming responses to users, function/tool calling, structured (schema-constrained) outputs, token management and context windows, temperature and other model parameters, error handling and retries, client-side rate limiting, and the practical levers for keeping API costs under control.

Table of Contents

  1. Introduction
  2. The Landscape and Setup
  3. OpenAI API Integration
  4. Azure OpenAI
  5. Chat / Completions APIs
  6. Streaming Responses
  7. Function / Tool Calling
  8. Structured Outputs
  9. Token Management
  10. Temperature and Model Parameters
  11. Error Handling and Retries
  12. Rate Limiting
  13. API Cost Optimization
  14. A Provider-Neutral Option: Microsoft.Extensions.AI
  15. Common Pitfalls
  16. Quick Reference Table
  17. Conclusion

Introduction

Calling a large language model from .NET looks deceptively simple — a few lines of code and text comes back. The difficulty is everything around those few lines: the model has no memory between calls, responses can take many seconds, the output is free text unless you constrain it, every call costs money in proportion to the text sent and received, and the service will rate-limit and occasionally fail. Production-quality AI integration is mostly about handling those realities well.

using OpenAI.Chat; ChatClient client = new(model: "gpt-4o-mini", apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")); ChatCompletion completion = await client.CompleteChatAsync("Explain dependency injection in one sentence."); Console.WriteLine(completion.Content[0].Text); 

That is the entire "hello world." This guide builds from it toward something you could run in production: conversation history, streaming, tools, validated JSON output, token budgeting, retries, rate limits, and cost controls.

A note on model names and API versions. Model names in this guide (gpt-4o-mini and similar) are placeholders — provider model lineups change frequently. Keep the model name in configuration, not in code, and check the provider's current model list and pricing page. Likewise, SDK property names have shifted between package versions (for example, MaxOutputTokenCount was previously named differently), so verify against the version you install.

1. The Landscape and Setup

Two routes to the same family of models

OpenAI API -> api.openai.com. Fastest access to new models and features. Authenticated with an API key. Billed by OpenAI. Azure OpenAI -> OpenAI-family models hosted in YOUR Azure subscription. Adds Azure's identity (Entra ID), private networking, regional deployment, quotas, content filtering, and enterprise compliance. Billed through Azure. 

Both are consumed from .NET through the same client-library design, which is why most code in this guide works against either with only the client construction changing.

Packages

dotnet add package OpenAI # official OpenAI .NET library dotnet add package Azure.AI.OpenAI # Azure OpenAI (builds on the OpenAI library) dotnet add package Azure.Identity # DefaultAzureCredential / Entra ID auth dotnet add package Microsoft.ML.Tokenizers # count tokens before sending (Section 8) dotnet add package Microsoft.Extensions.AI.OpenAI # provider-neutral IChatClient (Section 13) 

Choosing between them

Choose OpenAI directly when: you're prototyping, want newest models first, or have no Azure footprint. Choose Azure OpenAI when: your organization already runs on Azure, needs data-residency / private-endpoint / Entra ID (keyless) auth, or requires compliance controls and consolidated billing. 

Secrets: the one non-negotiable

NEVER hard-code an API key, commit it to source control, or ship it in a client (mobile app, SPA, desktop binary). A key in client code is a key anyone can extract and spend against your account. Development: dotnet user-secrets, or environment variables Production: Azure Key Vault / your platform's secret store, or — on Azure — keyless Entra ID authentication (Section 3) Architecture: clients call YOUR backend; only the backend talks to the AI provider 

2. OpenAI API Integration

Create the client once and reuse it

// Program.cs (ASP.NET Core) builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService<IConfiguration>(); return new ChatClient( model: config["OpenAI:Model"], // e.g. "gpt-4o-mini" — configurable, not hard-coded apiKey: config["OpenAI:ApiKey"]); // from user-secrets / Key Vault }); 

Client objects are designed to be thread-safe and reused: register ChatClient as a singleton. Constructing a new client per request wastes connection setup and defeats connection reuse.

Using it from an endpoint

app.MapPost("/summarize", async (SummarizeRequest req, ChatClient chat, CancellationToken ct) => { List<ChatMessage> messages = [ new SystemChatMessage("You summarize text in two sentences."), new UserChatMessage(req.Text), ]; ChatCompletion completion = await chat.CompleteChatAsync(messages, cancellationToken: ct); return Results.Ok(new { summary = completion.Content[0].Text }); }); public record SummarizeRequest(string Text); 

Two habits worth forming immediately:

- Pass the CancellationToken through. If the caller disconnects, the request stops instead of continuing to wait on (and be billed for) a response nobody will read. - Cap what callers can send. An endpoint that forwards arbitrary-length user text to a paid model is an invitation to run up your bill (Sections 8, 11, 12). 

The two OpenAI API styles

Chat Completions : the established, widely supported request/response format — a list of role-tagged messages in, an assistant message out. This guide focuses on it. Azure OpenAI supports it too. Responses API : OpenAI's newer API, with built-in conversation state and built-in tools; the .NET library exposes it via a separate client. 

The concepts here — messages, tokens, tools, streaming, structured output — carry over; consult current documentation to decide which API suits a new project.

3. Azure OpenAI

Same client types, different construction

using Azure.AI.OpenAI; using Azure.Identity; using OpenAI.Chat; // Option A: API key AzureOpenAIClient azureClient = new( new Uri("https://my-resource.openai.azure.com/"), new System.ClientModel.ApiKeyCredential(apiKey)); // Option B (preferred in production): keyless, via Microsoft Entra ID AzureOpenAIClient azureClient2 = new( new Uri("https://my-resource.openai.azure.com/"), new DefaultAzureCredential()); // Ask for a chat client by DEPLOYMENT name — then everything else is identical ChatClient chat = azureClient2.GetChatClient("my-gpt-deployment"); ChatCompletion completion = await chat.CompleteChatAsync("Hello!"); 

After GetChatClient, the rest of the code in this guide — messages, streaming, tools, structured outputs — works unchanged. That is the payoff of the shared client design.

What is genuinely different on Azure

DEPLOYMENT NAMES, not model names. In Azure you first DEPLOY a model under a name you choose ("my-gpt-deployment"), and your code passes that deployment name where OpenAI's API takes a model name. A "404 not found" often means a wrong deployment name, or the deployment is in a different resource/region than the endpoint you're calling. KEYLESS AUTH via Entra ID. DefaultAzureCredential uses your developer login locally and a managed identity in Azure — no secrets to store or rotate. Assign the identity an appropriate role on the Azure OpenAI resource (e.g. "Cognitive Services OpenAI User"). QUOTAS PER DEPLOYMENT. Rate limits (tokens/minute, requests/minute) are assigned per deployment and region, and you manage them in Azure. Hitting them returns 429 (Section 11). CONTENT FILTERING. Azure applies content-safety filters by default. A filtered prompt can return an error; a filtered response can end with a content-filter finish reason. Handle both rather than assuming every call returns normal text. NETWORKING AND COMPLIANCE. Private endpoints, virtual networks, regional data processing, and enterprise compliance certifications are the main reasons organizations choose Azure. MODEL AVAILABILITY. New models and features can reach Azure after the OpenAI API, and availability varies by region — check what your region offers before designing around a model. 

4. Chat / Completions APIs

Messages in, an assistant message out

A chat request is a list of messages, each with a role:

system -> instructions defining behavior, tone, and rules ("You are a support assistant for ACME. Answer only from the provided context.") user -> what the end user said assistant -> what the model said previously (history you replay back to it) tool -> the result of a function the model asked you to run (Section 6) 
List<ChatMessage> messages = [ new SystemChatMessage("You are a concise C# tutor."), new UserChatMessage("What is a record?"), ]; ChatCompletion first = await client.CompleteChatAsync(messages); messages.Add(new AssistantChatMessage(first)); // replay the model's own answer messages.Add(new UserChatMessage("How is it different from a class?")); ChatCompletion second = await client.CompleteChatAsync(messages); // now it has the context 

The model has no memory — YOU hold the conversation

Every call is STATELESS. The model only knows what is in the messages you send THIS time. "Remembering" the conversation means re-sending the history with every request. Consequences: - You must store conversation history (in memory, a cache, or a database). - Every extra turn makes the NEXT request bigger — and more expensive. - History eventually exceeds the context window and must be trimmed (Section 8). 

Reading the response

string text = completion.Content[0].Text; ChatFinishReason reason = completion.FinishReason; ChatTokenUsage usage = completion.Usage; // InputTokenCount, OutputTokenCount, TotalTokenCount 

Always check FinishReason — it tells you why generation stopped:

Stop -> the model finished naturally. The normal case. Length -> it hit the maximum output tokens (or the context limit) and was TRUNCATED mid-answer. Truncated JSON in particular will not parse. ToolCalls -> the model wants you to run one or more functions (Section 6). ContentFilter -> output was withheld by a content filter. 

Prompting structure that holds up in production

- Put stable instructions in the SYSTEM message; put per-request data in the USER message. - Clearly delimit untrusted content (a user's document, a web page) from your instructions. Text inside it can attempt "prompt injection" — instructions disguised as data. Never let model output you haven't validated trigger privileged actions. - Be specific about format and length. "Answer in at most three bullet points" is both better UX and cheaper (fewer output tokens). 

5. Streaming Responses

Show text as it's generated instead of waiting for the whole answer

A long response can take many seconds to complete. Streaming returns it in small pieces as they're produced, so users see the first words almost immediately — a large improvement in perceived speed.

await foreach (StreamingChatCompletionUpdate update in client.CompleteChatStreamingAsync(messages)) { foreach (ChatMessageContentPart part in update.ContentUpdate) Console.Write(part.Text); } 

Streaming from an ASP.NET Core endpoint (Server-Sent Events)

app.MapPost("/chat/stream", async (ChatRequest req, ChatClient client, HttpContext ctx, CancellationToken ct) => { ctx.Response.ContentType = "text/event-stream"; ctx.Response.Headers.CacheControl = "no-cache"; List<ChatMessage> messages = [ new SystemChatMessage("You are helpful."), new UserChatMessage(req.Message) ]; await foreach (var update in client.CompleteChatStreamingAsync(messages, cancellationToken: ct)) { foreach (var part in update.ContentUpdate) { // JSON-encode each fragment so newlines and special characters can't break the SSE framing await ctx.Response.WriteAsync($"data: {JsonSerializer.Serialize(part.Text)}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } } await ctx.Response.WriteAsync("data: [DONE]\n\n", ct); }); public record ChatRequest(string Message); 

Things specific to streaming

- Flush after each chunk, or the server may buffer and the user sees nothing until the end. - Pass the CancellationToken. If the user closes the tab, stop consuming the stream so you aren't generating and paying for output no one will read. - ERRORS CAN HAPPEN MID-STREAM, after the HTTP 200 and some text have already been sent. You can't change the status code any more — send an error event the client understands, and design the UI to cope with a partial answer. - Streamed TOOL CALLS arrive as FRAGMENTS (name and JSON arguments split across updates). You must concatenate the pieces before parsing the arguments (Section 6). - Usage/token counts and content-filter signals arrive differently when streaming; check the documentation for your library version if you rely on them for billing. - Streaming improves responsiveness, NOT cost: you pay the same tokens either way. 

6. Function / Tool Calling

Letting the model ask your code to do things

A model can't check the weather, query your database, or place an order. Tool calling lets you describe functions to the model; when it decides one is needed, it replies with a request to call it — the function name and JSON arguments — instead of text. Your code runs the function and sends the result back; the model then writes its final answer.

1. You send: user question + tool DEFINITIONS (name, description, JSON schema) 2. Model says: "call get_weather with {"city": "Pune"}" (FinishReason = ToolCalls) 3. YOU run get_weather("Pune") in your own code 4. You send: the whole conversation + the tool's RESULT 5. Model says: the final natural-language answer 

Defining a tool and running the loop

ChatTool weatherTool = ChatTool.CreateFunctionTool( functionName: "get_weather", functionDescription: "Get the current weather for a city.", functionParameters: BinaryData.FromString("""  { "type": "object", "properties": { "city": { "type": "string", "description": "City name, e.g. Pune" } }, "required": ["city"] } """));  var options = new ChatCompletionOptions { Tools = { weatherTool } }; List<ChatMessage> messages = [ new UserChatMessage("Do I need an umbrella in Pune today?") ]; bool done = false; int rounds = 0; while (!done && rounds++ < 5) // hard cap: never loop forever { ChatCompletion completion = await client.CompleteChatAsync(messages, options); switch (completion.FinishReason) { case ChatFinishReason.ToolCalls: messages.Add(new AssistantChatMessage(completion)); // the model's tool request MUST be in history foreach (ChatToolCall call in completion.ToolCalls) { string result; switch (call.FunctionName) { case "get_weather": using (JsonDocument args = JsonDocument.Parse(call.FunctionArguments)) { string city = args.RootElement.GetProperty("city").GetString()!; result = await weatherService.GetSummaryAsync(city); } break; default: result = $"Error: unknown tool '{call.FunctionName}'."; break; } messages.Add(new ToolChatMessage(call.Id, result)); // match the result to the call's Id } break; case ChatFinishReason.Stop: Console.WriteLine(completion.Content[0].Text); done = true; break; default: throw new InvalidOperationException($"Unexpected finish reason: {completion.FinishReason}"); } } 

Rules that make tool calling safe and reliable

- The MODEL NEVER EXECUTES ANYTHING. It only proposes calls; your code decides whether to run them. That is your security boundary. - VALIDATE the arguments. They come from a model, steered by user input. Treat them exactly like untrusted user input: parse defensively, check ranges and allowed values, and never concatenate them into SQL or shell commands (use parameters — see this series' SQL guides). - Authorize on the SERVER, as the real user. "The model asked for it" is not authorization. A tool that deletes data or spends money should require confirmation or a permission check. - Make tool results SMALL and relevant. Everything returned goes back into the model's context and counts as input tokens. - Return errors as TEXT to the model ("City not found") rather than throwing, so it can recover or apologize gracefully. - Handle MULTIPLE tool calls in one response (the model may request several at once), and cap the number of rounds as shown above. - The tool DESCRIPTION is what the model reads to decide when to use it — write it like documentation for a new colleague. Vague descriptions cause wrong or missed calls. 

7. Structured Outputs

Getting JSON you can actually deserialize

Asking a model "reply in JSON" works most of the time — and "most of the time" fails in production when a stray sentence or a missing field breaks your parser. Structured outputs constrain the model to produce JSON that conforms to a JSON Schema you supply.

public record Invoice(string Vendor, string InvoiceNumber, decimal Total, string Currency); var options = new ChatCompletionOptions { ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat( jsonSchemaFormatName: "invoice", jsonSchema: BinaryData.FromString("""  { "type": "object", "properties": { "Vendor": { "type": "string" }, "InvoiceNumber": { "type": "string" }, "Total": { "type": "number" }, "Currency": { "type": "string" } }, "required": ["Vendor", "InvoiceNumber", "Total", "Currency"], "additionalProperties": false } """),  jsonSchemaIsStrict: true) }; ChatCompletion completion = await client.CompleteChatAsync( [ new SystemChatMessage("Extract the invoice fields."), new UserChatMessage(invoiceText) ], options); Invoice? invoice = JsonSerializer.Deserialize<Invoice>(completion.Content[0].Text); 

How strict mode behaves

With strict mode on: - The output is constrained to match the schema. - The schema must follow the supported subset: every property listed in "required", and "additionalProperties": false on objects. - Optional fields are expressed as nullable types rather than omitted properties. - Not every JSON Schema feature is supported — consult the current docs. 

Still validate — the schema guarantees SHAPE, not TRUTH

A structured output is guaranteed to be well-formed JSON of the right shape. It is NOT guaranteed to be CORRECT. The model can return a perfectly valid Invoice with a hallucinated total. Therefore: - Apply business validation (is Total positive? is Currency a real ISO code?). - Check completion.FinishReason — a Length stop can still truncate the output. - Check for a REFUSAL: when the model declines a request for safety reasons, the response carries a refusal message instead of schema-conforming JSON, so inspect completion.Refusal before deserializing. - Use try/catch around Deserialize anyway; defensive code costs nothing. 

Structured outputs vs. tool calling

Use STRUCTURED OUTPUT when you want the model's FINAL ANSWER in a fixed shape (extraction, classification, form-filling). Use TOOL CALLING when the model needs to TRIGGER ACTIONS or fetch data mid-conversation. 

8. Token Management

Tokens are the unit of everything: cost, speed, and limits

A token is a chunk of text — roughly four characters, or about three-quarters of an English word, though it varies by language (many non-English languages and code use more tokens per word). The model reads and writes tokens, and you are billed per token, with input and output tokens usually priced differently (output is typically more expensive).

Context window = INPUT tokens + OUTPUT tokens, combined, must fit within the model's limit. Your request: system prompt + full chat history + tool definitions + new user message = INPUT tokens + the reply the model generates = OUTPUT tokens 

Measure actual usage from every response

ChatTokenUsage usage = completion.Usage; logger.LogInformation("Tokens in={In} out={Out} total={Total}", usage.InputTokenCount, usage.OutputTokenCount, usage.TotalTokenCount); 

Log these on every call. It is the foundation of cost tracking (Section 12) and of noticing when a prompt change has quietly doubled your spend.

Count tokens before sending

using Microsoft.ML.Tokenizers; Tokenizer tokenizer = TiktokenTokenizer.CreateForModel("gpt-4o"); int tokenCount = tokenizer.CountTokens(userText); if (tokenCount > 4_000) return Results.BadRequest("Input too long."); 

Counts from a local tokenizer are estimates for chat requests — the message framing adds a few tokens per message — so leave headroom rather than packing right up to the limit.

Keep conversation history within budget

Because history is re-sent on every turn, long conversations get progressively slower and costlier — and eventually exceed the window. Store history in your own type so you can trim it deliberately:

public record Turn(string Role, string Text); // "user" or "assistant" static List<Turn> TrimToBudget(List<Turn> history, Tokenizer tokenizer, int maxHistoryTokens) { var kept = new List<Turn>(); int used = 0; // Walk BACKWARD from the newest turn, keeping as many recent turns as fit for (int i = history.Count - 1; i >= 0; i--) { int cost = tokenizer.CountTokens(history[i].Text) + 4; // +4: rough per-message overhead if (used + cost > maxHistoryTokens) break; kept.Insert(0, history[i]); used += cost; } return kept; } 
// Build the request: the system prompt is ALWAYS included; only the history is trimmed var messages = new List<ChatMessage> { Source:https://dev.to/rhuturaj_takle/ai-apis-with-net-76d

Related posts