AI Production Kit
OpenAI Direct
.NET API
React (CRA + TS)
Secrets • Rate limit • Logs
Starter IA : API .NET + React + OpenAI (propre, prêt à scaler)
Un vrai socle livrable : endpoint /api/ai/chat, validation, timeouts, rate limiting, logs, secrets propres — et un mini front React pour tester en 30 secondes.
Objectif : démarrer vite et bien. Ce setup est parfait pour un POC, un side project, ou une base de série (streaming, auth, RAG, observabilité).
Architecture
React (localhost:3000)
|
| POST /api/ai/chat { prompt }
v
.NET API (localhost:7248)
|
| HttpClient -> OpenAI chat completions
v
JSON { answer, model }
1) Backend .NET : endpoint /api/ai/chat
1.1 appsettings.json (sans la clé)
{
"Ai": {
"OpenAI": {
"ApiKey": "",
"Model": "gpt-4o-mini"
}
},
"Cors": {
"AllowedOrigins": [ "http://localhost:3000" ]
}
}
1.2 User Secrets (local)
dotnet user-secrets init
dotnet user-secrets set "Ai:OpenAI:ApiKey" "TON_OPENAI_API_KEY"
dotnet user-secrets set "Ai:OpenAI:Model" "gpt-4o-mini"
1.3 LlmClient.cs (OpenAI direct)
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
public sealed class LlmClient
{
private readonly HttpClient _http;
private readonly IConfiguration _config;
public LlmClient(HttpClient http, IConfiguration config)
{
_http = http;
_config = config;
_http.Timeout = TimeSpan.FromSeconds(20);
}
public async Task<(string answer, string model)> ChatAsync(string prompt, CancellationToken ct)
{
var apiKey = _config["Ai:OpenAI:ApiKey"];
var model = _config["Ai:OpenAI:Model"] ?? "gpt-4o-mini";
if (string.IsNullOrWhiteSpace(apiKey))
throw new InvalidOperationException("Missing OpenAI ApiKey (Ai:OpenAI:ApiKey)");
var payload = new
{
model,
messages = new object[]
{
new { role = "system", content = "You help with .NET, Azure, architecture, best practices." },
new { role = "user", content = prompt }
},
temperature = 0.2
};
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var res = await _http.SendAsync(req, ct);
var json = await res.Content.ReadAsStringAsync(ct);
if (!res.IsSuccessStatusCode)
throw new InvalidOperationException($"OpenAI error {(int)res.StatusCode}: {json}");
using var doc = JsonDocument.Parse(json);
var answer = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? "";
return (answer.Trim(), model);
}
}
1.4 Program.cs complet (CORS + Rate Limit + endpoint)
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// CORS strict (React)
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
builder.Services.AddCors(opt =>
{
opt.AddPolicy("web", p =>
p.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials());
});
// Rate limiting (simple mais efficace)
builder.Services.AddRateLimiter(opt =>
{
opt.RejectionStatusCode = 429;
opt.AddPolicy("ai", ctx =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 5,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
});
builder.Services.AddHttpClient();
builder.Services.AddScoped<LlmClient>();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseCors("web");
app.UseRateLimiter();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapPost("/api/ai/chat", async (ChatRequest req, LlmClient llm, CancellationToken ct) =>
{
var prompt = (req?.Prompt ?? "").Trim();
if (string.IsNullOrWhiteSpace(prompt))
return Results.BadRequest(new { error = "Prompt is required" });
if (prompt.Length > 4000)
return Results.BadRequest(new { error = "Prompt too long (max 4000 chars)" });
var (answer, model) = await llm.ChatAsync(prompt, ct);
return Results.Ok(new { answer, model });
})
.RequireRateLimiting("ai");
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.Run();
public sealed class ChatRequest { public string Prompt { get; set; } = ""; }
2) Front React minimal (CRA + TS)
Tu peux garder ton UI chic, ici je te donne un minimal propre.
2.1 .env (recommandé)
REACT_APP_API_BASE_URL=https://localhost:7248
2.2 App.tsx
import React, { useState } from "react";
import "./App.css";
export default function App() {
const [prompt, setPrompt] = useState("");
const [answer, setAnswer] = useState("");
const [meta, setMeta] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const baseUrl = process.env.REACT_APP_API_BASE_URL || "";
async function send() {
setError(""); setAnswer(""); setMeta("");
const p = prompt.trim();
if (!p) return;
setLoading(true);
try {
const res = await fetch(`${baseUrl}/api/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: p }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "Request failed");
setAnswer(data.answer || "");
setMeta(data.model ? `Model: ${data.model}` : "");
} catch (e: any) {
setError(e?.message || "Unexpected error");
} finally {
setLoading(false);
}
}
return (
<div className="wrap">
<div className="card">
<div className="title">AI Starter — OpenAI Direct</div>
<div className="sub">Prompt → réponse via API .NET</div>
<textarea className="input" rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Ex: Donne-moi un exemple de middleware .NET propre…" />
<div className="actions">
<button className="btn" onClick={send} disabled={loading || !prompt.trim()}>
{loading ? "Génération..." : "Envoyer"}
</button>
<button className="btn ghost" disabled={loading}
onClick={() => { setPrompt(""); setAnswer(""); setError(""); setMeta(""); }}>
Reset
</button>
</div>
{error && <div className="error">{error}</div>}
{meta && <div className="meta">{meta}</div>}
<div className="out">
{answer ? <pre className="answer">{answer}</pre> : <div className="hint">La réponse s’affichera ici.</div>}
</div>
</div>
</div>
);
}
2.3 App.css (minimal)
.wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;
background: radial-gradient(900px circle at 10% 10%, rgba(51,204,204,.10), transparent 55%),
linear-gradient(135deg, rgba(10,12,14,1), rgba(12,16,20,1)); color:#fff;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;}
.card{width:min(980px,100%);border:1px solid rgba(255,255,255,.10);border-radius:18px;
background: rgba(255,255,255,.03);box-shadow:0 18px 60px rgba(0,0,0,.35);padding:16px;}
.title{font-weight:900;font-size:18px;}
.sub{color:rgba(255,255,255,.70);font-size:13px;margin:6px 0 12px;}
.input{width:100%;box-sizing:border-box;border-radius:14px;border:1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.20);color:#fff;padding:12px;outline:none;resize:vertical;}
.actions{display:flex;gap:10px;margin-top:10px;}
.btn{border:none;cursor:pointer;border-radius:12px;padding:10px 14px;font-weight:800;background:rgba(51,204,204,1);color:#062023;}
.btn:disabled{opacity:.55;cursor:not-allowed;}
.btn.ghost{background:rgba(255,255,255,.06);color:#fff;border:1px solid rgba(255,255,255,.12);}
.error{margin-top:10px;border:1px solid rgba(255,80,80,.30);background:rgba(255,80,80,.10);
border-radius:12px;padding:10px;color:rgba(255,200,200,1);}
.meta{margin-top:10px;color:rgba(255,255,255,.72);font-size:12px;}
.out{margin-top:12px;border:1px solid rgba(255,255,255,.10);border-radius:14px;background:rgba(0,0,0,.18);
padding:12px;min-height:140px;}
.answer{white-space:pre-wrap;margin:0;color:rgba(255,255,255,.92);line-height:1.55;font-size:14px;}
.hint{color:rgba(255,255,255,.55);font-size:13px;}
3) Run local
# API
dotnet run
# React
npm install
npm start
Checklist prod (rapide)
- Timeout LLM 15–25s max
- Rate limiting (déjà) + logs d’erreur
- Ne pas logguer le prompt brut si données sensibles
- Secrets via env vars en prod (jamais en dur)
Prochain article : Streaming (SSE/SignalR) + bouton Stop + UX “typing”.